improvement: v1 standalone treatment/case creation made possible.

This commit is contained in:
2026-08-19 15:05:58 +03:30
parent e5c39c6b7e
commit 8bfa8c88fe
39 changed files with 3296 additions and 387 deletions

View File

@@ -493,7 +493,25 @@
"caseSheetNoComments": "No comments.",
"errorDownloadCaseSheet": "Could not download the case sheet PDF.",
"externalCodePlaceholder": "external code e.g. exocad code",
"errorUpdateExternalCode": "Could not save the external code."
"errorUpdateExternalCode": "Could not save the external code.",
"addCase": "Add case",
"addCaseTitle": "New case",
"addCaseSubtitle": "Enter the referring clinic or patient, then add prosthesis lines. Start when the case is ready.",
"startCase": "Start",
"draftBadge": "Draft",
"referringClinic": "Referring clinic",
"referringDentist": "Dentist",
"patientName": "Patient name",
"partnerClinic": "Linked clinic",
"partnerClinicNone": "None",
"dueDateField": "Due date",
"caseLinesTitle": "Work lines",
"addLine": "Add line",
"lineChip": "Line {n}",
"removeLineAria": "Remove line {n}",
"errorCreateCase": "Could not create the case.",
"errorSaveCase": "Could not save the case.",
"errorStartCase": "Could not start the case."
},
"labCaseAccess": {
"pageTitle": "Case tasks",
@@ -676,7 +694,16 @@
"showAppointments": "Show appointments",
"appointmentsTitle": "My appointments",
"hideAppointments": "Hide appointments",
"emptyDay": "No appointments assigned to you on this day.",
"emptyDay": "No appointments or unscheduled treatments assigned to you on this day.",
"unscheduledHeading": "Without appointment",
"noAppointment": "No appointment",
"walkIn": "Walk-in",
"newTreatment": "New treatment",
"newWalkInTreatment": "Walk-in treatment",
"errorCreateTreatment": "Could not create treatment.",
"deleteEmptyTreatment": "Delete empty treatment",
"confirmDeleteEmptyTreatment": "Delete this treatment? It has no details yet.",
"errorDeleteTreatment": "Could not delete treatment.",
"casesTitle": "Treatment cases",
"casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.",
"detailsTitle": "Treatment details",
@@ -1181,6 +1208,15 @@
"APPOINTMENT_PATIENT_LOCKED": "Cannot change the patient while a treatment is linked to this appointment.",
"APPOINTMENT_HAS_TREATMENT": "This appointment cannot be deleted because a treatment is linked to it.",
"TREATMENT_DETAIL_SENT": "This detail was sent to a lab and can no longer be changed.",
"TREATMENT_NOT_FOUND": "Treatment not found.",
"TREATMENT_PATIENT_OR_WALK_IN": "Choose a patient or create a walk-in treatment.",
"TREATMENT_NOT_STANDALONE": "Only treatments without an appointment can be deleted this way.",
"TREATMENT_HAS_DETAILS": "This treatment has details and cannot be deleted.",
"LAB_CASE_NOT_EDITABLE": "This case can no longer be edited.",
"LAB_CASE_ALREADY_STARTED": "This case has already been started.",
"LAB_CASE_LINES_REQUIRED": "Add at least one work line with teeth and a prosthesis type.",
"LAB_CASE_START_INCOMPLETE": "Complete teeth and prosthesis types before starting this case.",
"LAB_CASE_CLIENT_REQUIRED": "Enter a clinic name or a patient name for this case.",
"BAD_REQUEST": "The request could not be processed.",
"INTERNAL_ERROR": "Something went wrong on our end. Please try again later."
}

View File

@@ -494,7 +494,25 @@
"caseSheetNoComments": "بدون توضیحات.",
"errorDownloadCaseSheet": "دانلود PDF برگه پرونده ممکن نشد.",
"externalCodePlaceholder": "کد خارجی مثلاً کد exocad",
"errorUpdateExternalCode": "ذخیره کد خارجی ممکن نشد."
"errorUpdateExternalCode": "ذخیره کد خارجی ممکن نشد.",
"addCase": "افزودن پرونده",
"addCaseTitle": "پرونده جدید",
"addCaseSubtitle": "کلینیک ارجاع‌دهنده یا بیمار را وارد کنید و خطوط پروتز را اضافه کنید. وقتی آماده بود، شروع کنید.",
"startCase": "شروع",
"draftBadge": "پیش‌نویس",
"referringClinic": "کلینیک ارجاع‌دهنده",
"referringDentist": "دندان‌پزشک",
"patientName": "نام بیمار",
"partnerClinic": "کلینیک متصل",
"partnerClinicNone": "هیچ‌کدام",
"dueDateField": "موعد",
"caseLinesTitle": "خطوط کار",
"addLine": "افزودن خط",
"lineChip": "خط {n}",
"removeLineAria": "حذف خط {n}",
"errorCreateCase": "ایجاد پرونده ممکن نشد.",
"errorSaveCase": "ذخیره پرونده ممکن نشد.",
"errorStartCase": "شروع پرونده ممکن نشد."
},
"labCaseAccess": {
"pageTitle": "وظایف پرونده",
@@ -677,7 +695,16 @@
"showAppointments": "نمایش نوبت‌ها",
"appointmentsTitle": "نوبت‌های من",
"hideAppointments": "پنهان کردن نوبت‌ها",
"emptyDay": "هیچ نوبتی به شما در این روز اختصاص داده نشده است.",
"emptyDay": "در این روز هیچ نوبت یا درمان بدون نوبتی به شما اختصاص داده نشده است.",
"unscheduledHeading": "بدون نوبت",
"noAppointment": "بدون نوبت",
"walkIn": "بدون نوبت (مراجع)",
"newTreatment": "درمان جدید",
"newWalkInTreatment": "درمان بدون نوبت",
"errorCreateTreatment": "ایجاد درمان ممکن نشد.",
"deleteEmptyTreatment": "حذف درمان خالی",
"confirmDeleteEmptyTreatment": "این درمان حذف شود؟ هنوز جزئیاتی ندارد.",
"errorDeleteTreatment": "حذف درمان ممکن نشد.",
"casesTitle": "پرونده‌های درمانی",
"casesSubtitle": "هر پرونده دارای دندان‌ها، یادداشت‌ها، پیوست‌ها و مقصدهای ارسال خود است.",
"detailsTitle": "جزئیات درمان",
@@ -1182,6 +1209,15 @@
"APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.",
"APPOINTMENT_HAS_TREATMENT": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.",
"TREATMENT_DETAIL_SENT": "این جزئیات به لابراتوار ارسال شده و دیگر قابل تغییر نیست.",
"TREATMENT_NOT_FOUND": "درمان پیدا نشد.",
"TREATMENT_PATIENT_OR_WALK_IN": "یک بیمار انتخاب کنید یا درمان بدون نوبت بسازید.",
"TREATMENT_NOT_STANDALONE": "فقط درمان‌های بدون نوبت را می‌توان این‌گونه حذف کرد.",
"TREATMENT_HAS_DETAILS": "این درمان جزئیات دارد و قابل حذف نیست.",
"LAB_CASE_NOT_EDITABLE": "این پرونده دیگر قابل ویرایش نیست.",
"LAB_CASE_ALREADY_STARTED": "این پرونده قبلاً شروع شده است.",
"LAB_CASE_LINES_REQUIRED": "حداقل یک خط کار با دندان و نوع پروتز اضافه کنید.",
"LAB_CASE_START_INCOMPLETE": "قبل از شروع پرونده، دندان‌ها و نوع پروتز را کامل کنید.",
"LAB_CASE_CLIENT_REQUIRED": "نام کلینیک یا نام بیمار را وارد کنید.",
"BAD_REQUEST": "درخواست قابل پردازش نبود.",
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
}

View File

@@ -494,7 +494,25 @@
"caseSheetNoComments": "Geen opmerkingen.",
"errorDownloadCaseSheet": "Case Sheet PDF kon niet worden gedownload.",
"externalCodePlaceholder": "externe code bijv. exocad-code",
"errorUpdateExternalCode": "Externe code kon niet worden opgeslagen."
"errorUpdateExternalCode": "Externe code kon niet worden opgeslagen.",
"addCase": "Case toevoegen",
"addCaseTitle": "Nieuwe case",
"addCaseSubtitle": "Vul de verwijzende kliniek of patiënt in en voeg protheselijnen toe. Start wanneer de case klaar is.",
"startCase": "Starten",
"draftBadge": "Concept",
"referringClinic": "Verwijzende kliniek",
"referringDentist": "Tandarts",
"patientName": "Patiëntnaam",
"partnerClinic": "Gekoppelde kliniek",
"partnerClinicNone": "Geen",
"dueDateField": "Vervaldatum",
"caseLinesTitle": "Werklijnen",
"addLine": "Lijn toevoegen",
"lineChip": "Lijn {n}",
"removeLineAria": "Lijn {n} verwijderen",
"errorCreateCase": "Case kon niet worden aangemaakt.",
"errorSaveCase": "Case kon niet worden opgeslagen.",
"errorStartCase": "Case kon niet worden gestart."
},
"labCaseAccess": {
"pageTitle": "Dossiertaken",
@@ -676,7 +694,16 @@
"showAppointments": "Afspraken tonen",
"appointmentsTitle": "Mijn afspraken",
"hideAppointments": "Afspraken verbergen",
"emptyDay": "Geen afspraken aan u toegewezen op deze dag.",
"emptyDay": "Geen afspraken of ongeplande behandelingen voor u op deze dag.",
"unscheduledHeading": "Zonder afspraak",
"noAppointment": "Geen afspraak",
"walkIn": "Inloop",
"newTreatment": "Nieuwe behandeling",
"newWalkInTreatment": "Inloopbehandeling",
"errorCreateTreatment": "Behandeling aanmaken is mislukt.",
"deleteEmptyTreatment": "Lege behandeling verwijderen",
"confirmDeleteEmptyTreatment": "Deze behandeling verwijderen? Er zijn nog geen details.",
"errorDeleteTreatment": "Behandeling kon niet worden verwijderd.",
"casesTitle": "Behandelcasussen",
"casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.",
"detailsTitle": "Behandeldetails",
@@ -1181,6 +1208,15 @@
"APPOINTMENT_PATIENT_LOCKED": "De patiënt kan niet worden gewijzigd zolang er een behandeling aan deze afspraak is gekoppeld.",
"APPOINTMENT_HAS_TREATMENT": "Deze afspraak kan niet worden verwijderd omdat er een behandeling aan is gekoppeld.",
"TREATMENT_DETAIL_SENT": "Dit detail is naar het lab verzonden en kan niet meer worden gewijzigd.",
"TREATMENT_NOT_FOUND": "Behandeling niet gevonden.",
"TREATMENT_PATIENT_OR_WALK_IN": "Kies een patiënt of maak een inloopbehandeling.",
"TREATMENT_NOT_STANDALONE": "Alleen behandelingen zonder afspraak kunnen op deze manier worden verwijderd.",
"TREATMENT_HAS_DETAILS": "Deze behandeling heeft details en kan niet worden verwijderd.",
"LAB_CASE_NOT_EDITABLE": "Deze case kan niet meer worden bewerkt.",
"LAB_CASE_ALREADY_STARTED": "Deze case is al gestart.",
"LAB_CASE_LINES_REQUIRED": "Voeg minstens één werkregel toe met tanden en een prothesetype.",
"LAB_CASE_START_INCOMPLETE": "Vul tanden en prothesetypes in voordat u deze case start.",
"LAB_CASE_CLIENT_REQUIRED": "Voer een klinieknaam of een patiëntnaam in voor deze case.",
"BAD_REQUEST": "Het verzoek kon niet worden verwerkt.",
"INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw."
}

