'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; onDeleted: () => void; onError: (message: string) => void; } export function CaseCreatePanel({ labCase, canEdit, onSaved, onStarted, onDeleted, 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(() => linesFromDetail(labCase)); const [activeLineId, setActiveLineId] = useState( () => linesFromDetail(labCase)[0]?.clientId ?? '', ); const [partners, setPartners] = useState([]); const [prosthesisOptions, setProsthesisOptions] = useState([]); const [applyAllProsthesis, setApplyAllProsthesis] = useState(''); const [saving, setSaving] = useState(false); const [starting, setStarting] = useState(false); const [deleting, setDeleting] = useState(false); const [uploadBusy, setUploadBusy] = useState(false); const rangeAnchorRef = useRef(null); const hydratedIdRef = useRef(labCase.id); const skipSaveRef = useRef(true); const startingRef = useRef(false); const deletingRef = useRef(false); const allowPersistWhileStartingRef = useRef(false); const attachmentInputRef = useRef(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> = {}; 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 (deletingRef.current) { return null; } 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 || deletingRef.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 handleDelete() { if (!canEdit) return; if (!window.confirm(t('confirmDeleteDraftCase'))) return; deletingRef.current = true; skipSaveRef.current = true; setDeleting(true); try { await casesApi.deleteDraft(labCase.id); onDeleted(); } catch (error: unknown) { deletingRef.current = false; setDeleting(false); onError(getUserFacingError(error, tErrors, t('errorDeleteCase'))); } } 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 || deleting; return (

{t('addCaseTitle')}

{t('addCaseSubtitle')}

{t('caseLinesTitle')}

{lines.map((line, idx) => { const isActive = line.clientId === activeLine?.clientId; return (
{lines.length > 1 ? ( ) : null}
); })}
{activeLine ? ( <> { 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 ? (

{tTreatment('prosthesisTypesTitle')}

{prosthesisRows.every((r) => r.kind === 'single') && prosthesisRows.reduce((sum, r) => sum + r.teeth.length, 0) > 1 ? ( ) : null}
{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 ( ); })}
) : null}