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