improvement/ux-overhaul up #61

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

View File

@@ -0,0 +1,40 @@
---
description: Treatment workspace — preview vs form, history, load flow, lab dispatch
globs: frontend/src/components/ui/treatment/**,frontend/src/components/treatment/**,frontend/src/components/shared/treatmentSelection.ts,frontend/src/components/shared/scrollWithinMain.ts,backend/src/modules/treatments/**
alwaysApply: false
---
# Treatment workspace
- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load.
- **Current draft** preview: heading “Current draft”; no load button while editing live.
- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch.
- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all.
- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. No new fetch params.
- **Lab shipment block:** prosthesis/lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment.
- **Lab case comments:** `DetailLabCaseCommentsSection` under detail when sent and lab tasks not all complete (`taskProgress` from API).
- **Today checkbox:** unchecked when viewing a non-today day; checking Today unlocks selection and auto-picks nearest appointment.
- **Lab search:** `LinkedOrganizationSearchCombobox` — select from results; invite lab via `/organizations?action=invite-lab` when permitted.
- **Scroll:** use `scrollWithinMainScrollContainer`; shared `Checkbox` only.
Full map: `.cursor/skills/treatment-workspace/SKILL.md`

View File

@@ -0,0 +1,190 @@
---
name: dyolink-treatment-workspace
description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace.
---
# Treatment workspace
Main orchestrator: `frontend/src/components/ui/treatment/TreatmentWorkspace.tsx`
Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmentId=`).
## Layout (top → bottom)
1. **Appointments strip**`AppointmentsStrip.tsx` + `ScheduleDayPicker.tsx` (Today checkbox) + `pickAutoAppointment()` in `components/shared/treatmentSelection.ts`
2. **Treatment preview**`TreatmentPreviewCard.tsx` (read-only summary; no load button for current draft)
3. **Treatment history**`PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`)
4. **Editor**`TreatmentDetailsEditor.tsx`, `FdiToothChart.tsx`, `LabCasesDispatchPanel.tsx`
## Two layers of state (critical)
| Layer | State | Updated when |
|-------|--------|--------------|
| **Preview** | `previewTreatment`; `selectedPreviewId !== null` = **browse mode** | History click updates preview only |
| **Form** | `details[]`, `labCaseDrafts[]` | Appointment change → draft API; **Load into workspace** → hydrate |
**Browse mode:** banner + “Load into workspace” / “Back to current draft”. No Open button on preview card.
**Lab dispatch attention:** `LabDispatchAttentionPanel` lists lab-dependent unsent details (current draft + user history). “Go to dispatch” / “Load & dispatch”.
## History API
`GET /treatments/patients/:id/history` returns saved treatments for **that patient** (not the whole days schedule). Non-owners see plans where `Treatment.providerUserId` or linked `Appointment.providerUserId` matches the logged-in user; org owners see all saved plans for the patient. New saves set `Treatment.providerUserId` to the logged-in clinician.
## History filters (client-side only)
`PastTreatmentsPanel` filters **already-fetched** history — no extra API params.
- **Not shipped to lab** — show treatments that have at least one lab-dependent detail (prosthesis via `labDependentCodes`) with `!sentAt`.
- **Date** — filter on `treatmentAt` matching that local calendar day.
- When **not shipped** is on and workspace is live (not browsing), prepend a synthetic **current draft** row (`id: 'current-draft'`) if it has pending lab-dependent details.
Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`.
## Lab shipment without teeth
Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment.
- Inline banner in `TreatmentDetailsEditor` + `LabShipmentBlockedNotice` above dispatch when active detail qualifies (`isLabDependentDetailMissingTeeth`).
- `handleAddLabCase` shows toast with `labShipmentBlockedBody`.
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth).
## Lab case comments on details
Below each detail in the editor when the linked lab case is **in progress** (not all tasks completed):
- `canCommentOnDetailLabCase(detail)` — requires `sentAt`, `labCaseId`, and `!isLabCaseCompleted(taskProgress)`.
- UI: `DetailLabCaseCommentsSection` → existing `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints.
- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`.
## Appointments default selection
On today: in-progress slot first, else nearest start time to `now`. Other days: first appointment. Re-runs every 60s on today unless `selectionLocked`. Frontend filters appointments to `providerUserId === userId`.
**Today checkbox** (`ScheduleDayPicker`): unchecked when `selectedDay` is not today (e.g. after loading a historical treatment). Checking Today calls `onSelectDay(today)` which unlocks selection (`selectionLocked = false`) and resets browse/historical context; appointments reload and auto-select nearest to now.
## Lab org search (dispatch)
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.
Pattern mirrors `PatientSearchCombobox` in appointments.
## Scroll
Use `scrollWithinMainScrollContainer()` (not raw `scrollIntoView`) when jumping to lab dispatch panel — dashboard `<main>` is the scroll container; document scroll conflicts with `.app-web-bg { overflow: hidden }`.
Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driven scroll jumps.
## Backend APIs
| Endpoint | Purpose |
|----------|---------|
| `GET /appointments?from&to` | Strip |
| `GET /treatments/patients/:patientId/history` | History (patient + org; filtered by provider) |
| `GET /treatments/appointments/:id/draft` | Load form on appointment select |
| `PUT .../draft`, `PUT .../lab-cases` | Autosave (600ms debounce) |
Draft writes require provider match (`ensureAppointmentProvider`) unless org owner.
## Edit gating
```typescript
canEditTreatmentForDay = canEdit && selectedAppointment && !isViewingPastDay && workspaceMode === 'live'
```
## Optional fields
- `@IsOptional()` email: use `@Transform` empty string → `undefined` before `@IsEmail` (see patients DTO).
- Form validation → inline errors; transient feedback → global `useToast()` via `ToastProvider`.
## When changing history scope
Filter in **backend** `listPatientHistory` on patient + org; provider scoping for non-owners. History is **per selected patient**, not per day or all appointments on the strip.
**UI filters** (not shipped, date) are client-side only — do not add API params unless product explicitly requires server-side filtering.

View File

@@ -38,6 +38,13 @@ frontend/src/
**Example thin page:** `app/.../treatment/page.tsx` → imports `TreatmentWorkspace` from `components/ui/treatment/`.
**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 |

View File

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

View File

@@ -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.",

View File

@@ -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": "یک نوبت را برای پیش‌نمایش درمان انتخاب کنید.",

View File

@@ -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.",

View File

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

View File

@@ -0,0 +1,28 @@
/**
* Scroll an element into view using the dashboard `<main>` scroll container only.
* Avoids document-level scrollIntoView, which conflicts with `.app-web-bg { overflow: hidden }`.
*/
export function scrollWithinMainScrollContainer(
element: HTMLElement | null,
options?: { behavior?: ScrollBehavior; padding?: number },
): void {
if (!element) return;
const behavior = options?.behavior ?? 'smooth';
const padding = options?.padding ?? 16;
const main = element.closest('main');
if (!main) {
element.scrollIntoView({ behavior, block: 'nearest' });
return;
}
const mainRect = main.getBoundingClientRect();
const elRect = element.getBoundingClientRect();
if (elRect.top < mainRect.top + padding) {
main.scrollBy({ top: elRect.top - mainRect.top - padding, behavior });
} else if (elRect.bottom > mainRect.bottom - padding) {
main.scrollBy({ top: elRect.bottom - mainRect.bottom + padding, behavior });
}
}

