'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel'; import { TreatmentCasesEditor } from '@/components/ui/treatment/TreatmentCasesEditor'; import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard'; import { TreatmentPreviewDialog, type TreatmentPreviewMode, } from '@/components/ui/treatment/TreatmentPreviewDialog'; import { ToastStack } from '@/components/ui/shared/Toast'; import { addCalendarDays, compareLocalDayStart, isSameLocalCalendarDay, startOfLocalDay, } from '@/components/appointments/appointmentTime'; import { appointmentsApi } from '@/lib/api/appointments'; import { treatmentsApi } from '@/lib/api/treatments'; import { pickAutoAppointment } from '@/components/shared/treatmentSelection'; import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions'; import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { useToast } from '@/lib/hooks/useToast'; import type { Organization } from '@/types/organization'; import type { AppointmentRecord } from '@/types/appointment'; import type { FdiToothId, LinkedOrganizationOption, PastTreatment, PastTreatmentCase, TreatmentAppointment, TreatmentCaseDraft, } from '@/types/treatment'; function newCase(): TreatmentCaseDraft { return { clientId: typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `case-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, treatmentType: 'consultation', teeth: [], comment: '', attachmentMetas: [], sendToOrganizationIds: [], sentAt: null, }; } function mapAppointment(record: AppointmentRecord): TreatmentAppointment { return { id: record.id, patientId: record.patientId, patientFirstName: record.patient.firstName, patientLastName: record.patient.lastName, providerUserId: record.providerUserId, startAt: record.startAt, endAt: record.endAt, purpose: record.purpose, }; } function mapCaseFromApi(c: PastTreatmentCase): TreatmentCaseDraft { return { clientId: c.clientId, id: c.id, treatmentType: c.treatmentType, teeth: c.teeth, comment: c.notes ?? '', attachmentMetas: c.attachmentMetas ?? [], sendToOrganizationIds: c.sendToOrganizationIds ?? [], sends: c.sends ?? [], sentAt: c.sentAt ?? null, }; } function serializeCases(cases: TreatmentCaseDraft[]) { return JSON.stringify( cases.map((c) => ({ clientId: c.clientId, id: c.id, treatmentType: c.treatmentType, teeth: c.teeth, comment: c.comment, attachmentMetas: c.attachmentMetas, sendToOrganizationIds: c.sendToOrganizationIds, sentAt: c.sentAt, })), ); } function casesToPreviewTreatment( cases: TreatmentCaseDraft[], meta: { title: string; patientId: string; treatmentAt: string; status: string; id?: string }, ): PastTreatment { return { id: meta.id ?? 'current-draft', patientId: meta.patientId, title: meta.title, treatmentAt: meta.treatmentAt, status: meta.status, cases: cases.map((c, idx) => ({ id: c.id ?? c.clientId ?? `draft-${idx + 1}`, clientId: c.clientId, treatmentType: c.treatmentType, teeth: c.teeth, notes: c.comment || null, attachmentMetas: c.attachmentMetas, sendToOrganizationIds: c.sendToOrganizationIds, sentAt: c.sentAt ?? null, })), documents: [], }; } interface TreatmentWorkspaceProps { userId: string; currentOrganization: Organization | null; } export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) { const { showError, showSuccess, messages: toastMessages } = useToast(); const canView = canViewTreatment(currentOrganization); const canEdit = canEditTreatment(currentOrganization); const [stripHidden, setStripHidden] = useState(false); const todayStart = useMemo(() => startOfLocalDay(new Date()), []); const [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date())); const [appointments, setAppointments] = useState([]); const [apptsLoading, setApptsLoading] = useState(false); const [selectionLocked, setSelectionLocked] = useState(false); const [selectedAppointmentId, setSelectedAppointmentId] = useState(null); const [history, setHistory] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); const [orgs, setOrgs] = useState([]); const [cases, setCases] = useState(() => [newCase()]); const [activeCaseId, setActiveCaseId] = useState(() => cases[0].clientId); const [savedSnapshot, setSavedSnapshot] = useState(null); const selectionLockedRef = useRef(selectionLocked); selectionLockedRef.current = selectionLocked; const [saveBusy, setSaveBusy] = useState(false); const [sendBusyId, setSendBusyId] = useState(null); const [uploadBusyCaseId, setUploadBusyCaseId] = useState(null); const [organizationSearch, setOrganizationSearch] = useState(''); const [recentOrganizationIds, setRecentOrganizationIds] = useState([]); const [previewOpen, setPreviewOpen] = useState(false); const [previewTreatment, setPreviewTreatment] = useState(null); const [previewMode, setPreviewMode] = useState('readonly'); const isDirty = useMemo(() => { if (savedSnapshot === null) { return cases.length !== 1 || cases[0].comment !== '' || cases[0].teeth.length > 0; } return serializeCases(cases) !== savedSnapshot; }, [cases, savedSnapshot]); const selectedAppointment = useMemo( () => appointments.find((a) => a.id === selectedAppointmentId) ?? null, [appointments, selectedAppointmentId], ); const isViewingPastDay = useMemo( () => compareLocalDayStart(selectedDay, todayStart) < 0, [selectedDay, todayStart], ); const canEditTreatmentForDay = canEdit && Boolean(selectedAppointment) && !isViewingPastDay; const activeCase = useMemo( () => cases.find((c) => c.clientId === activeCaseId) ?? cases[0], [cases, activeCaseId], ); const selectedTeethSet = useMemo(() => new Set(activeCase?.teeth ?? []), [activeCase?.teeth]); const currentDraftPreview = useMemo(() => { if (!selectedAppointment) return null; return casesToPreviewTreatment(cases, { title: `Draft · ${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`, patientId: selectedAppointment.patientId, treatmentAt: new Date().toISOString(), status: 'draft', }); }, [cases, selectedAppointment]); useEffect(() => { setSelectionLocked(false); }, [selectedDay]); useEffect(() => { let cancelled = false; setApptsLoading(true); void (async () => { try { const dayStart = startOfLocalDay(selectedDay); const dayEnd = addCalendarDays(dayStart, 1); const response = await appointmentsApi.list({ from: dayStart.toISOString(), to: dayEnd.toISOString(), }); if (cancelled) return; const list = response.data .filter((a) => a.providerUserId === userId) .map(mapAppointment); setAppointments(list); if (!selectionLockedRef.current) { setSelectedAppointmentId(pickAutoAppointment(list, selectedDay)); } } catch (error: unknown) { if (!cancelled) { showError(formatApiErrorMessage(error, 'Failed to load appointments.')); } } finally { if (!cancelled) setApptsLoading(false); } })(); return () => { cancelled = true; }; }, [userId, selectedDay, showError]); useEffect(() => { const today = startOfLocalDay(new Date()); if (!isSameLocalCalendarDay(selectedDay, today) || selectionLocked) return; const id = window.setInterval(() => { setSelectedAppointmentId((prev) => { const next = pickAutoAppointment(appointments, selectedDay); return next ?? prev; }); }, 60_000); return () => window.clearInterval(id); }, [selectedDay, appointments, selectionLocked]); useEffect(() => { let cancelled = false; void (async () => { try { const list = await treatmentsApi.listLinkedOrganizations(); if (!cancelled) setOrgs(list.data); } catch (error: unknown) { if (!cancelled) { showError(formatApiErrorMessage(error, 'Failed to load linked organizations.')); } } })(); return () => { cancelled = true; }; }, [showError]); useEffect(() => { if (!selectedAppointment) { setHistory([]); return; } let cancelled = false; setHistoryLoading(true); void (async () => { try { const response = await treatmentsApi.listPatientHistory(selectedAppointment.patientId); if (!cancelled) setHistory(response.data); } catch (error: unknown) { if (!cancelled) { showError(formatApiErrorMessage(error, 'Failed to load treatment history.')); } } finally { if (!cancelled) setHistoryLoading(false); } })(); return () => { cancelled = true; }; }, [selectedAppointment?.patientId, showError]); useEffect(() => { const appointmentId = selectedAppointment?.id; if (!appointmentId) return; let cancelled = false; void (async () => { try { const response = await treatmentsApi.getDraft(appointmentId); if (cancelled) return; if (response.data?.cases?.length) { const mapped = response.data.cases.map(mapCaseFromApi); setCases(mapped); setActiveCaseId((prev) => { const stillExists = mapped.some((c) => c.clientId === prev); return stillExists ? prev : mapped[0].clientId; }); setSavedSnapshot(serializeCases(mapped)); } else { const first = newCase(); setCases([first]); setActiveCaseId(first.clientId); setSavedSnapshot(serializeCases([first])); } setOrganizationSearch(''); } catch (error: unknown) { if (!cancelled) { showError(formatApiErrorMessage(error, 'Failed to load treatment draft.')); } } })(); return () => { cancelled = true; }; }, [selectedAppointment?.id, showError]); const confirmDiscardIfDirty = useCallback(() => { if (!isDirty) return true; return window.confirm('You have unsaved changes. Discard them and continue?'); }, [isDirty]); const onPickAppointment = useCallback( (id: string) => { if (!confirmDiscardIfDirty()) return; setSelectionLocked(true); setSelectedAppointmentId(id); }, [confirmDiscardIfDirty], ); const onSelectDay = useCallback( (day: Date) => { if (!confirmDiscardIfDirty()) return; setSelectedDay(day); }, [confirmDiscardIfDirty], ); const uploadForCase = useCallback( async (caseClientId: string, files: FileList | File[]) => { if (!canEditTreatmentForDay || !selectedAppointment) return; const list = files instanceof FileList ? Array.from(files) : files; if (!list.length) return; setUploadBusyCaseId(caseClientId); try { const uploaded = await treatmentsApi.uploadCaseAttachments( selectedAppointment.id, caseClientId, list, ); setCases((prev) => prev.map((c) => c.clientId === caseClientId ? { ...c, attachmentMetas: [...c.attachmentMetas, ...uploaded.data] } : c, ), ); showSuccess( `${uploaded.data.length} file${uploaded.data.length === 1 ? '' : 's'} uploaded successfully.`, ); } catch (error: unknown) { showError(formatApiErrorMessage(error, 'Failed to upload attachments.')); } finally { setUploadBusyCaseId(null); } }, [canEditTreatmentForDay, selectedAppointment, showSuccess, showError], ); const persistDraft = useCallback(async () => { if (!selectedAppointment) throw new Error('No appointment selected'); const response = await treatmentsApi.saveDraft(selectedAppointment.id, { cases: cases.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({ clientId, id, treatmentType, teeth, comment, attachmentIds: attachmentMetas.map((a) => a.id), })), }); const mapped = response.data.cases.map(mapCaseFromApi); setCases(mapped); setActiveCaseId((prev) => { const stillExists = mapped.some((c) => c.clientId === prev); return stillExists ? prev : mapped[0]?.clientId ?? prev; }); setSavedSnapshot(serializeCases(mapped)); return response.data; }, [cases, selectedAppointment]); const handleSaveAll = useCallback(async () => { if (!canEditTreatmentForDay || !selectedAppointment) return; setSaveBusy(true); try { await persistDraft(); showSuccess('Treatment draft saved.'); } catch (error: unknown) { showError(formatApiErrorMessage(error, 'Failed to save treatment draft.')); } finally { setSaveBusy(false); } }, [canEditTreatmentForDay, selectedAppointment, persistDraft, showSuccess, showError]); const handleSendCase = useCallback( async (treatmentCase: TreatmentCaseDraft) => { if (!canEditTreatmentForDay || !selectedAppointment) return; const targets = treatmentCase.sendToOrganizationIds.filter((id) => orgs.some((o) => o.id === id && o.active), ); if (targets.length === 0) { showError('Choose at least one active organization to send this case.'); return; } setSendBusyId(treatmentCase.clientId); try { const saved = await persistDraft(); const serverCase = saved.cases.find((c) => c.clientId === treatmentCase.clientId); if (!serverCase?.id) throw new Error('Case must be saved before sending.'); const response = await treatmentsApi.sendCase(serverCase.id, { organizationIds: targets }); setCases((prev) => { const next = prev.map((c) => c.clientId === treatmentCase.clientId ? { ...c, id: response.data.id, sentAt: response.data.sentAt, sendToOrganizationIds: response.data.sendToOrganizationIds, sends: response.data.sends, } : c, ); setSavedSnapshot(serializeCases(next)); return next; }); setRecentOrganizationIds((prev) => { const next = [...targets.filter((id) => !prev.includes(id)), ...prev]; return next.slice(0, 10); }); showSuccess('Case sent to selected organizations.'); } catch (error: unknown) { showError(formatApiErrorMessage(error, 'Failed to send case.')); } finally { setSendBusyId(null); } }, [canEditTreatmentForDay, selectedAppointment, orgs, persistDraft, showSuccess, showError], ); const openPreview = useCallback( (treatment: PastTreatment, mode: TreatmentPreviewMode) => { setPreviewTreatment(treatment); setPreviewMode(mode); setPreviewOpen(true); }, [], ); const openCurrentDraftPreview = useCallback(() => { if (!currentDraftPreview) return; openPreview(currentDraftPreview, canEditTreatmentForDay ? 'editable' : 'readonly'); }, [currentDraftPreview, canEditTreatmentForDay, openPreview]); const getCaseOrgIds = useCallback( (caseKey: string) => cases.find((c) => c.clientId === caseKey)?.sendToOrganizationIds ?? [], [cases], ); const toggleCaseOrg = useCallback((caseKey: string, orgId: string, checked: boolean) => { setCases((prev) => prev.map((c) => { if (c.clientId !== caseKey || c.sentAt) return c; const next = new Set(c.sendToOrganizationIds); if (checked) next.add(orgId); else next.delete(orgId); return { ...c, sendToOrganizationIds: [...next] }; }), ); }, []); if (!canView) { return (

Treatment workspace

You do not have permission to view the Treatment tab for this organization.

); } return (

Treatment

{canEdit ? 'Document cases for your appointments, save drafts, and send work to linked organizations.' : 'View-only access — you can review appointments and treatment history but cannot edit.'}

setStripHidden((s) => !s)} selectedDay={selectedDay} onSelectDay={onSelectDay} appointments={appointments} selectedAppointmentId={selectedAppointmentId} onSelectAppointment={onPickAppointment} loading={apptsLoading} /> {isViewingPastDay && (

Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.

)}
{selectedAppointment ? (

Selected patient

{selectedAppointment.patientFirstName} {selectedAppointment.patientLastName}

Purpose:{' '} {selectedAppointment.purpose}

) : (
{apptsLoading ? 'Loading appointments…' : 'Select a day with at least one appointment.'}
)} openPreview(t, 'readonly')} />
{ if (!canEditTreatmentForDay) return; setCases((prev) => prev.map((c) => { if (c.clientId !== activeCaseId) return c; const set = new Set(c.teeth); if (set.has(fdi)) set.delete(fdi); else set.add(fdi); return { ...c, teeth: [...set].sort() as FdiToothId[] }; }), ); }} disabled={!canEditTreatmentForDay} /> { setCases((prev) => prev.map((c) => { if (c.clientId !== activeCaseId || c.sentAt) return c; if (c.sendToOrganizationIds.includes(orgId)) return c; return { ...c, sendToOrganizationIds: [...c.sendToOrganizationIds, orgId] }; }), ); }} onAddCase={() => { const nextCase = newCase(); setCases((prev) => [...prev, nextCase]); setActiveCaseId(nextCase.clientId); }} onPreview={openCurrentDraftPreview} onSave={() => void handleSaveAll()} onSendCase={(c) => void handleSendCase(c)} onUploadFiles={(files) => void uploadForCase(activeCaseId, files ?? [])} />
setPreviewOpen(false)} treatment={ previewMode === 'editable' && currentDraftPreview ? currentDraftPreview : previewTreatment } mode={previewMode} orgs={orgs} sendBusyCaseId={sendBusyId} uploadBusyCaseId={uploadBusyCaseId} onAttach={(caseKey, files) => uploadForCase(caseKey, files)} onSend={(caseKey, organizationIds) => { const c = cases.find((item) => item.clientId === caseKey); if (!c) return; void handleSendCase({ ...c, sendToOrganizationIds: organizationIds }); }} getCaseOrgIds={getCaseOrgIds} onToggleCaseOrg={toggleCaseOrg} />
); }