diff --git a/backend/prisma/scripts/clear-clinical-test-data.ts b/backend/prisma/scripts/clear-clinical-test-data.ts
new file mode 100644
index 0000000..91bf5ff
--- /dev/null
+++ b/backend/prisma/scripts/clear-clinical-test-data.ts
@@ -0,0 +1,50 @@
+/**
+ * One-off cleanup: remove appointments, treatments, lab cases, and related rows.
+ * Keeps patients, organizations, users, and catalog data intact.
+ *
+ * Usage: npx ts-node prisma/scripts/clear-clinical-test-data.ts
+ */
+import { PrismaClient } from '@prisma/client';
+import { config } from 'dotenv';
+import path from 'path';
+
+config({ path: path.join(__dirname, '..', '..', '.env') });
+
+const prisma = new PrismaClient();
+
+async function main() {
+ const counts = {
+ labCaseTasks: await prisma.labCaseTask.count(),
+ labCaseSends: await prisma.labCaseSend.count(),
+ labCaseDetails: await prisma.labCaseDetail.count(),
+ labCases: await prisma.labCase.count(),
+ attachments: await prisma.treatmentDetailAttachment.count(),
+ treatmentDetails: await prisma.treatmentDetail.count(),
+ treatments: await prisma.treatment.count(),
+ appointments: await prisma.appointment.count(),
+ };
+
+ console.log('Current row counts:', counts);
+
+ await prisma.$transaction([
+ prisma.labCaseTask.deleteMany(),
+ prisma.labCaseSend.deleteMany(),
+ prisma.labCaseDetail.deleteMany(),
+ prisma.labCase.deleteMany(),
+ prisma.treatmentDetailAttachment.deleteMany(),
+ prisma.treatmentDetail.deleteMany(),
+ prisma.treatment.deleteMany(),
+ prisma.appointment.deleteMany(),
+ ]);
+
+ console.log('✅ Cleared appointments, treatments, lab cases, tasks, and attachments.');
+}
+
+main()
+ .catch((error) => {
+ console.error('❌ Cleanup failed:', error);
+ process.exit(1);
+ })
+ .finally(async () => {
+ await prisma.$disconnect();
+ });
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 05c96b0..3f889c3 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -396,6 +396,7 @@
"noPermissionBody": "You do not have permission to view the Treatment tab for this organization.",
"title": "Treatment",
"subtitleEdit": "Document cases for your appointments, save drafts, and send work to linked organizations.",
+ "subtitleEditPhase4": "Plan treatment details first, then group lab-dependent work into shipments in the lab dispatch panel.",
"subtitleReadOnly": "View-only access — you can review appointments and treatment history but cannot edit.",
"pastDayNotice": "Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.",
"selectedPatient": "Selected patient",
@@ -423,6 +424,13 @@
"emptyDay": "No appointments assigned to you on this day.",
"casesTitle": "Treatment cases",
"casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.",
+ "detailsTitle": "Treatment details",
+ "detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.",
+ "addDetail": "Add detail",
+ "detailLabel": "Detail {n}",
+ "detailSentBadge": "sent",
+ "detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.",
+ "detailsSaveHint": "Lab dispatch is configured separately below.",
"addCase": "Add case",
"caseLabel": "Case {n}",
"comments": "Comments",
@@ -441,6 +449,24 @@
"recent": "Recent:",
"noOrgMatch": "No active organization matches your search.",
"sendThisCase": "Send this case",
+ "labDispatchTitle": "Lab dispatch",
+ "labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.",
+ "addLabShipment": "Add lab shipment",
+ "labShipmentLabel": "Shipment {n}",
+ "includeDetails": "Include treatment details",
+ "labDetailLine": "Detail {n} · {type} · {teeth}",
+ "noLabDetails": "No lab-dependent treatment details yet. Add a lab type (e.g. endo) in treatment details above.",
+ "labDispatchEmpty": "Add a lab shipment to group details and send them to a lab.",
+ "labComment": "Message for the lab",
+ "labCommentPlaceholder": "Optional instructions for this shipment…",
+ "selectLab": "Destination lab",
+ "selectLabPlaceholder": "Choose a linked lab…",
+ "sendToLab": "Send to lab",
+ "saveLabShipments": "Save lab shipments",
+ "labDispatchSaveHint": "Saves shipment grouping without sending.",
+ "successLabShipmentsSaved": "Lab shipments saved.",
+ "errorSaveLabShipments": "Failed to save lab shipments.",
+ "errorLabCaseNeedsDetails": "Select at least one treatment detail for this shipment.",
"saveDraft": "Save treatment draft",
"unsavedChanges": "Unsaved changes",
"draftSaved": "Draft saved",
@@ -464,7 +490,10 @@
"moreCases": "+ {n} more case(s)",
"previewDialogTitle": "Treatment preview",
"previewDialogSubtitle": "Review cases, attachments, and send destinations.",
+ "previewDialogSubtitlePhase4": "Review treatment details and attachments.",
+ "previewLabDispatchHint": "Use the lab dispatch panel in the workspace to send work to labs.",
"noCases": "No cases in this treatment.",
+ "noDetails": "No treatment details in this draft.",
"typeLabel": "Type:",
"commentsLabel": "Comments:",
"commentsEmpty": "Comments: —",
@@ -474,7 +503,7 @@
"noActiveOrgs": "No active linked organizations.",
"confirmSend": "Confirm send",
"toothChartTitle": "FDI tooth chart",
- "toothChartHint": "Tap teeth to multi-select. Applies to the active case.",
+ "toothChartHint": "Tap teeth to multi-select. Applies to the active detail.",
"selectedLabel": "Selected:",
"selectedEmpty": "—",
"upperArch": "Upper arch",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index 4315638..c8394cb 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -396,6 +396,7 @@
"noPermissionBody": "شما مجوز مشاهده برگه درمان برای این سازمان را ندارید.",
"title": "درمان",
"subtitleEdit": "پروندههای نوبتهای خود را مستند کنید، پیشنویسها را ذخیره کنید و کار را به سازمانهای مرتبط ارسال کنید.",
+ "subtitleEditPhase4": "ابتدا جزئیات درمان را برنامهریزی کنید، سپس کار وابسته به لابراتوار را در بخش ارسال لاب گروهبندی کنید.",
"subtitleReadOnly": "دسترسی فقط خواندنی — میتوانید نوبتها و تاریخچه درمان را بررسی کنید اما نمیتوانید ویرایش کنید.",
"pastDayNotice": "روزهای گذشته فقط قابل مشاهده هستند. میتوانید نوبتها و تاریخچه را بررسی کنید، اما پروندههای درمانی قابل اضافه یا تغییر نیستند.",
"selectedPatient": "بیمار انتخاب شده",
@@ -423,6 +424,13 @@
"emptyDay": "هیچ نوبتی به شما در این روز اختصاص داده نشده است.",
"casesTitle": "پروندههای درمانی",
"casesSubtitle": "هر پرونده دارای دندانها، یادداشتها، پیوستها و مقصدهای ارسال خود است.",
+ "detailsTitle": "جزئیات درمان",
+ "detailsSubtitle": "دندانها، نوع، یادداشت و پیوستها را برای هر خط جزئیات برنامهریزی کنید.",
+ "addDetail": "افزودن جزئیات",
+ "detailLabel": "جزئیات {n}",
+ "detailSentBadge": "ارسالشده",
+ "detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.",
+ "detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی میشود.",
"addCase": "افزودن پرونده",
"caseLabel": "پرونده {n}",
"comments": "نظرات",
@@ -441,6 +449,24 @@
"recent": "اخیر:",
"noOrgMatch": "هیچ سازمان فعالی با جستجوی شما مطابقت ندارد.",
"sendThisCase": "ارسال این پرونده",
+ "labDispatchTitle": "ارسال به لابراتوار",
+ "labDispatchSubtitle": "جزئیات وابسته به لاب را در محمولهها گروهبندی کرده و به لابراتوارهای متصل ارسال کنید.",
+ "addLabShipment": "افزودن محموله لاب",
+ "labShipmentLabel": "محموله {n}",
+ "includeDetails": "شامل جزئیات درمان",
+ "labDetailLine": "جزئیات {n} · {type} · {teeth}",
+ "noLabDetails": "هنوز جزئیات وابسته به لاب وجود ندارد. نوع لاب (مثلاً اندو) در جزئیات درمان بالا اضافه کنید.",
+ "labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروهبندی و ارسال کنید.",
+ "labComment": "پیام برای لابراتوار",
+ "labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…",
+ "selectLab": "لابراتوار مقصد",
+ "selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…",
+ "sendToLab": "ارسال به لابراتوار",
+ "saveLabShipments": "ذخیره محمولههای لاب",
+ "labDispatchSaveHint": "گروهبندی محموله را بدون ارسال ذخیره میکند.",
+ "successLabShipmentsSaved": "محمولههای لاب ذخیره شد.",
+ "errorSaveLabShipments": "ذخیره محمولههای لاب ناموفق بود.",
+ "errorLabCaseNeedsDetails": "حداقل یک جزئیات درمان برای این محموله انتخاب کنید.",
"saveDraft": "ذخیره پیشنویس درمان",
"unsavedChanges": "تغییرات ذخیرهنشده",
"draftSaved": "پیشنویس ذخیره شد",
@@ -464,6 +490,9 @@
"moreCases": "+ {n} پرونده دیگر",
"previewDialogTitle": "پیشنمایش درمان",
"previewDialogSubtitle": "بررسی پروندهها، پیوستها و مقصدهای ارسال.",
+ "previewDialogSubtitlePhase4": "بررسی جزئیات درمان و پیوستها.",
+ "previewLabDispatchHint": "برای ارسال کار به لابراتوار از بخش ارسال لاب در فضای کاری استفاده کنید.",
+ "noDetails": "جزئیات درمانی در این پیشنویس وجود ندارد.",
"noCases": "هیچ پروندهای در این درمان وجود ندارد.",
"typeLabel": "نوع:",
"commentsLabel": "نظرات:",
@@ -474,7 +503,7 @@
"noActiveOrgs": "هیچ سازمان مرتبط فعالی وجود ندارد.",
"confirmSend": "تأیید ارسال",
"toothChartTitle": "نمودار دندانها FDI",
- "toothChartHint": "برای انتخاب چندگانه روی دندانها ضربه بزنید. برای پرونده فعال اعمال میشود.",
+ "toothChartHint": "برای انتخاب چندگانه روی دندانها ضربه بزنید. برای جزئیات فعال اعمال میشود.",
"selectedLabel": "انتخاب شده:",
"selectedEmpty": "—",
"upperArch": "قوس بالا",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index b2ae34e..16ea5b7 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -396,6 +396,7 @@
"noPermissionBody": "U heeft geen toestemming om het tabblad Behandeling voor deze organisatie te bekijken.",
"title": "Behandeling",
"subtitleEdit": "Documenteer casussen voor uw afspraken, sla concepten op en stuur werk naar gekoppelde organisaties.",
+ "subtitleEditPhase4": "Plan eerst behandeldetails, groepeer daarna lab-afhankelijk werk in het lab-dispatchpaneel.",
"subtitleReadOnly": "Alleen-lezen toegang — u kunt afspraken en behandelgeschiedenis bekijken, maar niet bewerken.",
"pastDayNotice": "Dagen in het verleden zijn alleen-lezen. U kunt afspraken en geschiedenis bekijken, maar behandelcasussen kunnen niet worden toegevoegd of gewijzigd.",
"selectedPatient": "Geselecteerde patiënt",
@@ -423,6 +424,13 @@
"emptyDay": "Geen afspraken aan u toegewezen op deze dag.",
"casesTitle": "Behandelcasussen",
"casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.",
+ "detailsTitle": "Behandeldetails",
+ "detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.",
+ "addDetail": "Detail toevoegen",
+ "detailLabel": "Detail {n}",
+ "detailSentBadge": "verzonden",
+ "detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.",
+ "detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.",
"addCase": "Case toevoegen",
"caseLabel": "Case {n}",
"comments": "Opmerkingen",
@@ -441,6 +449,24 @@
"recent": "Recent:",
"noOrgMatch": "Geen actieve organisatie komt overeen met uw zoekopdracht.",
"sendThisCase": "Verzend deze case",
+ "labDispatchTitle": "Lab-dispatch",
+ "labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.",
+ "addLabShipment": "Labzending toevoegen",
+ "labShipmentLabel": "Zending {n}",
+ "includeDetails": "Behandeldetails opnemen",
+ "labDetailLine": "Detail {n} · {type} · {teeth}",
+ "noLabDetails": "Nog geen lab-afhankelijke details. Voeg een labtype (bijv. endo) toe in de behandeldetails hierboven.",
+ "labDispatchEmpty": "Voeg een labzending toe om details te groeperen en naar een lab te sturen.",
+ "labComment": "Bericht voor het lab",
+ "labCommentPlaceholder": "Optionele instructies voor deze zending…",
+ "selectLab": "Bestemmingslab",
+ "selectLabPlaceholder": "Kies een gekoppeld lab…",
+ "sendToLab": "Versturen naar lab",
+ "saveLabShipments": "Labzendingen opslaan",
+ "labDispatchSaveHint": "Slaat groepering op zonder te verzenden.",
+ "successLabShipmentsSaved": "Labzendingen opgeslagen.",
+ "errorSaveLabShipments": "Labzendingen opslaan mislukt.",
+ "errorLabCaseNeedsDetails": "Selecteer minimaal één behandeldetail voor deze zending.",
"saveDraft": "Behandelconcept opslaan",
"unsavedChanges": "Niet-opgeslagen wijzigingen",
"draftSaved": "Concept opgeslagen",
@@ -464,7 +490,10 @@
"moreCases": "+ {n} meer case(s)",
"previewDialogTitle": "Behandelvoorbeeld",
"previewDialogSubtitle": "Bekijk casussen, bijlagen en verzendbestemmingen.",
+ "previewDialogSubtitlePhase4": "Bekijk behandeldetails en bijlagen.",
+ "previewLabDispatchHint": "Gebruik het lab-dispatchpaneel in de werkruimte om werk naar labs te sturen.",
"noCases": "Geen casussen in deze behandeling.",
+ "noDetails": "Geen behandeldetails in dit concept.",
"typeLabel": "Type:",
"commentsLabel": "Opmerkingen:",
"commentsEmpty": "Opmerkingen: —",
@@ -474,7 +503,7 @@
"noActiveOrgs": "Geen actieve gekoppelde organisaties.",
"confirmSend": "Bevestig verzending",
"toothChartTitle": "FDI-tanddiagram",
- "toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor de actieve case.",
+ "toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor het actieve detail.",
"selectedLabel": "Geselecteerd:",
"selectedEmpty": "—",
"upperArch": "Bovenboog",
diff --git a/frontend/src/app/[locale]/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx
index 246abcd..037e1c7 100644
--- a/frontend/src/app/[locale]/(dashboard)/layout.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx
@@ -59,8 +59,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
-
-
+
+
{children}
diff --git a/frontend/src/components/ui/shared/Checkbox.tsx b/frontend/src/components/ui/shared/Checkbox.tsx
index 2aa1114..453a12e 100644
--- a/frontend/src/components/ui/shared/Checkbox.tsx
+++ b/frontend/src/components/ui/shared/Checkbox.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useId } from 'react';
+import { useId, type KeyboardEvent } from 'react';
import { Check } from 'lucide-react';
type CheckboxProps = {
@@ -14,6 +14,7 @@ type CheckboxProps = {
/**
* App design-system checkbox: primary fill when checked, rounded, focus-visible ring.
+ * Uses a button-like label toggle so mouse clicks do not focus a hidden input (scroll jumps).
*/
export function Checkbox({
checked,
@@ -26,25 +27,42 @@ export function Checkbox({
const genId = useId();
const inputId = id ?? genId;
+ const toggle = () => {
+ if (!disabled) onChange(!checked);
+ };
+
+ const onKeyDown = (event: KeyboardEvent
) => {
+ if (disabled) return;
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ toggle();
+ }
+ };
+
return (
{
+ event.preventDefault();
+ toggle();
+ }}
+ onMouseDown={(event) => {
+ // Avoid focus-driven scroll-into-view in overflow panels.
+ if (event.button === 0) event.preventDefault();
+ }}
className={`
inline-flex items-center gap-2.5 cursor-pointer select-none rounded-[var(--radius-sm)] -m-0.5 p-0.5
- has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary/45 has-[:focus-visible]:ring-offset-2
- has-[:focus-visible]:ring-offset-background-secondary
+ focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 focus-visible:ring-offset-2
+ focus-visible:ring-offset-background-secondary
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className}
`}
>
- onChange(e.target.checked)}
- />
{
label?: string;
@@ -9,8 +9,8 @@ interface InputProps extends React.InputHTMLAttributes {
export const Input = forwardRef(
({ label, error, icon, className = '', id, ...props }, ref) => {
- const inputId =
- id || `input-${Math.random().toString(36).slice(2, 9)}`;
+ const generatedId = useId();
+ const inputId = id || generatedId;
return (
diff --git a/frontend/src/components/ui/shared/SearchBar.tsx b/frontend/src/components/ui/shared/SearchBar.tsx
index c93b8eb..fb1c9da 100644
--- a/frontend/src/components/ui/shared/SearchBar.tsx
+++ b/frontend/src/components/ui/shared/SearchBar.tsx
@@ -8,6 +8,8 @@ interface SearchBarProps {
placeholder: string;
onSubmit?: () => void;
actions?: ReactNode;
+ /** When true, renders inline without the outer surface-card wrapper (for nested panels). */
+ embedded?: boolean;
}
export function SearchBar({
@@ -16,23 +18,28 @@ export function SearchBar({
placeholder,
onSubmit,
actions,
+ embedded = false,
}: SearchBarProps) {
- return (
-
-
-
- onChange(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') onSubmit?.();
- }}
- icon={ }
- />
-
- {actions &&
{actions}
}
+ const field = (
+
+
+ onChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') onSubmit?.();
+ }}
+ icon={ }
+ />
+ {actions &&
{actions}
}
);
+
+ if (embedded) {
+ return field;
+ }
+
+ return
{field}
;
}
diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
new file mode 100644
index 0000000..068173f
--- /dev/null
+++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
@@ -0,0 +1,320 @@
+'use client';
+
+import { useTranslations } from 'next-intl';
+import { Button } from '@/components/ui/shared/Button';
+import { Checkbox } from '@/components/ui/shared/Checkbox';
+import { Dropdown } from '@/components/ui/shared/Dropdown';
+import { SearchBar } from '@/components/ui/shared/SearchBar';
+import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
+import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
+import { TREATMENT_TYPE_KEYS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
+import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
+
+interface LabCasesDispatchPanelProps {
+ details: TreatmentDetailDraft[];
+ labCases: LabCaseDraft[];
+ labDependentCodes: Set
;
+ activeLabCaseId: string | null;
+ onActiveLabCaseChange: (id: string) => void;
+ onLabCasesChange: (labCases: LabCaseDraft[]) => void;
+ disabled: boolean;
+ canEdit: boolean;
+ orgs: LinkedOrganizationOption[];
+ organizationSearch: string;
+ onOrganizationSearchChange: (value: string) => void;
+ recentOrganizationIds: string[];
+ onRecentOrganizationPick: (orgId: string) => void;
+ sendBusyId: string | null;
+ saveLabBusy: boolean;
+ onAddLabCase: () => void;
+ onSaveLabCases: () => void;
+ onSendLabCase: (labCase: LabCaseDraft) => void;
+}
+
+export function LabCasesDispatchPanel({
+ details,
+ labCases,
+ labDependentCodes,
+ activeLabCaseId,
+ onActiveLabCaseChange,
+ onLabCasesChange,
+ disabled,
+ canEdit,
+ orgs,
+ organizationSearch,
+ onOrganizationSearchChange,
+ recentOrganizationIds,
+ onRecentOrganizationPick,
+ sendBusyId,
+ saveLabBusy,
+ onAddLabCase,
+ onSaveLabCases,
+ onSendLabCase,
+}: LabCasesDispatchPanelProps) {
+ const t = useTranslations('treatment');
+ const activeLinkedOrganizations = orgs.filter((o) => o.active);
+ const filteredOrganizations = (() => {
+ const q = organizationSearch.trim().toLowerCase();
+ if (!q) return activeLinkedOrganizations;
+ return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q));
+ })();
+ const recentOrganizations = recentOrganizationIds
+ .map((id) => activeLinkedOrganizations.find((o) => o.id === id))
+ .filter(Boolean) as LinkedOrganizationOption[];
+
+ const labEligibleDetails = details.filter((d) => labDependentCodes.has(d.treatmentType));
+ const activeLabCase =
+ labCases.find((lc) => lc.clientId === activeLabCaseId) ?? labCases[0] ?? null;
+ const sent = Boolean(activeLabCase?.sentAt);
+
+ function detailSummary(d: TreatmentDetailDraft, idx: number) {
+ const typeKey = treatmentTypeLabelKey(d.treatmentType);
+ const typeLabel =
+ d.treatmentType in TREATMENT_TYPE_KEYS
+ ? t(typeKey as 'typeEndo')
+ : d.treatmentType;
+ const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
+ return `${t('detailLabel', { n: idx + 1 })} · ${typeLabel} · ${teeth}`;
+ }
+
+ function updateActiveLabCase(patch: Partial) {
+ if (!activeLabCase) return;
+ onLabCasesChange(
+ labCases.map((lc) => (lc.clientId === activeLabCase.clientId ? { ...lc, ...patch } : lc)),
+ );
+ }
+
+ function toggleDetailInActiveLabCase(detailClientId: string, checked: boolean) {
+ if (!activeLabCase || sent) return;
+
+ onLabCasesChange(
+ labCases.map((lc) => {
+ if (lc.sentAt) return lc;
+
+ if (lc.clientId === activeLabCase.clientId) {
+ const set = new Set(lc.detailClientIds);
+ if (checked) set.add(detailClientId);
+ else set.delete(detailClientId);
+ return { ...lc, detailClientIds: [...set] };
+ }
+
+ if (checked) {
+ return {
+ ...lc,
+ detailClientIds: lc.detailClientIds.filter((id) => id !== detailClientId),
+ };
+ }
+ return lc;
+ }),
+ );
+ }
+
+ function detailAssignedElsewhere(detailClientId: string): boolean {
+ if (!activeLabCase) return false;
+ return labCases.some(
+ (lc) =>
+ !lc.sentAt &&
+ lc.clientId !== activeLabCase.clientId &&
+ lc.detailClientIds.includes(detailClientId),
+ );
+ }
+
+ if (labEligibleDetails.length === 0) {
+ return (
+
+
{t('labDispatchTitle')}
+
{t('noLabDetails')}
+
+ );
+ }
+
+ return (
+
+
+
+
{t('labDispatchTitle')}
+
{t('labDispatchSubtitle')}
+
+
+ {t('addLabShipment')}
+
+
+
+ {labCases.length === 0 ? (
+
{t('labDispatchEmpty')}
+ ) : (
+ <>
+
+ {labCases.map((lc, idx) => {
+ const sentSummary = formatCaseSentSummary(
+ lc.sends,
+ {
+ organizationIds: lc.destinationOrganizationId ? [lc.destinationOrganizationId] : [],
+ sentAt: lc.sentAt ?? null,
+ orgs,
+ },
+ t,
+ );
+ return (
+ onActiveLabCaseChange(lc.clientId)}
+ className={`
+ rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
+ focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
+ ${
+ lc.clientId === activeLabCase?.clientId
+ ? 'border-primary bg-primary-soft font-medium text-text-primary'
+ : 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
+ }
+ `}
+ >
+ {t('labShipmentLabel', { n: idx + 1 })}
+ {sentSummary ? ` · ${sentSummary}` : ''}
+
+ );
+ })}
+
+
+ {activeLabCase && (
+
+
+
{t('includeDetails')}
+
+ {labEligibleDetails.map((d, idx) => {
+ const assignedElsewhere = detailAssignedElsewhere(d.clientId);
+ const inSentCase = labCases.some(
+ (lc) => lc.sentAt && lc.detailClientIds.includes(d.clientId),
+ );
+ const checked = activeLabCase.detailClientIds.includes(d.clientId);
+ const itemDisabled =
+ disabled || sent || inSentCase || assignedElsewhere;
+
+ return (
+ toggleDetailInActiveLabCase(d.clientId, next)}
+ label={detailSummary(d, idx)}
+ />
+ );
+ })}
+
+
+
+
+ {t('labComment')}
+
+
+
+
{t('selectLab')}
+
+ {recentOrganizations.length > 0 && (
+
+ {t('recent')}
+ {recentOrganizations.map((o) => (
+ onRecentOrganizationPick(o.id)}
+ className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
+ >
+ {o.name}
+
+ ))}
+
+ )}
+
+ updateActiveLabCase({
+ destinationOrganizationId: e.target.value || null,
+ })
+ }
+ disabled={disabled || sent || filteredOrganizations.length === 0}
+ >
+ {t('selectLabPlaceholder')}
+ {filteredOrganizations.map((o) => (
+
+ {o.name}
+
+ ))}
+
+ {filteredOrganizations.length === 0 && (
+
{t('noOrgMatch')}
+ )}
+
+
+
+ onSendLabCase(activeLabCase)}
+ >
+ {t('sendToLab')}
+
+ {sent && (
+
+ )}
+
+
+ )}
+
+ {canEdit && (
+
+
+ {t('saveLabShipments')}
+
+
{t('labDispatchSaveHint')}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ui/treatment/TreatmentCasesEditor.tsx b/frontend/src/components/ui/treatment/TreatmentCasesEditor.tsx
deleted file mode 100644
index 0d24eae..0000000
--- a/frontend/src/components/ui/treatment/TreatmentCasesEditor.tsx
+++ /dev/null
@@ -1,299 +0,0 @@
-'use client';
-
-import { useRef } from 'react';
-import { useTranslations } from 'next-intl';
-import { Button } from '@/components/ui/shared/Button';
-import { Checkbox } from '@/components/ui/shared/Checkbox';
-import { Dropdown } from '@/components/ui/shared/Dropdown';
-import { SearchBar } from '@/components/ui/shared/SearchBar';
-import type { LinkedOrganizationOption, TreatmentCaseDraft } from '@/types/treatment';
-import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
-import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
-
-interface TreatmentCasesEditorProps {
- cases: TreatmentCaseDraft[];
- activeCaseId: string;
- onActiveCaseChange: (id: string) => void;
- onCasesChange: (cases: TreatmentCaseDraft[]) => void;
- disabled: boolean;
- canEdit: boolean;
- isDirty: boolean;
- saveBusy: boolean;
- sendBusyId: string | null;
- uploadBusy: boolean;
- orgs: LinkedOrganizationOption[];
- organizationSearch: string;
- onOrganizationSearchChange: (value: string) => void;
- recentOrganizationIds: string[];
- onRecentOrganizationPick: (orgId: string) => void;
- onAddCase: () => void;
- onPreview: () => void;
- onSave: () => void;
- onSendCase: (c: TreatmentCaseDraft) => void;
- onUploadFiles: (files: FileList | null) => void;
-}
-
-export function TreatmentCasesEditor({
- cases,
- activeCaseId,
- onActiveCaseChange,
- onCasesChange,
- disabled,
- canEdit,
- isDirty,
- saveBusy,
- sendBusyId,
- uploadBusy,
- orgs,
- organizationSearch,
- onOrganizationSearchChange,
- recentOrganizationIds,
- onRecentOrganizationPick,
- onAddCase,
- onPreview,
- onSave,
- onSendCase,
- onUploadFiles,
-}: TreatmentCasesEditorProps) {
- const t = useTranslations('treatment');
- const tCommon = useTranslations('common');
- const attachmentInputRef = useRef(null);
- const activeCase = cases.find((c) => c.clientId === activeCaseId) ?? cases[0];
- const activeLinkedOrganizations = orgs.filter((o) => o.active);
- const filteredOrganizations = (() => {
- const q = organizationSearch.trim().toLowerCase();
- if (!q) return activeLinkedOrganizations;
- return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q));
- })();
- const recentOrganizations = recentOrganizationIds
- .map((id) => activeLinkedOrganizations.find((o) => o.id === id))
- .filter(Boolean) as LinkedOrganizationOption[];
-
- const treatmentTypeTextColor = activeCase
- ? (
- {
- consultation: '#ddd6fe',
- filling: '#fed7aa',
- endo: '#fecaca',
- visit: '#bae6fd',
- hygiene: '#d9f99d',
- } as Record
- )[activeCase.treatmentType]
- : undefined;
-
- if (!activeCase) return null;
-
- return (
-
-
-
-
{t('casesTitle')}
-
- {t('casesSubtitle')}
-
-
-
-
- {tCommon('preview')}
-
-
- {t('addCase')}
-
-
-
-
-
- {cases.map((c, idx) => {
- const sentSummary = formatCaseSentSummary(c.sends, {
- organizationIds: c.sendToOrganizationIds ?? [],
- sentAt: c.sentAt ?? null,
- orgs,
- }, t);
- return (
- onActiveCaseChange(c.clientId)}
- className={`
- rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
- focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
- ${
- c.clientId === activeCaseId
- ? 'border-primary bg-primary-soft font-medium text-text-primary'
- : 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
- }
- `}
- >
- {t('caseLabel', { n: idx + 1 })}
- {sentSummary ? ` · ${sentSummary}` : ''}
-
- );
- })}
-
-
-
-
- {t('comments')}
-
-
-
- {
- const nextType = e.target.value as TreatmentCaseDraft['treatmentType'];
- onCasesChange(
- cases.map((c) =>
- c.clientId === activeCaseId ? { ...c, treatmentType: nextType } : c,
- ),
- );
- }}
- disabled={disabled || Boolean(activeCase.sentAt)}
- className="capitalize"
- style={{ color: treatmentTypeTextColor }}
- >
- {t('typeConsultation')}
- {t('typeFilling')}
- {t('typeEndo')}
- {t('typeVisit')}
- {t('typeHygiene')}
-
-
-
-
-
{t('attachments')}
-
{
- onUploadFiles(e.target.files);
- e.target.value = '';
- }}
- className="sr-only"
- aria-label={t('attachFiles')}
- />
-
attachmentInputRef.current?.click()}
- aria-controls="treatment-case-attachments"
- >
- {t('chooseFiles')}
-
- {activeCase.attachmentMetas.length > 0 && (
-
- {activeCase.attachmentMetas.map((f) => (
-
- {f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
-
- ))}
-
- )}
-
-
-
-
- {t('sendToOrgs')}
-
-
-
- {recentOrganizations.length > 0 && (
-
- {t('recent')}
- {recentOrganizations.map((o) => (
- onRecentOrganizationPick(o.id)}
- className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
- >
- {o.name}
-
- ))}
-
- )}
-
-
- {filteredOrganizations.map((o) => (
-
{
- onCasesChange(
- cases.map((c) => {
- if (c.clientId !== activeCaseId) return c;
- const next = new Set(c.sendToOrganizationIds);
- if (checked) next.add(o.id);
- else next.delete(o.id);
- return { ...c, sendToOrganizationIds: [...next] };
- }),
- );
- }}
- label={o.name}
- />
- ))}
- {filteredOrganizations.length === 0 && (
- {t('noOrgMatch')}
- )}
-
-
-
-
- onSendCase(activeCase)}
- >
- {t('sendThisCase')}
-
- {activeCase.sentAt && (
-
- )}
-
-
-
- {canEdit && (
-
-
- {t('saveDraft')}
-
-
- {isDirty ? t('unsavedChanges') : t('draftSaved')}. {t('sendSavesFirst')}
-
-
- )}
-
- );
-}
diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
new file mode 100644
index 0000000..768586d
--- /dev/null
+++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
@@ -0,0 +1,196 @@
+'use client';
+
+import { useRef } from 'react';
+import { useTranslations } from 'next-intl';
+import { Button } from '@/components/ui/shared/Button';
+import { Dropdown } from '@/components/ui/shared/Dropdown';
+import type { TreatmentDetailDraft } from '@/types/treatment';
+import { TREATMENT_TYPE_COLORS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
+
+interface TreatmentDetailsEditorProps {
+ details: TreatmentDetailDraft[];
+ activeDetailId: string;
+ onActiveDetailChange: (id: string) => void;
+ onDetailsChange: (details: TreatmentDetailDraft[]) => void;
+ isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
+ disabled: boolean;
+ canEdit: boolean;
+ isDirty: boolean;
+ saveBusy: boolean;
+ uploadBusy: boolean;
+ onAddDetail: () => void;
+ onPreview: () => void;
+ onSave: () => void;
+ onUploadFiles: (files: FileList | null) => void;
+}
+
+export function TreatmentDetailsEditor({
+ details,
+ activeDetailId,
+ onActiveDetailChange,
+ onDetailsChange,
+ isDetailLocked,
+ disabled,
+ canEdit,
+ isDirty,
+ saveBusy,
+ uploadBusy,
+ onAddDetail,
+ onPreview,
+ onSave,
+ onUploadFiles,
+}: TreatmentDetailsEditorProps) {
+ const t = useTranslations('treatment');
+ const tCommon = useTranslations('common');
+ const attachmentInputRef = useRef(null);
+ const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0];
+
+ if (!activeDetail) return null;
+
+ const locked = isDetailLocked(activeDetail);
+ const readOnly = disabled || locked;
+ const treatmentTypeTextColor = TREATMENT_TYPE_COLORS[activeDetail.treatmentType];
+
+ return (
+
+
+
+
{t('detailsTitle')}
+
{t('detailsSubtitle')}
+
+
+
+ {tCommon('preview')}
+
+
+ {t('addDetail')}
+
+
+
+
+
+ {details.map((d, idx) => (
+ onActiveDetailChange(d.clientId)}
+ className={`
+ rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
+ focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
+ ${
+ d.clientId === activeDetailId
+ ? 'border-primary bg-primary-soft font-medium text-text-primary'
+ : 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
+ }
+ `}
+ >
+ {t('detailLabel', { n: idx + 1 })}
+ {isDetailLocked(d) ? ` · ${t('detailSentBadge')}` : ''}
+
+ ))}
+
+
+
+ {locked && (
+
+ {t('detailLockedInShipment')}
+
+ )}
+
+
+ {t('comments')}
+
+
+
+ {
+ const nextType = e.target.value as TreatmentDetailDraft['treatmentType'];
+ onDetailsChange(
+ details.map((d) =>
+ d.clientId === activeDetailId ? { ...d, treatmentType: nextType } : d,
+ ),
+ );
+ }}
+ disabled={readOnly}
+ className="capitalize"
+ style={{ color: treatmentTypeTextColor }}
+ >
+ {t('typeConsultation')}
+ {t('typeFilling')}
+ {t('typeEndo')}
+ {t('typeVisit')}
+ {t('typeHygiene')}
+
+
+
+
+
{t('attachments')}
+
{
+ onUploadFiles(e.target.files);
+ e.target.value = '';
+ }}
+ className="sr-only"
+ aria-label={t('attachFiles')}
+ />
+
attachmentInputRef.current?.click()}
+ aria-controls="treatment-detail-attachments"
+ >
+ {t('chooseFiles')}
+
+ {activeDetail.attachmentMetas.length > 0 && (
+
+ {activeDetail.attachmentMetas.map((f) => (
+
+ {f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
+
+ ))}
+
+ )}
+
+
+
+ {canEdit && (
+
+
+ {t('saveDraft')}
+
+
+ {isDirty ? t('unsavedChanges') : t('draftSaved')}. {t('detailsSaveHint')}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx
index 568a590..ccb9198 100644
--- a/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx
@@ -1,14 +1,13 @@
'use client';
-import { useRef, useState } from 'react';
+import { useRef } from 'react';
import { useTranslations } from 'next-intl';
-import { Loader2, Paperclip, Send } from 'lucide-react';
+import { Loader2, Paperclip } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
-import { Button } from '@/components/ui/shared/Button';
-import { Checkbox } from '@/components/ui/shared/Checkbox';
import type { LinkedOrganizationOption, PastTreatment, PastTreatmentCase } from '@/types/treatment';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { TreatmentLatestAttachmentPreview } from '@/components/ui/treatment/TreatmentLatestAttachmentPreview';
+import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
export type TreatmentPreviewMode = 'readonly' | 'editable';
@@ -18,12 +17,8 @@ interface TreatmentPreviewDialogProps {
treatment: PastTreatment | null;
mode: TreatmentPreviewMode;
orgs?: LinkedOrganizationOption[];
- sendBusyCaseId?: string | null;
uploadBusyCaseId?: string | null;
onAttach?: (caseKey: string, files: FileList) => void | Promise;
- onSend?: (caseKey: string, organizationIds: string[]) => void | Promise;
- getCaseOrgIds?: (caseKey: string) => string[];
- onToggleCaseOrg?: (caseKey: string, organizationId: string, checked: boolean) => void;
}
function caseKey(c: PastTreatmentCase): string {
@@ -33,35 +28,21 @@ function caseKey(c: PastTreatmentCase): string {
const caseActionIconClass =
'inline-flex items-center justify-center rounded-[var(--radius-sm)] p-1.5 text-text-secondary transition-colors hover:bg-background-card/80 hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-40';
-const TREATMENT_TYPE_KEYS = {
- consultation: 'typeConsultation',
- filling: 'typeFilling',
- endo: 'typeEndo',
- visit: 'typeVisit',
- hygiene: 'typeHygiene',
-} as const;
-
export function TreatmentPreviewDialog({
open,
onClose,
treatment,
mode,
orgs = [],
- sendBusyCaseId,
uploadBusyCaseId,
onAttach,
- onSend,
- getCaseOrgIds,
- onToggleCaseOrg,
}: TreatmentPreviewDialogProps) {
const t = useTranslations('treatment');
- const [expandedSendCaseId, setExpandedSendCaseId] = useState(null);
const fileInputsRef = useRef>({});
if (!open || !treatment) return null;
const editable = mode === 'editable';
- const activeOrgs = orgs.filter((o) => o.active);
return (
@@ -76,9 +57,7 @@ export function TreatmentPreviewDialog({
{t('previewDialogTitle')}
-
- {t('previewDialogSubtitle')}
-
+
{t('previewDialogSubtitlePhase4')}
@@ -94,8 +73,12 @@ export function TreatmentPreviewDialog({
{t('statusLabel')} {treatment.status}
+ {editable && (
+
{t('previewLabDispatchHint')}
+ )}
+
{treatment.details.length === 0 ? (
-
{t('noCases')}
+
{t('noDetails')}
) : (
{treatment.details.map((c, idx) => {
@@ -105,15 +88,10 @@ export function TreatmentPreviewDialog({
attachments.length > 0 ? attachments[attachments.length - 1] : null;
const sent = Boolean(c.sentAt);
const actionsEnabled = editable && !sent;
- const selectedOrgIds =
- getCaseOrgIds?.(key) ??
- (c.destinationOrganizationId ? [c.destinationOrganizationId] : []);
- const sendExpanded = expandedSendCaseId === key;
const comment = c.notes?.trim() ?? '';
const attachBusy = uploadBusyCaseId === key;
- const sendBusy = sendBusyCaseId === key;
- const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS];
- const typeLabel = typeKey ? t(typeKey) : c.treatmentType;
+ const typeKey = treatmentTypeLabelKey(c.treatmentType);
+ const typeLabel = t(typeKey as 'typeConsultation');
return (
- {t('caseLabel', { n: idx + 1 })}
+ {t('detailLabel', { n: idx + 1 })}
- {actionsEnabled && (
+ {actionsEnabled && onAttach && (
)}
@@ -191,7 +150,7 @@ export function TreatmentPreviewDialog({
{t('commentsLabel')} {comment}
) : (
-
{t('commentsEmpty')}
+
{t('commentsEmpty')}
)}
@@ -209,38 +168,6 @@ export function TreatmentPreviewDialog({
-
- {sendExpanded && editable && !sent && (
-
-
- {t('sendToLinkedOrgs')}
-
- {activeOrgs.length === 0 ? (
-
{t('noActiveOrgs')}
- ) : (
-
- {activeOrgs.map((o) => (
- onToggleCaseOrg?.(key, o.id, checked)}
- label={o.name}
- />
- ))}
-
- )}
-
void onSend?.(key, selectedOrgIds)}
- >
- {t('confirmSend')}
-
-
- )}
);
})}
diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
index 265a427..083dbff 100644
--- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
@@ -4,14 +4,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
+import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
-import { TreatmentCasesEditor } from '@/components/ui/treatment/TreatmentCasesEditor';
+import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
import {
TreatmentPreviewDialog,
type TreatmentPreviewMode,
} from '@/components/ui/treatment/TreatmentPreviewDialog';
import { ToastStack } from '@/components/ui/shared/Toast';
+import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
import {
addCalendarDays,
compareLocalDayStart,
@@ -19,6 +21,7 @@ import {
startOfLocalDay,
} from '@/components/appointments/appointmentTime';
import { appointmentsApi } from '@/lib/api/appointments';
+import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentsApi } from '@/lib/api/treatments';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions';
@@ -28,27 +31,21 @@ import type { Organization } from '@/types/organization';
import type { AppointmentRecord } from '@/types/appointment';
import type {
FdiToothId,
+ LabCaseDraft,
LinkedOrganizationOption,
+ PastLabCase,
PastTreatment,
PastTreatmentCase,
TreatmentAppointment,
- TreatmentCaseDraft,
+ TreatmentDetailDraft,
} from '@/types/treatment';
-const TREATMENT_TYPE_KEYS = {
- consultation: 'typeConsultation',
- filling: 'typeFilling',
- endo: 'typeEndo',
- visit: 'typeVisit',
- hygiene: 'typeHygiene',
-} as const;
-
-function newCase(): TreatmentCaseDraft {
+function newDetail(): TreatmentDetailDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
- : `case-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ : `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: 'consultation',
teeth: [],
comment: '',
@@ -58,6 +55,20 @@ function newCase(): TreatmentCaseDraft {
};
}
+function newLabCaseDraft(): LabCaseDraft {
+ return {
+ clientId:
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
+ ? crypto.randomUUID()
+ : `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ destinationOrganizationId: null,
+ labComment: '',
+ detailClientIds: [],
+ sentAt: null,
+ sends: [],
+ };
+}
+
function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
return {
id: record.id,
@@ -71,7 +82,7 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
};
}
-function mapDetailFromApi(d: PastTreatmentCase): TreatmentCaseDraft {
+function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
return {
clientId: d.clientId,
id: d.id,
@@ -86,23 +97,33 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentCaseDraft {
};
}
-function serializeCases(cases: TreatmentCaseDraft[]) {
+function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
+ return {
+ clientId: lc.clientId,
+ id: lc.id,
+ destinationOrganizationId: lc.destinationOrganizationId,
+ labComment: lc.labComment ?? '',
+ detailClientIds: lc.details.map((d) => d.clientId),
+ sentAt: lc.sentAt ?? null,
+ sends: lc.sends ?? [],
+ };
+}
+
+function serializeDetails(details: TreatmentDetailDraft[]) {
return JSON.stringify(
- cases.map((c) => ({
- clientId: c.clientId,
- id: c.id,
- treatmentType: c.treatmentType,
- teeth: c.teeth,
- comment: c.comment,
- attachmentMetas: c.attachmentMetas,
- sendToOrganizationIds: c.sendToOrganizationIds,
- sentAt: c.sentAt,
+ details.map((d) => ({
+ clientId: d.clientId,
+ id: d.id,
+ treatmentType: d.treatmentType,
+ teeth: d.teeth,
+ comment: d.comment,
+ attachmentMetas: d.attachmentMetas,
})),
);
}
-function casesToPreviewTreatment(
- cases: TreatmentCaseDraft[],
+function detailsToPreviewTreatment(
+ details: TreatmentDetailDraft[],
meta: { title: string; patientId: string; treatmentAt: string; status: string; id?: string },
): PastTreatment {
return {
@@ -111,17 +132,17 @@ function casesToPreviewTreatment(
title: meta.title,
treatmentAt: meta.treatmentAt,
status: meta.status,
- details: cases.map((c, idx) => ({
- id: c.id ?? c.clientId ?? `draft-${idx + 1}`,
- clientId: c.clientId,
- treatmentType: c.treatmentType,
- teeth: c.teeth,
- notes: c.comment || null,
- attachmentMetas: c.attachmentMetas,
- labCaseId: c.labCaseId ?? null,
- destinationOrganizationId: c.sendToOrganizationIds[0] ?? null,
- sends: c.sends ?? [],
- sentAt: c.sentAt ?? null,
+ details: details.map((d, idx) => ({
+ id: d.id ?? d.clientId ?? `draft-${idx + 1}`,
+ clientId: d.clientId,
+ treatmentType: d.treatmentType,
+ teeth: d.teeth,
+ notes: d.comment || null,
+ attachmentMetas: d.attachmentMetas,
+ labCaseId: d.labCaseId ?? null,
+ destinationOrganizationId: d.sendToOrganizationIds[0] ?? null,
+ sends: d.sends ?? [],
+ sentAt: d.sentAt ?? null,
})),
labCases: [],
documents: [],
@@ -152,17 +173,21 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [historyLoading, setHistoryLoading] = useState(false);
const [orgs, setOrgs] = useState([]);
+ const [labDependentCodes, setLabDependentCodes] = useState>(new Set());
- const [cases, setCases] = useState(() => [newCase()]);
- const [activeCaseId, setActiveCaseId] = useState(() => cases[0].clientId);
+ const [details, setDetails] = useState(() => [newDetail()]);
+ const [labCaseDrafts, setLabCaseDrafts] = useState([]);
+ const [activeDetailId, setActiveDetailId] = useState(() => details[0].clientId);
+ const [activeLabCaseId, setActiveLabCaseId] = useState(null);
const [savedSnapshot, setSavedSnapshot] = useState(null);
const selectionLockedRef = useRef(selectionLocked);
selectionLockedRef.current = selectionLocked;
const [saveBusy, setSaveBusy] = useState(false);
+ const [saveLabBusy, setSaveLabBusy] = useState(false);
const [sendBusyId, setSendBusyId] = useState(null);
- const [uploadBusyCaseId, setUploadBusyCaseId] = useState(null);
+ const [uploadBusyDetailId, setUploadBusyDetailId] = useState(null);
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState([]);
@@ -170,12 +195,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [previewTreatment, setPreviewTreatment] = useState(null);
const [previewMode, setPreviewMode] = useState('readonly');
+ const isDetailLocked = useCallback(
+ (detail: TreatmentDetailDraft) =>
+ labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientIds.includes(detail.clientId)),
+ [labCaseDrafts],
+ );
+
const isDirty = useMemo(() => {
if (savedSnapshot === null) {
- return cases.length !== 1 || cases[0].comment !== '' || cases[0].teeth.length > 0;
+ return details.length !== 1 || details[0].comment !== '' || details[0].teeth.length > 0;
}
- return serializeCases(cases) !== savedSnapshot;
- }, [cases, savedSnapshot]);
+ return serializeDetails(details) !== savedSnapshot;
+ }, [details, savedSnapshot]);
const selectedAppointment = useMemo(
() => appointments.find((a) => a.id === selectedAppointmentId) ?? null,
@@ -189,16 +220,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const canEditTreatmentForDay = canEdit && Boolean(selectedAppointment) && !isViewingPastDay;
- const activeCase = useMemo(
- () => cases.find((c) => c.clientId === activeCaseId) ?? cases[0],
- [cases, activeCaseId],
+ const activeDetail = useMemo(
+ () => details.find((d) => d.clientId === activeDetailId) ?? details[0],
+ [details, activeDetailId],
);
- const selectedTeethSet = useMemo(() => new Set(activeCase?.teeth ?? []), [activeCase?.teeth]);
+ const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const currentDraftPreview = useMemo(() => {
if (!selectedAppointment) return null;
- return casesToPreviewTreatment(cases, {
+ return detailsToPreviewTreatment(details, {
title: t('draftTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
@@ -206,7 +237,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
treatmentAt: new Date().toISOString(),
status: 'draft',
});
- }, [cases, selectedAppointment, t]);
+ }, [details, selectedAppointment, t]);
useEffect(() => {
setSelectionLocked(false);
@@ -262,8 +293,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
let cancelled = false;
void (async () => {
try {
- const list = await treatmentsApi.listLinkedOrganizations();
- if (!cancelled) setOrgs(list.data);
+ const [orgsResponse, catalogResponse] = await Promise.all([
+ treatmentsApi.listLinkedOrganizations(),
+ treatmentCatalogApi.list(),
+ ]);
+ if (cancelled) return;
+ setOrgs(orgsResponse.data);
+ setLabDependentCodes(
+ new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
+ );
} catch (error: unknown) {
if (!cancelled) {
showError(formatApiErrorMessage(error, t('errorLoadOrgs')));
@@ -311,18 +349,22 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
if (response.data?.details?.length) {
const mapped = response.data.details.map(mapDetailFromApi);
- setCases(mapped);
- setActiveCaseId((prev) => {
- const stillExists = mapped.some((c) => c.clientId === prev);
+ setDetails(mapped);
+ setActiveDetailId((prev) => {
+ const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0].clientId;
});
- setSavedSnapshot(serializeCases(mapped));
+ setSavedSnapshot(serializeDetails(mapped));
} else {
- const first = newCase();
- setCases([first]);
- setActiveCaseId(first.clientId);
- setSavedSnapshot(serializeCases([first]));
+ const first = newDetail();
+ setDetails([first]);
+ setActiveDetailId(first.clientId);
+ setSavedSnapshot(serializeDetails([first]));
}
+
+ const mappedLabCases = (response.data?.labCases ?? []).map(mapLabCaseDraftFromApi);
+ setLabCaseDrafts(mappedLabCases);
+ setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
} catch (error: unknown) {
if (!cancelled) {
@@ -357,31 +399,31 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[confirmDiscardIfDirty],
);
- const uploadForCase = useCallback(
- async (caseClientId: string, files: FileList | File[]) => {
+ const uploadForDetail = useCallback(
+ async (detailClientId: string, files: FileList | File[]) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
const list = files instanceof FileList ? Array.from(files) : files;
if (!list.length) return;
- setUploadBusyCaseId(caseClientId);
+ setUploadBusyDetailId(detailClientId);
try {
const uploaded = await treatmentsApi.uploadCaseAttachments(
selectedAppointment.id,
- caseClientId,
+ detailClientId,
list,
);
- setCases((prev) =>
- prev.map((c) =>
- c.clientId === caseClientId
- ? { ...c, attachmentMetas: [...c.attachmentMetas, ...uploaded.data] }
- : c,
+ setDetails((prev) =>
+ prev.map((d) =>
+ d.clientId === detailClientId
+ ? { ...d, attachmentMetas: [...d.attachmentMetas, ...uploaded.data] }
+ : d,
),
);
showSuccess(t('successFilesUploaded', { count: uploaded.data.length }));
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorUpload')));
} finally {
- setUploadBusyCaseId(null);
+ setUploadBusyDetailId(null);
}
},
[canEditTreatmentForDay, selectedAppointment, showSuccess, showError, t],
@@ -391,7 +433,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
if (!selectedAppointment) throw new Error('No appointment selected');
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
- details: cases.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
+ details: details.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
clientId,
id,
treatmentType,
@@ -401,14 +443,50 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
})),
});
const mapped = response.data.details.map(mapDetailFromApi);
- setCases(mapped);
- setActiveCaseId((prev) => {
- const stillExists = mapped.some((c) => c.clientId === prev);
+ setDetails(mapped);
+ setActiveDetailId((prev) => {
+ const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
});
- setSavedSnapshot(serializeCases(mapped));
+ setSavedSnapshot(serializeDetails(mapped));
return response.data;
- }, [cases, selectedAppointment]);
+ }, [details, selectedAppointment]);
+
+ const persistLabCases = useCallback(
+ async (savedTreatment: PastTreatment) => {
+ if (!selectedAppointment) throw new Error('No appointment selected');
+
+ const detailIdByClientId = new Map(
+ savedTreatment.details.map((d) => [d.clientId, d.id]),
+ );
+
+ const payload = labCaseDrafts.map((lc) => ({
+ clientId: lc.clientId,
+ id: lc.id,
+ destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
+ labComment: lc.labComment.trim() || undefined,
+ treatmentDetailIds: lc.detailClientIds
+ .map((clientId) => detailIdByClientId.get(clientId))
+ .filter((id): id is string => Boolean(id)),
+ }));
+
+ if (payload.length === 0) {
+ return savedTreatment;
+ }
+
+ const response = await treatmentsApi.saveLabCases(selectedAppointment.id, {
+ labCases: payload,
+ });
+ const mapped = response.data.labCases.map(mapLabCaseDraftFromApi);
+ setLabCaseDrafts(mapped);
+ setActiveLabCaseId((prev) => {
+ if (prev && mapped.some((lc) => lc.clientId === prev)) return prev;
+ return mapped[0]?.clientId ?? null;
+ });
+ return response.data;
+ },
+ [labCaseDrafts, selectedAppointment],
+ );
const handleSaveAll = useCallback(async () => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
@@ -423,70 +501,69 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
}
}, [canEditTreatmentForDay, selectedAppointment, persistDraft, showSuccess, showError, t]);
- const handleSendCase = useCallback(
- async (treatmentCase: TreatmentCaseDraft) => {
+ const handleSaveLabCases = useCallback(async () => {
+ if (!canEditTreatmentForDay || !selectedAppointment) return;
+ setSaveLabBusy(true);
+ try {
+ const saved = await persistDraft();
+ await persistLabCases(saved);
+ showSuccess(t('successLabShipmentsSaved'));
+ } catch (error: unknown) {
+ showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
+ } finally {
+ setSaveLabBusy(false);
+ }
+ }, [
+ canEditTreatmentForDay,
+ selectedAppointment,
+ persistDraft,
+ persistLabCases,
+ showSuccess,
+ showError,
+ t,
+ ]);
+
+ const handleSendLabCase = useCallback(
+ async (labCase: LabCaseDraft) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
- const destinationOrgId = treatmentCase.sendToOrganizationIds.find((id) =>
- orgs.some((o) => o.id === id && o.active),
- );
- if (!destinationOrgId) {
+ if (!labCase.destinationOrganizationId) {
showError(t('errorChooseOrg'));
return;
}
- setSendBusyId(treatmentCase.clientId);
+ if (labCase.detailClientIds.length === 0) {
+ showError(t('errorLabCaseNeedsDetails'));
+ return;
+ }
+
+ setSendBusyId(labCase.clientId);
try {
const saved = await persistDraft();
- const serverDetail = saved.details.find((c) => c.clientId === treatmentCase.clientId);
- if (!serverDetail?.id) throw new Error(t('errorCaseMustSave'));
+ const afterLabCases = await persistLabCases(saved);
- const labCaseClientId = treatmentCase.labCaseId
- ? saved.labCases.find((lc) => lc.id === treatmentCase.labCaseId)?.clientId
- : `lab-${treatmentCase.clientId}`;
-
- const existingLabCase = saved.labCases.find(
- (lc) =>
- lc.treatmentDetailIds.includes(serverDetail.id) &&
- !lc.sentAt,
+ const refreshedLabCase = afterLabCases.labCases.find(
+ (lc) => lc.clientId === labCase.clientId || lc.id === labCase.id,
);
+ if (!refreshedLabCase?.id) throw new Error(t('errorSendCase'));
- const withLabCases = await treatmentsApi.saveLabCases(selectedAppointment.id, {
- labCases: [
- {
- clientId: existingLabCase?.clientId ?? labCaseClientId ?? `lab-${treatmentCase.clientId}`,
- id: existingLabCase?.id ?? treatmentCase.labCaseId ?? undefined,
- destinationOrganizationId: destinationOrgId,
- treatmentDetailIds: [serverDetail.id],
- },
- ],
- });
+ const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
- const labCase = withLabCases.data.labCases.find((lc) =>
- lc.treatmentDetailIds.includes(serverDetail.id),
- );
- if (!labCase?.id) throw new Error(t('errorSendCase'));
-
- const response = await treatmentsApi.sendLabCase(labCase.id);
-
- setCases((prev) => {
- const next = prev.map((c) =>
- c.clientId === treatmentCase.clientId
+ setLabCaseDrafts((prev) =>
+ prev.map((lc) =>
+ lc.clientId === labCase.clientId
? {
- ...c,
- labCaseId: response.data.id,
+ ...lc,
+ id: response.data.id,
sentAt: response.data.sentAt,
- sendToOrganizationIds: response.data.destinationOrganizationId
- ? [response.data.destinationOrganizationId]
- : [],
+ destinationOrganizationId: response.data.destinationOrganizationId,
sends: response.data.sends,
}
- : c,
- );
- setSavedSnapshot(serializeCases(next));
- return next;
- });
+ : lc,
+ ),
+ );
+
setRecentOrganizationIds((prev) => {
- const next = [destinationOrgId, ...prev.filter((id) => id !== destinationOrgId)];
- return next.slice(0, 10);
+ const orgId = labCase.destinationOrganizationId!;
+ return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
});
showSuccess(t('successCaseSent'));
} catch (error: unknown) {
@@ -495,47 +572,33 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
setSendBusyId(null);
}
},
- [canEditTreatmentForDay, selectedAppointment, orgs, persistDraft, showSuccess, showError, t],
+ [
+ canEditTreatmentForDay,
+ selectedAppointment,
+ persistDraft,
+ persistLabCases,
+ showSuccess,
+ showError,
+ t,
+ ],
);
- const openPreview = useCallback(
- (treatment: PastTreatment, mode: TreatmentPreviewMode) => {
- setPreviewTreatment(treatment);
- setPreviewMode(mode);
- setPreviewOpen(true);
- },
- [],
- );
+ const openPreview = useCallback((treatment: PastTreatment, mode: TreatmentPreviewMode) => {
+ setPreviewTreatment(treatment);
+ setPreviewMode(mode);
+ setPreviewOpen(true);
+ }, []);
const openCurrentDraftPreview = useCallback(() => {
if (!currentDraftPreview) return;
openPreview(currentDraftPreview, canEditTreatmentForDay ? 'editable' : 'readonly');
}, [currentDraftPreview, canEditTreatmentForDay, openPreview]);
- const getCaseOrgIds = useCallback(
- (caseKey: string) => cases.find((c) => c.clientId === caseKey)?.sendToOrganizationIds ?? [],
- [cases],
- );
-
- const toggleCaseOrg = useCallback((caseKey: string, orgId: string, checked: boolean) => {
- setCases((prev) =>
- prev.map((c) => {
- if (c.clientId !== caseKey || c.sentAt) return c;
- const next = new Set(c.sendToOrganizationIds);
- if (checked) next.add(orgId);
- else next.delete(orgId);
- return { ...c, sendToOrganizationIds: [...next] };
- }),
- );
- }, []);
-
if (!canView) {
return (
{t('noPermissionTitle')}
-
- {t('noPermissionBody')}
-
+
{t('noPermissionBody')}
);
}
@@ -545,7 +608,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
@@ -579,7 +642,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
{t('purposeLabel')}{' '}
- {t(TREATMENT_TYPE_KEYS[selectedAppointment.purpose as keyof typeof TREATMENT_TYPE_KEYS] ?? selectedAppointment.purpose)}
+ {t(
+ treatmentTypeLabelKey(selectedAppointment.purpose) as 'typeConsultation',
+ )}
@@ -598,7 +663,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
openPreview(t, 'readonly')}
+ onReviewTreatment={(item) => openPreview(item, 'readonly')}
/>
@@ -606,53 +671,73 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
{
- if (!canEditTreatmentForDay) return;
- setCases((prev) =>
- prev.map((c) => {
- if (c.clientId !== activeCaseId) return c;
- const set = new Set(c.teeth);
+ if (!canEditTreatmentForDay || isDetailLocked(activeDetail)) return;
+ setDetails((prev) =>
+ prev.map((d) => {
+ if (d.clientId !== activeDetailId) return d;
+ const set = new Set(d.teeth);
if (set.has(fdi)) set.delete(fdi);
else set.add(fdi);
- return { ...c, teeth: [...set].sort() as FdiToothId[] };
+ return { ...d, teeth: [...set].sort() as FdiToothId[] };
}),
);
}}
- disabled={!canEditTreatmentForDay}
+ disabled={!canEditTreatmentForDay || isDetailLocked(activeDetail)}
/>
- {
+ const next = newDetail();
+ setDetails((prev) => [...prev, next]);
+ setActiveDetailId(next.clientId);
+ }}
+ onPreview={openCurrentDraftPreview}
+ onSave={() => void handleSaveAll()}
+ onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
+ />
+
+ {
- setCases((prev) =>
- prev.map((c) => {
- if (c.clientId !== activeCaseId || c.sentAt) return c;
- if (c.sendToOrganizationIds.includes(orgId)) return c;
- return { ...c, sendToOrganizationIds: [...c.sendToOrganizationIds, orgId] };
- }),
+ if (!activeLabCaseId) return;
+ setLabCaseDrafts((prev) =>
+ prev.map((lc) =>
+ lc.clientId === activeLabCaseId && !lc.sentAt
+ ? { ...lc, destinationOrganizationId: orgId }
+ : lc,
+ ),
);
}}
- onAddCase={() => {
- const nextCase = newCase();
- setCases((prev) => [...prev, nextCase]);
- setActiveCaseId(nextCase.clientId);
+ sendBusyId={sendBusyId}
+ saveLabBusy={saveLabBusy}
+ onAddLabCase={() => {
+ const next = newLabCaseDraft();
+ setLabCaseDrafts((prev) => [...prev, next]);
+ setActiveLabCaseId(next.clientId);
}}
- onPreview={openCurrentDraftPreview}
- onSave={() => void handleSaveAll()}
- onSendCase={(c) => void handleSendCase(c)}
- onUploadFiles={(files) => void uploadForCase(activeCaseId, files ?? [])}
+ onSaveLabCases={() => void handleSaveLabCases()}
+ onSendLabCase={(lc) => void handleSendLabCase(lc)}
/>
@@ -665,16 +750,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
}
mode={previewMode}
orgs={orgs}
- sendBusyCaseId={sendBusyId}
- uploadBusyCaseId={uploadBusyCaseId}
- onAttach={(caseKey, files) => uploadForCase(caseKey, files)}
- onSend={(caseKey, organizationIds) => {
- const c = cases.find((item) => item.clientId === caseKey);
- if (!c) return;
- void handleSendCase({ ...c, sendToOrganizationIds: organizationIds });
- }}
- getCaseOrgIds={getCaseOrgIds}
- onToggleCaseOrg={toggleCaseOrg}
+ uploadBusyCaseId={uploadBusyDetailId}
+ onAttach={(caseKey, files) => uploadForDetail(caseKey, files)}
/>
);
diff --git a/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts b/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts
new file mode 100644
index 0000000..5e5d16f
--- /dev/null
+++ b/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts
@@ -0,0 +1,19 @@
+export const TREATMENT_TYPE_KEYS = {
+ consultation: 'typeConsultation',
+ filling: 'typeFilling',
+ endo: 'typeEndo',
+ visit: 'typeVisit',
+ hygiene: 'typeHygiene',
+} as const;
+
+export const TREATMENT_TYPE_COLORS: Record = {
+ consultation: '#ddd6fe',
+ filling: '#fed7aa',
+ endo: '#fecaca',
+ visit: '#bae6fd',
+ hygiene: '#d9f99d',
+};
+
+export function treatmentTypeLabelKey(code: string): string {
+ return TREATMENT_TYPE_KEYS[code as keyof typeof TREATMENT_TYPE_KEYS] ?? code;
+}
diff --git a/frontend/src/lib/api/treatment-catalog.ts b/frontend/src/lib/api/treatment-catalog.ts
new file mode 100644
index 0000000..001d9fd
--- /dev/null
+++ b/frontend/src/lib/api/treatment-catalog.ts
@@ -0,0 +1,9 @@
+import { apiClient } from './client';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
+
+export const treatmentCatalogApi = {
+ list: async (): Promise<{ success: boolean; data: TreatmentCatalogEntry[] }> => {
+ const response = await apiClient.get('/treatment-catalog');
+ return response.data;
+ },
+};
diff --git a/frontend/src/types/treatment-catalog.ts b/frontend/src/types/treatment-catalog.ts
new file mode 100644
index 0000000..5ecb3d8
--- /dev/null
+++ b/frontend/src/types/treatment-catalog.ts
@@ -0,0 +1,6 @@
+export interface TreatmentCatalogEntry {
+ id: string;
+ code: string;
+ labDependent: boolean;
+ sortOrder: number;
+}
diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts
index ca00506..c511b29 100644
--- a/frontend/src/types/treatment.ts
+++ b/frontend/src/types/treatment.ts
@@ -136,6 +136,16 @@ export interface TreatmentDetailDraft {
/** @deprecated Use TreatmentDetailDraft — kept for editor components until Phase 4 rename */
export type TreatmentCaseDraft = TreatmentDetailDraft;
+export interface LabCaseDraft {
+ clientId: string;
+ id?: string;
+ destinationOrganizationId: string | null;
+ labComment: string;
+ detailClientIds: string[];
+ sentAt?: string | null;
+ sends?: LabCaseSendInfo[];
+}
+
export type SavedTreatmentDetailPayload = {
clientId: string;
id?: string;