View File

@@ -0,0 +1,67 @@
import type { PastTreatment, PastTreatmentDetail } from '@/types/treatment';
import { isDetailEligibleForLabAttention } from '@/components/treatment/treatmentDetailRules';
export type LabDispatchAttentionItem = {
key: string;
treatmentId: string;
appointmentId: string;
treatmentAt: string;
detailClientId: string;
detailNumber: number;
detail: PastTreatmentDetail;
isCurrentDraft: boolean;
};
function pushPendingDetails(
items: LabDispatchAttentionItem[],
treatment: PastTreatment,
labDependentCodes: Set<string>,
isCurrentDraft: boolean,
) {
if (!treatment.appointmentId) {
return;
}
treatment.details.forEach((detail, index) => {
if (
!isDetailEligibleForLabAttention(detail, labDependentCodes, isCurrentDraft)
) {
return;
}
items.push({
key: `${treatment.id}-${detail.clientId ?? detail.id}`,
treatmentId: treatment.id,
appointmentId: treatment.appointmentId!,
treatmentAt: treatment.treatmentAt,
detailClientId: detail.clientId ?? detail.id,
detailNumber: index + 1,
detail,
isCurrentDraft,
});
});
}
/** Lab-dependent details that still need a lab send (current draft + saved history). */
export function collectLabDispatchAttention(
labDependentCodes: Set<string>,
currentDraft: PastTreatment | null,
history: PastTreatment[],
currentAppointmentId: string | null,
): LabDispatchAttentionItem[] {
const items: LabDispatchAttentionItem[] = [];
if (currentDraft) {
pushPendingDetails(items, currentDraft, labDependentCodes, true);
}
for (const treatment of history) {
if (currentAppointmentId && treatment.appointmentId === currentAppointmentId) {
continue;
}
pushPendingDetails(items, treatment, labDependentCodes, false);
}
return items.sort(
(a, b) => new Date(b.treatmentAt).getTime() - new Date(a.treatmentAt).getTime(),
);
}

