'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 { Checkbox } from '@/components/ui/shared/Checkbox'; import { Button } from '@/components/ui/shared/Button'; import { Dropdown } from '@/components/ui/shared/Dropdown'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { Toast } 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 type { Organization } from '@/types/organization'; import type { AppointmentRecord } from '@/types/appointment'; import type { FdiToothId, LinkedOrganizationOption, PastTreatment, PastTreatmentCase, TreatmentAppointment, TreatmentAttachmentMeta, 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 ?? [], 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, })), ); } interface TreatmentWorkspaceProps { userId: string; currentOrganization: Organization | null; } export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) { 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 attachmentInputRef = useRef(null); const [saveBusy, setSaveBusy] = useState(false); const [sendBusyId, setSendBusyId] = useState(null); const [uploadBusy, setUploadBusy] = useState(false); const [banner, setBanner] = useState(null); const [errorBanner, setErrorBanner] = useState(null); const [organizationSearch, setOrganizationSearch] = useState(''); const [recentOrganizationIds, setRecentOrganizationIds] = useState([]); const [reviewTreatment, setReviewTreatment] = useState(null); 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 activeLinkedOrganizations = useMemo(() => orgs.filter((o) => o.active), [orgs]); const filteredOrganizations = useMemo(() => { const q = organizationSearch.trim().toLowerCase(); if (!q) return activeLinkedOrganizations; return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q)); }, [organizationSearch, activeLinkedOrganizations]); const recentOrganizations = useMemo(() => { if (recentOrganizationIds.length === 0) return []; const recentSet = new Set(recentOrganizationIds); return activeLinkedOrganizations .filter((o) => recentSet.has(o.id)) .sort((a, b) => recentOrganizationIds.indexOf(a.id) - recentOrganizationIds.indexOf(b.id)) .slice(0, 3); }, [recentOrganizationIds, activeLinkedOrganizations]); const currentDraftPreview = useMemo(() => { if (!selectedAppointment) return null; return { id: 'current-draft', patientId: selectedAppointment.patientId, title: `Current draft for ${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`, treatmentAt: new Date().toISOString(), status: 'draft', 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, })), documents: cases.flatMap((c) => c.attachmentMetas), }; }, [cases, selectedAppointment]); const treatmentTypeTextColor = useMemo(() => { const map: Record = { consultation: '#ddd6fe', filling: '#fed7aa', endo: '#fecaca', visit: '#bae6fd', hygiene: '#d9f99d', }; return map[activeCase.treatmentType]; }, [activeCase.treatmentType]); const loadDraftForAppointment = useCallback(async (appointmentId: string) => { const response = await treatmentsApi.getDraft(appointmentId); if (response.data?.cases?.length) { const mapped = response.data.cases.map(mapCaseFromApi); setCases(mapped); setActiveCaseId(mapped[0].clientId); setSavedSnapshot(serializeCases(mapped)); } else { const first = newCase(); setCases([first]); setActiveCaseId(first.clientId); setSavedSnapshot(serializeCases([first])); } setOrganizationSearch(''); setReviewTreatment(null); }, []); 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) { setErrorBanner(formatApiErrorMessage(error, 'Failed to load appointments.')); } } finally { if (!cancelled) setApptsLoading(false); } })(); return () => { cancelled = true; }; }, [userId, selectedDay]); 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) { setErrorBanner(formatApiErrorMessage(error, 'Failed to load linked organizations.')); } } })(); return () => { cancelled = true; }; }, []); 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) { setErrorBanner(formatApiErrorMessage(error, 'Failed to load treatment history.')); } } finally { if (!cancelled) setHistoryLoading(false); } })(); return () => { cancelled = true; }; }, [selectedAppointment?.patientId]); useEffect(() => { if (!selectedAppointment?.id) return; let cancelled = false; void (async () => { try { await loadDraftForAppointment(selectedAppointment.id); } catch (error: unknown) { if (!cancelled) { setErrorBanner(formatApiErrorMessage(error, 'Failed to load treatment draft.')); } } })(); return () => { cancelled = true; }; }, [selectedAppointment?.id, loadDraftForAppointment]); const fixActiveAfterCasesChange = useCallback((next: TreatmentCaseDraft[]) => { setCases(next); setActiveCaseId((id) => (next.some((c) => c.clientId === id) ? id : next[0].clientId)); }, []); const toggleTooth = useCallback( (fdi: FdiToothId) => { 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[] }; }), ); }, [activeCaseId, canEditTreatmentForDay], ); 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 addAttachments = useCallback( async (files: FileList | null) => { if (!files?.length || !canEditTreatmentForDay || !selectedAppointment) return; setUploadBusy(true); setErrorBanner(null); try { const uploaded = await treatmentsApi.uploadCaseAttachments( selectedAppointment.id, activeCaseId, Array.from(files), ); setCases((prev) => prev.map((c) => { if (c.clientId !== activeCaseId) return c; return { ...c, attachmentMetas: [...c.attachmentMetas, ...uploaded.data] }; }), ); } catch (error: unknown) { setErrorBanner(formatApiErrorMessage(error, 'Failed to upload attachments.')); } finally { setUploadBusy(false); } }, [activeCaseId, canEditTreatmentForDay, selectedAppointment], ); 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); setBanner(null); setErrorBanner(null); try { await persistDraft(); setBanner('Treatment draft saved. You can send cases later.'); } catch (error: unknown) { setErrorBanner(formatApiErrorMessage(error, 'Failed to save treatment draft.')); } finally { setSaveBusy(false); } }, [canEditTreatmentForDay, selectedAppointment, persistDraft]); 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) { setErrorBanner('Choose at least one active organization to send this case.'); return; } setSendBusyId(treatmentCase.clientId); setBanner(null); setErrorBanner(null); 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, } : c, ); setSavedSnapshot(serializeCases(next)); return next; }); setRecentOrganizationIds((prev) => { const next = [...targets.filter((id) => id && !prev.includes(id)), ...prev]; return next.slice(0, 10); }); setBanner('Case sent to selected organizations.'); } catch (error: unknown) { setErrorBanner(formatApiErrorMessage(error, 'Failed to send case.')); } finally { setSendBusyId(null); } }, [canEditTreatmentForDay, selectedAppointment, orgs, persistDraft, cases], ); 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}

Appointment purpose:{' '} {selectedAppointment.purpose}

) : (
{apptsLoading ? 'Loading appointments…' : 'Select a day with at least one appointment.'}
)}

Treatment review

{!reviewTreatment && (

Select a treatment from history, or preview the current draft.

)} {reviewTreatment && (

{reviewTreatment.title}

{new Date(reviewTreatment.treatmentAt).toLocaleDateString()}

Status: {reviewTreatment.status}

{reviewTreatment.cases.map((c, idx) => (

Case {idx + 1}

Type: {c.treatmentType}

Teeth: {c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}

{c.notes &&

Notes: {c.notes}

}
))}

Attachments: {reviewTreatment.documents.length}

)}

Treatment cases

Each case has its own teeth, notes, attachments, and destinations for send.

{cases.map((c, idx) => ( ))}
{activeCase && (