improvement/ux-overhaul up #61

Merged
rameen merged 24 commits from improvement/ux-overhaul into master 2026-07-14 22:46:19 +03:30
27 changed files with 1370 additions and 212 deletions
Showing only changes of commit f28cd06615 - Show all commits

View File

@@ -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`

View File

@@ -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 days 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 `<main>` is the scroll container; document scroll conflicts with `.app-web-bg { overflow: hidden }`.
Use shared `Checkbox` (not native `<input type="checkbox">`) 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.

View File

@@ -38,6 +38,13 @@ frontend/src/
**Example thin page:** `app/.../treatment/page.tsx` → imports `TreatmentWorkspace` from `components/ui/treatment/`. **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 ## Backend layout
``` ```
@@ -59,6 +66,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never
| Skill | When to use | | Skill | When to use |
|-------|-------------| |-------|-------------|
| `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature | | `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature |
| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts |
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout | | `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |
| `.cursor/skills/api-errors/` | New backend errors + frontend translations | | `.cursor/skills/api-errors/` | New backend errors + frontend translations |

View File

@@ -4,7 +4,7 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { LinkStatus } from '@prisma/client'; import { LabTaskStatus, LinkStatus } from '@prisma/client';
import { createReadStream, existsSync, mkdirSync } from 'fs'; import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
@@ -36,6 +36,7 @@ const treatmentInclude = {
orderBy: [{ sentAt: 'asc' as const }], orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } }, include: { organization: { select: { id: true, name: true } } },
}, },
tasks: { select: { 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.assertCanReadTreatment(actorUserId, organizationId);
await this.ensurePatientExists(patientId); await this.ensurePatientExists(patientId);
const membership = await this.getMembership(actorUserId, organizationId);
const isOwner = membership?.isOwner ?? false;
const items = await this.prisma.treatment.findMany({ const items = await this.prisma.treatment.findMany({
where: { where: {
patientId, patientId,
organizationId, organizationId,
details: { some: {} }, details: { some: {} },
...(isOwner
? {}
: {
OR: [
{ providerUserId: actorUserId },
{ appointment: { is: { providerUserId: actorUserId } } },
],
}),
}, },
include: treatmentInclude, include: treatmentInclude,
orderBy: [{ treatmentAt: 'desc' }], orderBy: [{ treatmentAt: 'desc' }, { createdAt: 'desc' }],
take: Math.min(Math.max(limit, 1), 100), take: Math.min(Math.max(limit, 1), 100),
}); });
@@ -205,7 +218,7 @@ export class TreatmentsService {
title, title,
treatmentAt: appointment.startAt, treatmentAt: appointment.startAt,
patientId: appointment.patientId, patientId: appointment.patientId,
providerUserId: appointment.providerUserId, providerUserId: actorUserId,
}, },
}) })
: await tx.treatment.create({ : await tx.treatment.create({
@@ -213,7 +226,7 @@ export class TreatmentsService {
organizationId, organizationId,
patientId: appointment.patientId, patientId: appointment.patientId,
appointmentId: appointment.id, appointmentId: appointment.id,
providerUserId: appointment.providerUserId, providerUserId: actorUserId,
title, title,
treatmentAt: appointment.startAt, treatmentAt: appointment.startAt,
}, },
@@ -566,6 +579,7 @@ export class TreatmentsService {
include: { organization: { select: { id: true, name: true } } }, include: { organization: { select: { id: true, name: true } } },
}, },
toothProsthesis: true, toothProsthesis: true,
tasks: { select: { id: true, status: true } },
}, },
}); });
@@ -758,10 +772,12 @@ export class TreatmentsService {
sentAt: Date; sentAt: Date;
organization?: { id: string; name: string }; organization?: { id: string; name: string };
}>; }>;
tasks?: Array<{ id: string; status: LabTaskStatus }>;
}; };
} | null; } | null;
}) { }) {
const labCase = d.labCaseLink?.labCase; const labCase = d.labCaseLink?.labCase;
const taskProgress = this.mapTaskProgress(labCase?.tasks ?? []);
return { return {
id: d.id, id: d.id,
clientId: d.clientKey ?? d.id, clientId: d.clientKey ?? d.id,
@@ -772,6 +788,7 @@ export class TreatmentsService {
labCaseId: labCase?.id ?? null, labCaseId: labCase?.id ?? null,
sentAt: labCase?.sentAt?.toISOString() ?? null, sentAt: labCase?.sentAt?.toISOString() ?? null,
destinationOrganizationId: labCase?.destinationOrganizationId ?? null, destinationOrganizationId: labCase?.destinationOrganizationId ?? null,
taskProgress,
sends: sends:
labCase?.sends.map((s) => ({ labCase?.sends.map((s) => ({
organizationId: s.organizationId, 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: { private mapAttachment(a: {
id: string; id: string;
fileName: string; fileName: string;

View File

@@ -618,6 +618,8 @@
"comments": "Comments", "comments": "Comments",
"commentsPlaceholder": "Write clinical notes for this case…", "commentsPlaceholder": "Write clinical notes for this case…",
"treatmentType": "Treatment type", "treatmentType": "Treatment type",
"treatmentTypePlaceholder": "Select treatment type…",
"treatmentTypeNotSelected": "Type not selected",
"typeConsultation": "consultation", "typeConsultation": "consultation",
"typeFilling": "filling", "typeFilling": "filling",
"typeEndo": "endo", "typeEndo": "endo",
@@ -630,6 +632,8 @@
"searchOrgsPlaceholder": "Search active organizations...", "searchOrgsPlaceholder": "Search active organizations...",
"recent": "Recent:", "recent": "Recent:",
"noOrgMatch": "No active organization matches your search.", "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", "sendThisCase": "Send this case",
"labDispatchTitle": "Lab dispatch", "labDispatchTitle": "Lab dispatch",
"labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.", "labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.",
@@ -663,10 +667,30 @@
"saveStatusError": "Could not save — check your connection", "saveStatusError": "Could not save — check your connection",
"sendSavesFirst": "Sending is per case and saves first automatically.", "sendSavesFirst": "Sending is per case and saves first automatically.",
"historyTitle": "Previous treatments", "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…", "loadingHistory": "Loading history…",
"historyEmpty": "No other treatments recorded for this patient yet.", "historyEmpty": "No other treatments recorded for this patient yet.",
"historyDetailLabel": "Detail {n} · {type}", "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", "previewTitle": "Treatment preview",
"openTreatment": "Open", "openTreatment": "Open",
"selectAppointment": "Select an appointment to preview its treatment.", "selectAppointment": "Select an appointment to preview its treatment.",

View File

@@ -619,6 +619,8 @@
"comments": "نظرات", "comments": "نظرات",
"commentsPlaceholder": "یادداشت‌های بالینی این پرونده را بنویسید...", "commentsPlaceholder": "یادداشت‌های بالینی این پرونده را بنویسید...",
"treatmentType": "نوع درمان", "treatmentType": "نوع درمان",
"treatmentTypePlaceholder": "نوع درمان را انتخاب کنید…",
"treatmentTypeNotSelected": "نوع انتخاب نشده",
"typeConsultation": "مشاوره", "typeConsultation": "مشاوره",
"typeFilling": "پر کردن", "typeFilling": "پر کردن",
"typeEndo": "درمان ریشه", "typeEndo": "درمان ریشه",
@@ -631,6 +633,8 @@
"searchOrgsPlaceholder": "جستجوی سازمان‌های فعال...", "searchOrgsPlaceholder": "جستجوی سازمان‌های فعال...",
"recent": "اخیر:", "recent": "اخیر:",
"noOrgMatch": "هیچ سازمان فعالی با جستجوی شما مطابقت ندارد.", "noOrgMatch": "هیچ سازمان فعالی با جستجوی شما مطابقت ندارد.",
"inviteLab": "دعوت از آزمایشگاه",
"noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.",
"sendThisCase": "ارسال این پرونده", "sendThisCase": "ارسال این پرونده",
"labDispatchTitle": "ارسال به لابراتوار", "labDispatchTitle": "ارسال به لابراتوار",
"labDispatchSubtitle": "جزئیات وابسته به لاب را در محموله‌ها گروه‌بندی کرده و به لابراتوارهای متصل ارسال کنید.", "labDispatchSubtitle": "جزئیات وابسته به لاب را در محموله‌ها گروه‌بندی کرده و به لابراتوارهای متصل ارسال کنید.",
@@ -664,10 +668,30 @@
"saveStatusError": "ذخیره ناموفق بود — اتصال را بررسی کنید", "saveStatusError": "ذخیره ناموفق بود — اتصال را بررسی کنید",
"sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره می‌کند.", "sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره می‌کند.",
"historyTitle": "درمان‌های قبلی", "historyTitle": "درمان‌های قبلی",
"historySubtitle": "برای پیش‌نمایش روی یک درمان کلیک کنید. از دکمه باز کردن در کارت پیش‌نمایش برای بارگذاری در فضای کاری استفاده کنید.", "historyPatientScope": "درمان‌های قبلی برای {patientName}",
"historyCurrentAppointment": "این نوبت",
"historySubtitle": "برای پیش‌نمایش روی یک طرح قبلی کلیک کنید، سپس در صورت نیاز آن را در فضای کار بارگذاری کنید.",
"historyFilterNotShipped": "ارسال‌نشده به لابراتوار",
"historyFilterDate": "تاریخ",
"historyClearFilters": "حذف فیلترها",
"historyFilterEmpty": "هیچ درمانی با این فیلترها یافت نشد.",
"labShipmentBlockedTitle": "ارسال به لابراتوار هنوز ممکن نیست",
"labShipmentBlockedBody": "قبل از ایجاد ارسال لابراتوار، حداقل یک دندان برای این جزئیات پروتز انتخاب کنید.",
"labCaseCommentsTitle": "نظرات پرونده لابراتوار",
"labCaseCommentsHint": "تا زمانی که پرونده در لابراتوار در حال انجام است با لابراتوار پیام بگذارید. پس از تکمیل همه کارها، نظردهی بسته می‌شود.",
"loadingHistory": "در حال بارگذاری تاریخچه...", "loadingHistory": "در حال بارگذاری تاریخچه...",
"historyEmpty": "هیچ درمان دیگری برای این بیمار ثبت نشده است.", "historyEmpty": "هیچ درمان دیگری برای این بیمار ثبت نشده است.",
"historyDetailLabel": "جزئیات {n} · {type}", "historyDetailLabel": "جزئیات {n} · {type}",
"previewCurrentDraft": "پیش‌نویس فعلی",
"previewBrowsingTitle": "پیش‌نمایش طرح ذخیره‌شده",
"browseBanner": "در حال مشاهده {date} — این طرح هنوز در ویرایشگر بارگذاری نشده است.",
"loadIntoWorkspace": "بارگذاری در فضای کاری",
"backToCurrentDraft": "بازگشت به پیش‌نویس فعلی",
"labAttentionTitle": "نیاز به ارسال به لابراتوار",
"labAttentionSubtitle": "این جزئیات وابسته به لاب هنوز به لابراتوار ارسال نشده‌اند.",
"labAttentionCurrentDraft": "نوبت فعلی",
"labAttentionGoDispatch": "رفتن به ارسال",
"labAttentionLoadDispatch": "بارگذاری و ارسال",
"previewTitle": "پیش‌نمایش درمان", "previewTitle": "پیش‌نمایش درمان",
"openTreatment": "باز کردن", "openTreatment": "باز کردن",
"selectAppointment": "یک نوبت را برای پیش‌نمایش درمان انتخاب کنید.", "selectAppointment": "یک نوبت را برای پیش‌نمایش درمان انتخاب کنید.",

View File

@@ -619,6 +619,8 @@
"comments": "Opmerkingen", "comments": "Opmerkingen",
"commentsPlaceholder": "Schrijf klinische notities voor deze case...", "commentsPlaceholder": "Schrijf klinische notities voor deze case...",
"treatmentType": "Behandeltype", "treatmentType": "Behandeltype",
"treatmentTypePlaceholder": "Selecteer behandeltype…",
"treatmentTypeNotSelected": "Type niet geselecteerd",
"typeConsultation": "consult", "typeConsultation": "consult",
"typeFilling": "vulling", "typeFilling": "vulling",
"typeEndo": "endo", "typeEndo": "endo",
@@ -631,6 +633,8 @@
"searchOrgsPlaceholder": "Zoek actieve organisaties...", "searchOrgsPlaceholder": "Zoek actieve organisaties...",
"recent": "Recent:", "recent": "Recent:",
"noOrgMatch": "Geen actieve organisatie komt overeen met uw zoekopdracht.", "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", "sendThisCase": "Verzend deze case",
"labDispatchTitle": "Lab-dispatch", "labDispatchTitle": "Lab-dispatch",
"labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.", "labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.",
@@ -664,10 +668,30 @@
"saveStatusError": "Opslaan mislukt — controleer uw verbinding", "saveStatusError": "Opslaan mislukt — controleer uw verbinding",
"sendSavesFirst": "Verzenden is per case en slaat eerst automatisch op.", "sendSavesFirst": "Verzenden is per case en slaat eerst automatisch op.",
"historyTitle": "Eerdere behandelingen", "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...", "loadingHistory": "Geschiedenis laden...",
"historyEmpty": "Geen andere behandelingen voor deze patiënt geregistreerd.", "historyEmpty": "Geen andere behandelingen voor deze patiënt geregistreerd.",
"historyDetailLabel": "Detail {n} · {type}", "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", "previewTitle": "Behandelvoorbeeld",
"openTreatment": "Openen", "openTreatment": "Openen",
"selectAppointment": "Selecteer een afspraak om de behandeling te bekijken.", "selectAppointment": "Selecteer een afspraak om de behandeling te bekijken.",

View File

@@ -90,7 +90,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
onOpenSidebar={() => setSidebarOpen(true)} onOpenSidebar={() => setSidebarOpen(true)}
/> />
<main className="p-3 sm:p-4 lg:p-6 flex-1 min-h-0 overflow-y-auto"> <main className="p-3 sm:p-4 lg:p-6 flex-1 min-h-0 overflow-y-auto overscroll-y-contain">
<div className="surface-panel p-3 sm:p-4 lg:p-6 min-w-0"> <div className="surface-panel p-3 sm:p-4 lg:p-6 min-w-0">
{children} {children}
</div> </div>

View File

@@ -0,0 +1,28 @@
/**
* Scroll an element into view using the dashboard `<main>` 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 });
}
}

View File

@@ -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<string>,
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<string>,
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(),
);
}

View File

@@ -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<DetailLike, 'treatmentType'>): 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<string>,
): boolean {
return (
Boolean(detail.id) &&
isDetailTypeSelected(detail) &&
labDependentCodes.has(detail.treatmentType) &&
detail.teeth.length > 0
);
}
export function isDetailEligibleForLabAttention(
detail: DetailLike,
labDependentCodes: Set<string>,
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<string>,
): 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);
}

View File

@@ -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<string>,
): 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<string>,
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;
});
}

View File

@@ -10,6 +10,9 @@ export const labSentBannerClass =
export const labPendingBannerClass = 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'; '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 { export function autosaveStatusClass(status: 'dirty' | 'saving' | 'saved' | 'error'): string {
switch (status) { switch (status) {
case 'dirty': case 'dirty':

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useToast } from '@/lib/hooks/useToast'; import { useToast } from '@/lib/hooks/useToast';
import { Check, History, Trash2, UserPlus, X } from 'lucide-react'; 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 { SearchBar } from '@/components/ui/shared/SearchBar';
import { Table } from '@/components/ui/shared/Table'; import { Table } from '@/components/ui/shared/Table';
import { getUserFacingError } from '@/components/shared/formatApiError'; import { getUserFacingError } from '@/components/shared/formatApiError';
import { useRouter } from '@/i18n/navigation';
function formatOrganizationStatusLabel(status: string): string { function formatOrganizationStatusLabel(status: string): string {
if (!status) return status; if (!status) return status;
@@ -45,6 +47,8 @@ export function OrganizationsPage() {
const tErrors = useTranslations('errors'); const tErrors = useTranslations('errors');
const tNav = useTranslations('nav'); const tNav = useTranslations('nav');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const searchParams = useSearchParams();
const router = useRouter();
const { currentOrganization } = useAuth(); const { currentOrganization } = useAuth();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const toast = useToast(); const toast = useToast();
@@ -123,6 +127,14 @@ export function OrganizationsPage() {
void loadList(); void loadList();
}, []); }, []);
useEffect(() => {
if (searchParams.get('action') !== 'invite-lab') {
return;
}
setShowInviteForm(true);
router.replace('/organizations');
}, [searchParams, router]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const q = query.trim(); const q = query.trim();

View File

@@ -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 (
<div className="space-y-2 border-t border-border/60 pt-4">
<div>
<p className="text-xs font-medium text-text-secondary">{t('labCaseCommentsTitle')}</p>
<p className="text-[11px] text-text-muted mt-0.5">{t('labCaseCommentsHint')}</p>
</div>
<LabCaseCommentsPanel
caseId={caseId}
canPost={canPost}
canToggleVisibility={false}
loadComments={async () => {
const response = await treatmentsApi.listLabCaseComments(caseId);
return response.data;
}}
onPost={async (body) => {
const response = await treatmentsApi.addLabCaseComment(caseId, { body });
return response.data;
}}
onError={onError}
/>
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { labNotSentBadgeClass, labSentBadgeClass } from '@/components/treatment/treatmentStatusStyles'; import { labNotSentBadgeClass, labSentBadgeClass } from '@/components/treatment/treatmentStatusStyles';
import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment'; import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
@@ -23,7 +24,7 @@ export function DetailLabSendBadge({
}: DetailLabSendBadgeProps) { }: DetailLabSendBadgeProps) {
const t = useTranslations('treatment'); const t = useTranslations('treatment');
if (!labDependentCodes.has(detail.treatmentType)) { if (!isDetailTypeSelected(detail) || !labDependentCodes.has(detail.treatmentType)) {
return null; return null;
} }

View File

@@ -4,9 +4,9 @@ import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Dropdown } from '@/components/ui/shared/Dropdown'; import { isDetailReadyForLabDispatch } from '@/components/treatment/treatmentDetailRules';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; 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 { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
@@ -30,6 +30,8 @@ interface LabCasesDispatchPanelProps {
onOrganizationSearchChange: (value: string) => void; onOrganizationSearchChange: (value: string) => void;
recentOrganizationIds: string[]; recentOrganizationIds: string[];
onRecentOrganizationPick: (orgId: string) => void; onRecentOrganizationPick: (orgId: string) => void;
canInviteLab?: boolean;
onInviteLab?: () => void;
sendBusyId: string | null; sendBusyId: string | null;
onAddLabCase: () => void; onAddLabCase: () => void;
onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void; onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void;
@@ -89,6 +91,8 @@ export function LabCasesDispatchPanel({
onOrganizationSearchChange, onOrganizationSearchChange,
recentOrganizationIds, recentOrganizationIds,
onRecentOrganizationPick, onRecentOrganizationPick,
canInviteLab = false,
onInviteLab,
sendBusyId, sendBusyId,
onAddLabCase, onAddLabCase,
onSendLabCase, onSendLabCase,
@@ -100,18 +104,13 @@ export function LabCasesDispatchPanel({
const [pendingComment, setPendingComment] = useState(''); const [pendingComment, setPendingComment] = useState('');
const activeLinkedOrganizations = orgs.filter((o) => o.active); 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 const recentOrganizations = recentOrganizationIds
.map((id) => activeLinkedOrganizations.find((o) => o.id === id)) .map((id) => activeLinkedOrganizations.find((o) => o.id === id))
.filter(Boolean) as LinkedOrganizationOption[]; .filter(Boolean) as LinkedOrganizationOption[];
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null; const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null;
const isLabDependentDetail = Boolean( const isLabDependentDetail = Boolean(
activeDetail && labDependentCodes.has(activeDetail.treatmentType), activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes),
); );
const labCaseForActiveDetail = const labCaseForActiveDetail =
@@ -214,6 +213,14 @@ export function LabCasesDispatchPanel({
updateActiveLabCase({ attachmentIds: [...set] }); updateActiveLabCase({ attachmentIds: [...set] });
} }
function handleSelectOrganization(org: LinkedOrganizationOption) {
updateActiveLabCase({
destinationOrganizationId: org.id,
toothProsthesis: [],
});
setApplyAllProsthesis('');
}
const activeDetailAttachments = activeDetail.attachmentMetas ?? []; const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
return ( return (
@@ -340,11 +347,16 @@ export function LabCasesDispatchPanel({
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p> <p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
<SearchBar <LinkedOrganizationSearchCombobox
embedded search={organizationSearch}
value={organizationSearch} onSearchChange={onOrganizationSearchChange}
onChange={onOrganizationSearchChange} organizations={activeLinkedOrganizations}
placeholder={t('searchOrgsPlaceholder')} selectedOrganizationId={activeLabCase.destinationOrganizationId}
onSelectOrganization={handleSelectOrganization}
disabled={disabled}
canInviteLab={canInviteLab}
onInviteLab={onInviteLab}
noPermissionMessage={t('noOrgInvitePermission')}
/> />
{recentOrganizations.length > 0 && ( {recentOrganizations.length > 0 && (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -362,28 +374,6 @@ export function LabCasesDispatchPanel({
))} ))}
</div> </div>
)} )}
<Dropdown
value={activeLabCase.destinationOrganizationId ?? ''}
onChange={(e) => {
const nextOrgId = e.target.value || null;
updateActiveLabCase({
destinationOrganizationId: nextOrgId,
toothProsthesis: [],
});
setApplyAllProsthesis('');
}}
disabled={disabled || filteredOrganizations.length === 0}
>
<option value="">{t('selectLabPlaceholder')}</option>
{filteredOrganizations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</Dropdown>
{filteredOrganizations.length === 0 && (
<p className="text-xs text-text-muted">{t('noOrgMatch')}</p>
)}
</div> </div>
{prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? ( {prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? (
@@ -411,7 +401,7 @@ export function LabCasesDispatchPanel({
))} ))}
</select> </select>
</label> </label>
<div className="overflow-x-auto"> <div className="overflow-x-auto overscroll-x-contain">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-left text-xs text-text-muted"> <tr className="text-left text-xs text-text-muted">

View File

@@ -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<string>;
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 (
<div className="surface-card p-4 space-y-3 border border-amber-500/35 bg-amber-500/5">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5 icon-flat" />
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary">{t('labAttentionTitle')}</h3>
<p className="text-[11px] text-text-muted mt-0.5">{t('labAttentionSubtitle')}</p>
</div>
</div>
<ul className="space-y-2 max-h-[min(240px,35vh)] overflow-y-auto pr-1">
{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 (
<li
key={item.key}
className="flex flex-col gap-2 rounded-[var(--radius-sm)] border border-border/60 bg-background-secondary/40 px-2.5 py-2 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
<time className="text-[11px] font-medium text-text-primary tabular-nums">
{dateLabel}
</time>
{item.isCurrentDraft ? (
<span className="text-[10px] uppercase tracking-wide text-primary font-medium">
{t('labAttentionCurrentDraft')}
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
<span className="text-[11px] text-text-muted tabular-nums">
{t('detailLabel', { n: item.detailNumber })}
</span>
<TreatmentTypeBadge
type={item.detail.treatmentType}
label={treatmentTypeLabelFromCatalog(
item.detail.treatmentType,
treatmentCatalog,
)}
/>
<DetailLabSendBadge
detail={item.detail}
labDependentCodes={labDependentCodes}
orgs={orgs}
/>
</div>
<p className="text-[11px] text-text-secondary truncate">
{t('teethLabel')} {teeth}
</p>
</div>
<Button
type="button"
variant="primary"
className="shrink-0 w-full sm:w-auto text-xs py-1.5"
onClick={() => onGoToDispatch(item)}
>
{item.isCurrentDraft ? t('labAttentionGoDispatch') : t('labAttentionLoadDispatch')}
</Button>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -0,0 +1,20 @@
'use client';
import { AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
export function LabShipmentBlockedNotice() {
const t = useTranslations('treatment');
return (
<div className="surface-card p-4 border border-amber-500/35 bg-amber-500/5">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5 icon-flat" />
<div className="min-w-0 space-y-1">
<h3 className="text-sm font-semibold text-text-primary">{t('labShipmentBlockedTitle')}</h3>
<p className="text-xs text-text-muted">{t('labShipmentBlockedBody')}</p>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-2">
<Input
placeholder={placeholder ?? t('searchOrgsPlaceholder')}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
disabled={disabled}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
{selectedOrg && !trimmed ? (
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
{selectedOrg.name}
</p>
) : null}
{showResults ? (
<div className="space-y-2 rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/30 p-2">
{filtered.length === 0 ? (
<div className="space-y-2 px-1 py-1">
<p className="text-sm text-text-muted">
{emptyResultsMessage ?? t('noOrgMatch')}
</p>
{canInviteLab && onInviteLab ? (
<Button type="button" variant="primary" onClick={onInviteLab} fullWidth>
{t('inviteLab')}
</Button>
) : noPermissionMessage ? (
<p className="text-xs text-text-muted">{noPermissionMessage}</p>
) : null}
</div>
) : (
<div className="max-h-48 space-y-1.5 overflow-y-auto overscroll-y-contain pr-1">
{filtered.map((org) => {
const isSelected = selectedOrganizationId === org.id;
return (
<button
key={org.id}
type="button"
onClick={() => handleSelect(org)}
className={`w-full rounded-[var(--radius-sm)] border px-2.5 py-2 text-left text-sm transition-colors ${
isSelected
? 'border-primary/60 bg-primary-soft'
: 'border-transparent hover:bg-background-card/70'
}`}
>
{org.name}
</button>
);
})}
</div>
)}
</div>
) : null}
</div>
);
}

View File

@@ -1,43 +1,127 @@
'use client'; 'use client';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl'; 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 { 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'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
interface PastTreatmentsPanelProps { interface PastTreatmentsPanelProps {
items: PastTreatment[]; items: PastTreatment[];
currentDraft?: PastTreatment | null;
patientName?: string;
currentAppointmentId?: string | null;
treatmentCatalog: TreatmentCatalogEntry[]; treatmentCatalog: TreatmentCatalogEntry[];
labDependentCodes: Set<string>;
orgs?: LinkedOrganizationOption[];
loading?: boolean; loading?: boolean;
selectedPreviewId?: string | null; selectedPreviewId?: string | null;
onSelectTreatment?: (treatment: PastTreatment) => void; 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({ export function PastTreatmentsPanel({
items, items,
currentDraft = null,
patientName,
currentAppointmentId,
treatmentCatalog, treatmentCatalog,
labDependentCodes,
orgs,
loading, loading,
selectedPreviewId, selectedPreviewId,
onSelectTreatment, onSelectTreatment,
}: PastTreatmentsPanelProps) { }: PastTreatmentsPanelProps) {
const t = useTranslations('treatment'); 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 ( return (
<div className="surface-card p-4 space-y-3"> <div className="surface-card p-4 space-y-3">
<div> <div>
<h3 className="text-sm font-semibold text-text-primary">{t('historyTitle')}</h3> <h3 className="text-sm font-semibold text-text-primary">
{patientName ? t('historyPatientScope', { patientName }) : t('historyTitle')}
</h3>
<p className="text-[11px] text-text-muted mt-0.5">{t('historySubtitle')}</p> <p className="text-[11px] text-text-muted mt-0.5">{t('historySubtitle')}</p>
</div> </div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/30 p-2.5">
<Checkbox
checked={notShippedOnly}
onChange={setNotShippedOnly}
label={t('historyFilterNotShipped')}
className="text-xs [&_span:last-child]:text-xs shrink-0"
/>
<label className="flex items-center gap-1.5 shrink-0">
<span className="text-xs font-medium text-text-muted whitespace-nowrap">
{t('historyFilterDate')}
</span>
<input
type="date"
value={filterDate}
onChange={(e) => setFilterDate(e.target.value)}
className={filterInputClass}
/>
</label>
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
disabled={!hasActiveFilters}
className="shrink-0"
>
{t('historyClearFilters')}
</Button>
</div>
{loading && <p className="text-sm text-text-muted">{t('loadingHistory')}</p>} {loading && <p className="text-sm text-text-muted">{t('loadingHistory')}</p>}
{!loading && items.length === 0 && ( {!loading && displayedItems.length === 0 && (
<p className="text-sm text-text-muted">{t('historyEmpty')}</p> <p className="text-sm text-text-muted">
{hasActiveFilters ? t('historyFilterEmpty') : t('historyEmpty')}
</p>
)} )}
<div className="space-y-1.5 max-h-[min(420px,50vh)] overflow-y-auto pr-1"> <div className="space-y-1.5 max-h-[min(420px,50vh)] overflow-y-auto overscroll-y-contain pr-1">
{items.map((treatment) => { {displayedItems.map((treatment) => {
const isSelected = selectedPreviewId === treatment.id; const isSelected = selectedPreviewId === treatment.id;
const isCurrentAppointment =
Boolean(currentAppointmentId) && treatment.appointmentId === currentAppointmentId;
const isLiveDraft = treatment.id === 'current-draft';
return ( return (
<article <article
@@ -61,28 +145,41 @@ export function PastTreatmentsPanel({
} }
`} `}
> >
<time <div className="flex flex-wrap items-center gap-2">
className="text-xs font-medium text-text-primary tabular-nums block" <time
dateTime={treatment.treatmentAt} className="text-xs font-medium text-text-primary tabular-nums"
> dateTime={treatment.treatmentAt}
{new Date(treatment.treatmentAt).toLocaleDateString(undefined, { >
year: 'numeric', {formatHistoryTimestamp(treatment.treatmentAt)}
month: 'short', </time>
day: 'numeric', {isLiveDraft ? (
})} <span className="text-[10px] font-medium uppercase tracking-wide text-primary">
</time> {t('labAttentionCurrentDraft')}
</span>
) : isCurrentAppointment ? (
<span className="text-[10px] font-medium uppercase tracking-wide text-primary">
{t('historyCurrentAppointment')}
</span>
) : null}
</div>
{treatment.details.length === 0 ? ( {treatment.details.length === 0 ? (
<p className="text-[10px] text-text-muted mt-1">{t('noDetails')}</p> <p className="text-[10px] text-text-muted mt-1">{t('noDetails')}</p>
) : ( ) : (
<div className="mt-1.5 divide-y divide-border/50 border-t border-border/40 pointer-events-none"> <div className="mt-1.5 divide-y divide-border/50 border-t border-border/40 pointer-events-none">
{treatment.details.map((detail, idx) => ( {treatment.details.map((detail, idx) => (
<div key={detail.clientId ?? detail.id} className="py-1.5"> <div key={`${detail.clientId ?? detail.id}-${idx}`} className="py-1.5 space-y-1">
<TreatmentHistoryDetailLine <TreatmentHistoryDetailLine
detail={detail} detail={detail}
detailNumber={idx + 1} detailNumber={idx + 1}
treatmentCatalog={treatmentCatalog} treatmentCatalog={treatmentCatalog}
/> />
<DetailLabSendBadge
detail={detail}
labDependentCodes={labDependentCodes}
orgs={orgs}
className="text-[10px] px-1.5 py-0"
/>
</div> </div>
))} ))}
</div> </div>

View File

@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge'; import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules';
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment'; import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
@@ -39,10 +40,16 @@ export function TreatmentDetailSummaryRow({
<span className={`text-text-muted tabular-nums ${compact ? 'text-[11px]' : 'text-xs'}`}> <span className={`text-text-muted tabular-nums ${compact ? 'text-[11px]' : 'text-xs'}`}>
{t('detailLabel', { n: detailNumber })} {t('detailLabel', { n: detailNumber })}
</span> </span>
<TreatmentTypeBadge {isDetailTypeSelected(detail) ? (
type={detail.treatmentType} <TreatmentTypeBadge
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)} type={detail.treatmentType}
/> label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
/>
) : (
<span className={`text-text-muted italic ${compact ? 'text-[11px]' : 'text-xs'}`}>
{t('treatmentTypeNotSelected')}
</span>
)}
</div> </div>
<DetailLabSendBadge detail={detail} labDependentCodes={labDependentCodes} orgs={orgs} /> <DetailLabSendBadge detail={detail} labDependentCodes={labDependentCodes} orgs={orgs} />
</div> </div>

View File

@@ -12,6 +12,14 @@ import {
import type { TreatmentDetailDraft } from '@/types/treatment'; import type { TreatmentDetailDraft } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; 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 { interface TreatmentDetailsEditorProps {
details: TreatmentDetailDraft[]; details: TreatmentDetailDraft[];
@@ -27,6 +35,7 @@ interface TreatmentDetailsEditorProps {
uploadBusy: boolean; uploadBusy: boolean;
onAddDetail: () => void; onAddDetail: () => void;
onUploadFiles: (files: FileList | null) => void; onUploadFiles: (files: FileList | null) => void;
onCommentError?: (message: string) => void;
} }
export function TreatmentDetailsEditor({ export function TreatmentDetailsEditor({
@@ -43,6 +52,7 @@ export function TreatmentDetailsEditor({
uploadBusy, uploadBusy,
onAddDetail, onAddDetail,
onUploadFiles, onUploadFiles,
onCommentError,
}: TreatmentDetailsEditorProps) { }: TreatmentDetailsEditorProps) {
const t = useTranslations('treatment'); const t = useTranslations('treatment');
const attachmentInputRef = useRef<HTMLInputElement>(null); const attachmentInputRef = useRef<HTMLInputElement>(null);
@@ -52,12 +62,19 @@ export function TreatmentDetailsEditor({
const locked = isDetailLocked(activeDetail); const locked = isDetailLocked(activeDetail);
const readOnly = disabled || locked; const readOnly = disabled || locked;
const treatmentTypeTextColor = treatmentTypeColor( const treatmentTypeTextColor = isDetailTypeSelected(activeDetail)
activeDetail.treatmentType, ? treatmentTypeColor(
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType), 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 showLabCaseComments = canCommentOnDetailLabCase(activeDetail);
const showPendingLabHint = isLabDependent && !locked && !readOnly;
return ( return (
<div className="surface-card p-3 sm:p-4 space-y-4"> <div className="surface-card p-3 sm:p-4 space-y-4">
@@ -107,6 +124,9 @@ export function TreatmentDetailsEditor({
{showPendingLabHint && ( {showPendingLabHint && (
<p className={labPendingBannerClass}>{t('detailPendingLabSend')}</p> <p className={labPendingBannerClass}>{t('detailPendingLabSend')}</p>
)} )}
{showMissingTeethLabBlock && (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
)}
<label className="block text-xs font-medium text-text-secondary"> <label className="block text-xs font-medium text-text-secondary">
{t('comments')} {t('comments')}
@@ -140,6 +160,7 @@ export function TreatmentDetailsEditor({
disabled={readOnly} disabled={readOnly}
style={{ color: treatmentTypeTextColor }} style={{ color: treatmentTypeTextColor }}
> >
<option value="">{t('treatmentTypePlaceholder')}</option>
{treatmentCatalog.map((entry, index) => ( {treatmentCatalog.map((entry, index) => (
<option <option
key={entry.code} key={entry.code}
@@ -192,6 +213,14 @@ export function TreatmentDetailsEditor({
</div> </div>
</div> </div>
{showLabCaseComments ? (
<DetailLabCaseCommentsSection
detail={activeDetail}
canPost={canEdit && !disabled}
onError={onCommentError}
/>
) : null}
{canEdit && saveStatus !== 'idle' && ( {canEdit && saveStatus !== 'idle' && (
<p <p
className={`text-xs pt-2 border-t border-border/60 ${autosaveStatusClass(saveStatus)}`} className={`text-xs pt-2 border-t border-border/60 ${autosaveStatusClass(saveStatus)}`}

View File

@@ -2,6 +2,7 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules';
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { PastTreatmentDetail } from '@/types/treatment'; import type { PastTreatmentDetail } from '@/types/treatment';
@@ -24,10 +25,14 @@ export function TreatmentHistoryDetailLine({
return ( return (
<div className="flex items-center gap-2 min-w-0 text-[11px] leading-tight"> <div className="flex items-center gap-2 min-w-0 text-[11px] leading-tight">
<span className="text-text-muted tabular-nums shrink-0">{detailNumber}.</span> <span className="text-text-muted tabular-nums shrink-0">{detailNumber}.</span>
<TreatmentTypeBadge {isDetailTypeSelected(detail) ? (
type={detail.treatmentType} <TreatmentTypeBadge
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)} type={detail.treatmentType}
/> label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
/>
) : (
<span className="text-text-muted italic shrink-0">{t('treatmentTypeNotSelected')}</span>
)}
<span className="text-text-secondary truncate min-w-0">{teeth}</span> <span className="text-text-secondary truncate min-w-0">{teeth}</span>
{attachmentCount > 0 && ( {attachmentCount > 0 && (
<span className="text-text-muted shrink-0 tabular-nums"> <span className="text-text-muted shrink-0 tabular-nums">

View File

@@ -1,58 +1,56 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow'; import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow';
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment'; import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
interface TreatmentPreviewCardProps { interface TreatmentPreviewCardProps {
treatment: PastTreatment | null; treatment: PastTreatment | null;
/** e.g. "Current draft" or a formatted date label while browsing. */
heading: string;
labDependentCodes: Set<string>; labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[]; treatmentCatalog: TreatmentCatalogEntry[];
orgs?: LinkedOrganizationOption[]; orgs?: LinkedOrganizationOption[];
openDisabled?: boolean;
onOpen: () => void;
} }
export function TreatmentPreviewCard({ export function TreatmentPreviewCard({
treatment, treatment,
heading,
labDependentCodes, labDependentCodes,
treatmentCatalog, treatmentCatalog,
orgs, orgs,
openDisabled = false,
onOpen,
}: TreatmentPreviewCardProps) { }: TreatmentPreviewCardProps) {
const t = useTranslations('treatment'); const t = useTranslations('treatment');
return ( return (
<div className="surface-card p-4 space-y-3"> <div className="surface-card p-4 space-y-3">
<div className="flex items-center justify-between gap-2"> <h3 className="text-sm font-semibold text-text-primary">{heading}</h3>
<h3 className="text-sm font-semibold text-text-primary">{t('previewTitle')}</h3>
<Button type="button" variant="primary" disabled={openDisabled || !treatment} onClick={onOpen}>
{t('openTreatment')}
</Button>
</div>
{!treatment ? ( {!treatment ? (
<p className="text-sm text-text-muted">{t('selectAppointment')}</p> <p className="text-sm text-text-muted">{t('selectAppointment')}</p>
) : ( ) : (
<div className="border border-border/70 rounded-[var(--radius-md)] p-3 bg-background-secondary/40 space-y-2"> <div className="border border-border/70 rounded-[var(--radius-md)] p-3 bg-background-secondary/40 space-y-2">
<div className="flex items-start justify-between gap-2"> {treatment.id !== 'current-draft' ? (
<p className="text-sm font-medium text-text-primary">{treatment.title}</p>
<time <time
className="text-xs text-text-muted tabular-nums shrink-0" className="text-xs text-text-muted tabular-nums block"
dateTime={treatment.treatmentAt} dateTime={treatment.treatmentAt}
> >
{new Date(treatment.treatmentAt).toLocaleDateString()} {new Date(treatment.treatmentAt).toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time> </time>
</div> ) : null}
<div className="space-y-2 max-h-[min(280px,40vh)] overflow-y-auto pr-1"> <div className="space-y-2 max-h-[min(280px,40vh)] overflow-y-auto pr-1">
{treatment.details.length === 0 ? ( {treatment.details.length === 0 ? (
<p className="text-xs text-text-muted">{t('noDetails')}</p> <p className="text-xs text-text-muted">{t('noDetails')}</p>
) : ( ) : (
treatment.details.map((detail, idx) => ( treatment.details.map((detail, idx) => (
<TreatmentDetailSummaryRow <TreatmentDetailSummaryRow
key={detail.clientId ?? detail.id} key={`${detail.clientId ?? detail.id}-${idx}`}
detail={detail} detail={detail}
detailNumber={idx + 1} detailNumber={idx + 1}
labDependentCodes={labDependentCodes} labDependentCodes={labDependentCodes}

View File

@@ -3,9 +3,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation'; import { useRouter } from '@/i18n/navigation';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip'; import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel'; import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel'; import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor'; import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard'; import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
@@ -20,7 +24,17 @@ import { appointmentsApi } from '@/lib/api/appointments';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentsApi } from '@/lib/api/treatments'; import { treatmentsApi } from '@/lib/api/treatments';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection'; import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions'; import {
areDetailsPersistable,
defaultTreatmentTypeForAppointment,
isDetailReadyForLabDispatch,
isEmptyDraftDetail,
isLabDependentDetailMissingTeeth,
} from '@/components/treatment/treatmentDetailRules';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { getUserFacingError } from '@/components/shared/formatApiError'; import { getUserFacingError } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast'; import { useToast } from '@/lib/hooks/useToast';
import type { Organization } from '@/types/organization'; import type { Organization } from '@/types/organization';
@@ -116,24 +130,13 @@ function buildWorkspaceSnapshot(
}; };
} }
function defaultTreatmentTypeForAppointment( function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft {
purpose: string | undefined,
catalog: TreatmentCatalogEntry[],
): TreatmentDetailDraft['treatmentType'] {
const treatmentOptions = catalog.filter((entry) => 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 {
return { return {
clientId: clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID() ? crypto.randomUUID()
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, : `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: defaultTreatmentType ?? 'restoration', treatmentType: defaultTreatmentType ?? '',
teeth: [], teeth: [],
comment: '', comment: '',
attachmentMetas: [], attachmentMetas: [],
@@ -179,6 +182,7 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
comment: d.notes ?? '', comment: d.notes ?? '',
attachmentMetas: d.attachmentMetas ?? [], attachmentMetas: d.attachmentMetas ?? [],
labCaseId: d.labCaseId ?? null, labCaseId: d.labCaseId ?? null,
taskProgress: d.taskProgress ?? null,
sendToOrganizationIds: d.destinationOrganizationId ? [d.destinationOrganizationId] : [], sendToOrganizationIds: d.destinationOrganizationId ? [d.destinationOrganizationId] : [],
sends: d.sends ?? [], sends: d.sends ?? [],
sentAt: d.sentAt ?? null, sentAt: d.sentAt ?? null,
@@ -220,7 +224,7 @@ function isDetailsDirty(
savedSnapshot: string | null, savedSnapshot: string | null,
): boolean { ): boolean {
if (savedSnapshot === null) { 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; return serializeDetails(details) !== savedSnapshot;
} }
@@ -242,6 +246,7 @@ function detailsToPreviewTreatment(
notes: d.comment || null, notes: d.comment || null,
attachmentMetas: d.attachmentMetas, attachmentMetas: d.attachmentMetas,
labCaseId: d.labCaseId ?? null, labCaseId: d.labCaseId ?? null,
taskProgress: d.taskProgress ?? null,
destinationOrganizationId: d.sendToOrganizationIds[0] ?? null, destinationOrganizationId: d.sendToOrganizationIds[0] ?? null,
sends: d.sends ?? [], sends: d.sends ?? [],
sentAt: d.sentAt ?? null, sentAt: d.sentAt ?? null,
@@ -316,6 +321,8 @@ export function TreatmentWorkspace({
labCaseDraftsRef.current = labCaseDrafts; labCaseDraftsRef.current = labCaseDrafts;
const skipNextGetDraftRef = useRef(false); const skipNextGetDraftRef = useRef(false);
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId); const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
const labPanelRef = useRef<HTMLDivElement>(null);
const historyRequestRef = useRef(0);
useEffect(() => { useEffect(() => {
pendingAppointmentIdRef.current = initialAppointmentId; pendingAppointmentIdRef.current = initialAppointmentId;
@@ -360,18 +367,25 @@ export function TreatmentWorkspace({
!isViewingPastDay && !isViewingPastDay &&
workspaceMode === 'live'; workspaceMode === 'live';
const historyPanelItems = useMemo(() => { const historyPanelItems = history;
return history.filter((item) => {
if ( const activeDetail = useMemo(
workspaceMode === 'live' && () => details.find((d) => d.clientId === activeDetailId) ?? details[0] ?? null,
selectedAppointmentId && [details, activeDetailId],
item.appointmentId === selectedAppointmentId );
) {
return false; const showLabDispatchPanel = useMemo(
} () => details.some((d) => isDetailReadyForLabDispatch(d, labDependentCodes)),
return true; [details, labDependentCodes],
}); );
}, [history, selectedAppointmentId, workspaceMode]);
const showLabShipmentBlocked = useMemo(
() =>
Boolean(
activeDetail && isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
),
[activeDetail, labDependentCodes],
);
const currentDraftPreview = useMemo<PastTreatment | null>(() => { const currentDraftPreview = useMemo<PastTreatment | null>(() => {
if (!selectedAppointment) return null; if (!selectedAppointment) return null;
@@ -388,17 +402,29 @@ export function TreatmentWorkspace({
const previewTreatment = useMemo(() => { const previewTreatment = useMemo(() => {
if (!selectedPreviewId) return currentDraftPreview; if (!selectedPreviewId) return currentDraftPreview;
return historyPanelItems.find((item) => item.id === selectedPreviewId) ?? currentDraftPreview; return (
}, [selectedPreviewId, historyPanelItems, currentDraftPreview]); history.find((item) => item.id === selectedPreviewId) ??
historyPanelItems.find((item) => item.id === selectedPreviewId) ??
currentDraftPreview
);
}, [selectedPreviewId, history, historyPanelItems, currentDraftPreview]);
const isPreviewAlreadyOpen = useMemo(() => { const isBrowsing = selectedPreviewId !== null;
if (!previewTreatment?.appointmentId || !selectedAppointmentId) return false;
if (selectedAppointmentId !== previewTreatment.appointmentId) return false; const previewHeading = isBrowsing
if (workspaceMode === 'historical') return true; ? t('previewBrowsingTitle')
if (workspaceMode === 'live' && selectedPreviewId === null) return true; : t('previewCurrentDraft');
if (workspaceMode === 'live' && selectedPreviewId === previewTreatment.id) return true;
return false; const labAttentionItems = useMemo(
}, [previewTreatment, selectedAppointmentId, workspaceMode, selectedPreviewId]); () =>
collectLabDispatchAttention(
labDependentCodes,
currentDraftPreview,
history,
selectedAppointmentId,
),
[labDependentCodes, currentDraftPreview, history, selectedAppointmentId],
);
const hydrateFromTreatment = useCallback((treatment: PastTreatment) => { const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
const mapped = treatment.details.map(mapDetailFromApi); const mapped = treatment.details.map(mapDetailFromApi);
@@ -417,11 +443,6 @@ export function TreatmentWorkspace({
setSaveStatus('idle'); 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 selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const wholePlanTeethSet = useMemo(() => { const wholePlanTeethSet = useMemo(() => {
@@ -459,10 +480,6 @@ export function TreatmentWorkspace({
setActiveLabCaseId(match?.clientId ?? null); setActiveLabCaseId(match?.clientId ?? null);
}, [activeDetailId, labCaseDrafts]); }, [activeDetailId, labCaseDrafts]);
useEffect(() => {
setSelectionLocked(false);
}, [selectedDay]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setApptsLoading(true); setApptsLoading(true);
@@ -550,28 +567,34 @@ export function TreatmentWorkspace({
setHistoryLoading(false); setHistoryLoading(false);
return; return;
} }
setHistoryPatientId(selectedAppointment.patientId); const nextPatientId = selectedAppointment.patientId;
setHistoryPatientId((prev) => {
if (prev !== nextPatientId) {
setHistory([]);
setHistoryLoading(true);
}
return nextPatientId;
});
}, [selectedAppointment?.patientId]); }, [selectedAppointment?.patientId]);
useEffect(() => { useEffect(() => {
if (!historyPatientId) return; if (!historyPatientId) return;
let cancelled = false; const requestId = ++historyRequestRef.current;
setHistoryLoading(true); setHistoryLoading(true);
void (async () => { void (async () => {
try { try {
const response = await treatmentsApi.listPatientHistory(historyPatientId); const response = await treatmentsApi.listPatientHistory(historyPatientId, 50);
if (!cancelled) setHistory(response.data); if (requestId !== historyRequestRef.current) return;
setHistory(response.data);
} catch (error: unknown) { } catch (error: unknown) {
if (!cancelled) { if (requestId !== historyRequestRef.current) return;
showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
}
} finally { } finally {
if (!cancelled) setHistoryLoading(false); if (requestId === historyRequestRef.current) {
setHistoryLoading(false);
}
} }
})(); })();
return () => {
cancelled = true;
};
}, [historyPatientId, showError, t]); }, [historyPatientId, showError, t]);
useEffect(() => { 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, { const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
details: currentDetails.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({ details: currentDetails.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
clientId, clientId,
@@ -674,6 +707,18 @@ export function TreatmentWorkspace({
[selectedAppointment, t], [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 () => { const runDraftSave = useCallback(async () => {
if (!selectedAppointment || saveInFlightRef.current) { if (!selectedAppointment || saveInFlightRef.current) {
if (saveInFlightRef.current) saveQueuedRef.current = true; if (saveInFlightRef.current) saveQueuedRef.current = true;
@@ -681,7 +726,8 @@ export function TreatmentWorkspace({
} }
if ( if (
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current) !isDetailsDirty(detailsRef.current, savedSnapshotRef.current) ||
!areDetailsPersistable(detailsRef.current)
) { ) {
return; return;
} }
@@ -691,6 +737,9 @@ export function TreatmentWorkspace({
try { try {
await persistDraft(); await persistDraft();
setSaveStatus('saved'); setSaveStatus('saved');
if (historyPatientId) {
await refreshHistory(historyPatientId);
}
} catch (error: unknown) { } catch (error: unknown) {
setSaveStatus('error'); setSaveStatus('error');
showError(getUserFacingError(error, tErrors, t('errorSaveDraft'))); showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
@@ -704,16 +753,7 @@ export function TreatmentWorkspace({
} }
} }
} }
}, [selectedAppointment, persistDraft, showError, t]); }, [selectedAppointment, persistDraft, showError, t, historyPatientId, refreshHistory]);
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]);
const flushDraftSave = useCallback(async (): Promise<boolean> => { const flushDraftSave = useCallback(async (): Promise<boolean> => {
if (autosaveTimerRef.current) { if (autosaveTimerRef.current) {
@@ -735,14 +775,11 @@ export function TreatmentWorkspace({
try { try {
await runDraftSave(); await runDraftSave();
if (historyPatientId) {
await refreshHistory(historyPatientId);
}
return true; return true;
} catch { } catch {
return window.confirm(t('confirmDiscard')); return window.confirm(t('confirmDiscard'));
} }
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, historyPatientId, refreshHistory, t]); }, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]);
useEffect(() => { useEffect(() => {
if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) { if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) {
@@ -787,6 +824,8 @@ export function TreatmentWorkspace({
[flushDraftSave, resetToLiveContext], [flushDraftSave, resetToLiveContext],
); );
const canAccessOrganizations = canAccessDashboardRoute(currentOrganization, '/organizations');
const onSelectDay = useCallback( const onSelectDay = useCallback(
(day: Date) => { (day: Date) => {
void (async () => { void (async () => {
@@ -794,7 +833,8 @@ export function TreatmentWorkspace({
if (!ok) return; if (!ok) return;
const patientIdToRefresh = historyPatientId; const patientIdToRefresh = historyPatientId;
resetToLiveContext(); resetToLiveContext();
setSelectedDay(day); setSelectionLocked(false);
setSelectedDay(startOfLocalDay(day));
if (patientIdToRefresh) { if (patientIdToRefresh) {
await refreshHistory(patientIdToRefresh); await refreshHistory(patientIdToRefresh);
} }
@@ -807,22 +847,23 @@ export function TreatmentWorkspace({
setSelectedPreviewId(treatment.id); setSelectedPreviewId(treatment.id);
}, []); }, []);
const handleOpenTreatment = useCallback(() => { const exitBrowse = useCallback(() => {
void (async () => { setSelectedPreviewId(null);
const treatment = previewTreatment; }, []);
if (!treatment?.appointmentId) {
const loadTreatmentIntoWorkspace = useCallback(
async (treatment: PastTreatment, focusDetailClientId?: string) => {
if (!treatment.appointmentId) {
showError(t('errorNoAppointmentForTreatment')); showError(t('errorNoAppointmentForTreatment'));
return; return false;
} }
if (isPreviewAlreadyOpen) return;
const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true; const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true;
if (!ok) return; if (!ok) return false;
const isHistorical = isTreatmentDayHistorical(treatment.treatmentAt, todayStart); const isHistorical = isTreatmentDayHistorical(treatment.treatmentAt, todayStart);
setWorkspaceMode(isHistorical ? 'historical' : 'live'); setWorkspaceMode(isHistorical ? 'historical' : 'live');
setSelectedPreviewId(treatment.id); setSelectedPreviewId(null);
setSelectedDay(startOfLocalDay(new Date(treatment.treatmentAt))); setSelectedDay(startOfLocalDay(new Date(treatment.treatmentAt)));
setSelectionLocked(true); setSelectionLocked(true);
setSelectedAppointmentId(treatment.appointmentId); setSelectedAppointmentId(treatment.appointmentId);
@@ -831,16 +872,58 @@ export function TreatmentWorkspace({
draftHydratingRef.current = true; draftHydratingRef.current = true;
hydrateFromTreatment(treatment); hydrateFromTreatment(treatment);
draftHydratingRef.current = false; draftHydratingRef.current = false;
})();
}, [ if (focusDetailClientId) {
previewTreatment, setActiveDetailId(focusDetailClientId);
isPreviewAlreadyOpen, const mappedLabCases = withoutEmptyLabCaseDrafts(
flushDraftSave, (treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
hydrateFromTreatment, );
showError, const linked = mappedLabCases.find(
t, (lc) => !lc.sentAt && lc.detailClientId === focusDetailClientId,
todayStart, );
]); 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( const uploadForDetail = useCallback(
async (detailClientId: string, files: FileList | File[]) => { async (detailClientId: string, files: FileList | File[]) => {
@@ -984,8 +1067,17 @@ export function TreatmentWorkspace({
} }
const activeDetail = details.find((d) => d.clientId === activeDetailId); const activeDetail = details.find((d) => d.clientId === activeDetailId);
const shouldIncludeActive = if (
Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)); activeDetail &&
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)
) {
showError(t('labShipmentBlockedBody'));
return;
}
const shouldIncludeActive = Boolean(
activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes),
);
const orphan = cleaned.find((lc) => !lc.sentAt && !lc.detailClientId); const orphan = cleaned.find((lc) => !lc.sentAt && !lc.detailClientId);
if (orphan && shouldIncludeActive) { if (orphan && shouldIncludeActive) {
@@ -1068,21 +1160,28 @@ export function TreatmentWorkspace({
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id); const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
const sentDetailClientId = labCase.detailClientId; const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
setDetails((prev) => if (draftResponse.data?.details?.length) {
prev.map((detail) => { const mapped = draftResponse.data.details.map(mapDetailFromApi);
if (detail.clientId !== sentDetailClientId) return detail; setDetails(mapped);
return { setSavedSnapshot(serializeDetails(mapped));
...detail, } else {
labCaseId: response.data.id, const sentDetailClientId = labCase.detailClientId;
sentAt: response.data.sentAt, setDetails((prev) =>
sends: response.data.sends, prev.map((detail) => {
sendToOrganizationIds: response.data.destinationOrganizationId if (detail.clientId !== sentDetailClientId) return detail;
? [response.data.destinationOrganizationId] return {
: detail.sendToOrganizationIds, ...detail,
}; labCaseId: response.data.id,
}), sentAt: response.data.sentAt,
); sends: response.data.sends,
sendToOrganizationIds: response.data.destinationOrganizationId
? [response.data.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
}),
);
}
setLabCaseDrafts((prev) => setLabCaseDrafts((prev) =>
prev.map((lc) => prev.map((lc) =>
@@ -1183,18 +1282,59 @@ export function TreatmentWorkspace({
</div> </div>
)} )}
<LabDispatchAttentionPanel
items={labAttentionItems}
treatmentCatalog={treatmentCatalog}
labDependentCodes={labDependentCodes}
orgs={orgs}
onGoToDispatch={handleGoToLabDispatch}
/>
{isBrowsing && previewTreatment ? (
<div className="rounded-[var(--radius-md)] border border-primary/40 bg-primary/5 px-3 py-3 space-y-3">
<p className="text-sm text-text-primary">
{t('browseBanner', {
date: new Date(previewTreatment.treatmentAt).toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
}),
})}
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button type="button" variant="primary" onClick={handleLoadIntoWorkspace}>
{t('loadIntoWorkspace')}
</Button>
<Button type="button" variant="ghost" onClick={exitBrowse}>
{t('backToCurrentDraft')}
</Button>
</div>
</div>
) : null}
<TreatmentPreviewCard <TreatmentPreviewCard
treatment={previewTreatment} treatment={previewTreatment}
heading={previewHeading}
labDependentCodes={labDependentCodes} labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog} treatmentCatalog={treatmentCatalog}
orgs={orgs} orgs={orgs}
openDisabled={isPreviewAlreadyOpen}
onOpen={handleOpenTreatment}
/> />
<PastTreatmentsPanel <PastTreatmentsPanel
items={historyPanelItems} items={historyPanelItems}
currentDraft={
workspaceMode === 'live' && !isBrowsing ? currentDraftPreview : null
}
patientName={
selectedAppointment
? `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`
: undefined
}
currentAppointmentId={selectedAppointmentId}
treatmentCatalog={treatmentCatalog} treatmentCatalog={treatmentCatalog}
labDependentCodes={labDependentCodes}
orgs={orgs}
loading={historyLoading} loading={historyLoading}
selectedPreviewId={selectedPreviewId} selectedPreviewId={selectedPreviewId}
onSelectTreatment={handleSelectPreviewTreatment} onSelectTreatment={handleSelectPreviewTreatment}
@@ -1208,15 +1348,12 @@ export function TreatmentWorkspace({
readOnly={showWholeTreatmentPlan} readOnly={showWholeTreatmentPlan}
headerControl={ headerControl={
details.length > 1 ? ( details.length > 1 ? (
<label className="flex items-center gap-2 text-[11px] text-text-muted cursor-pointer select-none"> <Checkbox
<input checked={showWholeTreatmentPlan}
type="checkbox" onChange={setShowWholeTreatmentPlan}
checked={showWholeTreatmentPlan} label={t('toothChartWholePlan')}
onChange={(e) => setShowWholeTreatmentPlan(e.target.checked)} className="text-[11px] [&_span:last-child]:text-[11px] [&_span:last-child]:text-text-muted"
className="rounded border-border" />
/>
{t('toothChartWholePlan')}
</label>
) : undefined ) : undefined
} }
onToggle={(fdi) => { onToggle={(fdi) => {
@@ -1254,8 +1391,12 @@ export function TreatmentWorkspace({
setActiveDetailId(next.clientId); setActiveDetailId(next.clientId);
}} }}
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])} onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
onCommentError={showError}
/> />
<div ref={labPanelRef}>
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
{showLabDispatchPanel ? (
<LabCasesDispatchPanel <LabCasesDispatchPanel
details={details} details={details}
activeDetailId={activeDetailId} activeDetailId={activeDetailId}
@@ -1288,7 +1429,11 @@ export function TreatmentWorkspace({
onAddLabCase={() => void handleAddLabCase()} onAddLabCase={() => void handleAddLabCase()}
onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)} onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)}
onCommentError={showError} onCommentError={showError}
canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')}
/> />
) : null}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -69,6 +69,11 @@ export interface LabCaseSendInfo {
/** @deprecated Use LabCaseSendInfo */ /** @deprecated Use LabCaseSendInfo */
export type TreatmentCaseSendInfo = LabCaseSendInfo; export type TreatmentCaseSendInfo = LabCaseSendInfo;
export interface LabCaseTaskProgress {
completed: number;
total: number;
}
export interface PastTreatmentDetail { export interface PastTreatmentDetail {
id: string; id: string;
clientId: string; clientId: string;
@@ -78,6 +83,7 @@ export interface PastTreatmentDetail {
attachmentMetas?: TreatmentAttachmentMeta[]; attachmentMetas?: TreatmentAttachmentMeta[];
labCaseId?: string | null; labCaseId?: string | null;
destinationOrganizationId?: string | null; destinationOrganizationId?: string | null;
taskProgress?: LabCaseTaskProgress | null;
sends?: LabCaseSendInfo[]; sends?: LabCaseSendInfo[];
sentAt?: string | null; sentAt?: string | null;
} }
@@ -131,6 +137,7 @@ export interface TreatmentDetailDraft {
comment: string; comment: string;
attachmentMetas: TreatmentAttachmentMeta[]; attachmentMetas: TreatmentAttachmentMeta[];
labCaseId?: string | null; labCaseId?: string | null;
taskProgress?: LabCaseTaskProgress | null;
sendToOrganizationIds: string[]; sendToOrganizationIds: string[];
sends?: LabCaseSendInfo[]; sends?: LabCaseSendInfo[];
sentAt?: string | null; sentAt?: string | null;