View File

@@ -0,0 +1,106 @@
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { TreatmentDetailDraft } from '@/types/treatment';
export type LabCaseTaskProgress = {
completed: number;
total: number;
};
type DetailLike = {
id?: string;
treatmentType: string;
teeth: readonly string[];
comment?: string;
sentAt?: string | null;
labCaseId?: string | null;
taskProgress?: LabCaseTaskProgress | null;
};
export function isAppointmentOnlyPurpose(
purpose: string | undefined,
catalog: TreatmentCatalogEntry[],
): boolean {
if (!purpose) return false;
const entry = catalog.find((e) => e.code === purpose);
return entry ? !entry.availableInTreatment : false;
}
/** Default detail type from appointment purpose — only when purpose is a real treatment type. */
export function defaultTreatmentTypeForAppointment(
purpose: string | undefined,
catalog: TreatmentCatalogEntry[],
): string | undefined {
if (!purpose || isAppointmentOnlyPurpose(purpose, catalog)) {
return undefined;
}
const treatmentOptions = catalog.filter((entry) => entry.availableInTreatment);
if (treatmentOptions.some((entry) => entry.code === purpose)) {
return purpose;
}
return undefined;
}
export function isDetailTypeSelected(detail: Pick<DetailLike, 'treatmentType'>): boolean {
return Boolean(detail.treatmentType?.trim());
}
export function isEmptyDraftDetail(detail: TreatmentDetailDraft): boolean {
return (
!isDetailTypeSelected(detail) &&
detail.teeth.length === 0 &&
!detail.comment.trim()
);
}
export function areDetailsPersistable(details: TreatmentDetailDraft[]): boolean {
return details.length > 0 && details.every((d) => isDetailTypeSelected(d));
}
/** Lab dispatch UI applies only to persisted prosthesis (or lab-dependent) lines with teeth. */
export function isDetailReadyForLabDispatch(
detail: DetailLike,
labDependentCodes: Set<string>,
): boolean {
return (
Boolean(detail.id) &&
isDetailTypeSelected(detail) &&
labDependentCodes.has(detail.treatmentType) &&
detail.teeth.length > 0
);
}
export function isDetailEligibleForLabAttention(
detail: DetailLike,
labDependentCodes: Set<string>,
isCurrentDraft: boolean,
): boolean {
if (detail.sentAt || !isDetailTypeSelected(detail)) return false;
if (!labDependentCodes.has(detail.treatmentType)) return false;
if (detail.teeth.length === 0) return false;
if (isCurrentDraft && !detail.id) return false;
return true;
}
/** Saved lab-dependent line with prosthesis type but no teeth — can save, cannot ship. */
export function isLabDependentDetailMissingTeeth(
detail: DetailLike,
labDependentCodes: Set<string>,
): boolean {
return (
Boolean(detail.id) &&
isDetailTypeSelected(detail) &&
labDependentCodes.has(detail.treatmentType) &&
detail.teeth.length === 0 &&
!detail.sentAt
);
}
export function isLabCaseCompleted(progress: LabCaseTaskProgress | null | undefined): boolean {
if (!progress || progress.total <= 0) return false;
return progress.completed >= progress.total;
}
/** Detail was sent to lab and the lab case still has open tasks. */
export function canCommentOnDetailLabCase(detail: DetailLike): boolean {
if (!detail.sentAt || !detail.labCaseId) return false;
return !isLabCaseCompleted(detail.taskProgress);
}

View File

