diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index ea9e9e9..71adcf0 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -15,7 +15,7 @@ import { resolveStaffFeatureLabel, formatAccessSummary, type FeaturePermState, -} from './staff-permission-form'; +} from '../../../components/staff/staff-permission-form'; import { Pencil, Trash2, Copy, Check, X } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; diff --git a/frontend/src/app/(dashboard)/treatment/page.tsx b/frontend/src/app/(dashboard)/treatment/page.tsx index efca58d..1014b41 100644 --- a/frontend/src/app/(dashboard)/treatment/page.tsx +++ b/frontend/src/app/(dashboard)/treatment/page.tsx @@ -1,10 +1,18 @@ +'use client'; + +import { TreatmentWorkspace } from '@/components/ui/treatment/TreatmentWorkspace'; +import { useAuth } from '@/lib/hooks/useAuth'; + export default function TreatmentPage() { + const { user, currentOrganization, isAuthReady } = useAuth(); + + if (!isAuthReady || !user) { + return ( +
Loading…
+ ); + } + return ( -
-

Treatment

-

- Treatment module is coming soon. -

-
+ ); } diff --git a/frontend/src/app/(dashboard)/staff/staff-permission-form.ts b/frontend/src/components/staff/staff-permission-form.ts similarity index 100% rename from frontend/src/app/(dashboard)/staff/staff-permission-form.ts rename to frontend/src/components/staff/staff-permission-form.ts diff --git a/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts b/frontend/src/components/staff/staffPermissions.ts similarity index 100% rename from frontend/src/app/(dashboard)/staff/components/staffPermissions.ts rename to frontend/src/components/staff/staffPermissions.ts diff --git a/frontend/src/components/treatment/fdiToothMeta.ts b/frontend/src/components/treatment/fdiToothMeta.ts new file mode 100644 index 0000000..cd3b0a9 --- /dev/null +++ b/frontend/src/components/treatment/fdiToothMeta.ts @@ -0,0 +1,24 @@ +import type { FdiToothId } from '@/types/treatment'; + +/** Upper arch: distal → mesial on each side (full occlusal layout left → right) */ +export const FDI_UPPER_LEFT_TO_RIGHT: FdiToothId[] = [ + '18', '17', '16', '15', '14', '13', '12', '11', + '21', '22', '23', '24', '25', '26', '27', '28', +]; + +/** Lower arch */ +export const FDI_LOWER_LEFT_TO_RIGHT: FdiToothId[] = [ + '48', '47', '46', '45', '44', '43', '42', '41', + '31', '32', '33', '34', '35', '36', '37', '38', +]; + +export type ToothShapeKind = 'incisor' | 'canine' | 'premolar' | 'molar'; + +/** Infer cartoon shape from FDI (last digit encodes tooth type within quadrant). */ +export function getToothShapeKind(fdi: FdiToothId): ToothShapeKind { + const n = Number(fdi.slice(1)); + if (n <= 2) return 'incisor'; + if (n === 3) return 'canine'; + if (n <= 5) return 'premolar'; + return 'molar'; +} diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index 0c9a8ac..d5f4903 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import { X } from 'lucide-react'; import { Button } from '@/components/ui/common/Button'; import { Dropdown } from '@/components/ui/common/Dropdown'; -import { APPOINTMENT_PURPOSES, type AppointmentPurpose } from '@/types/appointment'; +import type { AppointmentPurpose } from '@/types/appointment'; import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles'; import type { Patient } from '@/types/patient'; import { @@ -46,6 +46,16 @@ export function AppointmentBookingModal({ const [endTime, setEndTime] = useState('10:00'); const [purpose, setPurpose] = useState('consultation'); const [error, setError] = useState(''); + const purposeTextColor = + purpose === 'consultation' + ? '#ddd6fe' + : purpose === 'filling' + ? '#fed7aa' + : purpose === 'endo' + ? '#fecaca' + : purpose === 'visit' + ? '#bae6fd' + : '#d9f99d'; useEffect(() => { if (!open) { @@ -175,12 +185,23 @@ export function AppointmentBookingModal({ label="Purpose" value={purpose} onChange={(e) => setPurpose(e.target.value as AppointmentPurpose)} + style={{ color: purposeTextColor }} > - {APPOINTMENT_PURPOSES.map((p) => ( - - ))} + + + + + {error &&