View File

@@ -0,0 +1,14 @@
export type DayStripItemKind = 'appointment' | 'unscheduled';
export type DayStripItem = {
kind: DayStripItemKind;
id: string;
patientId: string;
patientFirstName: string;
patientLastName: string;
patientIsWalkIn: boolean;
colorCode: string;
timeLabel: string | null;
subtitle: string;
canDelete?: boolean;
};

View File

@@ -0,0 +1,669 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Trash2 } from 'lucide-react';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import {
applyShiftRange,
deriveTeethFromGroups,
groupsFromFlatTeeth,
linkedEdgesFromGroups,
linkAdjacentTeeth,
normalizeToothSelectionGroups,
pruneToothProsthesisForGroups,
toggleToothInGroups,
toothEdgeKey,
unlinkAdjacentTeeth,
type ToothSelectionGroup,
} from '@/components/treatment/toothSelectionGroups';
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
import { Button } from '@/components/ui/shared/Button';
import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
import { casesApi } from '@/lib/api/cases';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import type { LabCaseAttachmentMeta, LabCaseDetail } from '@/types/cases';
import type { FdiToothId } from '@/types/treatment';
import type { LinkedOrganizationOption } from '@/types/treatment';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
const INPUT_CLASS =
'mt-1 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35';
type LineDraft = {
clientId: string;
id?: string;
teeth: FdiToothId[];
toothSelectionGroups: ToothSelectionGroup[];
comment: string;
toothProsthesis: Array<{
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;
detailClientId: string;
}>;
attachments: LabCaseAttachmentMeta[];
};
function newLine(): LineDraft {
const clientId =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `line-${Date.now()}`;
return {
clientId,
teeth: [],
toothSelectionGroups: [],
comment: '',
toothProsthesis: [],
attachments: [],
};
}
function linesFromDetail(labCase: LabCaseDetail): LineDraft[] {
const source = labCase.lines ?? [];
if (source.length === 0) return [newLine()];
return source.map((line) => {
const groups = normalizeToothSelectionGroups(line.toothSelectionGroups);
const teeth = (line.teeth as FdiToothId[]) ?? [];
return {
clientId: line.clientId || line.id,
id: line.id,
teeth,
toothSelectionGroups: groups.length ? groups : groupsFromFlatTeeth(teeth),
comment: line.comment ?? '',
toothProsthesis: labCase.toothProsthesis
.filter((tp) => tp.lineId === line.id)
.map((tp) => ({
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
selectionGroupId: tp.selectionGroupId,
detailClientId: line.clientId || line.id,
})),
attachments: labCase.attachments.filter(
(a) => a.detailClientKey === (line.clientId || line.id),
),
};
});
}
interface CaseCreatePanelProps {
labCase: LabCaseDetail;
canEdit: boolean;
onSaved: (detail: LabCaseDetail) => void;
onStarted: (detail: LabCaseDetail) => void;
onError: (message: string) => void;
}
export function CaseCreatePanel({
labCase,
canEdit,
onSaved,
onStarted,
onError,
}: CaseCreatePanelProps) {
const t = useTranslations('cases');
const tTreatment = useTranslations('treatment');
const tErrors = useTranslations('errors');
const tCommon = useTranslations('common');
const [referringClinicName, setReferringClinicName] = useState(
labCase.referringClinicName ?? '',
);
const [referringDentistName, setReferringDentistName] = useState(
labCase.referringDentistName ?? '',
);
const [patientDisplayName, setPatientDisplayName] = useState(
labCase.patientDisplayName ?? '',
);
const [patientDisplayMobile, setPatientDisplayMobile] = useState(
labCase.patientDisplayMobile ?? '',
);
const [partnerClinicOrganizationId, setPartnerClinicOrganizationId] = useState(
labCase.partnerClinicOrganizationId ?? '',
);
const [dueDate, setDueDate] = useState(toDateInputValue(labCase.dueDate));
const [lines, setLines] = useState<LineDraft[]>(() => linesFromDetail(labCase));
const [activeLineId, setActiveLineId] = useState(
() => linesFromDetail(labCase)[0]?.clientId ?? '',
);
const [partners, setPartners] = useState<LinkedOrganizationOption[]>([]);
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [saving, setSaving] = useState(false);
const [starting, setStarting] = useState(false);
const [uploadBusy, setUploadBusy] = useState(false);
const rangeAnchorRef = useRef<FdiToothId | null>(null);
const hydratedIdRef = useRef(labCase.id);
const skipSaveRef = useRef(true);
const startingRef = useRef(false);
const allowPersistWhileStartingRef = useRef(false);
const attachmentInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (hydratedIdRef.current === labCase.id) return;
hydratedIdRef.current = labCase.id;
skipSaveRef.current = true;
const nextLines = linesFromDetail(labCase);
setReferringClinicName(labCase.referringClinicName ?? '');
setReferringDentistName(labCase.referringDentistName ?? '');
setPatientDisplayName(labCase.patientDisplayName ?? '');
setPatientDisplayMobile(labCase.patientDisplayMobile ?? '');
setPartnerClinicOrganizationId(labCase.partnerClinicOrganizationId ?? '');
setDueDate(toDateInputValue(labCase.dueDate));
setLines(nextLines);
setActiveLineId(nextLines[0]?.clientId ?? '');
}, [labCase]);
useEffect(() => {
void casesApi.listLinkedClinics().then((r) => setPartners(r.data)).catch(() => undefined);
void prosthesisCatalogApi.list().then((r) => setProsthesisOptions(r.data)).catch(() => undefined);
}, []);
const activeLine = lines.find((l) => l.clientId === activeLineId) ?? lines[0];
const groups =
activeLine?.toothSelectionGroups.length
? activeLine.toothSelectionGroups
: groupsFromFlatTeeth(activeLine?.teeth ?? []);
const selected = new Set(activeLine?.teeth ?? []);
const linkedEdges = linkedEdgesFromGroups(groups);
const prosthesisRows = groups.map((g) => ({
groupId: g.groupId,
kind: g.kind,
teeth: g.teeth,
}));
const toothColors = useMemo(() => {
const colors: Partial<Record<FdiToothId, string>> = {};
for (const tp of activeLine?.toothProsthesis ?? []) {
const color = prosthesisTypeColorFromCatalog(tp.prosthesisTypeCode, prosthesisOptions);
if (color) colors[tp.tooth as FdiToothId] = color;
}
return colors;
}, [activeLine?.toothProsthesis, prosthesisOptions]);
const buildPayload = useCallback(
() => ({
referringClinicName: referringClinicName.trim() || null,
referringDentistName: referringDentistName.trim() || null,
patientDisplayName: patientDisplayName.trim() || null,
patientDisplayMobile: patientDisplayMobile.trim() || null,
partnerClinicOrganizationId: partnerClinicOrganizationId || null,
dueDate: dueDate || null,
lines: lines.map((line) => ({
clientId: line.clientId,
id: line.id,
teeth: line.teeth,
toothSelectionGroups: line.toothSelectionGroups,
comment: line.comment,
toothProsthesis: line.toothProsthesis.map((tp) => ({
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
selectionGroupId: tp.selectionGroupId,
})),
attachmentIds: line.attachments.map((a) => a.id),
})),
}),
[
referringClinicName,
referringDentistName,
patientDisplayName,
patientDisplayMobile,
partnerClinicOrganizationId,
dueDate,
lines,
],
);
const persist = useCallback(async () => {
if (startingRef.current && !allowPersistWhileStartingRef.current) {
return null;
}
setSaving(true);
try {
const response = await casesApi.update(labCase.id, buildPayload());
setLines((prev) =>
prev.map((line) => {
const saved = response.data.lines?.find((l) => l.clientId === line.clientId);
return saved ? { ...line, id: saved.id } : line;
}),
);
onSaved(response.data);
return response.data;
} catch (error: unknown) {
onError(getUserFacingError(error, tErrors, t('errorSaveCase')));
return null;
} finally {
setSaving(false);
}
}, [buildPayload, labCase.id, onError, onSaved, t, tErrors]);
const persistRef = useRef(persist);
persistRef.current = persist;
useEffect(() => {
if (skipSaveRef.current) {
skipSaveRef.current = false;
return;
}
if (!canEdit || startingRef.current) return;
const timeout = setTimeout(() => {
void persistRef.current();
}, 500);
return () => clearTimeout(timeout);
}, [buildPayload, canEdit]);
function updateActiveLine(patch: (line: LineDraft) => LineDraft) {
setLines((prev) =>
prev.map((line) => (line.clientId === activeLine?.clientId ? patch(line) : line)),
);
}
async function handleStart() {
if (!canEdit) return;
setStarting(true);
startingRef.current = true;
skipSaveRef.current = true;
allowPersistWhileStartingRef.current = true;
try {
const saved = await persist();
allowPersistWhileStartingRef.current = false;
if (!saved) {
startingRef.current = false;
return;
}
skipSaveRef.current = true;
const response = await casesApi.start(labCase.id);
onStarted(response.data);
} catch (error: unknown) {
startingRef.current = false;
onError(getUserFacingError(error, tErrors, t('errorStartCase')));
} finally {
allowPersistWhileStartingRef.current = false;
setStarting(false);
}
}
async function handleUpload(files: FileList | null) {
if (!files?.length || !activeLine || !canEdit) return;
setUploadBusy(true);
try {
await persist();
const response = await casesApi.uploadLineAttachments(
labCase.id,
activeLine.clientId,
Array.from(files),
);
updateActiveLine((line) => ({
...line,
attachments: [...line.attachments, ...response.data],
}));
} catch (error: unknown) {
onError(getUserFacingError(error, tErrors, t('errorSaveCase')));
} finally {
setUploadBusy(false);
if (attachmentInputRef.current) attachmentInputRef.current.value = '';
}
}
const disabled = !canEdit || starting;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-text-primary">{t('addCaseTitle')}</h2>
<p className="text-sm text-text-muted mt-0.5">{t('addCaseSubtitle')}</p>
</div>
<Button
type="button"
variant="primary"
disabled={disabled || saving}
onClick={() => void handleStart()}
>
{starting ? tCommon('loading') : t('startCase')}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-xs font-medium text-text-muted">
{t('referringClinic')}
<input
className={INPUT_CLASS}
value={referringClinicName}
disabled={disabled}
onChange={(e) => setReferringClinicName(e.target.value)}
/>
</label>
<label className="text-xs font-medium text-text-muted">
{t('referringDentist')}
<input
className={INPUT_CLASS}
value={referringDentistName}
disabled={disabled}
onChange={(e) => setReferringDentistName(e.target.value)}
/>
</label>
<label className="text-xs font-medium text-text-muted">
{t('patientName')}
<input
className={INPUT_CLASS}
value={patientDisplayName}
disabled={disabled}
onChange={(e) => setPatientDisplayName(e.target.value)}
/>
</label>
<label className="text-xs font-medium text-text-muted">
{t('patientMobile')}
<input
className={INPUT_CLASS}
value={patientDisplayMobile}
disabled={disabled}
onChange={(e) => setPatientDisplayMobile(e.target.value)}
/>
</label>
<label className="text-xs font-medium text-text-muted">
{t('partnerClinic')}
<select
className={`${FORM_SELECT_CLASS} ${INPUT_CLASS} mt-1`}
value={partnerClinicOrganizationId}
disabled={disabled}
onChange={(e) => setPartnerClinicOrganizationId(e.target.value)}
>
<option value="">{t('partnerClinicNone')}</option>
{partners.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
</label>
<label className="text-xs font-medium text-text-muted">
{t('dueDateField')}
<AppDateInput
value={dueDate}
onChange={setDueDate}
disabled={disabled}
className={`${INPUT_CLASS} mt-1`}
/>
</label>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-text-primary">{t('caseLinesTitle')}</p>
<Button
type="button"
variant="primary"
disabled={disabled}
onClick={() => {
const line = newLine();
setLines((prev) => [...prev, line]);
setActiveLineId(line.clientId);
}}
>
{t('addLine')}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{lines.map((line, idx) => {
const isActive = line.clientId === activeLine?.clientId;
return (
<div
key={line.clientId}
className={`inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border ${
isActive
? 'border-primary bg-primary-soft'
: 'border-border/70 hover:border-border'
}`}
>
<button
type="button"
className="px-3 py-1.5 text-sm text-text-primary"
onClick={() => setActiveLineId(line.clientId)}
>
{t('lineChip', { n: idx + 1 })}
</button>
{lines.length > 1 ? (
<button
type="button"
disabled={disabled}
title={tCommon('delete')}
aria-label={t('removeLineAria', { n: idx + 1 })}
onClick={() => {
setLines((prev) => {
const next = prev.filter((l) => l.clientId !== line.clientId);
if (activeLineId === line.clientId) {
setActiveLineId(next[0]?.clientId ?? '');
}
return next;
});
}}
className="inline-flex items-center border-s border-border/60 px-1.5 text-text-muted hover:bg-red-500/15 hover:text-red-600"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</button>
) : null}
</div>
);
})}
</div>
{activeLine ? (
<>
<FdiToothChart
selected={selected}
linkedEdges={linkedEdges}
toothColors={toothColors}
disabled={disabled}
onToggle={(fdi, event) => {
if (disabled) return;
const currentGroups =
activeLine.toothSelectionGroups.length > 0
? activeLine.toothSelectionGroups
: groupsFromFlatTeeth(activeLine.teeth);
let nextGroups: ToothSelectionGroup[] | null = null;
if (event.shiftKey) {
const anchor = rangeAnchorRef.current;
if (!anchor || anchor === fdi) {
rangeAnchorRef.current = fdi;
return;
}
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
rangeAnchorRef.current = fdi;
} else {
nextGroups = toggleToothInGroups(currentGroups, fdi);
rangeAnchorRef.current = fdi;
}
if (!nextGroups) return;
updateActiveLine((line) => ({
...line,
toothSelectionGroups: nextGroups!,
teeth: deriveTeethFromGroups(nextGroups!),
toothProsthesis: pruneToothProsthesisForGroups(
line.toothProsthesis,
line.clientId,
nextGroups!,
),
}));
}}
onToggleLink={(a, b) => {
if (disabled) return;
const currentGroups =
activeLine.toothSelectionGroups.length > 0
? activeLine.toothSelectionGroups
: groupsFromFlatTeeth(activeLine.teeth);
const edgeLinked = linkedEdgesFromGroups(currentGroups).has(toothEdgeKey(a, b));
const nextGroups = edgeLinked
? unlinkAdjacentTeeth(currentGroups, a, b)
: linkAdjacentTeeth(currentGroups, a, b);
if (!nextGroups) return;
updateActiveLine((line) => ({
...line,
toothSelectionGroups: nextGroups,
teeth: deriveTeethFromGroups(nextGroups),
toothProsthesis: pruneToothProsthesisForGroups(
line.toothProsthesis,
line.clientId,
nextGroups,
),
}));
}}
/>
{prosthesisRows.length > 0 ? (
<div className="space-y-3">
<p className="text-xs font-medium text-text-secondary">
{tTreatment('prosthesisTypesTitle')}
</p>
{prosthesisRows.every((r) => r.kind === 'single') &&
prosthesisRows.reduce((sum, r) => sum + r.teeth.length, 0) > 1 ? (
<label className="block text-xs text-text-muted space-y-1">
{tTreatment('prosthesisApplyAll')}
<select
value={applyAllProsthesis}
disabled={disabled || prosthesisOptions.length === 0}
onChange={(e) => {
const code = e.target.value;
setApplyAllProsthesis(code);
if (!code) return;
updateActiveLine((line) => ({
...line,
toothProsthesis: prosthesisRows.flatMap((row) =>
row.teeth.map((tooth) => ({
tooth,
prosthesisTypeCode: code,
selectionGroupId: row.groupId,
detailClientId: line.clientId,
})),
),
}));
}}
className={`${FORM_SELECT_CLASS} w-full mt-1`}
>
<option value="">{tTreatment('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</label>
) : null}
<div className="space-y-3">
{prosthesisRows.map((row) => {
const current =
activeLine.toothProsthesis.find(
(tp) =>
tp.selectionGroupId === row.groupId &&
(row.teeth as string[]).includes(tp.tooth),
)?.prosthesisTypeCode ??
activeLine.toothProsthesis.find((tp) =>
(row.teeth as string[]).includes(tp.tooth),
)?.prosthesisTypeCode ??
'';
return (
<label
key={row.groupId}
className="block text-xs text-text-muted rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/40 px-3 py-2"
>
<span className="grid grid-cols-1 gap-2 md:grid-cols-2 md:items-center md:gap-3">
<span className="flex flex-wrap items-center gap-2 text-text-secondary">
{row.kind === 'connected' ? <ConnectedSelectionBadge /> : null}
<span>
{row.kind === 'connected'
? tTreatment('prosthesisConnectedLabel')
: tTreatment('prosthesisColTooth')}
{': '}
<span className="text-text-primary">{row.teeth.join(', ')}</span>
</span>
</span>
<select
value={current}
disabled={disabled}
onChange={(e) => {
const code = e.target.value;
updateActiveLine((line) => {
const rest = line.toothProsthesis.filter(
(tp) => !(row.teeth as string[]).includes(tp.tooth),
);
const next = code
? row.teeth.map((tooth) => ({
tooth,
prosthesisTypeCode: code,
selectionGroupId: row.groupId,
detailClientId: line.clientId,
}))
: [];
return { ...line, toothProsthesis: [...rest, ...next] };
});
}}
className={`${FORM_SELECT_CLASS} w-full min-w-0`}
>
<option value="">{tTreatment('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</span>
</label>
);
})}
</div>
</div>
) : null}
<label className="block text-xs font-medium text-text-muted">
{tTreatment('comments')}
<textarea
className={`${INPUT_CLASS} resize-y min-h-[60px]`}
value={activeLine.comment}
disabled={disabled}
onChange={(e) =>
updateActiveLine((line) => ({ ...line, comment: e.target.value }))
}
/>
</label>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-text-muted">{tTreatment('attachments')}</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || uploadBusy}
onClick={() => attachmentInputRef.current?.click()}
>
{tTreatment('chooseFiles')}
</Button>
<input
ref={attachmentInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => void handleUpload(e.target.files)}
/>
</div>
{activeLine.attachments.length ? (
<ul className="text-xs text-text-secondary space-y-1">
{activeLine.attachments.map((file) => (
<li key={file.id}>{file.fileName}</li>
))}
</ul>
) : (
<p className="text-xs text-text-muted">{t('noAttachments')}</p>
)}
</div>
</>
) : null}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
import { CaseCreatePanel } from '@/components/ui/lab/CaseCreatePanel';
import { LabCaseProsthesisGroupsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import {
@@ -79,6 +80,7 @@ export function CasesPage() {
const [assignableStaff, setAssignableStaff] = useState<AssignableTaskStaff[]>([]);
const [assigningTaskId, setAssigningTaskId] = useState<string | null>(null);
const [commentCount, setCommentCount] = useState(0);
const [creatingCase, setCreatingCase] = useState(false);
const canEdit = canEditCases(currentOrganization);
const canEditComments = canEditTasks(currentOrganization);
@@ -260,7 +262,7 @@ export function CasesPage() {
item.id === selectedCaseId ? { ...item, hasUnread: false } : item,
),
);
});
}).catch(() => undefined);
void tasksApi
.listComments(selectedCaseId)
.then((r) => setCommentCount(r.data.length))
@@ -272,6 +274,34 @@ export function CasesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes
}, [selectedCaseId]);
async function handleAddCase() {
if (!canEdit || creatingCase) return;
setCreatingCase(true);
toast.setError('');
try {
const response = await casesApi.create();
setSelectedCase(response.data);
setSelectedCaseId(response.data.id);
setMobileDetailOpen(true);
setPage(1);
await loadCases({
q: search,
clinicOrganizationId: clinicId,
prosthesisTypeCode,
sentFrom,
sentTo,
page: 1,
});
} catch (error: unknown) {
toast.showError(getUserFacingError(error, tErrors, t('errorCreateCase')));
} finally {
setCreatingCase(false);
}
}
const isDraftCase = (item: { origin?: string; startedAt?: string | null }) =>
item.origin === 'LAB_INTERNAL' && !item.startedAt;
function scrollToComments() {
document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
}
@@ -365,15 +395,30 @@ export function CasesPage() {
mobileDetailOpen && selectedCaseId ? 'hidden lg:flex' : 'flex'
}`}
>
<SearchBar
embedded
value={search}
onChange={(value) => {
setSearch(value);
setPage(1);
}}
placeholder={t('searchPlaceholder')}
/>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<SearchBar
embedded
value={search}
onChange={(value) => {
setSearch(value);
setPage(1);
}}
placeholder={t('searchPlaceholder')}
/>
</div>
{canEdit ? (
<Button
variant="primary"
size="sm"
className="shrink-0"
disabled={creatingCase}
onClick={() => void handleAddCase()}
>
{t('addCase')}
</Button>
) : null}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<label className="space-y-1">
@@ -484,6 +529,11 @@ export function CasesPage() {
{t('importantLabel')}
</Badge>
) : null}
{isDraftCase(item) ? (
<Badge variant="default" fixedWidth={false}>
{t('draftBadge')}
</Badge>
) : null}
</div>
{item.hasUnread ? (
<span
@@ -500,7 +550,9 @@ export function CasesPage() {
/>
</div>
<div className="text-[10px] text-text-muted mt-1">
{formatCaseDateTime(item.sentAt, locale)}
{isDraftCase(item)
? t('draftBadge')
: formatCaseDateTime(item.startedAt ?? item.sentAt, locale)}
</div>
<div className="mt-1.5">
<CaseTaskProgressBar
@@ -557,6 +609,57 @@ export function CasesPage() {
<p className="text-sm text-text-muted">{t('emptyList')}</p>
) : loadingDetail || !selectedCase ? (
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
) : isDraftCase(selectedCase) ? (
<CaseCreatePanel
key={selectedCase.id}
labCase={selectedCase}
canEdit={canEdit}
onSaved={(detail) => {
setSelectedCase(detail);
setCases((prev) =>
prev.map((item) =>
item.id === detail.id
? {
...item,
startedAt: detail.startedAt,
origin: detail.origin,
dueDate: detail.dueDate,
clinic: detail.clinic,
patient: detail.patient,
}
: item,
),
);
}}
onStarted={(detail) => {
setSelectedCase(detail);
setCases((prev) =>
prev.map((item) =>
item.id === detail.id
? {
...item,
startedAt: detail.startedAt,
origin: detail.origin,
dueDate: detail.dueDate,
clinic: detail.clinic,
patient: detail.patient,
prosthesisGroups: item.prosthesisGroups,
taskProgress: detail.taskProgress,
}
: item,
),
);
void loadCases({
q: search,
clinicOrganizationId: clinicId,
prosthesisTypeCode,
sentFrom,
sentTo,
page,
});
}}
onError={toast.showError}
/>
) : (
<CaseDetailPanel
labCase={selectedCase}

View File

@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { useTranslations } from 'next-intl';
import { Eye, EyeOff, Send } from 'lucide-react';
import { getUserFacingError } from '@/components/shared/formatApiError';
@@ -56,18 +56,20 @@ export function LabCaseCommentsPanel({
const toggleBusy = useAsyncActionById();
const orderedComments = useMemo(() => sortNewestFirst(comments), [comments]);
const loadCommentsRef = useRef(loadComments);
loadCommentsRef.current = loadComments;
const refresh = useCallback(async () => {
setLoading(true);
try {
const items = await loadComments();
const items = await loadCommentsRef.current();
setComments(sortNewestFirst(items));
} catch (error: unknown) {
onError?.(getUserFacingError(error, tErrors, t('errorLoad')));
} finally {
setLoading(false);
}
}, [loadComments, onError, t, tErrors]);
}, [onError, t, tErrors]);
useEffect(() => {
void refresh();

View File

@@ -1,26 +1,23 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useTranslations } from 'next-intl';
import { CalendarDays } from 'lucide-react';
import { Card } from '@/components/ui/shared/Card';
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
import {
treatmentTypeBannerStyle,
treatmentTypeLabelFromCatalog,
} from '@/components/shared/treatmentTypeDisplay';
import { DayStripCard } from '@/components/ui/treatment/DayStripCard';
import type { DayStripItem } from '@/components/treatment/dayStrip';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { TreatmentAppointment } from '@/types/treatment';
import { APP_DATE, formatAppTimeRange } from '@/lib/i18n/format';
interface AppointmentsStripProps {
stripHidden: boolean;
onToggleStripHidden: () => void;
selectedDay: Date;
onSelectDay: (day: Date) => void;
appointments: TreatmentAppointment[];
selectedAppointmentId: string | null;
onSelectAppointment: (id: string) => void;
items: DayStripItem[];
selectedItemId: string | null;
onSelectItem: (item: DayStripItem) => void;
onDeleteUnscheduled?: (item: DayStripItem) => void;
treatmentCatalog: TreatmentCatalogEntry[];
loading?: boolean;
}
@@ -30,13 +27,13 @@ export function AppointmentsStrip({
onToggleStripHidden,
selectedDay,
onSelectDay,
appointments,
selectedAppointmentId,
onSelectAppointment,
items,
selectedItemId,
onSelectItem,
onDeleteUnscheduled,
treatmentCatalog,
loading = false,
}: AppointmentsStripProps) {
const locale = useLocale();
const t = useTranslations('treatment');
if (stripHidden) {
@@ -54,6 +51,9 @@ export function AppointmentsStrip({
);
}
const timed = items.filter((item) => item.kind === 'appointment');
const unscheduled = items.filter((item) => item.kind === 'unscheduled');
return (
<Card className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
@@ -80,37 +80,44 @@ export function AppointmentsStrip({
)}
</div>
<div className="flex flex-wrap gap-2">
{appointments.length === 0 && (
<p className="text-sm text-text-muted">{t('emptyDay')}</p>
)}
{appointments.map((a) => {
const sel = a.id === selectedAppointmentId;
const timeLabel = formatAppTimeRange(a.startAt, a.endAt, locale);
const purposeLabel = treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog);
const purposeIndex = treatmentCatalog.findIndex((e) => e.code === a.purpose);
return (
<Card
as="button"
key={a.id}
type="button"
onClick={() => onSelectAppointment(a.id)}
padding="none"
style={treatmentTypeBannerStyle(a.purpose, purposeIndex < 0 ? 0 : purposeIndex)}
className={`
text-start rounded-[var(--radius-sm)] px-3 py-2 w-full sm:w-auto sm:min-w-[200px] sm:max-w-[280px] transition-shadow min-h-[52px]
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${sel ? 'ring-2 ring-primary ring-offset-2 ring-offset-background-secondary shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]' : 'hover:brightness-110'}
`}
>
<p className="text-[11px] font-medium tabular-nums opacity-95">{timeLabel}</p>
<p className="text-sm font-medium leading-tight truncate mt-0.5">
{a.patientFirstName} {a.patientLastName}
</p>
<p className="text-[11px] opacity-90 mt-0.5 truncate">{purposeLabel}</p>
</Card>
);
})}
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{timed.length === 0 && unscheduled.length === 0 && (
<p className="text-sm text-text-muted">{t('emptyDay')}</p>
)}
{timed.map((item) => (
<DayStripCard
key={`${item.kind}-${item.id}`}
item={item}
selected={item.id === selectedItemId}
treatmentCatalog={treatmentCatalog}
onSelect={() => onSelectItem(item)}
/>
))}
</div>
{unscheduled.length > 0 ? (
<div className="space-y-2">
<p className="text-[10px] uppercase tracking-wide text-text-muted">{t('unscheduledHeading')}</p>
<div className="flex flex-wrap gap-2">
{unscheduled.map((item) => (
<DayStripCard
key={`${item.kind}-${item.id}`}
item={item}
selected={item.id === selectedItemId}
treatmentCatalog={treatmentCatalog}
onSelect={() => onSelectItem(item)}
canDelete={item.canDelete}
onDelete={
item.canDelete && onDeleteUnscheduled
? () => onDeleteUnscheduled(item)
: undefined
}
deleteAriaLabel={t('deleteEmptyTreatment')}
/>
))}
</div>
</div>
) : null}
</div>
</Card>
);

View File

@@ -0,0 +1,97 @@
'use client';
import { Trash2 } from 'lucide-react';
import { Card } from '@/components/ui/shared/Card';
import { treatmentTypeBannerStyle } from '@/components/shared/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { DayStripItem } from '@/components/treatment/dayStrip';
interface DayStripCardProps {
item: DayStripItem;
selected: boolean;
treatmentCatalog: TreatmentCatalogEntry[];
onSelect: () => void;
canDelete?: boolean;
onDelete?: () => void;
deleteAriaLabel?: string;
}
export function DayStripCard({
item,
selected,
treatmentCatalog,
onSelect,
canDelete = false,
onDelete,
deleteAriaLabel,
}: DayStripCardProps) {
const purposeIndex = treatmentCatalog.findIndex((e) => e.code === item.colorCode);
const bannerStyle = treatmentTypeBannerStyle(item.colorCode, purposeIndex < 0 ? 0 : purposeIndex);
const selectedClass = selected
? 'ring-2 ring-primary ring-offset-2 ring-offset-background-secondary shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]'
: 'hover:brightness-110';
const shellClass = `
text-start rounded-[var(--radius-sm)] w-full sm:w-auto sm:min-w-[200px] sm:max-w-[280px] transition-shadow min-h-[52px]
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${selectedClass}
`;
const body = (
<>
{item.timeLabel ? (
<p className="text-[11px] font-medium tabular-nums opacity-95">{item.timeLabel}</p>
) : (
<p className="text-[11px] font-medium opacity-95">{item.subtitle}</p>
)}
<p className="text-sm font-medium leading-tight truncate mt-0.5">
{item.patientFirstName} {item.patientLastName}
</p>
{item.timeLabel ? (
<p className="text-[11px] opacity-90 mt-0.5 truncate">{item.subtitle}</p>
) : null}
</>
);
if (!canDelete || !onDelete) {
return (
<Card
as="button"
type="button"
onClick={onSelect}
padding="none"
style={bannerStyle}
className={`${shellClass} px-3 py-2`}
>
{body}
</Card>
);
}
return (
<Card
padding="none"
style={bannerStyle}
className={`${shellClass} flex items-stretch overflow-hidden`}
>
<button
type="button"
onClick={onSelect}
className="min-w-0 flex-1 text-start px-3 py-2 focus:outline-none"
>
{body}
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
aria-label={deleteAriaLabel}
title={deleteAriaLabel}
className="shrink-0 self-center pe-2.5 ps-1 opacity-80 hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 rounded-[var(--radius-sm)] text-inherit"
>
<Trash2 className="h-4 w-4 text-inherit" aria-hidden />
</button>
</Card>
);
}

View File

@@ -33,6 +33,7 @@ import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { treatmentsApi } from '@/lib/api/treatments';
import { notificationsApi } from '@/lib/api/notifications';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import type { DayStripItem } from '@/components/treatment/dayStrip';
import {
areDetailsPersistable,
defaultTreatmentTypeForAppointment,
@@ -59,7 +60,7 @@ import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithi
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
import { tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import { APP_DATE, formatAppDate } from '@/lib/i18n/format';
import { APP_DATE, formatAppDate, formatAppTimeRange } from '@/lib/i18n/format';
import { useAuth } from '@/lib/hooks/useAuth';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
@@ -144,7 +145,7 @@ function enrichDetailsWithLabSendState(
}
function buildWorkspaceSnapshot(
appointment: TreatmentAppointment,
ctx: { id: string; patientId: string; treatmentAt: string; appointmentId: string | null },
details: TreatmentDetailDraft[],
labCaseDrafts: LabCaseDraft[],
title: string,
@@ -153,12 +154,12 @@ function buildWorkspaceSnapshot(
const detailsForPreview = enrichDetailsWithLabSendState(details, labCaseDrafts);
return {
...detailsToPreviewTreatment(detailsForPreview, {
id: id ?? `preview-${appointment.id}`,
id: id ?? `preview-${ctx.id}`,
title,
patientId: appointment.patientId,
treatmentAt: appointment.startAt,
patientId: ctx.patientId,
treatmentAt: ctx.treatmentAt,
}),
appointmentId: appointment.id,
appointmentId: ctx.appointmentId,
labCases: labCaseDraftsToPast(labCaseDrafts, details),
};
}
@@ -233,7 +234,7 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
destinationOrganizationId: lc.destinationOrganizationId,
detailClientId: lc.detail?.clientId ?? null,
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
detailClientId: lc.detail?.clientId ?? tp.treatmentDetailId,
detailClientId: lc.detail?.clientId ?? tp.treatmentDetailId ?? '',
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
selectionGroupId: tp.selectionGroupId ?? '',
@@ -349,9 +350,11 @@ export function TreatmentWorkspace({
const [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date()));
const [appointments, setAppointments] = useState<TreatmentAppointment[]>([]);
const [standaloneTreatments, setStandaloneTreatments] = useState<PastTreatment[]>([]);
const [apptsLoading, setApptsLoading] = useState(false);
const [selectionLocked, setSelectionLocked] = useState(false);
const [selectedAppointmentId, setSelectedAppointmentId] = useState<string | null>(null);
const [selectedStandaloneId, setSelectedStandaloneId] = useState<string | null>(null);
const [history, setHistory] = useState<PastTreatment[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
@@ -460,6 +463,16 @@ export function TreatmentWorkspace({
[appointments, selectedAppointmentId],
);
const selectedStandalone = useMemo(
() => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
[standaloneTreatments, selectedStandaloneId],
);
const liveTreatmentId = selectedStandalone?.id ?? null;
const hasLiveContext = Boolean(selectedAppointment || selectedStandalone);
const walkInLabel = t('walkIn');
const activePatient = useMemo(() => {
if (selectedAppointment) {
return {
@@ -467,6 +480,17 @@ export function TreatmentWorkspace({
firstName: selectedAppointment.patientFirstName,
lastName: selectedAppointment.patientLastName,
purpose: selectedAppointment.purpose,
isWalkIn: false,
};
}
if (selectedStandalone) {
const isWalkIn = Boolean(selectedStandalone.patient?.isWalkIn);
return {
id: selectedStandalone.patientId,
firstName: isWalkIn ? walkInLabel : (selectedStandalone.patient?.firstName ?? ''),
lastName: isWalkIn ? '' : (selectedStandalone.patient?.lastName ?? ''),
purpose: selectedStandalone.details[0]?.treatmentType,
isWalkIn,
};
}
if (searchedPatient) {
@@ -475,14 +499,15 @@ export function TreatmentWorkspace({
firstName: searchedPatient.firstName,
lastName: searchedPatient.lastName,
purpose: undefined as string | undefined,
isWalkIn: false,
};
}
return null;
}, [selectedAppointment, searchedPatient]);
}, [selectedAppointment, selectedStandalone, searchedPatient, walkInLabel]);
const activePatientId = activePatient?.id ?? null;
const activePatientName = activePatient
? `${activePatient.firstName} ${activePatient.lastName}`
? `${activePatient.firstName} ${activePatient.lastName}`.trim()
: null;
const unreadUpdatesCount = unreadLabCases.length;
@@ -504,10 +529,10 @@ export function TreatmentWorkspace({
: t('labShipmentsSubtitle');
useEffect(() => {
if (selectedAppointment && searchedPatient?.id === selectedAppointment.patientId) {
if (selectedStandalone && searchedPatient?.id === selectedStandalone.patientId) {
setSearchedPatient(null);
}
}, [selectedAppointment, searchedPatient?.id]);
}, [selectedAppointment, selectedStandalone, searchedPatient?.id]);
const isViewingPastDay = useMemo(
() => compareLocalDayStart(selectedDay, todayStart) < 0,
@@ -516,7 +541,7 @@ export function TreatmentWorkspace({
const canEditTreatmentForDay =
canEdit &&
Boolean(selectedAppointment) &&
hasLiveContext &&
!isViewingPastDay &&
workspaceMode === 'live';
@@ -554,18 +579,75 @@ export function TreatmentWorkspace({
[activeDetail, labDependentCodes],
);
const dayStripItems = useMemo<DayStripItem[]>(() => {
const timed: DayStripItem[] = appointments.map((a) => ({
kind: 'appointment',
id: a.id,
patientId: a.patientId,
patientFirstName: a.patientFirstName,
patientLastName: a.patientLastName,
patientIsWalkIn: false,
colorCode: a.purpose,
timeLabel: formatAppTimeRange(a.startAt, a.endAt, locale),
subtitle: treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog),
}));
const unscheduled: DayStripItem[] = standaloneTreatments.map((tr) => {
const isWalkIn = Boolean(tr.patient?.isWalkIn);
const colorCode = tr.details[0]?.treatmentType || 'visit';
return {
kind: 'unscheduled' as const,
id: tr.id,
patientId: tr.patientId,
patientFirstName: isWalkIn ? t('walkIn') : (tr.patient?.firstName ?? ''),
patientLastName: isWalkIn ? '' : (tr.patient?.lastName ?? ''),
patientIsWalkIn: isWalkIn,
colorCode,
timeLabel: null,
subtitle: t('noAppointment'),
canDelete: tr.details.length === 0,
};
});
return [...timed, ...unscheduled];
}, [appointments, standaloneTreatments, locale, treatmentCatalog, t]);
const selectedStripItemId = selectedAppointmentId ?? selectedStandaloneId;
const currentDraftPreview = useMemo<PastTreatment | null>(() => {
if (!selectedAppointment) return null;
return buildWorkspaceSnapshot(
selectedAppointment,
details,
labCaseDrafts,
t('treatmentPlanTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
'current-draft',
);
}, [details, labCaseDrafts, selectedAppointment, t]);
if (selectedAppointment) {
return buildWorkspaceSnapshot(
{
id: selectedAppointment.id,
patientId: selectedAppointment.patientId,
treatmentAt: selectedAppointment.startAt,
appointmentId: selectedAppointment.id,
},
details,
labCaseDrafts,
t('treatmentPlanTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
'current-draft',
);
}
if (selectedStandalone) {
const name = selectedStandalone.patient?.isWalkIn
? t('walkIn')
: `${selectedStandalone.patient?.firstName ?? ''} ${selectedStandalone.patient?.lastName ?? ''}`.trim();
return buildWorkspaceSnapshot(
{
id: selectedStandalone.id,
patientId: selectedStandalone.patientId,
treatmentAt: selectedStandalone.treatmentAt,
appointmentId: null,
},
details,
labCaseDrafts,
t('treatmentPlanTitle', { patientName: name || t('walkIn') }),
selectedStandalone.id,
);
}
return null;
}, [details, labCaseDrafts, selectedAppointment, selectedStandalone, t]);
const previewTreatment = useMemo(() => {
if (!selectedPreviewId) return currentDraftPreview;
@@ -678,25 +760,43 @@ export function TreatmentWorkspace({
try {
const dayStart = startOfLocalDay(selectedDay);
const dayEnd = addCalendarDays(dayStart, 1);
const response = await appointmentsApi.list({
from: dayStart.toISOString(),
to: dayEnd.toISOString(),
});
const [apptsResponse, standaloneResponse] = await Promise.all([
appointmentsApi.list({
from: dayStart.toISOString(),
to: dayEnd.toISOString(),
}),
treatmentsApi.listDayStandalone({
from: dayStart.toISOString(),
to: dayEnd.toISOString(),
}),
]);
if (cancelled) return;
const list = response.data
const list = apptsResponse.data
.filter((a) => a.providerUserId === userId)
.map(mapAppointment);
setAppointments(list);
setStandaloneTreatments(standaloneResponse.data);
if (!selectionLockedRef.current) {
const pendingId = pendingAppointmentIdRef.current;
if (pendingId && list.some((appointment) => appointment.id === pendingId)) {
setSelectedAppointmentId(pendingId);
setSelectedStandaloneId(null);
setSelectionLocked(true);
pendingAppointmentIdRef.current = null;
router.replace('/treatment', { scroll: false });
} else {
pendingAppointmentIdRef.current = null;
setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
const autoAppt = pickAutoAppointment(list, selectedDay);
if (autoAppt) {
setSelectedAppointmentId(autoAppt);
setSelectedStandaloneId(null);
} else if (standaloneResponse.data[0]) {
setSelectedAppointmentId(null);
setSelectedStandaloneId(standaloneResponse.data[0].id);
} else {
setSelectedAppointmentId(null);
setSelectedStandaloneId(null);
}
}
}
} catch (error: unknown) {
@@ -866,7 +966,8 @@ export function TreatmentWorkspace({
useEffect(() => {
const appointmentId = selectedAppointment?.id;
if (!appointmentId || workspaceMode !== 'live') return;
const treatmentId = selectedStandalone?.id;
if ((!appointmentId && !treatmentId) || workspaceMode !== 'live') return;
if (skipNextGetDraftRef.current) {
skipNextGetDraftRef.current = false;
@@ -882,7 +983,9 @@ export function TreatmentWorkspace({
void (async () => {
try {
const response = await treatmentsApi.getDraft(appointmentId);
const response = appointmentId
? await treatmentsApi.getDraft(appointmentId)
: await treatmentsApi.getDraftByTreatment(treatmentId!);
if (cancelled) return;
if (response.data?.details?.length) {
@@ -923,36 +1026,49 @@ export function TreatmentWorkspace({
cancelled = true;
draftHydratingRef.current = false;
};
}, [selectedAppointment?.id, selectedAppointment?.purpose, workspaceMode, treatmentCatalog, showError, t]);
}, [
selectedAppointment?.id,
selectedAppointment?.purpose,
selectedStandalone?.id,
workspaceMode,
treatmentCatalog,
showError,
t,
tErrors,
]);
const persistDraft = useCallback(
async (options?: { force?: boolean }) => {
if (!selectedAppointment) throw new Error('No appointment selected');
if (!selectedAppointment && !selectedStandalone) throw new Error('No visit selected');
const patientName = selectedAppointment
? `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`
: selectedStandalone?.patient?.isWalkIn
? t('walkIn')
: `${selectedStandalone?.patient?.firstName ?? ''} ${selectedStandalone?.patient?.lastName ?? ''}`.trim();
const patientId = selectedAppointment?.patientId ?? selectedStandalone!.patientId;
const treatmentAt = selectedAppointment?.startAt ?? selectedStandalone!.treatmentAt;
const currentDetails = detailsRef.current;
const dirty = isDetailsDirty(currentDetails, savedSnapshotRef.current);
if (!options?.force && !dirty) {
return detailsToPreviewTreatment(currentDetails, {
title: t('treatmentPlanTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
patientId: selectedAppointment.patientId,
treatmentAt: selectedAppointment.startAt,
title: t('treatmentPlanTitle', { patientName }),
patientId,
treatmentAt,
});
}
if (!areDetailsPersistable(currentDetails)) {
return detailsToPreviewTreatment(currentDetails, {
title: t('treatmentPlanTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
patientId: selectedAppointment.patientId,
treatmentAt: selectedAppointment.startAt,
title: t('treatmentPlanTitle', { patientName }),
patientId,
treatmentAt,
});
}
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
const payload = {
details: currentDetails.map(
({ clientId, id, treatmentType, teeth, toothSelectionGroups, comment, attachmentMetas }) => ({
clientId,
@@ -964,7 +1080,10 @@ export function TreatmentWorkspace({
attachmentIds: attachmentMetas.map((a) => a.id),
}),
),
});
};
const response = selectedAppointment
? await treatmentsApi.saveDraft(selectedAppointment.id, payload)
: await treatmentsApi.saveDraftByTreatment(selectedStandalone!.id, payload);
const mapped = response.data.details.map(mapDetailFromApi);
const sentSnapshot = serializeDetails(currentDetails);
const localNow = detailsRef.current;
@@ -990,7 +1109,7 @@ export function TreatmentWorkspace({
}
return response.data;
},
[selectedAppointment, t],
[selectedAppointment, selectedStandalone, t],
);
const refreshHistory = useCallback(async (patientId: string, options?: { silentLabCases?: boolean }) => {
@@ -1007,7 +1126,7 @@ export function TreatmentWorkspace({
}, [refreshPatientLabCases, showError, t, tErrors]);
const runDraftSave = useCallback(async () => {
if (!selectedAppointment || saveInFlightRef.current) {
if (!hasLiveContext || saveInFlightRef.current) {
if (saveInFlightRef.current) saveQueuedRef.current = true;
return;
}
@@ -1040,7 +1159,7 @@ export function TreatmentWorkspace({
}
}
}
}, [selectedAppointment, persistDraft, showError, t, historyPatientId, refreshHistory]);
}, [hasLiveContext, persistDraft, showError, t, historyPatientId, refreshHistory]);
const flushDraftSave = useCallback(async (): Promise<boolean> => {
if (autosaveTimerRef.current) {
@@ -1048,7 +1167,7 @@ export function TreatmentWorkspace({
autosaveTimerRef.current = null;
}
if (workspaceModeRef.current !== 'live' || !selectedAppointment || !canEditTreatmentForDay) {
if (workspaceModeRef.current !== 'live' || !hasLiveContext || !canEditTreatmentForDay) {
return true;
}
@@ -1066,10 +1185,10 @@ export function TreatmentWorkspace({
} catch {
return window.confirm(t('confirmDiscard'));
}
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]);
}, [hasLiveContext, canEditTreatmentForDay, runDraftSave, t]);
useEffect(() => {
if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) {
if (draftHydratingRef.current || !canEditTreatmentForDay || !hasLiveContext) {
return;
}
@@ -1091,7 +1210,7 @@ export function TreatmentWorkspace({
autosaveTimerRef.current = null;
}
};
}, [details, isDirty, canEditTreatmentForDay, selectedAppointment?.id, runDraftSave]);
}, [details, isDirty, canEditTreatmentForDay, hasLiveContext, runDraftSave]);
const resetToLiveContext = useCallback(() => {
setWorkspaceMode('live');
@@ -1107,11 +1226,90 @@ export function TreatmentWorkspace({
setSearchedPatient(null);
setSelectionLocked(true);
setSelectedAppointmentId(id);
setSelectedStandaloneId(null);
})();
},
[flushDraftSave, resetToLiveContext],
);
const onPickStripItem = useCallback(
(item: DayStripItem) => {
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
resetToLiveContext();
setSearchedPatient(null);
setSelectionLocked(true);
if (item.kind === 'appointment') {
setSelectedAppointmentId(item.id);
setSelectedStandaloneId(null);
} else {
setSelectedAppointmentId(null);
setSelectedStandaloneId(item.id);
}
})();
},
[flushDraftSave, resetToLiveContext],
);
const createStandaloneTreatment = useCallback(
async (opts: { patientId?: string; walkIn?: boolean }) => {
const ok = await flushDraftSave();
if (!ok) return;
try {
const created = await treatmentsApi.createStandalone({
...opts,
treatmentAt: selectedDay.toISOString(),
});
resetToLiveContext();
setSelectionLocked(true);
setSelectedAppointmentId(null);
setSelectedStandaloneId(created.data.id);
setStandaloneTreatments((prev) =>
prev.some((row) => row.id === created.data.id) ? prev : [...prev, created.data],
);
skipNextGetDraftRef.current = true;
hydrateFromTreatment(created.data);
if (created.data.patientId && !created.data.patient?.isWalkIn) {
await refreshHistory(created.data.patientId);
}
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorCreateTreatment')));
}
},
[flushDraftSave, selectedDay, resetToLiveContext, hydrateFromTreatment, refreshHistory, showError, t, tErrors],
);
const deleteStandaloneTreatment = useCallback(
async (item: DayStripItem) => {
if (!canEdit || item.kind !== 'unscheduled' || !item.canDelete) return;
if (!window.confirm(t('confirmDeleteEmptyTreatment'))) return;
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = null;
}
try {
await treatmentsApi.deleteStandalone(item.id);
setStandaloneTreatments((prev) => prev.filter((row) => row.id !== item.id));
if (selectedStandaloneId === item.id) {
resetToLiveContext();
setSelectedStandaloneId(null);
setSelectedAppointmentId(null);
}
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorDeleteTreatment')));
}
},
[
canEdit,
resetToLiveContext,
selectedStandaloneId,
showError,
t,
tErrors,
],
);
const canAccessOrganizations = canAccessDashboardRoute(currentOrganization, '/organizations');
const onSelectDay = useCallback(
@@ -1123,6 +1321,8 @@ export function TreatmentWorkspace({
resetToLiveContext();
setSearchedPatient(null);
setSelectionLocked(false);
setSelectedAppointmentId(null);
setSelectedStandaloneId(null);
setSelectedDay(startOfLocalDay(day));
if (patientIdToRefresh) {
await refreshHistory(patientIdToRefresh);
@@ -1146,17 +1346,14 @@ export function TreatmentWorkspace({
focusDetailClientId?: string,
options?: { scrollToLabPanel?: boolean },
) => {
if (!treatment.appointmentId) {
showError(t('errorNoAppointmentForTreatment'));
return false;
}
const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true;
if (!ok) return false;
let treatmentToLoad = treatment;
try {
const draftResponse = await treatmentsApi.getDraft(treatment.appointmentId);
const draftResponse = treatment.appointmentId
? await treatmentsApi.getDraft(treatment.appointmentId)
: await treatmentsApi.getDraftByTreatment(treatment.id);
if (draftResponse.data) {
treatmentToLoad = draftResponse.data;
}
@@ -1170,7 +1367,16 @@ export function TreatmentWorkspace({
const nextDay = startOfLocalDay(new Date(treatmentToLoad.treatmentAt));
setSelectedDay((prev) => (compareLocalDayStart(prev, nextDay) === 0 ? prev : nextDay));
setSelectionLocked(true);
setSelectedAppointmentId(treatmentToLoad.appointmentId ?? treatment.appointmentId);
if (treatmentToLoad.appointmentId ?? treatment.appointmentId) {
setSelectedAppointmentId(treatmentToLoad.appointmentId ?? treatment.appointmentId ?? null);
setSelectedStandaloneId(null);
} else {
setSelectedAppointmentId(null);
setSelectedStandaloneId(treatmentToLoad.id);
setStandaloneTreatments((prev) =>
prev.some((row) => row.id === treatmentToLoad.id) ? prev : [...prev, treatmentToLoad],
);
}
skipNextGetDraftRef.current = true;
draftHydratingRef.current = true;
@@ -1204,12 +1410,20 @@ export function TreatmentWorkspace({
return true;
},
[flushDraftSave, hydrateFromTreatment, showError, t, todayStart],
[flushDraftSave, hydrateFromTreatment, todayStart],
);
const handleSelectSearchedPatient = useCallback(
(patient: Patient) => {
void (async () => {
const alreadySelected =
selectedAppointment?.patientId === patient.id ||
selectedStandalone?.patientId === patient.id ||
searchedPatient?.id === patient.id;
if (alreadySelected && hasLiveContext) {
return;
}
setPatientSearchBusy(true);
setSearchedPatient({
id: patient.id,
@@ -1219,24 +1433,48 @@ export function TreatmentWorkspace({
try {
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
const latest = response.data[0];
if (!latest) {
showError(t('errorNoTreatmentForPatient'));
setSearchedPatient(null);
if (latest) {
const ok = await loadTreatmentIntoWorkspace(latest);
if (!ok) {
setSearchedPatient(null);
}
return;
}
const ok = await loadTreatmentIntoWorkspace(latest);
if (!ok) {
setSearchedPatient(null);
}
const created = await treatmentsApi.createStandalone({
patientId: patient.id,
treatmentAt: selectedDay.toISOString(),
});
setSelectionLocked(true);
setSelectedAppointmentId(null);
setSelectedStandaloneId(created.data.id);
setStandaloneTreatments((prev) =>
prev.some((row) => row.id === created.data.id) ? prev : [...prev, created.data],
);
skipNextGetDraftRef.current = true;
hydrateFromTreatment(created.data);
await refreshHistory(patient.id);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorNoTreatmentForPatient')));
showError(getUserFacingError(error, tErrors, t('errorCreateTreatment')));
setSearchedPatient(null);
} finally {
setPatientSearchBusy(false);
}
})();
},
[loadTreatmentIntoWorkspace, showError, t, tErrors],
[
selectedAppointment?.patientId,
selectedStandalone?.patientId,
searchedPatient?.id,
hasLiveContext,
loadTreatmentIntoWorkspace,
selectedDay,
hydrateFromTreatment,
refreshHistory,
showError,
t,
tErrors,
],
);
const handleLoadIntoWorkspace = useCallback(() => {
@@ -1386,7 +1624,7 @@ export function TreatmentWorkspace({
const uploadForDetail = useCallback(
async (detailClientId: string, files: FileList | File[]) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
if (!canEditTreatmentForDay || !hasLiveContext) return;
const target = detailsRef.current.find((d) => d.clientId === detailClientId);
if (target && isDetailLocked(target)) return;
const list = files instanceof FileList ? Array.from(files) : files;
@@ -1394,11 +1632,17 @@ export function TreatmentWorkspace({
setUploadBusyDetailId(detailClientId);
try {
const uploaded = await treatmentsApi.uploadCaseAttachments(
selectedAppointment.id,
detailClientId,
list,
);
const uploaded = selectedAppointment
? await treatmentsApi.uploadCaseAttachments(
selectedAppointment.id,
detailClientId,
list,
)
: await treatmentsApi.uploadDetailAttachmentsByTreatment(
selectedStandalone!.id,
detailClientId,
list,
);
setDetails((prev) =>
prev.map((d) =>
d.clientId === detailClientId
@@ -1413,12 +1657,12 @@ export function TreatmentWorkspace({
setUploadBusyDetailId(null);
}
},
[canEditTreatmentForDay, isDetailLocked, selectedAppointment, showSuccess, showError, t, tErrors],
[canEditTreatmentForDay, isDetailLocked, hasLiveContext, selectedAppointment, selectedStandalone, showSuccess, showError, t, tErrors],
);
const persistLabCases = useCallback(
async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => {
if (!selectedAppointment) throw new Error('No appointment selected');
if (!selectedAppointment && !selectedStandalone) throw new Error('No visit selected');
const drafts = draftsOverride ?? labCaseDrafts;
const detailIdByClientId = new Map(
@@ -1467,9 +1711,13 @@ export function TreatmentWorkspace({
return savedTreatment;
}
const response = await treatmentsApi.saveLabCases(selectedAppointment.id, {
labCases: payload,
});
const response = selectedAppointment
? await treatmentsApi.saveLabCases(selectedAppointment.id, {
labCases: payload,
})
: await treatmentsApi.saveLabCasesByTreatment(selectedStandalone!.id, {
labCases: payload,
});
const mapped = withoutEmptyLabCaseDrafts(response.data.labCases.map(mapLabCaseDraftFromApi));
setLabCaseDrafts(mapped);
setActiveLabCaseId((prev) => {
@@ -1478,7 +1726,7 @@ export function TreatmentWorkspace({
});
return response.data;
},
[labCaseDrafts, selectedAppointment],
[labCaseDrafts, selectedAppointment, selectedStandalone],
);
const handleLabCasesChange = useCallback(
@@ -1498,7 +1746,7 @@ export function TreatmentWorkspace({
);
if (
removedPersistedDraft &&
selectedAppointment &&
hasLiveContext &&
canEditTreatmentForDay
) {
void (async () => {
@@ -1565,7 +1813,7 @@ export function TreatmentWorkspace({
);
const handleAddLabCase = useCallback(async () => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
if (!canEditTreatmentForDay || !hasLiveContext) return;
const cleaned = withoutEmptyLabCaseDrafts(labCaseDrafts);
const existing = cleaned.find(
@@ -1636,7 +1884,7 @@ export function TreatmentWorkspace({
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
useEffect(() => {
if (entryStep !== 'lab') return;
if (!canEditTreatmentForDay || !selectedAppointment) return;
if (!canEditTreatmentForDay || !hasLiveContext) return;
const activeDetail = details.find((d) => d.clientId === activeDetailId);
if (!activeDetail || !isDetailReadyForLabDispatch(activeDetail, labDependentCodes)) return;
if (isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)) return;
@@ -1660,7 +1908,7 @@ export function TreatmentWorkspace({
const handleSendLabCase = useCallback(
async (labCase: LabCaseDraft, comment?: string) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
if (!canEditTreatmentForDay || !hasLiveContext) return;
if (!labCase.destinationOrganizationId) {
showError(t('errorChooseOrg'));
return;
@@ -1695,7 +1943,9 @@ export function TreatmentWorkspace({
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
const draftResponse = selectedAppointment
? await treatmentsApi.getDraft(selectedAppointment.id)
: await treatmentsApi.getDraftByTreatment(selectedStandalone!.id);
if (draftResponse.data?.details?.length) {
const mapped = draftResponse.data.details.map(mapDetailFromApi);
detailsRef.current = mapped;
@@ -1746,9 +1996,11 @@ export function TreatmentWorkspace({
});
showSuccess(t('successCaseSent'));
notifyTabBadgesChanged();
if (selectedAppointment.patientId) {
if (selectedAppointment?.patientId) {
// Silent: avoid rail/history loading flicker while staying on Lab step.
void refreshHistory(selectedAppointment.patientId, { silentLabCases: true });
} else if (selectedStandalone?.patientId) {
void refreshHistory(selectedStandalone.patientId, { silentLabCases: true });
}
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSendCase')));
@@ -1792,9 +2044,10 @@ export function TreatmentWorkspace({
onToggleStripHidden={() => setStripHidden((s) => !s)}
selectedDay={selectedDay}
onSelectDay={onSelectDay}
appointments={appointments}
selectedAppointmentId={selectedAppointmentId}
onSelectAppointment={onPickAppointment}
items={dayStripItems}
selectedItemId={selectedStripItemId}
onSelectItem={onPickStripItem}
onDeleteUnscheduled={canEdit ? deleteStandaloneTreatment : undefined}
treatmentCatalog={treatmentCatalog}
loading={apptsLoading}
/>
@@ -1823,6 +2076,39 @@ export function TreatmentWorkspace({
placeholder={tPatients('searchPlaceholder')}
emptyResultsMessage={tPatients('noResults')}
/>
{canEdit && !isViewingPastDay ? (
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
disabled={
!(
searchedPatient?.id ||
selectedAppointment?.patientId ||
(selectedStandalone && !selectedStandalone.patient?.isWalkIn)
)
}
onClick={() => {
const patientId =
searchedPatient?.id ??
selectedAppointment?.patientId ??
selectedStandalone?.patientId;
if (!patientId) return;
return createStandaloneTreatment({ patientId });
}}
>
{t('newTreatment')}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => createStandaloneTreatment({ walkIn: true })}
>
{t('newWalkInTreatment')}
</Button>
</div>
) : null}
{activePatient ? (
<div className="space-y-0.5 border-t border-border/60 pt-3">
@@ -1844,7 +2130,7 @@ export function TreatmentWorkspace({
)}
</div>
{workspaceMode === 'live' && !isBrowsing && selectedAppointment ? (
{workspaceMode === 'live' && !isBrowsing && hasLiveContext ? (
<TreatmentPreviewCard
treatment={currentDraftPreview}
heading={t('previewCurrentDraft')}

View File

@@ -2,10 +2,36 @@ import { apiClient } from './client';
import type {
CasesFilterOptions,
AssignableTaskStaff,
LabCaseAttachmentMeta,
LabCaseDetail,
ListLabCasesParams,
PaginatedLabCases,
} from '@/types/cases';
import type { LinkedOrganizationOption } from '@/types/treatment';
export interface LabInternalCaseLinePayload {
clientId: string;
id?: string;
teeth: string[];
toothSelectionGroups?: Array<{ groupId: string; kind: string; teeth: string[] }>;
comment?: string;
toothProsthesis?: Array<{
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;
}>;
attachmentIds?: string[];
}
export interface LabInternalCasePayload {
referringClinicName?: string | null;
referringDentistName?: string | null;
patientDisplayName?: string | null;
patientDisplayMobile?: string | null;
partnerClinicOrganizationId?: string | null;
dueDate?: string | null;
lines?: LabInternalCaseLinePayload[];
}
export const casesApi = {
list: async (
@@ -15,6 +41,48 @@ export const casesApi = {
return response.data;
},
create: async (
payload: LabInternalCasePayload = {},
): Promise<{ success: boolean; data: LabCaseDetail }> => {
const response = await apiClient.post('/cases', payload);
return response.data;
},
update: async (
id: string,
payload: LabInternalCasePayload,
): Promise<{ success: boolean; data: LabCaseDetail }> => {
const response = await apiClient.put(`/cases/${id}`, payload);
return response.data;
},
start: async (id: string): Promise<{ success: boolean; data: LabCaseDetail }> => {
const response = await apiClient.post(`/cases/${id}/start`);
return response.data;
},
uploadLineAttachments: async (
caseId: string,
lineClientId: string,
files: File[],
): Promise<{ success: boolean; data: LabCaseAttachmentMeta[] }> => {
const form = new FormData();
for (const file of files) {
form.append('files', file);
}
const response = await apiClient.post(
`/cases/${caseId}/lines/${encodeURIComponent(lineClientId)}/attachments`,
form,
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 },
);
return response.data;
},
listLinkedClinics: async (): Promise<{ success: boolean; data: LinkedOrganizationOption[] }> => {
const response = await apiClient.get('/cases/linked-clinics');
return response.data;
},
getOne: async (id: string): Promise<{ success: boolean; data: LabCaseDetail }> => {
const response = await apiClient.get(`/cases/${id}`);
return response.data;

View File

@@ -46,6 +46,68 @@ export const treatmentsApi = {
return response.data;
},
createStandalone: async (payload: {
patientId?: string;
walkIn?: boolean;
treatmentAt: string;
}): Promise<{ success: boolean; data: PastTreatment }> => {
const response = await apiClient.post('/treatments', payload);
return response.data;
},
deleteStandalone: async (treatmentId: string): Promise<{ success: boolean }> => {
const response = await apiClient.delete(`/treatments/${treatmentId}`);
return response.data;
},
listDayStandalone: async (params: {
from: string;
to: string;
}): Promise<{ success: boolean; data: PastTreatment[] }> => {
const response = await apiClient.get('/treatments/day', { params });
return response.data;
},
getDraftByTreatment: async (
treatmentId: string,
): Promise<{ success: boolean; data: PastTreatment | null }> => {
const response = await apiClient.get(`/treatments/${treatmentId}/draft`);
return response.data;
},
saveDraftByTreatment: async (
treatmentId: string,
payload: { details: SavedTreatmentDetailPayload[] },
): Promise<{ success: boolean; data: PastTreatment }> => {
const response = await apiClient.put(`/treatments/${treatmentId}/draft`, payload);
return response.data;
},
saveLabCasesByTreatment: async (
treatmentId: string,
payload: { labCases: SaveLabCasePayload[] },
): Promise<{ success: boolean; data: PastTreatment }> => {
const response = await apiClient.put(`/treatments/${treatmentId}/lab-cases`, payload);
return response.data;
},
uploadDetailAttachmentsByTreatment: async (
treatmentId: string,
detailClientId: string,
files: File[],
): Promise<{ success: boolean; data: import('@/types/treatment').TreatmentAttachmentMeta[] }> => {
const form = new FormData();
for (const file of files) {
form.append('files', file);
}
const response = await apiClient.post(
`/treatments/${treatmentId}/details/${encodeURIComponent(detailClientId)}/attachments`,
form,
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 },
);
return response.data;
},
saveDraft: async (
appointmentId: string,
payload: { details: SavedTreatmentDetailPayload[] },

View File

@@ -10,6 +10,8 @@ export interface LabCaseProsthesisGroup {
export interface LabCaseListItem {
id: string;
sentAt: string | null;
startedAt?: string | null;
origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
dueDate: string | null;
isOverdue: boolean;
isImportant: boolean;
@@ -86,16 +88,33 @@ export interface LabCaseAttachmentMeta {
mimeType: string;
sizeBytes: number;
createdAt: string;
detailClientKey?: string | null;
}
export interface LabCaseLine {
id: string;
clientId: string;
treatmentType: string;
teeth: string[];
toothSelectionGroups?: unknown;
comment: string | null;
}
export interface LabCaseDetail {
id: string;
sentAt: string | null;
startedAt?: string | null;
origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
dueDate: string | null;
isOverdue: boolean;
isImportant: boolean;
/** Optional external lab-app code (e.g. exocad); used as PDF order number when set. */
externalCode?: string | null;
referringClinicName?: string | null;
referringDentistName?: string | null;
patientDisplayName?: string | null;
patientDisplayMobile?: string | null;
partnerClinicOrganizationId?: string | null;
clinic: { id: string; name: string };
patient: {
id: string;
@@ -111,8 +130,10 @@ export interface LabCaseDetail {
teeth: string[];
comment: string | null;
} | null;
lines?: LabCaseLine[];
toothProsthesis: Array<{
treatmentDetailId: string;
treatmentDetailId: string | null;
lineId?: string | null;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;

View File

@@ -116,7 +116,7 @@ export interface PastLabCase {
toothSelectionGroups?: ToothSelectionGroup[];
} | null;
toothProsthesis?: Array<{
treatmentDetailId: string;
treatmentDetailId?: string | null;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;
@@ -131,8 +131,15 @@ export interface PastTreatment {
id: string;
patientId: string;
appointmentId?: string | null;
providerUserId?: string | null;
title: string;
treatmentAt: string;
patient?: {
id: string;
firstName: string;
lastName: string;
isWalkIn: boolean;
} | null;
details: PastTreatmentDetail[];
labCases: PastLabCase[];
documents: TreatmentAttachmentMeta[];