@@ -0,0 +1,49 @@
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
import type { PastTreatment } from '@/types/treatment';
export type TreatmentHistoryFilters = {
notShippedOnly: boolean;
date: string;
};
export function treatmentHasUnshippedProsthesis(
treatment: PastTreatment,
labDependentCodes: Set<string>,
): boolean {
return treatment.details.some(
(detail) => labDependentCodes.has(detail.treatmentType) && !detail.sentAt,
);
}
export function treatmentMatchesHistoryDate(treatmentAt: string, date: string): boolean {
if (!date) return true;
const treatmentDay = startOfLocalDay(new Date(treatmentAt)).getTime();
const filterDay = startOfLocalDay(new Date(`${date}T00:00:00`)).getTime();
return treatmentDay === filterDay;
}
export function filterTreatmentHistoryItems(
history: PastTreatment[],
currentDraft: PastTreatment | null,
labDependentCodes: Set<string>,
filters: TreatmentHistoryFilters,
): PastTreatment[] {
const source = (() => {
if (!currentDraft) return history;
const withoutCurrentAppointment = currentDraft.appointmentId
? history.filter((item) => item.appointmentId !== currentDraft.appointmentId)
: history;
return [currentDraft, ...withoutCurrentAppointment];
})();
return source.filter((treatment) => {
if (!treatmentMatchesHistoryDate(treatment.treatmentAt, filters.date)) {
return false;
}
if (filters.notShippedOnly && !treatmentHasUnshippedProsthesis(treatment, labDependentCodes)) {
return false;
}
return true;
});
}

View File

@@ -10,6 +10,9 @@ export const labSentBannerClass =
export const labPendingBannerClass =
'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':

View File

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

View File

