'use client'; import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge'; import { isDetailReadyForLabDispatch, isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules'; import { AppDateInput } from '@/components/ui/shared/AppDateInput'; import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; import { DetailLabCaseCommentsSection } from '@/components/ui/treatment/DetailLabCaseCommentsSection'; import { LabCaseTrackerCard } from '@/components/ui/treatment/LabCaseTrackerCard'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import { treatmentsApi } from '@/lib/api/treatments'; import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay'; import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment'; import type { PatientLabCaseSummary } from '@/types/lab-case-activity'; import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups'; interface LabCasesDispatchPanelProps { details: TreatmentDetailDraft[]; activeDetailId: string; labCases: LabCaseDraft[]; labDependentCodes: Set; treatmentCatalog: TreatmentCatalogEntry[]; labCaseSummary?: PatientLabCaseSummary | null; locale: string; onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void; onLabCaseMarkedRead?: (labCaseId: string) => void; onLabCaseActivityChange?: () => void; activeLabCaseId: string | null; onLabCasesChange: (labCases: LabCaseDraft[]) => void; disabled: boolean; canEdit: boolean; orgs: LinkedOrganizationOption[]; organizationSearch: string; onOrganizationSearchChange: (value: string) => void; recentOrganizationIds: string[]; onRecentOrganizationPick: (orgId: string) => void; canInviteLab?: boolean; onInviteLab?: () => void; sendBusyId: string | null; onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void | Promise; onCommentError?: (message: string) => void; } type ProsthesisGroupRow = { groupId: string; kind: 'connected' | 'single'; teeth: string[]; detailClientId: string; detailNumber: number; }; function prosthesisGroupRows( labCase: LabCaseDraft, activeDetail: TreatmentDetailDraft, detailNumber: number, ): ProsthesisGroupRow[] { if (labCase.detailClientId !== activeDetail.clientId) return []; if (activeDetail.treatmentType !== 'prosthesis') return []; const groups = activeDetail.toothSelectionGroups.length > 0 ? activeDetail.toothSelectionGroups : groupsFromFlatTeeth(activeDetail.teeth); return groups.map((g) => ({ groupId: g.groupId, kind: g.kind, teeth: g.teeth, detailClientId: activeDetail.clientId, detailNumber, })); } function isProsthesisMapComplete( labCase: LabCaseDraft, rows: ProsthesisGroupRow[], ): boolean { if (rows.length === 0) return true; return rows.every((row) => row.teeth.every((tooth) => labCase.toothProsthesis.some( (tp) => tp.detailClientId === row.detailClientId && tp.tooth === tooth && tp.selectionGroupId === row.groupId && Boolean(tp.prosthesisTypeCode), ), ), ); } export function LabCasesDispatchPanel({ details, activeDetailId, labCases, labDependentCodes, treatmentCatalog, labCaseSummary, locale, onLabCaseSummaryChange, onLabCaseMarkedRead, onLabCaseActivityChange, activeLabCaseId, onLabCasesChange, disabled, canEdit, orgs, organizationSearch, onOrganizationSearchChange, recentOrganizationIds, onRecentOrganizationPick, canInviteLab = false, onInviteLab, sendBusyId, onSendLabCase, onCommentError, }: LabCasesDispatchPanelProps) { const t = useTranslations('treatment'); const [prosthesisOptions, setProsthesisOptions] = useState([]); const [applyAllProsthesis, setApplyAllProsthesis] = useState(''); const [pendingComment, setPendingComment] = useState(''); const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId); const activeLinkedOrganizations = orgs.filter((o) => o.active); const recentOrganizations = recentOrganizationIds .map((id) => activeLinkedOrganizations.find((o) => o.id === id)) .filter(Boolean) as LinkedOrganizationOption[]; const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null; const isLabDependentDetail = Boolean( activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes), ); const labCaseForActiveDetail = labCases.find((lc) => lc.detailClientId === activeDetailId) ?? null; const activeLabCase = labCaseForActiveDetail ?? (activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null); const detailAlreadyInShipment = Boolean(labCaseForActiveDetail); const sent = Boolean(activeLabCase?.sentAt); const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1; const activeLabOrgName = activeLabCase?.destinationOrganizationId ? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name : null; const prosthesisRows = activeLabCase && activeDetail ? prosthesisGroupRows(activeLabCase, activeDetail, activeDetailNumber) : []; const prosthesisComplete = activeLabCase ? isProsthesisMapComplete(activeLabCase, prosthesisRows) : true; const flatToothCount = prosthesisRows.reduce((sum, row) => sum + row.teeth.length, 0); useEffect(() => { if (!activeLabCase?.destinationOrganizationId) { setProsthesisOptions([]); return; } let cancelled = false; void prosthesisCatalogApi .list(activeLabCase.destinationOrganizationId) .then((res) => { if (!cancelled) setProsthesisOptions(res.data); }) .catch(() => { if (!cancelled) setProsthesisOptions([]); }); return () => { cancelled = true; }; }, [activeLabCase?.destinationOrganizationId]); useEffect(() => { setPendingComment(''); }, [activeLabCase?.clientId]); if (!activeDetail || !isLabDependentDetail) { return null; } function detailSummary(d: TreatmentDetailDraft) { const typeLabel = treatmentTypeLabelFromCatalog(d.treatmentType, treatmentCatalog); const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone'); return `${t('detailLabel', { n: activeDetailNumber })} · ${typeLabel} · ${teeth}`; } function updateActiveLabCase(patch: Partial) { if (!activeLabCase) return; onLabCasesChange( labCases.map((lc) => (lc.clientId === activeLabCase.clientId ? { ...lc, ...patch } : lc)), ); } function setGroupProsthesis(row: ProsthesisGroupRow, prosthesisTypeCode: string) { if (!activeLabCase) return; const toothSet = new Set(row.teeth); const rest = activeLabCase.toothProsthesis.filter( (tp) => !(tp.detailClientId === row.detailClientId && toothSet.has(tp.tooth)), ); const next = prosthesisTypeCode ? [ ...rest, ...row.teeth.map((tooth) => ({ detailClientId: row.detailClientId, tooth, prosthesisTypeCode, selectionGroupId: row.groupId, })), ] : rest; updateActiveLabCase({ toothProsthesis: next }); } function applyProsthesisToAll(code: string) { if (!activeLabCase || !code) return; const next = prosthesisRows.flatMap((row) => row.teeth.map((tooth) => ({ detailClientId: row.detailClientId, tooth, prosthesisTypeCode: code, selectionGroupId: row.groupId, })), ); updateActiveLabCase({ toothProsthesis: next }); } function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) { if (!activeLabCase || sent) return; const set = new Set(activeLabCase.attachmentIds); if (checked) set.add(attachmentId); else set.delete(attachmentId); updateActiveLabCase({ attachmentIds: [...set] }); } function handleSelectOrganization(org: LinkedOrganizationOption) { updateActiveLabCase({ destinationOrganizationId: org.id, toothProsthesis: [], }); setApplyAllProsthesis(''); } const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress); const canEditDueDate = canEdit && !disabled && (!sent || !caseFullyComplete); // Comments/progress belong to the shipment context, even when the treatment is opened from history. // Do not block commenting just because the treatment editor is read-only. const canPostComments = canEdit && !caseFullyComplete; const canShowComments = Boolean(activeLabCase?.id); const commentsDeferSubmit = Boolean(!sent); async function handleSentDueDateBlur(nextValue: string) { if (!activeLabCase?.id || !sent || !canEditDueDate) return; const dueDate = nextValue || null; if (dueDate === (activeLabCase.dueDate?.slice(0, 10) ?? null)) return; try { const response = await treatmentsApi.updateLabCaseDueDate(activeLabCase.id, dueDate); updateActiveLabCase({ dueDate: response.data.dueDate, taskProgress: response.data.taskProgress ?? activeLabCase.taskProgress, }); } catch (error) { onCommentError?.(error instanceof Error ? error.message : t('dueDateUpdateError')); } } function renderDueDateField() { if (!activeLabCase) return null; const inputValue = toDateInputValue(activeLabCase.dueDate); const dueDateInputId = `lab-case-due-date-${activeLabCase.clientId}`; return (
{ updateActiveLabCase({ dueDate: next || null }); }} onBlur={(committed) => { if (sent) void handleSentDueDateBlur(committed); }} className={`${FORM_SELECT_CLASS} w-full min-w-0 max-w-full sm:max-w-[11rem] rounded-md py-1.5 text-sm`} />
{sent && caseFullyComplete && activeLabCase.dueDate ? (

{t('dueDateLockedCompleted')}

) : null}
); } function renderIncludedDetailSummary() { if (!activeDetail) return null; if (activeDetail.treatmentType !== 'prosthesis' || !activeLabCase) { return (

{detailSummary(activeDetail)}

); } const selectionGroups = activeDetail.toothSelectionGroups.length > 0 ? activeDetail.toothSelectionGroups : groupsFromFlatTeeth(activeDetail.teeth); const rows = selectionGroups.map((group) => { const codes = new Set( activeLabCase.toothProsthesis .filter( (tp) => tp.detailClientId === activeDetail.clientId && group.teeth.includes(tp.tooth as never) && tp.prosthesisTypeCode, ) .map((tp) => tp.prosthesisTypeCode), ); const code = codes.size === 1 ? [...codes][0] : ''; return { groupId: group.groupId, kind: group.kind, teeth: group.teeth, code, label: code ? prosthesisOptions.find((p) => p.code === code)?.label ?? code : t('prosthesisUnassigned'), }; }); return (
{rows.map((g) => (

{g.kind === 'connected' ? ( ) : null} {g.label}: {g.teeth.join(', ')}

))}
); } const activeDetailAttachments = activeDetail.attachmentMetas ?? []; return (

{t('labDispatchTitle')}

{t('labDispatchSubtitle')} {t('labDispatchSendHint')}

{detailAlreadyInShipment ? renderDueDateField() : null}
{activeLabCase ? (
{sent ? ( <> {renderIncludedDetailSummary()} {hasTrackerSummary && labCaseSummary ? ( ) : null} {canShowComments && activeLabCase?.id ? ( ) : null} {activeLabOrgName ? (

{t('selectLab')}

{activeLabOrgName}

) : null} ) : ( <> {renderIncludedDetailSummary()} {!sent && activeDetailAttachments.length > 0 ? (

{t('labShipmentAttachments')}

{t('labShipmentAttachmentsHint')}

{activeDetailAttachments.map((att) => ( toggleAttachmentInActiveLabCase(att.id, next)} label={`${att.fileName} (${(att.sizeBytes / 1024).toFixed(1)} KB)`} /> ))}
) : null} {hasTrackerSummary && labCaseSummary ? ( ) : null} {canShowComments && activeLabCase?.id ? ( ) : null}

{t('selectLab')}

{recentOrganizations.length > 0 && (
{t('recent')} {recentOrganizations.map((o) => ( ))}
)}
{prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? (

{t('prosthesisTypesTitle')}

{flatToothCount > 1 && prosthesisRows.every((r) => r.kind === 'single') ? ( ) : null}
{prosthesisRows.map((row) => { const current = activeLabCase.toothProsthesis.find( (tp) => tp.detailClientId === row.detailClientId && tp.selectionGroupId === row.groupId && row.teeth.includes(tp.tooth), )?.prosthesisTypeCode ?? activeLabCase.toothProsthesis.find( (tp) => tp.detailClientId === row.detailClientId && row.teeth.includes(tp.tooth), )?.prosthesisTypeCode ?? ''; return ( ); })}
) : null}
)}
) : null}
); }