{error}

} diff --git a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx new file mode 100644 index 0000000..4b06018 --- /dev/null +++ b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { CalendarDays } from 'lucide-react'; +import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker'; +import { startOfLocalDay } from '@/lib/appointmentTime'; +import type { TreatmentAppointment } from '@/types/treatment'; + +interface AppointmentsStripProps { + stripHidden: boolean; + onToggleStripHidden: () => void; + selectedDay: Date; + onSelectDay: (day: Date) => void; + /** Same lower bound as Appointments schedule (cannot pick days before this). */ + minScheduleDate: Date; + appointments: TreatmentAppointment[]; + selectedAppointmentId: string | null; + onSelectAppointment: (id: string) => void; + loading?: boolean; +} + +export function AppointmentsStrip({ + stripHidden, + onToggleStripHidden, + selectedDay, + onSelectDay, + minScheduleDate, + appointments, + selectedAppointmentId, + onSelectAppointment, + loading = false, +}: AppointmentsStripProps) { + if (stripHidden) { + return ( +
+

Appointments are hidden.

+ +
+ ); + } + + return ( +
+
+
+ +

My appointments

+
+ +
+ +
+ onSelectDay(startOfLocalDay(d))} + /> + {loading && ( +

Loading appointments…

+ )} +
+ +
+ {appointments.length === 0 && ( +

No appointments assigned to you on this day.

+ )} + {appointments.map((a) => { + const sel = a.id === selectedAppointmentId; + const start = new Date(a.startAt); + const end = new Date(a.endAt); + const timeLabel = `${start.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + })} – ${end.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + })}`; + const palette = purposeStyle(a.purpose); + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/ui/treatment/FdiToothChart.tsx b/frontend/src/components/ui/treatment/FdiToothChart.tsx new file mode 100644 index 0000000..5857f48 --- /dev/null +++ b/frontend/src/components/ui/treatment/FdiToothChart.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { useId } from 'react'; +import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta'; +import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph'; +import type { FdiToothId } from '@/types/treatment'; + +const TOOTH_NUMBER_GAP = 'mt-1'; + +/** Six teeth each side of the midline (anteriors): slightly larger glyph + wrapper. */ +const MIDLINE_SIX = new Set([ + '13', '12', '11', '21', '22', '23', + '43', '42', '41', '31', '32', '33', +]); + +function quadrantMirrored(fdi: FdiToothId): boolean { + const q = fdi[0]; + return q === '2' || q === '3'; +} + +interface FdiToothChartProps { + selected: ReadonlySet; + onToggle: (fdi: FdiToothId) => void; + disabled?: boolean; +} + +export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartProps) { + const uid = useId().replace(/:/g, ''); + const archPeak = 10; + + const TOOTH_TWEAKS: Record = { + '18': { glyph: 'w-[1.98rem] h-[4.65rem]', offset: 7, rotate: -11 }, + '17': { glyph: 'w-[2rem] h-[4.75rem]', offset: 8, rotate: -8 }, + '16': { glyph: 'w-[2.1rem] h-[4.95rem]', offset: 9, rotate: -6 }, + '15': { glyph: 'w-[1.8rem] h-[4.8rem]', offset: 9, rotate: -4 }, + '14': { glyph: 'w-[1.8rem] h-[4.85rem]', offset: 9, rotate: -2 }, + '13': { glyph: 'w-[1.9rem] h-[5.28rem]', offset: 8, rotate: -1 }, + '12': { glyph: 'w-[1.8rem] h-[5.02rem]', offset: 7, rotate: -1 }, + '11': { glyph: 'w-[1.8rem] h-[5.08rem]', offset: 7, rotate: -0.5 }, + '21': { glyph: 'w-[1.8rem] h-[5.08rem]', offset: 7, rotate: 0.5 }, + '22': { glyph: 'w-[1.8rem] h-[5.02rem]', offset: 7, rotate: 1 }, + '23': { glyph: 'w-[1.9rem] h-[5.28rem]', offset: 8, rotate: 1 }, + '24': { glyph: 'w-[1.8rem] h-[4.85rem]', offset: 9, rotate: 2 }, + '25': { glyph: 'w-[1.8rem] h-[4.8rem]', offset: 9, rotate: 4 }, + '26': { glyph: 'w-[2.1rem] h-[4.95rem]', offset: 9, rotate: 6 }, + '27': { glyph: 'w-[2rem] h-[4.75rem]', offset: 8, rotate: 8 }, + '28': { glyph: 'w-[1.98rem] h-[4.65rem]', offset: 7, rotate: 11 }, + + '48': { glyph: 'w-[1.98rem] h-[4.7rem]', offset: -7, rotate: -11 }, + '47': { glyph: 'w-[2rem] h-[4.8rem]', offset: -8, rotate: -8 }, + '46': { glyph: 'w-[2.15rem] h-[5rem]', offset: -9, rotate: -6 }, + '45': { glyph: 'w-[1.82rem] h-[4.85rem]', offset: -9, rotate: -4 }, + '44': { glyph: 'w-[1.82rem] h-[4.9rem]', offset: -9, rotate: -2 }, + '43': { glyph: 'w-[1.88rem] h-[5.34rem]', offset: -8, rotate: -1 }, + '42': { glyph: 'w-[1.76rem] h-[5.04rem]', offset: -7, rotate: -1 }, + '41': { glyph: 'w-[1.74rem] h-[4.98rem]', offset: -7, rotate: -0.5 }, + '31': { glyph: 'w-[1.74rem] h-[4.98rem]', offset: -7, rotate: 0.5 }, + '32': { glyph: 'w-[1.76rem] h-[5.04rem]', offset: -7, rotate: 1 }, + '33': { glyph: 'w-[1.88rem] h-[5.34rem]', offset: -8, rotate: 1 }, + '34': { glyph: 'w-[1.82rem] h-[4.9rem]', offset: -9, rotate: 2 }, + '35': { glyph: 'w-[1.82rem] h-[4.85rem]', offset: -9, rotate: 4 }, + '36': { glyph: 'w-[2.15rem] h-[5rem]', offset: -9, rotate: 6 }, + '37': { glyph: 'w-[2rem] h-[4.8rem]', offset: -8, rotate: 8 }, + '38': { glyph: 'w-[1.98rem] h-[4.7rem]', offset: -7, rotate: 11 }, + }; + + const toothSizeClass = (fdi: FdiToothId) => { + const kind = getToothShapeKind(fdi); + const mid = MIDLINE_SIX.has(fdi); + switch (kind) { + case 'incisor': + return mid + ? { wrapper: 'w-[2.1rem]', glyph: 'w-[1.68rem] h-[4.78rem]' } + : { wrapper: 'w-[2rem]', glyph: 'w-[1.6rem] h-[4.6rem]' }; + case 'canine': + return mid + ? { wrapper: 'w-[2.2rem]', glyph: 'w-[1.78rem] h-[5.05rem]' } + : { wrapper: 'w-[2.1rem]', glyph: 'w-[1.7rem] h-[4.9rem]' }; + case 'premolar': + return mid + ? { wrapper: 'w-[2.34rem]', glyph: 'w-[1.93rem] h-[5.02rem]' } + : { wrapper: 'w-[2.25rem]', glyph: 'w-[1.85rem] h-[4.85rem]' }; + case 'molar': + return mid + ? { wrapper: 'w-[2.52rem]', glyph: 'w-[2.12rem] h-[5.1rem]' } + : { wrapper: 'w-[2.45rem]', glyph: 'w-[2.05rem] h-[4.95rem]' }; + default: + return { wrapper: 'w-[2.25rem]', glyph: 'w-[1.85rem] h-[4.85rem]' }; + } + }; + + const archOffset = (index: number, count: number, upper?: boolean) => { + const center = (count - 1) / 2; + const dist = Math.abs(index - center) / center; + const curve = (1 - Math.pow(dist, 1.45)) * archPeak; + return upper ? curve : -curve; + }; + + const archRotate = (index: number, count: number) => { + const center = (count - 1) / 2; + const normalized = (index - center) / center; + return normalized * 6; + }; + + const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => ( +
+ {teeth.map((fdi, i) => { + const kind = getToothShapeKind(fdi); + const gid = `${uid}-g-${fdi}-${i}`; + const isSel = selected.has(fdi); + const size = toothSizeClass(fdi); + const tweak = TOOTH_TWEAKS[fdi]; + const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper); + const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length); + const alignItems = upper ? 'items-end' : 'items-start'; + return ( +
+
+ +
+
+ ); + })} +
+ ); + + return ( +
+
+
+

FDI tooth chart

+

+ Tap teeth to multi-select (FDI). Selection applies to the active treatment record until you save. +

+
+
+

+ Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')} +

+
+
+ +

Upper arch

+ +
+
+
+ +
+ {FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => { + const size = toothSizeClass(fdi); + const isSel = selected.has(fdi); + return ( + + {fdi} + + ); + })} +
+ +
+ +
+
+ {FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => { + const size = toothSizeClass(fdi); + const isSel = selected.has(fdi); + return ( + + {fdi} + + ); + })} +
+
+ +
+
+
+
+ +

