improvement/ux-overhaul up #61
40
.cursor/rules/treatment-workspace.mdc
Normal file
40
.cursor/rules/treatment-workspace.mdc
Normal 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`
|
||||
|
||||
190
.cursor/skills/treatment-workspace/SKILL.md
Normal file
190
.cursor/skills/treatment-workspace/SKILL.md
Normal 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 day’s schedule). Non-owners see plans where `Treatment.providerUserId` or linked `Appointment.providerUserId` matches the logged-in user; org owners see all saved plans for the patient. New saves set `Treatment.providerUserId` to the logged-in clinician.
|
||||
|
||||
|
||||
|
||||
## History filters (client-side only)
|
||||
|
||||
|
||||
|
||||
`PastTreatmentsPanel` filters **already-fetched** history — no extra API params.
|
||||
|
||||
- **Not shipped to lab** — show treatments that have at least one lab-dependent detail (prosthesis via `labDependentCodes`) with `!sentAt`.
|
||||
- **Date** — filter on `treatmentAt` matching that local calendar day.
|
||||
- When **not shipped** is on and workspace is live (not browsing), prepend a synthetic **current draft** row (`id: 'current-draft'`) if it has pending lab-dependent details.
|
||||
|
||||
Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`.
|
||||
|
||||
|
||||
|
||||
## Lab shipment without teeth
|
||||
|
||||
|
||||
|
||||
Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment.
|
||||
|
||||
- Inline banner in `TreatmentDetailsEditor` + `LabShipmentBlockedNotice` above dispatch when active detail qualifies (`isLabDependentDetailMissingTeeth`).
|
||||
- `handleAddLabCase` shows toast with `labShipmentBlockedBody`.
|
||||
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth).
|
||||
|
||||
|
||||
|
||||
## Lab case comments on details
|
||||
|
||||
|
||||
|
||||
Below each detail in the editor when the linked lab case is **in progress** (not all tasks completed):
|
||||
|
||||
- `canCommentOnDetailLabCase(detail)` — requires `sentAt`, `labCaseId`, and `!isLabCaseCompleted(taskProgress)`.
|
||||
- UI: `DetailLabCaseCommentsSection` → existing `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints.
|
||||
- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`.
|
||||
|
||||
|
||||
|
||||
## Appointments default selection
|
||||
|
||||
|
||||
|
||||
On today: in-progress slot first, else nearest start time to `now`. Other days: first appointment. Re-runs every 60s on today unless `selectionLocked`. Frontend filters appointments to `providerUserId === userId`.
|
||||
|
||||
|
||||
|
||||
**Today checkbox** (`ScheduleDayPicker`): unchecked when `selectedDay` is not today (e.g. after loading a historical treatment). Checking Today calls `onSelectDay(today)` which unlocks selection (`selectionLocked = false`) and resets browse/historical context; appointments reload and auto-select nearest to now.
|
||||
|
||||
|
||||
|
||||
## Lab org search (dispatch)
|
||||
|
||||
|
||||
|
||||
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.
|
||||
|
||||
|
||||
|
||||
Pattern mirrors `PatientSearchCombobox` in appointments.
|
||||
|
||||
|
||||
|
||||
## Scroll
|
||||
|
||||
|
||||
|
||||
Use `scrollWithinMainScrollContainer()` (not raw `scrollIntoView`) when jumping to lab dispatch panel — dashboard `<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.
|
||||
|
||||
@@ -38,6 +38,13 @@ frontend/src/
|
||||
|
||||
**Example thin page:** `app/.../treatment/page.tsx` → imports `TreatmentWorkspace` from `components/ui/treatment/`.
|
||||
|
||||
**Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow.
|
||||
|
||||
**Treatment lab rules (quick ref):**
|
||||
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add.
|
||||
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
|
||||
- **Lab case comments** on a detail when sent and lab case tasks are not all `COMPLETED` (`taskProgress` from API).
|
||||
|
||||
## Backend layout
|
||||
|
||||
```
|
||||
@@ -59,6 +66,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never
|
||||
| Skill | When to use |
|
||||
|-------|-------------|
|
||||
| `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature |
|
||||
| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts |
|
||||
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |
|
||||
| `.cursor/skills/api-errors/` | New backend errors + frontend translations |
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LinkStatus } from '@prisma/client';
|
||||
import { LabTaskStatus, LinkStatus } from '@prisma/client';
|
||||
import { createReadStream, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
@@ -36,6 +36,7 @@ const treatmentInclude = {
|
||||
orderBy: [{ sentAt: 'asc' as const }],
|
||||
include: { organization: { select: { id: true, name: true } } },
|
||||
},
|
||||
tasks: { select: { id: true, status: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -64,6 +65,7 @@ const treatmentInclude = {
|
||||
},
|
||||
},
|
||||
},
|
||||
tasks: { select: { id: true, status: true } },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -124,14 +126,25 @@ export class TreatmentsService {
|
||||
await this.assertCanReadTreatment(actorUserId, organizationId);
|
||||
await this.ensurePatientExists(patientId);
|
||||
|
||||
const membership = await this.getMembership(actorUserId, organizationId);
|
||||
const isOwner = membership?.isOwner ?? false;
|
||||
|
||||
const items = await this.prisma.treatment.findMany({
|
||||
where: {
|
||||
patientId,
|
||||
organizationId,
|
||||
details: { some: {} },
|
||||
...(isOwner
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ providerUserId: actorUserId },
|
||||
{ appointment: { is: { providerUserId: actorUserId } } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
include: treatmentInclude,
|
||||
orderBy: [{ treatmentAt: 'desc' }],
|
||||
orderBy: [{ treatmentAt: 'desc' }, { createdAt: 'desc' }],
|
||||
take: Math.min(Math.max(limit, 1), 100),
|
||||
});
|
||||
|
||||
@@ -205,7 +218,7 @@ export class TreatmentsService {
|
||||
title,
|
||||
treatmentAt: appointment.startAt,
|
||||
patientId: appointment.patientId,
|
||||
providerUserId: appointment.providerUserId,
|
||||
providerUserId: actorUserId,
|
||||
},
|
||||
})
|
||||
: await tx.treatment.create({
|
||||
@@ -213,7 +226,7 @@ export class TreatmentsService {
|
||||
organizationId,
|
||||
patientId: appointment.patientId,
|
||||
appointmentId: appointment.id,
|
||||
providerUserId: appointment.providerUserId,
|
||||
providerUserId: actorUserId,
|
||||
title,
|
||||
treatmentAt: appointment.startAt,
|
||||
},
|
||||
@@ -566,6 +579,7 @@ export class TreatmentsService {
|
||||
include: { organization: { select: { id: true, name: true } } },
|
||||
},
|
||||
toothProsthesis: true,
|
||||
tasks: { select: { id: true, status: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -758,10 +772,12 @@ export class TreatmentsService {
|
||||
sentAt: Date;
|
||||
organization?: { id: string; name: string };
|
||||
}>;
|
||||
tasks?: Array<{ id: string; status: LabTaskStatus }>;
|
||||
};
|
||||
} | null;
|
||||
}) {
|
||||
const labCase = d.labCaseLink?.labCase;
|
||||
const taskProgress = this.mapTaskProgress(labCase?.tasks ?? []);
|
||||
return {
|
||||
id: d.id,
|
||||
clientId: d.clientKey ?? d.id,
|
||||
@@ -772,6 +788,7 @@ export class TreatmentsService {
|
||||
labCaseId: labCase?.id ?? null,
|
||||
sentAt: labCase?.sentAt?.toISOString() ?? null,
|
||||
destinationOrganizationId: labCase?.destinationOrganizationId ?? null,
|
||||
taskProgress,
|
||||
sends:
|
||||
labCase?.sends.map((s) => ({
|
||||
organizationId: s.organizationId,
|
||||
@@ -846,6 +863,12 @@ export class TreatmentsService {
|
||||
};
|
||||
}
|
||||
|
||||
private mapTaskProgress(tasks: Array<{ status: LabTaskStatus }>) {
|
||||
const total = tasks.length;
|
||||
const completed = tasks.filter((task) => task.status === LabTaskStatus.COMPLETED).length;
|
||||
return { completed, total };
|
||||
}
|
||||
|
||||
private mapAttachment(a: {
|
||||
id: string;
|
||||
fileName: string;
|
||||
|
||||
@@ -618,6 +618,8 @@
|
||||
"comments": "Comments",
|
||||
"commentsPlaceholder": "Write clinical notes for this case…",
|
||||
"treatmentType": "Treatment type",
|
||||
"treatmentTypePlaceholder": "Select treatment type…",
|
||||
"treatmentTypeNotSelected": "Type not selected",
|
||||
"typeConsultation": "consultation",
|
||||
"typeFilling": "filling",
|
||||
"typeEndo": "endo",
|
||||
@@ -630,6 +632,8 @@
|
||||
"searchOrgsPlaceholder": "Search active organizations...",
|
||||
"recent": "Recent:",
|
||||
"noOrgMatch": "No active organization matches your search.",
|
||||
"inviteLab": "Invite a lab",
|
||||
"noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.",
|
||||
"sendThisCase": "Send this case",
|
||||
"labDispatchTitle": "Lab dispatch",
|
||||
"labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.",
|
||||
@@ -663,10 +667,30 @@
|
||||
"saveStatusError": "Could not save — check your connection",
|
||||
"sendSavesFirst": "Sending is per case and saves first automatically.",
|
||||
"historyTitle": "Previous treatments",
|
||||
"historySubtitle": "Click a treatment to preview it. Use Open in the preview card to load it in the workspace.",
|
||||
"historyPatientScope": "Previous treatments for {patientName}",
|
||||
"historyCurrentAppointment": "This appointment",
|
||||
"historySubtitle": "Click a past plan to preview it, then load it into the workspace if needed.",
|
||||
"historyFilterNotShipped": "Not shipped to lab",
|
||||
"historyFilterDate": "Date",
|
||||
"historyClearFilters": "Remove filters",
|
||||
"historyFilterEmpty": "No treatments match these filters.",
|
||||
"labShipmentBlockedTitle": "Lab shipment not available yet",
|
||||
"labShipmentBlockedBody": "Select at least one tooth on this prosthesis detail before you can create a lab shipment.",
|
||||
"labCaseCommentsTitle": "Lab case comments",
|
||||
"labCaseCommentsHint": "Message the lab while this case is still in progress. Comments close when all lab tasks are completed.",
|
||||
"loadingHistory": "Loading history…",
|
||||
"historyEmpty": "No other treatments recorded for this patient yet.",
|
||||
"historyDetailLabel": "Detail {n} · {type}",
|
||||
"previewCurrentDraft": "Current draft",
|
||||
"previewBrowsingTitle": "Previewing saved plan",
|
||||
"browseBanner": "Viewing {date} — this plan is not loaded in the editor yet.",
|
||||
"loadIntoWorkspace": "Load into workspace",
|
||||
"backToCurrentDraft": "Back to current draft",
|
||||
"labAttentionTitle": "Lab dispatch needed",
|
||||
"labAttentionSubtitle": "These lab-dependent details have not been sent to a lab yet.",
|
||||
"labAttentionCurrentDraft": "Current appointment",
|
||||
"labAttentionGoDispatch": "Go to dispatch",
|
||||
"labAttentionLoadDispatch": "Load & dispatch",
|
||||
"previewTitle": "Treatment preview",
|
||||
"openTreatment": "Open",
|
||||
"selectAppointment": "Select an appointment to preview its treatment.",
|
||||
|
||||
@@ -619,6 +619,8 @@
|
||||
"comments": "نظرات",
|
||||
"commentsPlaceholder": "یادداشتهای بالینی این پرونده را بنویسید...",
|
||||
"treatmentType": "نوع درمان",
|
||||
"treatmentTypePlaceholder": "نوع درمان را انتخاب کنید…",
|
||||
"treatmentTypeNotSelected": "نوع انتخاب نشده",
|
||||
"typeConsultation": "مشاوره",
|
||||
"typeFilling": "پر کردن",
|
||||
"typeEndo": "درمان ریشه",
|
||||
@@ -631,6 +633,8 @@
|
||||
"searchOrgsPlaceholder": "جستجوی سازمانهای فعال...",
|
||||
"recent": "اخیر:",
|
||||
"noOrgMatch": "هیچ سازمان فعالی با جستجوی شما مطابقت ندارد.",
|
||||
"inviteLab": "دعوت از آزمایشگاه",
|
||||
"noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.",
|
||||
"sendThisCase": "ارسال این پرونده",
|
||||
"labDispatchTitle": "ارسال به لابراتوار",
|
||||
"labDispatchSubtitle": "جزئیات وابسته به لاب را در محمولهها گروهبندی کرده و به لابراتوارهای متصل ارسال کنید.",
|
||||
@@ -664,10 +668,30 @@
|
||||
"saveStatusError": "ذخیره ناموفق بود — اتصال را بررسی کنید",
|
||||
"sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره میکند.",
|
||||
"historyTitle": "درمانهای قبلی",
|
||||
"historySubtitle": "برای پیشنمایش روی یک درمان کلیک کنید. از دکمه باز کردن در کارت پیشنمایش برای بارگذاری در فضای کاری استفاده کنید.",
|
||||
"historyPatientScope": "درمانهای قبلی برای {patientName}",
|
||||
"historyCurrentAppointment": "این نوبت",
|
||||
"historySubtitle": "برای پیشنمایش روی یک طرح قبلی کلیک کنید، سپس در صورت نیاز آن را در فضای کار بارگذاری کنید.",
|
||||
"historyFilterNotShipped": "ارسالنشده به لابراتوار",
|
||||
"historyFilterDate": "تاریخ",
|
||||
"historyClearFilters": "حذف فیلترها",
|
||||
"historyFilterEmpty": "هیچ درمانی با این فیلترها یافت نشد.",
|
||||
"labShipmentBlockedTitle": "ارسال به لابراتوار هنوز ممکن نیست",
|
||||
"labShipmentBlockedBody": "قبل از ایجاد ارسال لابراتوار، حداقل یک دندان برای این جزئیات پروتز انتخاب کنید.",
|
||||
"labCaseCommentsTitle": "نظرات پرونده لابراتوار",
|
||||
"labCaseCommentsHint": "تا زمانی که پرونده در لابراتوار در حال انجام است با لابراتوار پیام بگذارید. پس از تکمیل همه کارها، نظردهی بسته میشود.",
|
||||
"loadingHistory": "در حال بارگذاری تاریخچه...",
|
||||
"historyEmpty": "هیچ درمان دیگری برای این بیمار ثبت نشده است.",
|
||||
"historyDetailLabel": "جزئیات {n} · {type}",
|
||||
"previewCurrentDraft": "پیشنویس فعلی",
|
||||
"previewBrowsingTitle": "پیشنمایش طرح ذخیرهشده",
|
||||
"browseBanner": "در حال مشاهده {date} — این طرح هنوز در ویرایشگر بارگذاری نشده است.",
|
||||
"loadIntoWorkspace": "بارگذاری در فضای کاری",
|
||||
"backToCurrentDraft": "بازگشت به پیشنویس فعلی",
|
||||
"labAttentionTitle": "نیاز به ارسال به لابراتوار",
|
||||
"labAttentionSubtitle": "این جزئیات وابسته به لاب هنوز به لابراتوار ارسال نشدهاند.",
|
||||
"labAttentionCurrentDraft": "نوبت فعلی",
|
||||
"labAttentionGoDispatch": "رفتن به ارسال",
|
||||
"labAttentionLoadDispatch": "بارگذاری و ارسال",
|
||||
"previewTitle": "پیشنمایش درمان",
|
||||
"openTreatment": "باز کردن",
|
||||
"selectAppointment": "یک نوبت را برای پیشنمایش درمان انتخاب کنید.",
|
||||
|
||||
@@ -619,6 +619,8 @@
|
||||
"comments": "Opmerkingen",
|
||||
"commentsPlaceholder": "Schrijf klinische notities voor deze case...",
|
||||
"treatmentType": "Behandeltype",
|
||||
"treatmentTypePlaceholder": "Selecteer behandeltype…",
|
||||
"treatmentTypeNotSelected": "Type niet geselecteerd",
|
||||
"typeConsultation": "consult",
|
||||
"typeFilling": "vulling",
|
||||
"typeEndo": "endo",
|
||||
@@ -631,6 +633,8 @@
|
||||
"searchOrgsPlaceholder": "Zoek actieve organisaties...",
|
||||
"recent": "Recent:",
|
||||
"noOrgMatch": "Geen actieve organisatie komt overeen met uw zoekopdracht.",
|
||||
"inviteLab": "Lab uitnodigen",
|
||||
"noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.",
|
||||
"sendThisCase": "Verzend deze case",
|
||||
"labDispatchTitle": "Lab-dispatch",
|
||||
"labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.",
|
||||
@@ -664,10 +668,30 @@
|
||||
"saveStatusError": "Opslaan mislukt — controleer uw verbinding",
|
||||
"sendSavesFirst": "Verzenden is per case en slaat eerst automatisch op.",
|
||||
"historyTitle": "Eerdere behandelingen",
|
||||
"historySubtitle": "Klik op een behandeling om te bekijken. Gebruik Open in de voorbeeldkkaart om deze in de werkruimte te laden.",
|
||||
"historyPatientScope": "Eerdere behandelingen voor {patientName}",
|
||||
"historyCurrentAppointment": "Deze afspraak",
|
||||
"historySubtitle": "Klik op een eerdere planning voor een voorbeeld; laad deze indien nodig in de werkruimte.",
|
||||
"historyFilterNotShipped": "Niet naar lab verzonden",
|
||||
"historyFilterDate": "Datum",
|
||||
"historyClearFilters": "Filters verwijderen",
|
||||
"historyFilterEmpty": "Geen behandelingen komen overeen met deze filters.",
|
||||
"labShipmentBlockedTitle": "Labverzending nog niet beschikbaar",
|
||||
"labShipmentBlockedBody": "Selecteer minstens één tand voor dit prothesedetail voordat u een labverzending kunt aanmaken.",
|
||||
"labCaseCommentsTitle": "Opmerkingen labcase",
|
||||
"labCaseCommentsHint": "Stuur berichten naar het lab terwijl deze case nog in behandeling is. Opmerkingen sluiten wanneer alle labtaken zijn afgerond.",
|
||||
"loadingHistory": "Geschiedenis laden...",
|
||||
"historyEmpty": "Geen andere behandelingen voor deze patiënt geregistreerd.",
|
||||
"historyDetailLabel": "Detail {n} · {type}",
|
||||
"previewCurrentDraft": "Huidig concept",
|
||||
"previewBrowsingTitle": "Opgeslagen planning bekijken",
|
||||
"browseBanner": "U bekijkt {date} — deze planning is nog niet in de editor geladen.",
|
||||
"loadIntoWorkspace": "In werkruimte laden",
|
||||
"backToCurrentDraft": "Terug naar huidig concept",
|
||||
"labAttentionTitle": "Labverzending nodig",
|
||||
"labAttentionSubtitle": "Deze lab-afhankelijke details zijn nog niet naar een lab verzonden.",
|
||||
"labAttentionCurrentDraft": "Huidige afspraak",
|
||||
"labAttentionGoDispatch": "Naar verzending",
|
||||
"labAttentionLoadDispatch": "Laden & verzenden",
|
||||
"previewTitle": "Behandelvoorbeeld",
|
||||
"openTreatment": "Openen",
|
||||
"selectAppointment": "Selecteer een afspraak om de behandeling te bekijken.",
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
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">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
28
frontend/src/components/shared/scrollWithinMain.ts
Normal file
28
frontend/src/components/shared/scrollWithinMain.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
67
frontend/src/components/treatment/labDispatchAttention.ts
Normal file
67
frontend/src/components/treatment/labDispatchAttention.ts
Normal 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(),
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/treatment/treatmentDetailRules.ts
Normal file
106
frontend/src/components/treatment/treatmentDetailRules.ts
Normal 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);
|
||||
}
|
||||
49
frontend/src/components/treatment/treatmentHistoryFilters.ts
Normal file
49
frontend/src/components/treatment/treatmentHistoryFilters.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,9 @@ export const labSentBannerClass =
|
||||
export const labPendingBannerClass =
|
||||
'text-xs rounded-[var(--radius-sm)] border border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-2 py-1.5';
|
||||
|
||||
export const labBlockedBannerClass =
|
||||
'text-xs rounded-[var(--radius-sm)] border border-amber-500/35 bg-amber-500/5 text-amber-800 dark:text-amber-300 px-2 py-1.5';
|
||||
|
||||
export function autosaveStatusClass(status: 'dirty' | 'saving' | 'saved' | 'error'): string {
|
||||
switch (status) {
|
||||
case 'dirty':
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useToast } from '@/lib/hooks/useToast';
|
||||
import { Check, History, Trash2, UserPlus, X } from 'lucide-react';
|
||||
@@ -25,6 +26,7 @@ import { Input } from '@/components/ui/shared/Input';
|
||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||
import { Table } from '@/components/ui/shared/Table';
|
||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
|
||||
function formatOrganizationStatusLabel(status: string): string {
|
||||
if (!status) return status;
|
||||
@@ -45,6 +47,8 @@ export function OrganizationsPage() {
|
||||
const tErrors = useTranslations('errors');
|
||||
const tNav = useTranslations('nav');
|
||||
const tCommon = useTranslations('common');
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { currentOrganization } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
@@ -123,6 +127,14 @@ export function OrganizationsPage() {
|
||||
void loadList();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('action') !== 'invite-lab') {
|
||||
return;
|
||||
}
|
||||
setShowInviteForm(true);
|
||||
router.replace('/organizations');
|
||||
}, [searchParams, router]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const q = query.trim();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules';
|
||||
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
||||
import { labNotSentBadgeClass, labSentBadgeClass } from '@/components/treatment/treatmentStatusStyles';
|
||||
import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
|
||||
@@ -23,7 +24,7 @@ export function DetailLabSendBadge({
|
||||
}: DetailLabSendBadgeProps) {
|
||||
const t = useTranslations('treatment');
|
||||
|
||||
if (!labDependentCodes.has(detail.treatmentType)) {
|
||||
if (!isDetailTypeSelected(detail) || !labDependentCodes.has(detail.treatmentType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||
import { isDetailReadyForLabDispatch } from '@/components/treatment/treatmentDetailRules';
|
||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||
import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox';
|
||||
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
||||
@@ -30,6 +30,8 @@ interface LabCasesDispatchPanelProps {
|
||||
onOrganizationSearchChange: (value: string) => void;
|
||||
recentOrganizationIds: string[];
|
||||
onRecentOrganizationPick: (orgId: string) => void;
|
||||
canInviteLab?: boolean;
|
||||
onInviteLab?: () => void;
|
||||
sendBusyId: string | null;
|
||||
onAddLabCase: () => void;
|
||||
onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void;
|
||||
@@ -89,6 +91,8 @@ export function LabCasesDispatchPanel({
|
||||
onOrganizationSearchChange,
|
||||
recentOrganizationIds,
|
||||
onRecentOrganizationPick,
|
||||
canInviteLab = false,
|
||||
onInviteLab,
|
||||
sendBusyId,
|
||||
onAddLabCase,
|
||||
onSendLabCase,
|
||||
@@ -100,18 +104,13 @@ export function LabCasesDispatchPanel({
|
||||
const [pendingComment, setPendingComment] = useState('');
|
||||
|
||||
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
||||
const filteredOrganizations = (() => {
|
||||
const q = organizationSearch.trim().toLowerCase();
|
||||
if (!q) return activeLinkedOrganizations;
|
||||
return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q));
|
||||
})();
|
||||
const recentOrganizations = recentOrganizationIds
|
||||
.map((id) => activeLinkedOrganizations.find((o) => o.id === id))
|
||||
.filter(Boolean) as LinkedOrganizationOption[];
|
||||
|
||||
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null;
|
||||
const isLabDependentDetail = Boolean(
|
||||
activeDetail && labDependentCodes.has(activeDetail.treatmentType),
|
||||
activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes),
|
||||
);
|
||||
|
||||
const labCaseForActiveDetail =
|
||||
@@ -214,6 +213,14 @@ export function LabCasesDispatchPanel({
|
||||
updateActiveLabCase({ attachmentIds: [...set] });
|
||||
}
|
||||
|
||||
function handleSelectOrganization(org: LinkedOrganizationOption) {
|
||||
updateActiveLabCase({
|
||||
destinationOrganizationId: org.id,
|
||||
toothProsthesis: [],
|
||||
});
|
||||
setApplyAllProsthesis('');
|
||||
}
|
||||
|
||||
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
|
||||
|
||||
return (
|
||||
@@ -340,11 +347,16 @@ export function LabCasesDispatchPanel({
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
||||
<SearchBar
|
||||
embedded
|
||||
value={organizationSearch}
|
||||
onChange={onOrganizationSearchChange}
|
||||
placeholder={t('searchOrgsPlaceholder')}
|
||||
<LinkedOrganizationSearchCombobox
|
||||
search={organizationSearch}
|
||||
onSearchChange={onOrganizationSearchChange}
|
||||
organizations={activeLinkedOrganizations}
|
||||
selectedOrganizationId={activeLabCase.destinationOrganizationId}
|
||||
onSelectOrganization={handleSelectOrganization}
|
||||
disabled={disabled}
|
||||
canInviteLab={canInviteLab}
|
||||
onInviteLab={onInviteLab}
|
||||
noPermissionMessage={t('noOrgInvitePermission')}
|
||||
/>
|
||||
{recentOrganizations.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -362,28 +374,6 @@ export function LabCasesDispatchPanel({
|
||||
))}
|
||||
</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>
|
||||
|
||||
{prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? (
|
||||
@@ -411,7 +401,7 @@ export function LabCasesDispatchPanel({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto overscroll-x-contain">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-text-muted">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||
import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine';
|
||||
import type { PastTreatment } from '@/types/treatment';
|
||||
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
|
||||
import { filterTreatmentHistoryItems } from '@/components/treatment/treatmentHistoryFilters';
|
||||
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
interface PastTreatmentsPanelProps {
|
||||
items: PastTreatment[];
|
||||
currentDraft?: PastTreatment | null;
|
||||
patientName?: string;
|
||||
currentAppointmentId?: string | null;
|
||||
treatmentCatalog: TreatmentCatalogEntry[];
|
||||
labDependentCodes: Set<string>;
|
||||
orgs?: LinkedOrganizationOption[];
|
||||
loading?: boolean;
|
||||
selectedPreviewId?: string | null;
|
||||
onSelectTreatment?: (treatment: PastTreatment) => void;
|
||||
}
|
||||
|
||||
function formatHistoryTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function PastTreatmentsPanel({
|
||||
items,
|
||||
currentDraft = null,
|
||||
patientName,
|
||||
currentAppointmentId,
|
||||
treatmentCatalog,
|
||||
labDependentCodes,
|
||||
orgs,
|
||||
loading,
|
||||
selectedPreviewId,
|
||||
onSelectTreatment,
|
||||
}: PastTreatmentsPanelProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const [notShippedOnly, setNotShippedOnly] = useState(false);
|
||||
const [filterDate, setFilterDate] = useState('');
|
||||
|
||||
const hasActiveFilters = notShippedOnly || Boolean(filterDate);
|
||||
|
||||
const displayedItems = useMemo(
|
||||
() =>
|
||||
filterTreatmentHistoryItems(items, currentDraft, labDependentCodes, {
|
||||
notShippedOnly,
|
||||
date: filterDate,
|
||||
}),
|
||||
[items, currentDraft, labDependentCodes, notShippedOnly, filterDate],
|
||||
);
|
||||
|
||||
function clearFilters() {
|
||||
setNotShippedOnly(false);
|
||||
setFilterDate('');
|
||||
}
|
||||
|
||||
const filterInputClass = `${FORM_SELECT_CLASS} rounded-md px-2 py-1.5 text-xs min-w-[9.5rem]`;
|
||||
|
||||
return (
|
||||
<div className="surface-card p-4 space-y-3">
|
||||
<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>
|
||||
</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 && items.length === 0 && (
|
||||
<p className="text-sm text-text-muted">{t('historyEmpty')}</p>
|
||||
{!loading && displayedItems.length === 0 && (
|
||||
<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">
|
||||
{items.map((treatment) => {
|
||||
<div className="space-y-1.5 max-h-[min(420px,50vh)] overflow-y-auto overscroll-y-contain pr-1">
|
||||
{displayedItems.map((treatment) => {
|
||||
const isSelected = selectedPreviewId === treatment.id;
|
||||
const isCurrentAppointment =
|
||||
Boolean(currentAppointmentId) && treatment.appointmentId === currentAppointmentId;
|
||||
const isLiveDraft = treatment.id === 'current-draft';
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -61,28 +145,41 @@ export function PastTreatmentsPanel({
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<time
|
||||
className="text-xs font-medium text-text-primary tabular-nums block"
|
||||
className="text-xs font-medium text-text-primary tabular-nums"
|
||||
dateTime={treatment.treatmentAt}
|
||||
>
|
||||
{new Date(treatment.treatmentAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{formatHistoryTimestamp(treatment.treatmentAt)}
|
||||
</time>
|
||||
{isLiveDraft ? (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-primary">
|
||||
{t('labAttentionCurrentDraft')}
|
||||
</span>
|
||||
) : isCurrentAppointment ? (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-primary">
|
||||
{t('historyCurrentAppointment')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{treatment.details.length === 0 ? (
|
||||
<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">
|
||||
{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
|
||||
detail={detail}
|
||||
detailNumber={idx + 1}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
/>
|
||||
<DetailLabSendBadge
|
||||
detail={detail}
|
||||
labDependentCodes={labDependentCodes}
|
||||
orgs={orgs}
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
|
||||
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
||||
import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules';
|
||||
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
|
||||
@@ -39,10 +40,16 @@ export function TreatmentDetailSummaryRow({
|
||||
<span className={`text-text-muted tabular-nums ${compact ? 'text-[11px]' : 'text-xs'}`}>
|
||||
{t('detailLabel', { n: detailNumber })}
|
||||
</span>
|
||||
{isDetailTypeSelected(detail) ? (
|
||||
<TreatmentTypeBadge
|
||||
type={detail.treatmentType}
|
||||
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
|
||||
/>
|
||||
) : (
|
||||
<span className={`text-text-muted italic ${compact ? 'text-[11px]' : 'text-xs'}`}>
|
||||
{t('treatmentTypeNotSelected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DetailLabSendBadge detail={detail} labDependentCodes={labDependentCodes} orgs={orgs} />
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,14 @@ import {
|
||||
import type { TreatmentDetailDraft } from '@/types/treatment';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
|
||||
import {
|
||||
canCommentOnDetailLabCase,
|
||||
isDetailReadyForLabDispatch,
|
||||
isDetailTypeSelected,
|
||||
isLabDependentDetailMissingTeeth,
|
||||
} from '@/components/treatment/treatmentDetailRules';
|
||||
import { DetailLabCaseCommentsSection } from '@/components/ui/treatment/DetailLabCaseCommentsSection';
|
||||
import { labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
|
||||
|
||||
interface TreatmentDetailsEditorProps {
|
||||
details: TreatmentDetailDraft[];
|
||||
@@ -27,6 +35,7 @@ interface TreatmentDetailsEditorProps {
|
||||
uploadBusy: boolean;
|
||||
onAddDetail: () => void;
|
||||
onUploadFiles: (files: FileList | null) => void;
|
||||
onCommentError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function TreatmentDetailsEditor({
|
||||
@@ -43,6 +52,7 @@ export function TreatmentDetailsEditor({
|
||||
uploadBusy,
|
||||
onAddDetail,
|
||||
onUploadFiles,
|
||||
onCommentError,
|
||||
}: TreatmentDetailsEditorProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -52,12 +62,19 @@ export function TreatmentDetailsEditor({
|
||||
|
||||
const locked = isDetailLocked(activeDetail);
|
||||
const readOnly = disabled || locked;
|
||||
const treatmentTypeTextColor = treatmentTypeColor(
|
||||
const treatmentTypeTextColor = isDetailTypeSelected(activeDetail)
|
||||
? treatmentTypeColor(
|
||||
activeDetail.treatmentType,
|
||||
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
|
||||
)
|
||||
: undefined;
|
||||
const showPendingLabHint =
|
||||
isDetailReadyForLabDispatch(activeDetail, labDependentCodes) && !locked && !readOnly;
|
||||
const showMissingTeethLabBlock = isLabDependentDetailMissingTeeth(
|
||||
activeDetail,
|
||||
labDependentCodes,
|
||||
);
|
||||
const isLabDependent = labDependentCodes.has(activeDetail.treatmentType);
|
||||
const showPendingLabHint = isLabDependent && !locked && !readOnly;
|
||||
const showLabCaseComments = canCommentOnDetailLabCase(activeDetail);
|
||||
|
||||
return (
|
||||
<div className="surface-card p-3 sm:p-4 space-y-4">
|
||||
@@ -107,6 +124,9 @@ export function TreatmentDetailsEditor({
|
||||
{showPendingLabHint && (
|
||||
<p className={labPendingBannerClass}>{t('detailPendingLabSend')}</p>
|
||||
)}
|
||||
{showMissingTeethLabBlock && (
|
||||
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
|
||||
)}
|
||||
|
||||
<label className="block text-xs font-medium text-text-secondary">
|
||||
{t('comments')}
|
||||
@@ -140,6 +160,7 @@ export function TreatmentDetailsEditor({
|
||||
disabled={readOnly}
|
||||
style={{ color: treatmentTypeTextColor }}
|
||||
>
|
||||
<option value="">{t('treatmentTypePlaceholder')}</option>
|
||||
{treatmentCatalog.map((entry, index) => (
|
||||
<option
|
||||
key={entry.code}
|
||||
@@ -192,6 +213,14 @@ export function TreatmentDetailsEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLabCaseComments ? (
|
||||
<DetailLabCaseCommentsSection
|
||||
detail={activeDetail}
|
||||
canPost={canEdit && !disabled}
|
||||
onError={onCommentError}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{canEdit && saveStatus !== 'idle' && (
|
||||
<p
|
||||
className={`text-xs pt-2 border-t border-border/60 ${autosaveStatusClass(saveStatus)}`}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
||||
import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules';
|
||||
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { PastTreatmentDetail } from '@/types/treatment';
|
||||
@@ -24,10 +25,14 @@ export function TreatmentHistoryDetailLine({
|
||||
return (
|
||||
<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>
|
||||
{isDetailTypeSelected(detail) ? (
|
||||
<TreatmentTypeBadge
|
||||
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>
|
||||
{attachmentCount > 0 && (
|
||||
<span className="text-text-muted shrink-0 tabular-nums">
|
||||
|
||||
@@ -1,58 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow';
|
||||
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
interface TreatmentPreviewCardProps {
|
||||
treatment: PastTreatment | null;
|
||||
/** e.g. "Current draft" or a formatted date label while browsing. */
|
||||
heading: string;
|
||||
labDependentCodes: Set<string>;
|
||||
treatmentCatalog: TreatmentCatalogEntry[];
|
||||
orgs?: LinkedOrganizationOption[];
|
||||
openDisabled?: boolean;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export function TreatmentPreviewCard({
|
||||
treatment,
|
||||
heading,
|
||||
labDependentCodes,
|
||||
treatmentCatalog,
|
||||
orgs,
|
||||
openDisabled = false,
|
||||
onOpen,
|
||||
}: TreatmentPreviewCardProps) {
|
||||
const t = useTranslations('treatment');
|
||||
|
||||
return (
|
||||
<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">{t('previewTitle')}</h3>
|
||||
<Button type="button" variant="primary" disabled={openDisabled || !treatment} onClick={onOpen}>
|
||||
{t('openTreatment')}
|
||||
</Button>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{heading}</h3>
|
||||
|
||||
{!treatment ? (
|
||||
<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="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-text-primary">{treatment.title}</p>
|
||||
{treatment.id !== 'current-draft' ? (
|
||||
<time
|
||||
className="text-xs text-text-muted tabular-nums shrink-0"
|
||||
className="text-xs text-text-muted tabular-nums block"
|
||||
dateTime={treatment.treatmentAt}
|
||||
>
|
||||
{new Date(treatment.treatmentAt).toLocaleDateString()}
|
||||
{new Date(treatment.treatmentAt).toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2 max-h-[min(280px,40vh)] overflow-y-auto pr-1">
|
||||
{treatment.details.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">{t('noDetails')}</p>
|
||||
) : (
|
||||
treatment.details.map((detail, idx) => (
|
||||
<TreatmentDetailSummaryRow
|
||||
key={detail.clientId ?? detail.id}
|
||||
key={`${detail.clientId ?? detail.id}-${idx}`}
|
||||
detail={detail}
|
||||
detailNumber={idx + 1}
|
||||
labDependentCodes={labDependentCodes}
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
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 { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||
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 { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
|
||||
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 { treatmentsApi } from '@/lib/api/treatments';
|
||||
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 { useToast } from '@/lib/hooks/useToast';
|
||||
import type { Organization } from '@/types/organization';
|
||||
@@ -116,24 +130,13 @@ function buildWorkspaceSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
function defaultTreatmentTypeForAppointment(
|
||||
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 {
|
||||
function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft {
|
||||
return {
|
||||
clientId:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
treatmentType: defaultTreatmentType ?? 'restoration',
|
||||
treatmentType: defaultTreatmentType ?? '',
|
||||
teeth: [],
|
||||
comment: '',
|
||||
attachmentMetas: [],
|
||||
@@ -179,6 +182,7 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
|
||||
comment: d.notes ?? '',
|
||||
attachmentMetas: d.attachmentMetas ?? [],
|
||||
labCaseId: d.labCaseId ?? null,
|
||||
taskProgress: d.taskProgress ?? null,
|
||||
sendToOrganizationIds: d.destinationOrganizationId ? [d.destinationOrganizationId] : [],
|
||||
sends: d.sends ?? [],
|
||||
sentAt: d.sentAt ?? null,
|
||||
@@ -220,7 +224,7 @@ function isDetailsDirty(
|
||||
savedSnapshot: string | null,
|
||||
): boolean {
|
||||
if (savedSnapshot === null) {
|
||||
return details.length !== 1 || details[0].comment !== '' || details[0].teeth.length > 0;
|
||||
return details.some((d) => !isEmptyDraftDetail(d)) || details.length > 1;
|
||||
}
|
||||
return serializeDetails(details) !== savedSnapshot;
|
||||
}
|
||||
@@ -242,6 +246,7 @@ function detailsToPreviewTreatment(
|
||||
notes: d.comment || null,
|
||||
attachmentMetas: d.attachmentMetas,
|
||||
labCaseId: d.labCaseId ?? null,
|
||||
taskProgress: d.taskProgress ?? null,
|
||||
destinationOrganizationId: d.sendToOrganizationIds[0] ?? null,
|
||||
sends: d.sends ?? [],
|
||||
sentAt: d.sentAt ?? null,
|
||||
@@ -316,6 +321,8 @@ export function TreatmentWorkspace({
|
||||
labCaseDraftsRef.current = labCaseDrafts;
|
||||
const skipNextGetDraftRef = useRef(false);
|
||||
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
||||
const labPanelRef = useRef<HTMLDivElement>(null);
|
||||
const historyRequestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
pendingAppointmentIdRef.current = initialAppointmentId;
|
||||
@@ -360,18 +367,25 @@ export function TreatmentWorkspace({
|
||||
!isViewingPastDay &&
|
||||
workspaceMode === 'live';
|
||||
|
||||
const historyPanelItems = useMemo(() => {
|
||||
return history.filter((item) => {
|
||||
if (
|
||||
workspaceMode === 'live' &&
|
||||
selectedAppointmentId &&
|
||||
item.appointmentId === selectedAppointmentId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [history, selectedAppointmentId, workspaceMode]);
|
||||
const historyPanelItems = history;
|
||||
|
||||
const activeDetail = useMemo(
|
||||
() => details.find((d) => d.clientId === activeDetailId) ?? details[0] ?? null,
|
||||
[details, activeDetailId],
|
||||
);
|
||||
|
||||
const showLabDispatchPanel = useMemo(
|
||||
() => details.some((d) => isDetailReadyForLabDispatch(d, labDependentCodes)),
|
||||
[details, labDependentCodes],
|
||||
);
|
||||
|
||||
const showLabShipmentBlocked = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
activeDetail && isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
|
||||
),
|
||||
[activeDetail, labDependentCodes],
|
||||
);
|
||||
|
||||
const currentDraftPreview = useMemo<PastTreatment | null>(() => {
|
||||
if (!selectedAppointment) return null;
|
||||
@@ -388,17 +402,29 @@ export function TreatmentWorkspace({
|
||||
|
||||
const previewTreatment = useMemo(() => {
|
||||
if (!selectedPreviewId) return currentDraftPreview;
|
||||
return historyPanelItems.find((item) => item.id === selectedPreviewId) ?? currentDraftPreview;
|
||||
}, [selectedPreviewId, historyPanelItems, currentDraftPreview]);
|
||||
return (
|
||||
history.find((item) => item.id === selectedPreviewId) ??
|
||||
historyPanelItems.find((item) => item.id === selectedPreviewId) ??
|
||||
currentDraftPreview
|
||||
);
|
||||
}, [selectedPreviewId, history, historyPanelItems, currentDraftPreview]);
|
||||
|
||||
const isPreviewAlreadyOpen = useMemo(() => {
|
||||
if (!previewTreatment?.appointmentId || !selectedAppointmentId) return false;
|
||||
if (selectedAppointmentId !== previewTreatment.appointmentId) return false;
|
||||
if (workspaceMode === 'historical') return true;
|
||||
if (workspaceMode === 'live' && selectedPreviewId === null) return true;
|
||||
if (workspaceMode === 'live' && selectedPreviewId === previewTreatment.id) return true;
|
||||
return false;
|
||||
}, [previewTreatment, selectedAppointmentId, workspaceMode, selectedPreviewId]);
|
||||
const isBrowsing = selectedPreviewId !== null;
|
||||
|
||||
const previewHeading = isBrowsing
|
||||
? t('previewBrowsingTitle')
|
||||
: t('previewCurrentDraft');
|
||||
|
||||
const labAttentionItems = useMemo(
|
||||
() =>
|
||||
collectLabDispatchAttention(
|
||||
labDependentCodes,
|
||||
currentDraftPreview,
|
||||
history,
|
||||
selectedAppointmentId,
|
||||
),
|
||||
[labDependentCodes, currentDraftPreview, history, selectedAppointmentId],
|
||||
);
|
||||
|
||||
const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
|
||||
const mapped = treatment.details.map(mapDetailFromApi);
|
||||
@@ -417,11 +443,6 @@ export function TreatmentWorkspace({
|
||||
setSaveStatus('idle');
|
||||
}, []);
|
||||
|
||||
const activeDetail = useMemo(
|
||||
() => details.find((d) => d.clientId === activeDetailId) ?? details[0],
|
||||
[details, activeDetailId],
|
||||
);
|
||||
|
||||
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
||||
|
||||
const wholePlanTeethSet = useMemo(() => {
|
||||
@@ -459,10 +480,6 @@ export function TreatmentWorkspace({
|
||||
setActiveLabCaseId(match?.clientId ?? null);
|
||||
}, [activeDetailId, labCaseDrafts]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionLocked(false);
|
||||
}, [selectedDay]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setApptsLoading(true);
|
||||
@@ -550,28 +567,34 @@ export function TreatmentWorkspace({
|
||||
setHistoryLoading(false);
|
||||
return;
|
||||
}
|
||||
setHistoryPatientId(selectedAppointment.patientId);
|
||||
const nextPatientId = selectedAppointment.patientId;
|
||||
setHistoryPatientId((prev) => {
|
||||
if (prev !== nextPatientId) {
|
||||
setHistory([]);
|
||||
setHistoryLoading(true);
|
||||
}
|
||||
return nextPatientId;
|
||||
});
|
||||
}, [selectedAppointment?.patientId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyPatientId) return;
|
||||
let cancelled = false;
|
||||
const requestId = ++historyRequestRef.current;
|
||||
setHistoryLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await treatmentsApi.listPatientHistory(historyPatientId);
|
||||
if (!cancelled) setHistory(response.data);
|
||||
const response = await treatmentsApi.listPatientHistory(historyPatientId, 50);
|
||||
if (requestId !== historyRequestRef.current) return;
|
||||
setHistory(response.data);
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
if (requestId !== historyRequestRef.current) return;
|
||||
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setHistoryLoading(false);
|
||||
if (requestId === historyRequestRef.current) {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [historyPatientId, showError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -652,6 +675,16 @@ export function TreatmentWorkspace({
|
||||
});
|
||||
}
|
||||
|
||||
if (!areDetailsPersistable(currentDetails)) {
|
||||
return detailsToPreviewTreatment(currentDetails, {
|
||||
title: t('treatmentPlanTitle', {
|
||||
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
|
||||
}),
|
||||
patientId: selectedAppointment.patientId,
|
||||
treatmentAt: selectedAppointment.startAt,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
|
||||
details: currentDetails.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
|
||||
clientId,
|
||||
@@ -674,6 +707,18 @@ export function TreatmentWorkspace({
|
||||
[selectedAppointment, t],
|
||||
);
|
||||
|
||||
const refreshHistory = useCallback(async (patientId: string) => {
|
||||
const requestId = ++historyRequestRef.current;
|
||||
try {
|
||||
const response = await treatmentsApi.listPatientHistory(patientId, 50);
|
||||
if (requestId !== historyRequestRef.current) return;
|
||||
setHistory(response.data);
|
||||
} catch (error: unknown) {
|
||||
if (requestId !== historyRequestRef.current) return;
|
||||
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
||||
}
|
||||
}, [showError, t]);
|
||||
|
||||
const runDraftSave = useCallback(async () => {
|
||||
if (!selectedAppointment || saveInFlightRef.current) {
|
||||
if (saveInFlightRef.current) saveQueuedRef.current = true;
|
||||
@@ -681,7 +726,8 @@ export function TreatmentWorkspace({
|
||||
}
|
||||
|
||||
if (
|
||||
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current)
|
||||
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current) ||
|
||||
!areDetailsPersistable(detailsRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -691,6 +737,9 @@ export function TreatmentWorkspace({
|
||||
try {
|
||||
await persistDraft();
|
||||
setSaveStatus('saved');
|
||||
if (historyPatientId) {
|
||||
await refreshHistory(historyPatientId);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setSaveStatus('error');
|
||||
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
|
||||
@@ -704,16 +753,7 @@ export function TreatmentWorkspace({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedAppointment, persistDraft, showError, t]);
|
||||
|
||||
const refreshHistory = useCallback(async (patientId: string) => {
|
||||
try {
|
||||
const response = await treatmentsApi.listPatientHistory(patientId);
|
||||
setHistory(response.data);
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
||||
}
|
||||
}, [showError, t]);
|
||||
}, [selectedAppointment, persistDraft, showError, t, historyPatientId, refreshHistory]);
|
||||
|
||||
const flushDraftSave = useCallback(async (): Promise<boolean> => {
|
||||
if (autosaveTimerRef.current) {
|
||||
@@ -735,14 +775,11 @@ export function TreatmentWorkspace({
|
||||
|
||||
try {
|
||||
await runDraftSave();
|
||||
if (historyPatientId) {
|
||||
await refreshHistory(historyPatientId);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return window.confirm(t('confirmDiscard'));
|
||||
}
|
||||
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, historyPatientId, refreshHistory, t]);
|
||||
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) {
|
||||
@@ -787,6 +824,8 @@ export function TreatmentWorkspace({
|
||||
[flushDraftSave, resetToLiveContext],
|
||||
);
|
||||
|
||||
const canAccessOrganizations = canAccessDashboardRoute(currentOrganization, '/organizations');
|
||||
|
||||
const onSelectDay = useCallback(
|
||||
(day: Date) => {
|
||||
void (async () => {
|
||||
@@ -794,7 +833,8 @@ export function TreatmentWorkspace({
|
||||
if (!ok) return;
|
||||
const patientIdToRefresh = historyPatientId;
|
||||
resetToLiveContext();
|
||||
setSelectedDay(day);
|
||||
setSelectionLocked(false);
|
||||
setSelectedDay(startOfLocalDay(day));
|
||||
if (patientIdToRefresh) {
|
||||
await refreshHistory(patientIdToRefresh);
|
||||
}
|
||||
@@ -807,22 +847,23 @@ export function TreatmentWorkspace({
|
||||
setSelectedPreviewId(treatment.id);
|
||||
}, []);
|
||||
|
||||
const handleOpenTreatment = useCallback(() => {
|
||||
void (async () => {
|
||||
const treatment = previewTreatment;
|
||||
if (!treatment?.appointmentId) {
|
||||
const exitBrowse = useCallback(() => {
|
||||
setSelectedPreviewId(null);
|
||||
}, []);
|
||||
|
||||
const loadTreatmentIntoWorkspace = useCallback(
|
||||
async (treatment: PastTreatment, focusDetailClientId?: string) => {
|
||||
if (!treatment.appointmentId) {
|
||||
showError(t('errorNoAppointmentForTreatment'));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPreviewAlreadyOpen) return;
|
||||
|
||||
const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true;
|
||||
if (!ok) return;
|
||||
if (!ok) return false;
|
||||
|
||||
const isHistorical = isTreatmentDayHistorical(treatment.treatmentAt, todayStart);
|
||||
setWorkspaceMode(isHistorical ? 'historical' : 'live');
|
||||
setSelectedPreviewId(treatment.id);
|
||||
setSelectedPreviewId(null);
|
||||
setSelectedDay(startOfLocalDay(new Date(treatment.treatmentAt)));
|
||||
setSelectionLocked(true);
|
||||
setSelectedAppointmentId(treatment.appointmentId);
|
||||
@@ -831,16 +872,58 @@ export function TreatmentWorkspace({
|
||||
draftHydratingRef.current = true;
|
||||
hydrateFromTreatment(treatment);
|
||||
draftHydratingRef.current = false;
|
||||
})();
|
||||
}, [
|
||||
previewTreatment,
|
||||
isPreviewAlreadyOpen,
|
||||
flushDraftSave,
|
||||
hydrateFromTreatment,
|
||||
showError,
|
||||
t,
|
||||
todayStart,
|
||||
]);
|
||||
|
||||
if (focusDetailClientId) {
|
||||
setActiveDetailId(focusDetailClientId);
|
||||
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
||||
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
|
||||
);
|
||||
const linked = mappedLabCases.find(
|
||||
(lc) => !lc.sentAt && lc.detailClientId === focusDetailClientId,
|
||||
);
|
||||
if (linked) {
|
||||
setActiveLabCaseId(linked.clientId);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
scrollWithinMainScrollContainer(labPanelRef.current);
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[flushDraftSave, hydrateFromTreatment, showError, t, todayStart],
|
||||
);
|
||||
|
||||
const handleLoadIntoWorkspace = useCallback(() => {
|
||||
if (!previewTreatment) return;
|
||||
void loadTreatmentIntoWorkspace(previewTreatment);
|
||||
}, [loadTreatmentIntoWorkspace, previewTreatment]);
|
||||
|
||||
const handleGoToLabDispatch = useCallback(
|
||||
(item: LabDispatchAttentionItem) => {
|
||||
if (item.isCurrentDraft) {
|
||||
exitBrowse();
|
||||
setActiveDetailId(item.detailClientId);
|
||||
const linked = labCaseDrafts.find(
|
||||
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
|
||||
);
|
||||
if (linked) {
|
||||
setActiveLabCaseId(linked.clientId);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
scrollWithinMainScrollContainer(labPanelRef.current);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const treatment =
|
||||
history.find((entry) => entry.id === item.treatmentId) ??
|
||||
historyPanelItems.find((entry) => entry.id === item.treatmentId);
|
||||
if (!treatment) return;
|
||||
void loadTreatmentIntoWorkspace(treatment, item.detailClientId);
|
||||
},
|
||||
[exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
|
||||
);
|
||||
|
||||
const uploadForDetail = useCallback(
|
||||
async (detailClientId: string, files: FileList | File[]) => {
|
||||
@@ -984,8 +1067,17 @@ export function TreatmentWorkspace({
|
||||
}
|
||||
|
||||
const activeDetail = details.find((d) => d.clientId === activeDetailId);
|
||||
const shouldIncludeActive =
|
||||
Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType));
|
||||
if (
|
||||
activeDetail &&
|
||||
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)
|
||||
) {
|
||||
showError(t('labShipmentBlockedBody'));
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldIncludeActive = Boolean(
|
||||
activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes),
|
||||
);
|
||||
|
||||
const orphan = cleaned.find((lc) => !lc.sentAt && !lc.detailClientId);
|
||||
if (orphan && shouldIncludeActive) {
|
||||
@@ -1068,6 +1160,12 @@ export function TreatmentWorkspace({
|
||||
|
||||
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
|
||||
|
||||
const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
|
||||
if (draftResponse.data?.details?.length) {
|
||||
const mapped = draftResponse.data.details.map(mapDetailFromApi);
|
||||
setDetails(mapped);
|
||||
setSavedSnapshot(serializeDetails(mapped));
|
||||
} else {
|
||||
const sentDetailClientId = labCase.detailClientId;
|
||||
setDetails((prev) =>
|
||||
prev.map((detail) => {
|
||||
@@ -1083,6 +1181,7 @@ export function TreatmentWorkspace({
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setLabCaseDrafts((prev) =>
|
||||
prev.map((lc) =>
|
||||
@@ -1183,18 +1282,59 @@ export function TreatmentWorkspace({
|
||||
</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
|
||||
treatment={previewTreatment}
|
||||
heading={previewHeading}
|
||||
labDependentCodes={labDependentCodes}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
orgs={orgs}
|
||||
openDisabled={isPreviewAlreadyOpen}
|
||||
onOpen={handleOpenTreatment}
|
||||
/>
|
||||
|
||||
<PastTreatmentsPanel
|
||||
items={historyPanelItems}
|
||||
currentDraft={
|
||||
workspaceMode === 'live' && !isBrowsing ? currentDraftPreview : null
|
||||
}
|
||||
patientName={
|
||||
selectedAppointment
|
||||
? `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`
|
||||
: undefined
|
||||
}
|
||||
currentAppointmentId={selectedAppointmentId}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
labDependentCodes={labDependentCodes}
|
||||
orgs={orgs}
|
||||
loading={historyLoading}
|
||||
selectedPreviewId={selectedPreviewId}
|
||||
onSelectTreatment={handleSelectPreviewTreatment}
|
||||
@@ -1208,15 +1348,12 @@ export function TreatmentWorkspace({
|
||||
readOnly={showWholeTreatmentPlan}
|
||||
headerControl={
|
||||
details.length > 1 ? (
|
||||
<label className="flex items-center gap-2 text-[11px] text-text-muted cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={showWholeTreatmentPlan}
|
||||
onChange={(e) => setShowWholeTreatmentPlan(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
onChange={setShowWholeTreatmentPlan}
|
||||
label={t('toothChartWholePlan')}
|
||||
className="text-[11px] [&_span:last-child]:text-[11px] [&_span:last-child]:text-text-muted"
|
||||
/>
|
||||
{t('toothChartWholePlan')}
|
||||
</label>
|
||||
) : undefined
|
||||
}
|
||||
onToggle={(fdi) => {
|
||||
@@ -1254,8 +1391,12 @@ export function TreatmentWorkspace({
|
||||
setActiveDetailId(next.clientId);
|
||||
}}
|
||||
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
||||
onCommentError={showError}
|
||||
/>
|
||||
|
||||
<div ref={labPanelRef}>
|
||||
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
|
||||
{showLabDispatchPanel ? (
|
||||
<LabCasesDispatchPanel
|
||||
details={details}
|
||||
activeDetailId={activeDetailId}
|
||||
@@ -1288,7 +1429,11 @@ export function TreatmentWorkspace({
|
||||
onAddLabCase={() => void handleAddLabCase()}
|
||||
onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)}
|
||||
onCommentError={showError}
|
||||
canInviteLab={canAccessOrganizations}
|
||||
onInviteLab={() => router.push('/organizations?action=invite-lab')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,11 @@ export interface LabCaseSendInfo {
|
||||
/** @deprecated Use LabCaseSendInfo */
|
||||
export type TreatmentCaseSendInfo = LabCaseSendInfo;
|
||||
|
||||
export interface LabCaseTaskProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PastTreatmentDetail {
|
||||
id: string;
|
||||
clientId: string;
|
||||
@@ -78,6 +83,7 @@ export interface PastTreatmentDetail {
|
||||
attachmentMetas?: TreatmentAttachmentMeta[];
|
||||
labCaseId?: string | null;
|
||||
destinationOrganizationId?: string | null;
|
||||
taskProgress?: LabCaseTaskProgress | null;
|
||||
sends?: LabCaseSendInfo[];
|
||||
sentAt?: string | null;
|
||||
}
|
||||
@@ -131,6 +137,7 @@ export interface TreatmentDetailDraft {
|
||||
comment: string;
|
||||
attachmentMetas: TreatmentAttachmentMeta[];
|
||||
labCaseId?: string | null;
|
||||
taskProgress?: LabCaseTaskProgress | null;
|
||||
sendToOrganizationIds: string[];
|
||||
sends?: LabCaseSendInfo[];
|
||||
sentAt?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user