@@ -0,0 +1,46 @@
'use client';
import { useTranslations } from 'next-intl';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { treatmentsApi } from '@/lib/api/treatments';
import type { TreatmentDetailDraft } from '@/types/treatment';
interface DetailLabCaseCommentsSectionProps {
detail: TreatmentDetailDraft;
canPost: boolean;
onError?: (message: string) => void;
}
export function DetailLabCaseCommentsSection({
detail,
canPost,
onError,
}: DetailLabCaseCommentsSectionProps) {
const t = useTranslations('treatment');
const caseId = detail.labCaseId;
if (!caseId) return null;
return (
<div className="space-y-2 border-t border-border/60 pt-4">
<div>
<p className="text-xs font-medium text-text-secondary">{t('labCaseCommentsTitle')}</p>
<p className="text-[11px] text-text-muted mt-0.5">{t('labCaseCommentsHint')}</p>
</div>
<LabCaseCommentsPanel
caseId={caseId}
canPost={canPost}
canToggleVisibility={false}
loadComments={async () => {
const response = await treatmentsApi.listLabCaseComments(caseId);
return response.data;
}}
onPost={async (body) => {
const response = await treatmentsApi.addLabCaseComment(caseId, { body });
return response.data;
}}
onError={onError}
/>
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
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;
}

View File

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

View File

@@ -0,0 +1,106 @@
'use client';
import { useTranslations } from 'next-intl';
import { AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import type { LinkedOrganizationOption } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
interface LabDispatchAttentionPanelProps {
items: LabDispatchAttentionItem[];
treatmentCatalog: TreatmentCatalogEntry[];
labDependentCodes: Set<string>;
orgs?: LinkedOrganizationOption[];
onGoToDispatch: (item: LabDispatchAttentionItem) => void;
}
export function LabDispatchAttentionPanel({
items,
treatmentCatalog,
labDependentCodes,
orgs,
onGoToDispatch,
}: LabDispatchAttentionPanelProps) {
const t = useTranslations('treatment');
if (items.length === 0) {
return null;
}
return (
<div className="surface-card p-4 space-y-3 border border-amber-500/35 bg-amber-500/5">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5 icon-flat" />
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary">{t('labAttentionTitle')}</h3>
<p className="text-[11px] text-text-muted mt-0.5">{t('labAttentionSubtitle')}</p>
</div>
</div>
<ul className="space-y-2 max-h-[min(240px,35vh)] overflow-y-auto pr-1">
{items.map((item) => {
const teeth = item.detail.teeth.length
? [...item.detail.teeth].sort().join(', ')
: t('teethNone');
const dateLabel = new Date(item.treatmentAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return (
<li
key={item.key}
className="flex flex-col gap-2 rounded-[var(--radius-sm)] border border-border/60 bg-background-secondary/40 px-2.5 py-2 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
<time className="text-[11px] font-medium text-text-primary tabular-nums">
{dateLabel}
</time>
{item.isCurrentDraft ? (
<span className="text-[10px] uppercase tracking-wide text-primary font-medium">
{t('labAttentionCurrentDraft')}
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
<span className="text-[11px] text-text-muted tabular-nums">
{t('detailLabel', { n: item.detailNumber })}
</span>
<TreatmentTypeBadge
type={item.detail.treatmentType}
label={treatmentTypeLabelFromCatalog(
item.detail.treatmentType,
treatmentCatalog,
)}
/>
<DetailLabSendBadge
detail={item.detail}
labDependentCodes={labDependentCodes}
orgs={orgs}
/>
</div>
<p className="text-[11px] text-text-secondary truncate">
{t('teethLabel')} {teeth}
</p>
</div>
<Button
type="button"
variant="primary"
className="shrink-0 w-full sm:w-auto text-xs py-1.5"
onClick={() => onGoToDispatch(item)}
>
{item.isCurrentDraft ? t('labAttentionGoDispatch') : t('labAttentionLoadDispatch')}
</Button>
</li>
);
})}
</ul>
</div>
);
}

View File

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

View File

@@ -0,0 +1,109 @@
'use client';
import { Search } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import type { LinkedOrganizationOption } from '@/types/treatment';
interface LinkedOrganizationSearchComboboxProps {
search: string;
onSearchChange: (value: string) => void;
organizations: LinkedOrganizationOption[];
selectedOrganizationId?: string | null;
onSelectOrganization: (org: LinkedOrganizationOption) => void;
disabled?: boolean;
canInviteLab?: boolean;
onInviteLab?: () => void;
placeholder?: string;
emptyResultsMessage?: string;
noPermissionMessage?: string;
}
export function LinkedOrganizationSearchCombobox({
search,
onSearchChange,
organizations,
selectedOrganizationId,
onSelectOrganization,
disabled = false,
canInviteLab = false,
onInviteLab,
placeholder,
emptyResultsMessage,
noPermissionMessage,
}: LinkedOrganizationSearchComboboxProps) {
const t = useTranslations('treatment');
const trimmed = search.trim();
const showResults = !disabled && trimmed.length > 0;
const filtered = trimmed
? organizations.filter((o) => o.name.toLowerCase().includes(trimmed.toLowerCase()))
: [];
const selectedOrg = selectedOrganizationId
? organizations.find((o) => o.id === selectedOrganizationId)
: null;
function handleSelect(org: LinkedOrganizationOption) {
onSelectOrganization(org);
onSearchChange('');
}
return (
<div className="space-y-2">
<Input
placeholder={placeholder ?? t('searchOrgsPlaceholder')}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
disabled={disabled}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
{selectedOrg && !trimmed ? (
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
{selectedOrg.name}
</p>
) : null}
{showResults ? (
<div className="space-y-2 rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/30 p-2">
{filtered.length === 0 ? (
<div className="space-y-2 px-1 py-1">
<p className="text-sm text-text-muted">
{emptyResultsMessage ?? t('noOrgMatch')}
</p>
{canInviteLab && onInviteLab ? (
<Button type="button" variant="primary" onClick={onInviteLab} fullWidth>
{t('inviteLab')}
</Button>
) : noPermissionMessage ? (
<p className="text-xs text-text-muted">{noPermissionMessage}</p>
) : null}
</div>
) : (
<div className="max-h-48 space-y-1.5 overflow-y-auto overscroll-y-contain pr-1">
{filtered.map((org) => {
const isSelected = selectedOrganizationId === org.id;
return (
<button
key={org.id}
type="button"
onClick={() => handleSelect(org)}
className={`w-full rounded-[var(--radius-sm)] border px-2.5 py-2 text-left text-sm transition-colors ${
isSelected
? 'border-primary/60 bg-primary-soft'
: 'border-transparent hover:bg-background-card/70'
}`}
>
{org.name}
</button>
);
})}
</div>
)}
</div>
) : null}
</div>
);
}

View File

@@ -1,43 +1,127 @@
'use client';
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({
}
`}
>
<time
className="text-xs font-medium text-text-primary tabular-nums block"
dateTime={treatment.treatmentAt}
>
{new Date(treatment.treatmentAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
<div className="flex flex-wrap items-center gap-2">
<time
className="text-xs font-medium text-text-primary tabular-nums"
dateTime={treatment.treatmentAt}
>
{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>

View File

@@ -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>
<TreatmentTypeBadge
type={detail.treatmentType}
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
/>
{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>

View File

@@ -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(
activeDetail.treatmentType,
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
const treatmentTypeTextColor = isDetailTypeSelected(activeDetail)
? treatmentTypeColor(
activeDetail.treatmentType,
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
)
: undefined;
const showPendingLabHint =
isDetailReadyForLabDispatch(activeDetail, labDependentCodes) && !locked && !readOnly;
const showMissingTeethLabBlock = isLabDependentDetailMissingTeeth(
activeDetail,
labDependentCodes,
);
const isLabDependent = labDependentCodes.has(activeDetail.treatmentType);
const showPendingLabHint = isLabDependent && !locked && !readOnly;
const showLabCaseComments = canCommentOnDetailLabCase(activeDetail);
return (
<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)}`}

View File

@@ -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>
<TreatmentTypeBadge
type={detail.treatmentType}
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
/>
{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">

View File

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

View File

@@ -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) {
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
}
if (requestId !== historyRequestRef.current) return;
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
} finally {
if (!cancelled) setHistoryLoading(false);
if (requestId === historyRequestRef.current) {
setHistoryLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [historyPatientId, showError, t]);
useEffect(() => {
@@ -652,6 +675,16 @@ export function TreatmentWorkspace({
});
}
if (!areDetailsPersistable(currentDetails)) {
return detailsToPreviewTreatment(currentDetails, {
title: t('treatmentPlanTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
patientId: selectedAppointment.patientId,
treatmentAt: selectedAppointment.startAt,
});
}
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
details: currentDetails.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
clientId,
@@ -674,6 +707,18 @@ export function TreatmentWorkspace({
[selectedAppointment, t],
);
const refreshHistory = useCallback(async (patientId: string) => {
const requestId = ++historyRequestRef.current;
try {
const response = await treatmentsApi.listPatientHistory(patientId, 50);
if (requestId !== historyRequestRef.current) return;
setHistory(response.data);
} catch (error: unknown) {
if (requestId !== historyRequestRef.current) return;
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
}
}, [showError, t]);
const runDraftSave = useCallback(async () => {
if (!selectedAppointment || saveInFlightRef.current) {
if (saveInFlightRef.current) saveQueuedRef.current = true;
@@ -681,7 +726,8 @@ export function TreatmentWorkspace({
}
if (
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current)
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current) ||
!areDetailsPersistable(detailsRef.current)
) {
return;
}
@@ -691,6 +737,9 @@ export function TreatmentWorkspace({
try {
await persistDraft();
setSaveStatus('saved');
if (historyPatientId) {
await refreshHistory(historyPatientId);
}
} catch (error: unknown) {
setSaveStatus('error');
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
@@ -704,16 +753,7 @@ export function TreatmentWorkspace({
}
}
}
}, [selectedAppointment, persistDraft, showError, t]);
const refreshHistory = useCallback(async (patientId: string) => {
try {
const response = await treatmentsApi.listPatientHistory(patientId);
setHistory(response.data);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
}
}, [showError, t]);
}, [selectedAppointment, persistDraft, showError, t, historyPatientId, refreshHistory]);
const flushDraftSave = useCallback(async (): Promise<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,21 +1160,28 @@ export function TreatmentWorkspace({
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
const sentDetailClientId = labCase.detailClientId;
setDetails((prev) =>
prev.map((detail) => {
if (detail.clientId !== sentDetailClientId) return detail;
return {
...detail,
labCaseId: response.data.id,
sentAt: response.data.sentAt,
sends: response.data.sends,
sendToOrganizationIds: response.data.destinationOrganizationId
? [response.data.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
}),
);
const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
if (draftResponse.data?.details?.length) {
const mapped = draftResponse.data.details.map(mapDetailFromApi);
setDetails(mapped);
setSavedSnapshot(serializeDetails(mapped));
} else {
const sentDetailClientId = labCase.detailClientId;
setDetails((prev) =>
prev.map((detail) => {
if (detail.clientId !== sentDetailClientId) return detail;
return {
...detail,
labCaseId: response.data.id,
sentAt: response.data.sentAt,
sends: response.data.sends,
sendToOrganizationIds: response.data.destinationOrganizationId
? [response.data.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
}),
);
}
setLabCaseDrafts((prev) =>
prev.map((lc) =>
@@ -1183,18 +1282,59 @@ export function TreatmentWorkspace({
</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"
checked={showWholeTreatmentPlan}
onChange={(e) => setShowWholeTreatmentPlan(e.target.checked)}
className="rounded border-border"
/>
{t('toothChartWholePlan')}
</label>
<Checkbox
checked={showWholeTreatmentPlan}
onChange={setShowWholeTreatmentPlan}
label={t('toothChartWholePlan')}
className="text-[11px] [&_span:last-child]:text-[11px] [&_span:last-child]:text-text-muted"
/>
) : 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>

View File

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