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 (
+
+ );
+});
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 && (
+
+
+
+
+ {
+ const nextType = e.target.value as TreatmentRecordDraft['treatmentType'];
+ setRecords((prev) =>
+ prev.map((r) =>
+ r.clientId === activeRecordId ? { ...r, treatmentType: nextType } : r,
+ ),
+ );
+ }}
+ disabled={!selectedAppointment}
+ className="capitalize"
+ style={{ color: treatmentTypeTextColor }}
+ >
+
+
+
+
+
+
+
+
+
+
Attachments (mock)
+
{
+ addAttachments(e.target.files);
+ e.target.value = '';
+ }}
+ className="sr-only"
+ aria-label="Attach files for this treatment record"
+ />
+
+ {activeRecord.attachmentMetas.length > 0 && (
+
+ {activeRecord.attachmentMetas.map((f) => (
+ -
+ {f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
+
+ ))}
+
+ )}
+
+
+
+
+ Send this record to linked organizations
+
+
+
+ {recentOrganizations.length > 0 && (
+
+ Recent:
+ {recentOrganizations.map((o) => (
+
+ ))}
+
+ )}
+
+
+ {filteredOrganizations.map((o) => (
+
{
+ setRecords((prev) =>
+ prev.map((r) => {
+ if (r.clientId !== activeRecordId) return r;
+ const next = new Set(r.sendToOrganizationIds);
+ if (checked) next.add(o.id);
+ else next.delete(o.id);
+ return { ...r, sendToOrganizationIds: [...next] };
+ }),
+ );
+ }}
+ label={`${o.name}${o.active ? '' : ' (inactive)'}`}
+ />
+ ))}
+ {filteredOrganizations.length === 0 && (
+ No active organization matches your search.
+ )}
+
+
+
+
+
+ {activeRecord.sentAt && (
+
+ Sent at {new Date(activeRecord.sentAt).toLocaleString()}
+
+ )}
+
+
+ )}
+
+
+
+
+ Saving stores all records locally (mock). Sending is per record and can happen after save.
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/ui/treatment/toothPathModel.ts b/frontend/src/components/ui/treatment/toothPathModel.ts
new file mode 100644
index 0000000..1753629
--- /dev/null
+++ b/frontend/src/components/ui/treatment/toothPathModel.ts
@@ -0,0 +1,113 @@
+import type { FdiToothId } from '@/types/treatment';
+import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta';
+
+export interface ToothPathModel {
+ crown: string;
+ roots: string[];
+ /** Occlusal groove etc. — open path, stroke only */
+ detailStroke?: { d: string; widthScale: number };
+}
+
+/** FDI-specific closed paths for the clinical (gradient + roots) chart. */
+export function getToothPathModel(fdi: FdiToothId, kind: ToothShapeKind, upper?: boolean): ToothPathModel | null {
+ const lastDigit = Number(fdi[1]);
+ const isThirdMolar = lastDigit === 8;
+ const isSecondMolar = lastDigit === 7;
+ const isFirstMolar = lastDigit === 6;
+ const isCentralIncisor = lastDigit === 1;
+ const isLateralIncisor = lastDigit === 2;
+
+ switch (kind) {
+ case 'incisor':
+ if (isLateralIncisor) {
+ return {
+ crown: 'M12 9 C14 6, 22 6, 24 9 C26 12, 26 25, 23 30 C21 32, 15 32, 13 30 C10 25, 10 12, 12 9 Z',
+ roots: ['M15.5 30 C14.5 40, 14.5 51, 16 60 C17 65, 19 65, 20 60 C21.5 51, 21.5 40, 20.5 30 Z'],
+ };
+ }
+ return {
+ crown: 'M11 8 C13 5, 23 5, 25 8 C27 12, 27 26, 24 31 C22 33, 14 33, 12 31 C9 26, 9 12, 11 8 Z',
+ roots: ['M15 31 C14 42, 14 53, 16 63 C17 68, 19 68, 20 63 C22 53, 22 42, 21 31 Z'],
+ };
+ case 'canine':
+ return {
+ crown: 'M18 6 C22 6, 25 10, 26 15 C26 20, 24 30, 22 33 C20 35, 16 35, 14 33 C12 30, 10 20, 10 15 C11 10, 14 6, 18 6 Z',
+ roots: ['M16 33 C14 44, 13 55, 14 66 C15 72, 18 75, 20 66 C21 55, 20 44, 20 33 Z'],
+ };
+ case 'premolar':
+ if (upper && lastDigit === 4) {
+ return {
+ crown: 'M9 12 C11 8, 15 7, 18 9 C21 7, 25 8, 27 12 C29 16, 29 25, 26 31 C24 34, 12 34, 10 31 C7 25, 7 16, 9 12 Z',
+ roots: [
+ 'M13 31 C12 39, 12 48, 13 56 C14 62, 16 64, 17 58 C18 54, 18 48, 18 41 Z',
+ 'M23 31 C24 39, 24 48, 23 56 C22 62, 20 64, 19 58 C18 54, 18 48, 18 41 Z',
+ ],
+ };
+ }
+ return {
+ crown: 'M9 12 C11 8, 15 7, 18 9 C21 7, 25 8, 27 12 C29 16, 29 26, 26 31 C24 34, 12 34, 10 31 C7 26, 7 16, 9 12 Z',
+ roots: ['M16 31 C15 42, 15 53, 16 62 C17 67, 19 67, 20 62 C21 53, 21 42, 20 31 Z'],
+ };
+ case 'molar':
+ if (isThirdMolar) {
+ const crown =
+ 'M9 15 C11 11, 14 10, 17 11 C20 9, 24 10, 26 14 C28 18, 28 26, 25 31 C23 33, 11 33, 9 31 C6 26, 6 19, 9 15 Z';
+ if (upper) {
+ return {
+ crown,
+ roots: [
+ 'M14 32 C13 39, 13 47, 14 54 C15 59, 17 61, 18 55 C19 50, 19 41, 18 33 Z',
+ 'M22 32 C23 39, 23 47, 22 54 C21 59, 19 61, 18 55 C17 50, 17 41, 18 33 Z',
+ ],
+ };
+ }
+ return {
+ crown,
+ roots: [
+ 'M14 32 C12 39, 12 47, 13 55 C14 61, 16 64, 18 57 C19 52, 19 44, 18 36 Z',
+ 'M22 32 C24 39, 24 47, 23 55 C22 61, 20 64, 18 57 C17 52, 17 44, 18 36 Z',
+ ],
+ };
+ }
+ if (isSecondMolar && upper) {
+ return {
+ crown: 'M8 14 C10 9, 13 8, 16 10 C18 8, 21 8, 23 10 C26 8, 29 9, 30 14 C31 18, 31 27, 28 32 C26 35, 10 35, 8 32 C5 27, 5 18, 8 14 Z',
+ roots: [
+ 'M11 35 C9 43, 9 50, 10 57 C11 62, 13 64, 14 58 C15 52, 15 44, 14 36 Z',
+ 'M18 35 C17 44, 17 52, 18 60 C19 66, 20 66, 21 60 C22 52, 22 44, 21 35 Z',
+ 'M25 35 C27 43, 27 50, 26 57 C25 62, 23 64, 22 58 C21 52, 21 44, 22 36 Z',
+ ],
+ };
+ }
+ if (isFirstMolar && !upper) {
+ return {
+ crown: 'M7 14 C9 9, 13 8, 16 10 C18 8, 21 8, 23 10 C26 8, 30 9, 31 14 C32 18, 32 27, 29 32 C27 35, 9 35, 7 32 C4 27, 4 18, 7 14 Z',
+ roots: [
+ 'M11 35 C9 43, 9 50, 10 58 C11 64, 14 67, 16 60 C17 56, 17 50, 17 44 Z',
+ 'M25 35 C27 43, 27 50, 26 58 C25 64, 22 67, 20 60 C19 56, 19 50, 19 44 Z',
+ ],
+ detailStroke: { d: 'M15 61 Q18 66 21 61', widthScale: 0.85 },
+ };
+ }
+ if (upper) {
+ return {
+ crown: 'M7 14 C9 9, 13 8, 16 10 C18 8, 21 8, 23 10 C26 8, 30 9, 31 14 C32 18, 32 27, 29 32 C27 35, 9 35, 7 32 C4 27, 4 18, 7 14 Z',
+ roots: [
+ 'M11 35 C9 43, 9 50, 10 57 C11 62, 13 64, 14 58 C15 52, 15 44, 14 36 Z',
+ 'M18 35 C17 44, 17 52, 18 60 C19 66, 20 66, 21 60 C22 52, 22 44, 21 35 Z',
+ 'M25 35 C27 43, 27 50, 26 57 C25 62, 23 64, 22 58 C21 52, 21 44, 22 36 Z',
+ ],
+ };
+ }
+ return {
+ crown: 'M7 14 C9 9, 13 8, 16 10 C18 8, 21 8, 23 10 C26 8, 30 9, 31 14 C32 18, 32 27, 29 32 C27 35, 9 35, 7 32 C4 27, 4 18, 7 14 Z',
+ roots: [
+ 'M11 35 C9 43, 9 50, 10 58 C11 64, 14 67, 16 60 C17 56, 17 50, 17 44 Z',
+ 'M25 35 C27 43, 27 50, 26 58 C25 64, 22 67, 20 60 C19 56, 19 50, 19 44 Z',
+ ],
+ detailStroke: { d: 'M15 61 Q18 66 21 61', widthScale: 0.85 },
+ };
+ default:
+ return null;
+ }
+}
diff --git a/frontend/src/lib/mocks/treatmentMockApi.ts b/frontend/src/lib/mocks/treatmentMockApi.ts
new file mode 100644
index 0000000..21dbff9
--- /dev/null
+++ b/frontend/src/lib/mocks/treatmentMockApi.ts
@@ -0,0 +1,200 @@
+import {
+ addCalendarDays,
+ isSameLocalCalendarDay,
+ startOfLocalDay,
+} from '@/lib/appointmentTime';
+import type {
+ FdiToothId,
+ LinkedOrganizationOption,
+ PastTreatment,
+ SaveTreatmentPayload,
+ SendTreatmentRecordPayload,
+ TreatmentAppointment,
+} from '@/types/treatment';
+
+const MOCK_DELAY_MS = 280;
+
+function sleep(ms = MOCK_DELAY_MS) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function pad2(n: number) {
+ return String(n).padStart(2, '0');
+}
+
+/** Builds deterministic ids so React keys stay stable across hot reloads */
+function mockApptId(dayKey: string, slotIndex: number | string) {
+ return `mock-appt-${dayKey}-${slotIndex}`;
+}
+
+function buildSlotsForDay(day: Date, userId: string): TreatmentAppointment[] {
+ const dayStart = startOfLocalDay(day);
+ const y = dayStart.getFullYear();
+ const m = dayStart.getMonth();
+ const d = dayStart.getDate();
+ const dayKey = `${y}-${pad2(m + 1)}-${pad2(d)}`;
+
+ const base: Omit[] = [
+ {
+ patientId: 'pat-alice',
+ patientFirstName: 'Alice',
+ patientLastName: 'Moradi',
+ providerUserId: userId,
+ purpose: 'consultation',
+ },
+ {
+ patientId: 'pat-babak',
+ patientFirstName: 'Babak',
+ patientLastName: 'Karimi',
+ providerUserId: userId,
+ purpose: 'filling',
+ },
+ {
+ patientId: 'pat-sara',
+ patientFirstName: 'Sara',
+ patientLastName: 'Hosseini',
+ providerUserId: userId,
+ purpose: 'endo',
+ },
+ ];
+
+ const slots: TreatmentAppointment[] = [];
+ const times = [
+ [9, 0, 9, 45],
+ [11, 15, 12, 0],
+ [14, 30, 15, 30],
+ ];
+
+ times.forEach(([h1, m1, h2, m2], i) => {
+ const baseSlot = base[i % base.length];
+ const startAt = new Date(y, m, d, h1, m1, 0, 0).toISOString();
+ const endAt = new Date(y, m, d, h2, m2, 0, 0).toISOString();
+ slots.push({
+ id: mockApptId(dayKey, i),
+ ...baseSlot,
+ startAt,
+ endAt,
+ });
+ });
+
+ const today = new Date();
+ if (isSameLocalCalendarDay(day, today)) {
+ const now = today.getTime();
+ const start = new Date(now - 12 * 60 * 1000);
+ const end = new Date(now + 48 * 60 * 1000);
+ slots.unshift({
+ id: mockApptId(dayKey, 'now'),
+ patientId: 'pat-hesam',
+ patientFirstName: 'Hesam',
+ patientLastName: 'Aghaie',
+ providerUserId: userId,
+ startAt: start.toISOString(),
+ endAt: end.toISOString(),
+ purpose: 'visit',
+ });
+ }
+
+ return slots.sort((a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime());
+}
+
+const MOCK_HISTORY: Record = {
+ 'pat-hesam': [
+ {
+ id: 'pt-h-1',
+ patientId: 'pat-hesam',
+ title: 'Root canal 45',
+ treatmentAt: new Date(2026, 8, 12).toISOString(),
+ status: 'completed',
+ records: [
+ {
+ id: 'ptr-1',
+ treatmentType: 'endo',
+ teeth: ['45'] as FdiToothId[],
+ notes: 'Instrumented and temporized.',
+ },
+ ],
+ documents: [
+ {
+ id: 'doc-1',
+ fileName: 'periapical-45.png',
+ mimeType: 'image/png',
+ sizeBytes: 842_120,
+ },
+ {
+ id: 'doc-2',
+ fileName: 'consent-signed.pdf',
+ mimeType: 'application/pdf',
+ sizeBytes: 312_000,
+ },
+ ],
+ },
+ {
+ id: 'pt-h-2',
+ patientId: 'pat-hesam',
+ title: 'Filling 14, 15',
+ treatmentAt: new Date(2025, 9, 10).toISOString(),
+ status: 'completed',
+ records: [
+ {
+ id: 'ptr-2',
+ treatmentType: 'filling',
+ teeth: ['14', '15'] as FdiToothId[],
+ notes: 'Composite restoration.',
+ },
+ ],
+ documents: [
+ {
+ id: 'doc-3',
+ fileName: 'notes.txt',
+ mimeType: 'text/plain',
+ sizeBytes: 420,
+ },
+ ],
+ },
+ ],
+ 'pat-alice': [
+ {
+ id: 'pt-a-1',
+ patientId: 'pat-alice',
+ title: 'Hygiene visit',
+ treatmentAt: addCalendarDays(new Date(), -21).toISOString(),
+ status: 'completed',
+ records: [{ id: 'ptr-a', treatmentType: 'hygiene', teeth: [], notes: 'Scale & polish.' }],
+ documents: [],
+ },
+ ],
+};
+
+const MOCK_ORGS: LinkedOrganizationOption[] = [
+ { id: 'org-lab-north', name: 'North Dental Lab', active: true },
+ { id: 'org-lab-smile', name: 'Smile Ceramics', active: true },
+ { id: 'org-lab-old', name: 'Legacy Lab (inactive)', active: false },
+];
+
+export async function fetchMyAppointmentsForDay(
+ userId: string,
+ day: Date,
+): Promise {
+ await sleep();
+ return buildSlotsForDay(day, userId);
+}
+
+export async function fetchPastTreatments(patientId: string): Promise {
+ await sleep();
+ return MOCK_HISTORY[patientId] ?? [];
+}
+
+export async function fetchLinkedOrganizations(): Promise {
+ await sleep(180);
+ return MOCK_ORGS;
+}
+
+export async function saveTreatmentDraft(_payload: SaveTreatmentPayload): Promise<{ ok: true }> {
+ await sleep();
+ return { ok: true };
+}
+
+export async function sendTreatmentRecord(_payload: SendTreatmentRecordPayload): Promise<{ ok: true }> {
+ await sleep();
+ return { ok: true };
+}
diff --git a/frontend/src/lib/treatmentSelection.ts b/frontend/src/lib/treatmentSelection.ts
new file mode 100644
index 0000000..482d00d
--- /dev/null
+++ b/frontend/src/lib/treatmentSelection.ts
@@ -0,0 +1,25 @@
+import { isSameLocalCalendarDay } from '@/lib/appointmentTime';
+import type { TreatmentAppointment } from '@/types/treatment';
+
+/**
+ * For the selected calendar day: if it is today, pick the appointment whose time range contains now;
+ * otherwise pick the first appointment of that day. Returns null when there are no appointments.
+ */
+export function pickAutoAppointment(
+ appointments: TreatmentAppointment[],
+ selectedDay: Date,
+): string | null {
+ if (appointments.length === 0) return null;
+
+ const now = new Date();
+ if (isSameLocalCalendarDay(selectedDay, now)) {
+ const t = now.getTime();
+ for (const a of appointments) {
+ const s = new Date(a.startAt).getTime();
+ const e = new Date(a.endAt).getTime();
+ if (t >= s && t <= e) return a.id;
+ }
+ }
+
+ return appointments[0].id;
+}
diff --git a/frontend/src/shared/permissions.ts b/frontend/src/shared/permissions.ts
index 627a2f5..753e73f 100644
--- a/frontend/src/shared/permissions.ts
+++ b/frontend/src/shared/permissions.ts
@@ -80,3 +80,10 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean
hasPermission(org, 'TAB_TREATMENT_EDIT')
);
}
+
+/** Treatment composer, scheduling columns, and saving clinical workflows */
+export function canEditTreatment(org: Organization | null): boolean {
+ if (!org) return false;
+ if (org.isOwner) return true;
+ return hasPermission(org, 'TAB_TREATMENT_EDIT');
+}
diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts
new file mode 100644
index 0000000..79332e3
--- /dev/null
+++ b/frontend/src/types/treatment.ts
@@ -0,0 +1,112 @@
+/** Permanent dentition FDI ids (11–48), stored as two-digit strings */
+export type FdiToothId =
+ | '11'
+ | '12'
+ | '13'
+ | '14'
+ | '15'
+ | '16'
+ | '17'
+ | '18'
+ | '21'
+ | '22'
+ | '23'
+ | '24'
+ | '25'
+ | '26'
+ | '27'
+ | '28'
+ | '31'
+ | '32'
+ | '33'
+ | '34'
+ | '35'
+ | '36'
+ | '37'
+ | '38'
+ | '41'
+ | '42'
+ | '43'
+ | '44'
+ | '45'
+ | '46'
+ | '47'
+ | '48';
+
+export interface TreatmentAppointment {
+ id: string;
+ patientId: string;
+ patientFirstName: string;
+ patientLastName: string;
+ providerUserId: string;
+ startAt: string;
+ endAt: string;
+ purpose: string;
+}
+
+export interface TreatmentAttachmentMeta {
+ id: string;
+ fileName: string;
+ mimeType: string;
+ sizeBytes: number;
+}
+
+export const TREATMENT_TYPES = [
+ 'consultation',
+ 'filling',
+ 'endo',
+ 'visit',
+ 'hygiene',
+] as const;
+
+export type TreatmentType = (typeof TREATMENT_TYPES)[number];
+
+export interface PastTreatmentRecord {
+ id: string;
+ treatmentType: TreatmentType;
+ teeth: FdiToothId[];
+ notes?: string | null;
+}
+
+export interface PastTreatment {
+ id: string;
+ patientId: string;
+ title: string;
+ treatmentAt: string;
+ status: string;
+ records: PastTreatmentRecord[];
+ documents: TreatmentAttachmentMeta[];
+}
+
+export interface LinkedOrganizationOption {
+ id: string;
+ name: string;
+ active: boolean;
+}
+
+export interface TreatmentRecordDraft {
+ clientId: string;
+ treatmentType: TreatmentType;
+ teeth: FdiToothId[];
+ comment: string;
+ attachmentMetas: TreatmentAttachmentMeta[];
+ /** Organizations selected for sending this record (mock only until API exists) */
+ sendToOrganizationIds: string[];
+ sentAt?: string | null;
+}
+
+/** Payload for persisting a draft (mock API); omits ephemeral client-only fields */
+export type SavedTreatmentRecordPayload = Omit;
+
+export interface SaveTreatmentPayload {
+ appointmentId: string;
+ patientId: string;
+ records: SavedTreatmentRecordPayload[];
+}
+
+export interface SendTreatmentRecordPayload {
+ appointmentId: string;
+ patientId: string;
+ recordClientId: string;
+ organizationIds: string[];
+}