From a70cbc78606b5ab1ca1d0724f2c07bf07c1f2635 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 7 May 2026 03:40:29 +0330 Subject: [PATCH 1/5] feature: a mocked implementation of v1 UI done. --- .../src/app/(dashboard)/treatment/page.tsx | 20 +- .../treatment/AppointmentsStrip.tsx | 114 ++++ .../components/treatment/FdiToothChart.tsx | 83 +++ .../treatment/PastTreatmentsPanel.tsx | 77 +++ .../src/components/treatment/ToothGlyph.tsx | 140 +++++ .../treatment/TreatmentWorkspace.tsx | 494 ++++++++++++++++++ .../src/components/treatment/fdiToothMeta.ts | 24 + frontend/src/lib/mocks/treatmentMockApi.ts | 198 +++++++ frontend/src/lib/treatmentSelection.ts | 25 + frontend/src/shared/permissions.ts | 7 + frontend/src/types/treatment.ts | 100 ++++ 11 files changed, 1276 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/treatment/AppointmentsStrip.tsx create mode 100644 frontend/src/components/treatment/FdiToothChart.tsx create mode 100644 frontend/src/components/treatment/PastTreatmentsPanel.tsx create mode 100644 frontend/src/components/treatment/ToothGlyph.tsx create mode 100644 frontend/src/components/treatment/TreatmentWorkspace.tsx create mode 100644 frontend/src/components/treatment/fdiToothMeta.ts create mode 100644 frontend/src/lib/mocks/treatmentMockApi.ts create mode 100644 frontend/src/lib/treatmentSelection.ts create mode 100644 frontend/src/types/treatment.ts diff --git a/frontend/src/app/(dashboard)/treatment/page.tsx b/frontend/src/app/(dashboard)/treatment/page.tsx index efca58d..dbd764e 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/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/components/treatment/AppointmentsStrip.tsx b/frontend/src/components/treatment/AppointmentsStrip.tsx new file mode 100644 index 0000000..4b06018 --- /dev/null +++ b/frontend/src/components/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/treatment/FdiToothChart.tsx b/frontend/src/components/treatment/FdiToothChart.tsx new file mode 100644 index 0000000..0b0cd24 --- /dev/null +++ b/frontend/src/components/treatment/FdiToothChart.tsx @@ -0,0 +1,83 @@ +'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/treatment/ToothGlyph'; +import type { FdiToothId } from '@/types/treatment'; + +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 Row = ({ teeth }: { teeth: FdiToothId[] }) => ( +
+ {teeth.map((fdi, i) => { + const kind = getToothShapeKind(fdi); + const gid = `${uid}-g-${fdi}-${i}`; + const isSel = selected.has(fdi); + return ( +
+ + + {fdi} + +
+ ); + })} +
+ ); + + 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

+ +
+
+

Lower arch

+ +
+
+
+ ); +} diff --git a/frontend/src/components/treatment/PastTreatmentsPanel.tsx b/frontend/src/components/treatment/PastTreatmentsPanel.tsx new file mode 100644 index 0000000..1e2dd6c --- /dev/null +++ b/frontend/src/components/treatment/PastTreatmentsPanel.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { FileText } from 'lucide-react'; +import type { PastTreatment } from '@/types/treatment'; + +interface PastTreatmentsPanelProps { + items: PastTreatment[]; + loading?: boolean; +} + +export function PastTreatmentsPanel({ items, loading }: 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.teeth.length > 0 ? `Teeth ${[...r.teeth].sort().join(', ')}` : 'No teeth tagged'} + {r.notes ? ` — ${r.notes}` : ''} +
  • + ))} +
+ )} + + {t.documents.length > 0 && ( +
+

Attachments

+
    + {t.documents.map((doc) => ( +
  • + + {doc.fileName} + + {(doc.sizeBytes / 1024).toFixed(1)} KB + +
  • + ))} +
+
+ )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/treatment/ToothGlyph.tsx b/frontend/src/components/treatment/ToothGlyph.tsx new file mode 100644 index 0000000..badfef6 --- /dev/null +++ b/frontend/src/components/treatment/ToothGlyph.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { memo } from 'react'; +import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta'; + +interface ToothGlyphProps { + kind: ToothShapeKind; + gradientId: string; + selected: boolean; + mirrored?: boolean; +} + +/** Stylized single tooth for FDI chart (outline + radial fill). Crown above, root(s) below. */ +export const ToothGlyph = memo(function ToothGlyph({ + kind, + gradientId, + selected, + mirrored, +}: ToothGlyphProps) { + const stroke = selected ? 'var(--color-primary)' : '#1e293b'; + const strokeW = selected ? 2.4 : 1.35; + const filter = selected ? 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' : undefined; + + const inner = () => { + switch (kind) { + case 'incisor': + return ( + <> + + + + ); + case 'canine': + return ( + <> + + + + ); + case 'premolar': + return ( + <> + + + + ); + case 'molar': + return ( + <> + + + + + ); + default: + return null; + } + }; + + return ( + + + + + + + + + {inner()} + + ); +}); diff --git a/frontend/src/components/treatment/TreatmentWorkspace.tsx b/frontend/src/components/treatment/TreatmentWorkspace.tsx new file mode 100644 index 0000000..dbe1e3f --- /dev/null +++ b/frontend/src/components/treatment/TreatmentWorkspace.tsx @@ -0,0 +1,494 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AppointmentsStrip } from '@/components/treatment/AppointmentsStrip'; +import { FdiToothChart } from '@/components/treatment/FdiToothChart'; +import { PastTreatmentsPanel } from '@/components/treatment/PastTreatmentsPanel'; +import { Checkbox } from '@/components/ui/common/Checkbox'; +import { Button } from '@/components/ui/common/Button'; +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)}`, + 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 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]); + + 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); + }, [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, + ), + ); + 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 records

+

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

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