Lower arch

+
+ ); +} diff --git a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx new file mode 100644 index 0000000..1c6c497 --- /dev/null +++ b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { FileText } from 'lucide-react'; +import type { PastTreatment } from '@/types/treatment'; + +interface PastTreatmentsPanelProps { + items: PastTreatment[]; + loading?: boolean; + selectedTreatmentId?: string | null; + onSelectTreatment?: (treatment: PastTreatment) => void; +} + +export function PastTreatmentsPanel({ + items, + loading, + selectedTreatmentId, + onSelectTreatment, +}: PastTreatmentsPanelProps) { + return ( +
+
+

Previous treatments

+

+ Document preview is not implemented yet; file names are listed for context. +

+
+ + {loading &&

Loading history...

} + + {!loading && items.length === 0 && ( +

No prior treatments for this patient.

+ )} + +
+ {items.map((t) => ( +
+
+

{t.title}

+ +
+

Status: {t.status}

+ + {t.records.length > 0 && ( +
    + {t.records.map((r) => ( +
  • + Record: + {r.treatmentType} + {' | '} + {r.teeth.length > 0 ? `Teeth ${[...r.teeth].sort().join(', ')}` : 'No teeth tagged'} + {r.notes ? ` — ${r.notes}` : ''} +
  • + ))} +
+ )} + + {onSelectTreatment && ( +
+ +
+ )} + + {t.documents.length > 0 && ( +
+

Attachments

+
    + {t.documents.map((doc) => ( +
  • + + {doc.fileName} + + {(doc.sizeBytes / 1024).toFixed(1)} KB + +
  • + ))} +
+
+ )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/ui/treatment/ToothGlyph.tsx b/frontend/src/components/ui/treatment/ToothGlyph.tsx new file mode 100644 index 0000000..67e081f --- /dev/null +++ b/frontend/src/components/ui/treatment/ToothGlyph.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { memo, type ReactNode } from 'react'; +import type { FdiToothId } from '@/types/treatment'; +import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta'; +import { getToothPathModel, type ToothPathModel } from '@/components/ui/treatment/toothPathModel'; + +interface ToothGlyphProps { + fdi: FdiToothId; + kind: ToothShapeKind; + gradientId: string; + selected: boolean; + upper?: boolean; + mirrored?: boolean; + upsideDown?: boolean; + className?: string; +} + +function filledSilhouette( + model: ToothPathModel, + crownFill: string, + rootFill: string, + stroke: string, + strokeW: number, +): ReactNode { + return ( + <> + + {model.roots.map((d, i) => ( + + ))} + + ); +} + +function detailOnly(model: ToothPathModel, stroke: string, strokeW: number): ReactNode { + if (!model.detailStroke) return null; + const { d, widthScale } = model.detailStroke; + return ; +} + +function renderClinicalFill(model: ToothPathModel, selected: boolean, gradientId: string): ReactNode { + const stroke = selected ? 'var(--color-primary)' : '#1e293b'; + const strokeW = selected ? 2.4 : 1.35; + const fill = `url(#${gradientId})`; + return ( + <> + {filledSilhouette(model, fill, fill, stroke, strokeW)} + {detailOnly(model, stroke, strokeW)} + + ); +} +/** FDI chart tooth: clinical gradient + roots only. */ +export const ToothGlyph = memo(function ToothGlyph({ + fdi, + kind, + gradientId, + selected, + upper, + mirrored, + upsideDown, + className = 'w-8 h-[4.85rem]', +}: ToothGlyphProps) { + const model: ToothPathModel | null = getToothPathModel(fdi, kind, upper); + const filter = selected ? 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' : undefined; + + if (!model) { + return ( + + + ? + + + ); + } + + const body = renderClinicalFill(model, selected, gradientId); + + return ( + + + + + + + + + + {body} + + + ); +}); diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx new file mode 100644 index 0000000..e18ce0f --- /dev/null +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -0,0 +1,656 @@ +'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/common/Checkbox'; +import { Button } from '@/components/ui/common/Button'; +import { Dropdown } from '@/components/ui/common/Dropdown'; +import { SearchBar } from '@/components/ui/common/SearchBar'; +import { isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime'; +import { + fetchLinkedOrganizations, + fetchMyAppointmentsForDay, + fetchPastTreatments, + saveTreatmentDraft, + sendTreatmentRecord, +} from '@/lib/mocks/treatmentMockApi'; +import { pickAutoAppointment } from '@/lib/treatmentSelection'; +import { canEditTreatment } from '@/shared/permissions'; +import type { Organization } from '@/types/organization'; +import type { + FdiToothId, + LinkedOrganizationOption, + PastTreatment, + TreatmentAppointment, + TreatmentAttachmentMeta, + TreatmentRecordDraft, +} from '@/types/treatment'; + +function newRecord(): TreatmentRecordDraft { + return { + clientId: + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `rec-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + treatmentType: 'consultation', + teeth: [], + comment: '', + attachmentMetas: [], + sendToOrganizationIds: [], + sentAt: null, + }; +} + +interface TreatmentWorkspaceProps { + userId: string; + currentOrganization: Organization | null; +} + +export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) { + const canEdit = canEditTreatment(currentOrganization); + + const [stripHidden, setStripHidden] = useState(false); + + const scheduleMinDate = 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 [records, setRecords] = useState(() => [newRecord()]); + const [activeRecordId, setActiveRecordId] = useState(() => records[0].clientId); + + const selectionLockedRef = useRef(selectionLocked); + selectionLockedRef.current = selectionLocked; + + const attachmentInputRef = useRef(null); + + const [saveBusy, setSaveBusy] = useState(false); + const [sendBusyId, setSendBusyId] = useState(null); + const [banner, setBanner] = useState(null); + const [organizationSearch, setOrganizationSearch] = useState(''); + const [recentOrganizationIds, setRecentOrganizationIds] = useState([]); + const [reviewTreatment, setReviewTreatment] = useState(null); + + const selectedAppointment = useMemo( + () => appointments.find((a) => a.id === selectedAppointmentId) ?? null, + [appointments, selectedAppointmentId], + ); + + const activeRecord = useMemo( + () => records.find((r) => r.clientId === activeRecordId) ?? records[0], + [records, activeRecordId], + ); + + const selectedTeethSet = useMemo(() => new Set(activeRecord.teeth), [activeRecord.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', + records: records.map((r, idx) => ({ + id: r.clientId || `draft-${idx + 1}`, + treatmentType: r.treatmentType, + teeth: r.teeth, + notes: r.comment || null, + })), + documents: records.flatMap((r) => r.attachmentMetas), + }; + }, [records, selectedAppointment]); + + const treatmentTypeTextColor = useMemo(() => { + const map: Record = { + consultation: '#ddd6fe', + filling: '#fed7aa', + endo: '#fecaca', + visit: '#bae6fd', + hygiene: '#d9f99d', + }; + return map[activeRecord.treatmentType]; + }, [activeRecord.treatmentType]); + + useEffect(() => { + setSelectionLocked(false); + }, [selectedDay]); + + useEffect(() => { + let cancelled = false; + setApptsLoading(true); + void (async () => { + try { + const list = await fetchMyAppointmentsForDay(userId, selectedDay); + if (cancelled) return; + setAppointments(list); + if (!selectionLockedRef.current) { + setSelectedAppointmentId(pickAutoAppointment(list, selectedDay)); + } + } 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 () => { + const list = await fetchLinkedOrganizations(); + if (!cancelled) setOrgs(list); + })(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!selectedAppointment) { + setHistory([]); + return; + } + let cancelled = false; + setHistoryLoading(true); + void (async () => { + const items = await fetchPastTreatments(selectedAppointment.patientId); + if (!cancelled) { + setHistory(items); + setHistoryLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [selectedAppointment?.patientId]); + + useEffect(() => { + const first = newRecord(); + setRecords([first]); + setActiveRecordId(first.clientId); + setOrganizationSearch(''); + setReviewTreatment(null); + }, [selectedAppointment?.id]); + + const fixActiveAfterRecordsChange = useCallback((next: TreatmentRecordDraft[]) => { + setRecords(next); + setActiveRecordId((id) => (next.some((r) => r.clientId === id) ? id : next[0].clientId)); + }, []); + + const toggleTooth = useCallback( + (fdi: FdiToothId) => { + if (!canEdit) return; + setRecords((prev) => + prev.map((r) => { + if (r.clientId !== activeRecordId) return r; + const set = new Set(r.teeth); + if (set.has(fdi)) set.delete(fdi); + else set.add(fdi); + return { ...r, teeth: [...set].sort() as FdiToothId[] }; + }), + ); + }, + [activeRecordId, canEdit], + ); + + const onPickAppointment = useCallback((id: string) => { + setSelectionLocked(true); + setSelectedAppointmentId(id); + }, []); + + const addAttachments = useCallback( + (files: FileList | null) => { + if (!files?.length || !canEdit) return; + setRecords((prev) => + prev.map((r) => { + if (r.clientId !== activeRecordId) return r; + const added: TreatmentAttachmentMeta[] = [...r.attachmentMetas]; + for (let i = 0; i < files.length; i++) { + const f = files[i]; + added.push({ + id: `local-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 7)}`, + fileName: f.name, + mimeType: f.type || 'application/octet-stream', + sizeBytes: f.size, + }); + } + return { ...r, attachmentMetas: added }; + }), + ); + }, + [activeRecordId, canEdit], + ); + + const handleSaveAll = useCallback(async () => { + if (!canEdit || !selectedAppointment) return; + setSaveBusy(true); + setBanner(null); + try { + await saveTreatmentDraft({ + appointmentId: selectedAppointment.id, + patientId: selectedAppointment.patientId, + records: records.map(({ clientId: _c, sentAt: _s, ...rest }) => rest), + }); + setBanner('Treatment draft saved (mock). You can send records later.'); + } finally { + setSaveBusy(false); + } + }, [canEdit, selectedAppointment, records]); + + const handleSendRecord = useCallback( + async (record: TreatmentRecordDraft) => { + if (!canEdit || !selectedAppointment) return; + const targets = record.sendToOrganizationIds.filter((id) => + orgs.some((o) => o.id === id && o.active), + ); + if (targets.length === 0) { + setBanner('Choose at least one active organization to send this record.'); + return; + } + setSendBusyId(record.clientId); + setBanner(null); + try { + await sendTreatmentRecord({ + appointmentId: selectedAppointment.id, + patientId: selectedAppointment.patientId, + recordClientId: record.clientId, + organizationIds: targets, + }); + setRecords((prev) => + prev.map((r) => + r.clientId === record.clientId ? { ...r, sentAt: new Date().toISOString() } : r, + ), + ); + setRecentOrganizationIds((prev) => { + const next = [...record.sendToOrganizationIds.filter((id) => id && !prev.includes(id)), ...prev]; + return next.slice(0, 10); + }); + setBanner('Record sent to selected organizations (mock).'); + } finally { + setSendBusyId(null); + } + }, + [canEdit, selectedAppointment, orgs], + ); + + if (!canEdit) { + return ( +
+

Treatment workspace

+

+ Your role can view the Treatment tab, but editing clinical workflows requires{' '} + Treatment edit permission. +

+
+ ); + } + + return ( +
+
+

Treatment

+

+ Mocked data — appointments, history, save, and send are simulated until backend endpoints exist. +

+
+ + setStripHidden((s) => !s)} + selectedDay={selectedDay} + onSelectDay={setSelectedDay} + minScheduleDate={scheduleMinDate} + appointments={appointments} + selectedAppointmentId={selectedAppointmentId} + onSelectAppointment={onPickAppointment} + loading={apptsLoading} + /> + + {banner && ( +
+ {banner} +
+ )} + +
+
+ {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.records.map((r, idx) => ( +
+

Record {idx + 1}

+

+ Type: {r.treatmentType} +

+

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

+ {r.notes &&

Notes: {r.notes}

} +
+ ))} +
+

+ Attachments: {reviewTreatment.documents.length} +

+
+ )} +
+
+ +
+ + +
+
+
+

Treatment records

+

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

+
+ +
+ +
+ {records.map((r, idx) => ( + + ))} +
+ + {activeRecord && ( +
+