improvement: v1 standalone treatment/case creation made possible.
This commit is contained in:
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
14
frontend/src/components/treatment/dayStrip.ts
Normal file
14
frontend/src/components/treatment/dayStrip.ts
Normal 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;
|
||||
};
|
||||
669
frontend/src/components/ui/lab/CaseCreatePanel.tsx
Normal file
669
frontend/src/components/ui/lab/CaseCreatePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
97
frontend/src/components/ui/treatment/DayStripCard.tsx
Normal file
97
frontend/src/components/ui/treatment/DayStripCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user