Compare commits

...

7 Commits

21 changed files with 1008 additions and 291 deletions

View File

@@ -14,9 +14,10 @@ import { AppointmentScheduleGrid } from '@/components/ui/appointments/Appointmen
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch'; import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker'; import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
import { Toast } from '@/components/ui/common/Toast';
import type { AppointmentPurpose } from '@/types/appointment'; import type { AppointmentPurpose } from '@/types/appointment';
import { formatApiErrorMessage } from '@/lib/formatApiError'; import { formatApiErrorMessage } from '@/lib/formatApiError';
import { getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime'; import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime';
const EMPTY_PATIENT_FORM: CreatePatientInput = { const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '', firstName: '',
@@ -47,6 +48,7 @@ export default function AppointmentsPage() {
const [bookingHour, setBookingHour] = useState(9); const [bookingHour, setBookingHour] = useState(9);
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null); const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
const [bookingProviderName, setBookingProviderName] = useState(''); const [bookingProviderName, setBookingProviderName] = useState('');
const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null);
const [savingAppointment, setSavingAppointment] = useState(false); const [savingAppointment, setSavingAppointment] = useState(false);
const [toastError, setToastError] = useState(''); const [toastError, setToastError] = useState('');
@@ -57,6 +59,14 @@ export default function AppointmentsPage() {
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
const todayStart = useMemo(() => startOfLocalDay(new Date()), []); const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
const isViewingPastDay = useMemo(
() => compareLocalDayStart(scheduleDate, todayStart) < 0,
[scheduleDate, todayStart],
);
const activeEditingAppointment = useMemo(
() => appointments.find((a) => a.id === editingAppointmentId) ?? null,
[appointments, editingAppointmentId],
);
const scheduleLoadGen = useRef(0); const scheduleLoadGen = useRef(0);
@@ -154,6 +164,12 @@ export default function AppointmentsPage() {
} }
function handleSlotClick(hour: number, providerUserId: string, providerName: string) { function handleSlotClick(hour: number, providerUserId: string, providerName: string) {
if (isViewingPastDay) {
setToastSuccess('');
setToastError('');
setToastInfo('Past appointments are view-only.');
return;
}
if (!selectedPatient) { if (!selectedPatient) {
setToastSuccess(''); setToastSuccess('');
setToastError(''); setToastError('');
@@ -163,6 +179,22 @@ export default function AppointmentsPage() {
setBookingHour(hour); setBookingHour(hour);
setBookingProviderId(providerUserId); setBookingProviderId(providerUserId);
setBookingProviderName(providerName); setBookingProviderName(providerName);
setEditingAppointmentId(null);
setBookingOpen(true);
}
function handleAppointmentClick(appointment: AppointmentRecord) {
if (isViewingPastDay) {
setToastSuccess('');
setToastError('');
setToastInfo('Past appointments are view-only.');
return;
}
const provider = providers.find((p) => p.userId === appointment.providerUserId);
setBookingHour(new Date(appointment.startAt).getHours());
setBookingProviderId(appointment.providerUserId);
setBookingProviderName(provider?.name ?? bookingProviderName);
setEditingAppointmentId(appointment.id);
setBookingOpen(true); setBookingOpen(true);
} }
@@ -178,15 +210,22 @@ export default function AppointmentsPage() {
setToastSuccess(''); setToastSuccess('');
setToastInfo(''); setToastInfo('');
try { try {
await appointmentsApi.create(payload); if (activeEditingAppointment) {
await appointmentsApi.update(activeEditingAppointment.id, payload);
} else {
await appointmentsApi.create(payload);
}
setBookingOpen(false); setBookingOpen(false);
setToastSuccess('Appointment saved.'); setEditingAppointmentId(null);
setToastSuccess(activeEditingAppointment ? 'Appointment updated.' : 'Appointment saved.');
await loadSchedule(); await loadSchedule();
} catch (err: unknown) { } catch (err: unknown) {
const message = const message =
err && typeof err === 'object' && 'message' in err err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message) ? String((err as { message: unknown }).message)
: 'Could not save appointment.'; : activeEditingAppointment
? 'Could not update appointment.'
: 'Could not save appointment.';
setToastError(message); setToastError(message);
} finally { } finally {
setSavingAppointment(false); setSavingAppointment(false);
@@ -280,20 +319,15 @@ export default function AppointmentsPage() {
)} )}
</div> </div>
{scheduleError && (
<div className="rounded-[var(--radius-sm)] border border-red-500/50 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{scheduleError}
</div>
)}
<AppointmentScheduleGrid <AppointmentScheduleGrid
day={scheduleDate} day={scheduleDate}
providers={providers} providers={providers}
appointments={appointments} appointments={appointments}
canBook={canManageAppointments} canBook={canManageAppointments && !isViewingPastDay}
canDelete={canManageAppointments} canDelete={canManageAppointments}
onDeleteAppointment={(id) => void handleDeleteAppointment(id)} onDeleteAppointment={(id) => void handleDeleteAppointment(id)}
onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)} onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)}
onAppointmentClick={(apt) => handleAppointmentClick(apt)}
/> />
</div> </div>
</div> </div>
@@ -305,7 +339,11 @@ export default function AppointmentsPage() {
providerUserId={bookingProviderId} providerUserId={bookingProviderId}
providerName={bookingProviderName} providerName={bookingProviderName}
initialHour={bookingHour} initialHour={bookingHour}
onClose={() => setBookingOpen(false)} editingAppointment={activeEditingAppointment}
onClose={() => {
setBookingOpen(false);
setEditingAppointmentId(null);
}}
onSubmit={handleSaveAppointment} onSubmit={handleSaveAppointment}
loading={savingAppointment} loading={savingAppointment}
/> />
@@ -323,24 +361,13 @@ export default function AppointmentsPage() {
</div> </div>
)} )}
{(toastError || toastSuccess || toastInfo) && ( {(scheduleError || toastError || toastSuccess || toastInfo) && (
<div className="fixed bottom-4 left-4 right-4 z-[70] flex justify-center pointer-events-none"> <div className="fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none">
<div className="pointer-events-auto w-full max-w-lg space-y-2"> <div className="pointer-events-auto w-full space-y-2">
{toastError && ( {scheduleError && <Toast variant="danger">{scheduleError}</Toast>}
<div className="rounded-[var(--radius-sm)] border border-red-500/50 bg-red-500/10 px-3 py-2 text-sm text-red-300 shadow-lg"> {toastError && <Toast variant="danger">{toastError}</Toast>}
{toastError} {toastInfo && <Toast variant="warning">{toastInfo}</Toast>}
</div> {toastSuccess && <Toast variant="success">{toastSuccess}</Toast>}
)}
{toastInfo && (
<div className="rounded-[var(--radius-sm)] border border-amber-500/45 bg-amber-500/10 px-3 py-2 text-sm text-amber-100 shadow-lg">
{toastInfo}
</div>
)}
{toastSuccess && (
<div className="rounded-[var(--radius-sm)] border border-emerald-500/50 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 shadow-lg">
{toastSuccess}
</div>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { Pencil } from 'lucide-react'; import { Pencil } from 'lucide-react';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/common/Button';
import { Badge } from '@/components/ui/common/Badge'; import { Badge } from '@/components/ui/common/Badge';
import { Card } from '@/components/ui/common/Card';
import { Table } from '@/components/ui/common/Table'; import { Table } from '@/components/ui/common/Table';
import { SearchBar } from '@/components/ui/common/SearchBar'; import { SearchBar } from '@/components/ui/common/SearchBar';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
@@ -195,19 +196,19 @@ export default function BillingPage() {
} }
function StatCard({ title, count, amount, color }: StatCardProps) { function StatCard({ title, count, amount, color }: StatCardProps) {
const colors: Record<StatCardColor, string> = { const colors: Record<StatCardColor, string> = {
blue: 'bg-sky-900/30 text-sky-300 border-sky-700/60', blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border',
yellow: 'bg-amber-900/30 text-amber-300 border-amber-700/60', yellow: '!bg-badge-warning-bg !text-badge-warning-fg !border-badge-warning-border',
green: 'bg-emerald-900/30 text-emerald-300 border-emerald-700/60', green: '!bg-badge-success-bg !text-badge-success-fg !border-badge-success-border',
red: 'bg-red-950/30 text-red-300 border-red-700/60', red: '!bg-badge-danger-bg !text-badge-danger-fg !border-badge-danger-border',
}; };
return ( return (
<div className={`p-4 rounded-xl border ${colors[color]}`}> <Card className={`${colors[color]}`}>
<p className="text-sm font-medium opacity-80">{title}</p> <p className="text-sm font-medium">{title}</p>
<p className="text-2xl font-bold mt-1">{count}</p> <p className="text-2xl font-bold mt-1">{count}</p>
<p className="text-sm font-medium mt-1"> <p className="text-sm font-medium mt-1">
${amount.toLocaleString()} ${amount.toLocaleString()}
</p> </p>
</div> </Card>
); );
} }

View File

@@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth'; import { authApi } from '@/lib/api/auth';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/common/Button';
import { Toast } from '@/components/ui/common/Toast';
import type { SubscriptionAlertData } from '@/types/subscription'; import type { SubscriptionAlertData } from '@/types/subscription';
const PLAN_OPTIONS = [ const PLAN_OPTIONS = [
@@ -69,7 +70,7 @@ export default function SubscriptionsSettingsPage() {
: 'text-red-400'; : 'text-red-400';
return ( return (
<div className="space-y-6"> <div className="relative space-y-6 pb-24">
<div> <div>
<Link <Link
href="/today" href="/today"
@@ -189,13 +190,16 @@ export default function SubscriptionsSettingsPage() {
> >
Start purchase process Start purchase process
</Button> </Button>
{purchaseNotice && (
<div className="rounded-[var(--radius-md)] border border-emerald-500/30 bg-emerald-500/10 px-4 py-3">
<p className="text-sm text-emerald-200">{purchaseNotice}</p>
</div>
)}
</div> </div>
</div> </div>
{purchaseNotice && (
<div className="fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none">
<div className="pointer-events-auto w-full">
<Toast variant="success">{purchaseNotice}</Toast>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -2,6 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { Card } from '@/components/ui/common/Card';
export default function TodayPage() { export default function TodayPage() {
const { currentOrganization } = useAuth(); const { currentOrganization } = useAuth();
@@ -27,29 +28,26 @@ export default function TodayPage() {
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<Card title="Today's Appointments" value="12" sub="Monday 2/5/2026" /> <Card>
<Card title="Active Patients" value="675" /> <p className="text-sm text-card-muted">Today's Appointments</p>
<Card title="New Lab Case" value="5" sub="35 ↑" /> <p className="text-2xl font-semibold mt-2">12</p>
<Card title="Today invoices" value="1200$" sub="21,300 $" /> <p className="text-xs text-text-muted mt-1">Monday 2/5/2026</p>
</Card>
<Card>
<p className="text-sm text-card-muted">Active Patients</p>
<p className="text-2xl font-semibold mt-2">675</p>
</Card>
<Card>
<p className="text-sm text-card-muted">New Lab Case</p>
<p className="text-2xl font-semibold mt-2">5</p>
<p className="text-xs text-text-muted mt-1">35 </p>
</Card>
<Card>
<p className="text-sm text-card-muted">Today invoices</p>
<p className="text-2xl font-semibold mt-2">1200$</p>
<p className="text-xs text-text-muted mt-1">21,300 $</p>
</Card>
</div> </div>
</div> </div>
); );
}
function Card({
title,
value,
sub,
}: {
title: string;
value: string;
sub?: string;
}) {
return (
<div className="surface-card p-4">
<p className="text-sm text-text-secondary">{title}</p>
<p className="text-2xl font-semibold mt-2">{value}</p>
{sub && <p className="text-xs text-text-muted mt-1">{sub}</p>}
</div>
);
} }

View File

@@ -4,11 +4,12 @@ import { useEffect, useState } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/common/Button';
import { Dropdown } from '@/components/ui/common/Dropdown'; import { Dropdown } from '@/components/ui/common/Dropdown';
import { APPOINTMENT_PURPOSES, type AppointmentPurpose } from '@/types/appointment'; import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles'; import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
import type { Patient } from '@/types/patient'; import type { Patient } from '@/types/patient';
import { import {
combineLocalDateAndTime, combineLocalDateAndTime,
compareLocalDayStart,
formatTimeForInput, formatTimeForInput,
isSameLocalCalendarDay, isSameLocalCalendarDay,
} from '@/lib/appointmentTime'; } from '@/lib/appointmentTime';
@@ -28,6 +29,7 @@ interface AppointmentBookingModalProps {
endAt: string; endAt: string;
purpose: AppointmentPurpose; purpose: AppointmentPurpose;
}) => Promise<void>; }) => Promise<void>;
editingAppointment?: AppointmentRecord | null;
loading?: boolean; loading?: boolean;
} }
@@ -40,40 +42,59 @@ export function AppointmentBookingModal({
initialHour, initialHour,
onClose, onClose,
onSubmit, onSubmit,
editingAppointment = null,
loading = false, loading = false,
}: AppointmentBookingModalProps) { }: AppointmentBookingModalProps) {
const [startTime, setStartTime] = useState('09:00'); const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00'); const [endTime, setEndTime] = useState('10:00');
const [purpose, setPurpose] = useState<AppointmentPurpose>('consultation'); const [purpose, setPurpose] = useState<AppointmentPurpose>('consultation');
const [error, setError] = useState(''); const [error, setError] = useState('');
const purposeTextColor =
purpose === 'consultation'
? '#ddd6fe'
: purpose === 'filling'
? '#fed7aa'
: purpose === 'endo'
? '#fecaca'
: purpose === 'visit'
? '#bae6fd'
: '#d9f99d';
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
return; return;
} }
const start = new Date( if (editingAppointment) {
scheduleDate.getFullYear(), const start = new Date(editingAppointment.startAt);
scheduleDate.getMonth(), const end = new Date(editingAppointment.endAt);
scheduleDate.getDate(), setStartTime(formatTimeForInput(start));
initialHour, setEndTime(formatTimeForInput(end));
0, setPurpose((editingAppointment.purpose as AppointmentPurpose) ?? 'consultation');
0, } else {
0, const start = new Date(
); scheduleDate.getFullYear(),
const end = new Date( scheduleDate.getMonth(),
scheduleDate.getFullYear(), scheduleDate.getDate(),
scheduleDate.getMonth(), initialHour,
scheduleDate.getDate(), 0,
initialHour < 23 ? initialHour + 1 : 23, 0,
initialHour < 23 ? 0 : 59, 0,
0, );
0, const end = new Date(
); scheduleDate.getFullYear(),
setStartTime(formatTimeForInput(start)); scheduleDate.getMonth(),
setEndTime(formatTimeForInput(end)); scheduleDate.getDate(),
setPurpose('consultation'); initialHour < 23 ? initialHour + 1 : 23,
initialHour < 23 ? 0 : 59,
0,
0,
);
setStartTime(formatTimeForInput(start));
setEndTime(formatTimeForInput(end));
setPurpose('consultation');
}
setError(''); setError('');
}, [open, scheduleDate, initialHour]); }, [open, scheduleDate, initialHour, editingAppointment]);
if (!open || !providerUserId) { if (!open || !providerUserId) {
return null; return null;
@@ -87,7 +108,7 @@ export function AppointmentBookingModal({
if (!providerUserId) { if (!providerUserId) {
return; return;
} }
if (!patient) { if (!editingAppointment && !patient) {
setError('Select a patient first.'); setError('Select a patient first.');
return; return;
} }
@@ -106,9 +127,22 @@ export function AppointmentBookingModal({
return; return;
} }
const today = new Date();
if (compareLocalDayStart(scheduleDate, today) < 0) {
setError('Past appointments are view-only.');
return;
}
const effectivePatientId = editingAppointment?.patientId ?? patient?.id;
const effectiveProviderId = editingAppointment?.providerUserId ?? providerUserId;
if (!effectivePatientId || !effectiveProviderId) {
setError('Missing appointment details.');
return;
}
await onSubmit({ await onSubmit({
patientId: patient.id, patientId: effectivePatientId,
providerUserId, providerUserId: effectiveProviderId,
startAt: startAt.toISOString(), startAt: startAt.toISOString(),
endAt: endAt.toISOString(), endAt: endAt.toISOString(),
purpose, purpose,
@@ -125,7 +159,7 @@ export function AppointmentBookingModal({
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<h2 id="appointment-modal-title" className="text-lg font-semibold text-text-primary pr-2"> <h2 id="appointment-modal-title" className="text-lg font-semibold text-text-primary pr-2">
New appointment {editingAppointment ? 'Edit appointment' : 'New appointment'}
</h2> </h2>
<button <button
type="button" type="button"
@@ -144,7 +178,11 @@ export function AppointmentBookingModal({
<div> <div>
<label className="block text-sm font-medium text-text-secondary mb-1">Patient</label> <label className="block text-sm font-medium text-text-secondary mb-1">Patient</label>
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2"> <p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
{patient ? `${patient.firstName} ${patient.lastName}` : '—'} {editingAppointment
? `${editingAppointment.patient.firstName} ${editingAppointment.patient.lastName}`
: patient
? `${patient.firstName} ${patient.lastName}`
: '—'}
</p> </p>
</div> </div>
@@ -175,12 +213,23 @@ export function AppointmentBookingModal({
label="Purpose" label="Purpose"
value={purpose} value={purpose}
onChange={(e) => setPurpose(e.target.value as AppointmentPurpose)} onChange={(e) => setPurpose(e.target.value as AppointmentPurpose)}
style={{ color: purposeTextColor }}
> >
{APPOINTMENT_PURPOSES.map((p) => ( <option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }}>
<option key={p} value={p}> {APPOINTMENT_PURPOSE_LABEL.consultation}
{APPOINTMENT_PURPOSE_LABEL[p]} </option>
</option> <option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }}>
))} {APPOINTMENT_PURPOSE_LABEL.filling}
</option>
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.endo}
</option>
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.visit}
</option>
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.hygiene}
</option>
</Dropdown> </Dropdown>
{error && <p className="text-sm text-red-400">{error}</p>} {error && <p className="text-sm text-red-400">{error}</p>}

View File

@@ -3,7 +3,10 @@
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import { formatHourLabel } from '@/lib/appointmentTime'; import { formatHourLabel } from '@/lib/appointmentTime';
import { purposeDeleteIconClass, purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import {
purposeDeleteIconClass,
purposeStyle,
} from '@/components/ui/appointments/appointmentPurposeStyles';
const HOUR_PX = 40; const HOUR_PX = 40;
const HOURS = Array.from({ length: 24 }, (_, i) => i); const HOURS = Array.from({ length: 24 }, (_, i) => i);
@@ -32,6 +35,7 @@ interface AppointmentScheduleGridProps {
canDelete?: boolean; canDelete?: boolean;
onDeleteAppointment?: (id: string) => void; onDeleteAppointment?: (id: string) => void;
onSlotClick: (hour: number, providerUserId: string, providerName: string) => void; onSlotClick: (hour: number, providerUserId: string, providerName: string) => void;
onAppointmentClick?: (appointment: AppointmentRecord) => void;
} }
export function AppointmentScheduleGrid({ export function AppointmentScheduleGrid({
@@ -42,6 +46,7 @@ export function AppointmentScheduleGrid({
canDelete = false, canDelete = false,
onDeleteAppointment, onDeleteAppointment,
onSlotClick, onSlotClick,
onAppointmentClick,
}: AppointmentScheduleGridProps) { }: AppointmentScheduleGridProps) {
const gridHeight = HOURS.length * HOUR_PX; const gridHeight = HOURS.length * HOUR_PX;
@@ -117,9 +122,11 @@ export function AppointmentScheduleGrid({
return null; return null;
} }
return ( return (
<div <button
type="button"
key={apt.id} key={apt.id}
className={`absolute left-0.5 right-0.5 rounded-[var(--radius-sm)] border pointer-events-none z-10 flex flex-row items-center gap-1.5 px-1.5 py-1 min-h-[36px] ${purposeStyle(apt.purpose)}`} onClick={() => onAppointmentClick?.(apt)}
className={`absolute left-0.5 right-0.5 rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex flex-row items-center gap-1.5 px-1.5 py-1 min-h-[36px] text-left ${purposeStyle(apt.purpose)} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35`}
style={{ top: pos.top, height: pos.height, minHeight: 36 }} style={{ top: pos.top, height: pos.height, minHeight: 36 }}
> >
<div className="pointer-events-none flex-1 min-w-0 overflow-hidden text-left"> <div className="pointer-events-none flex-1 min-w-0 overflow-hidden text-left">
@@ -133,7 +140,7 @@ export function AppointmentScheduleGrid({
{canDelete && onDeleteAppointment && ( {canDelete && onDeleteAppointment && (
<button <button
type="button" type="button"
className="group pointer-events-auto shrink-0 self-center z-20 mr-2 ml-1 inline-flex cursor-pointer items-center justify-center rounded-[var(--radius-sm)] border-0 bg-transparent p-1 outline-none focus-visible:ring-2 focus-visible:ring-primary/35" className="group pointer-events-auto shrink-0 self-center z-20 mr-0.5 ml-0.5 inline-flex cursor-pointer items-center justify-center rounded-[var(--radius-sm)] bg-transparent p-1 outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
aria-label="Delete appointment" aria-label="Delete appointment"
title="Delete appointment" title="Delete appointment"
onClick={(e) => { onClick={(e) => {
@@ -144,7 +151,7 @@ export function AppointmentScheduleGrid({
<Trash2 className={`w-4 h-4 ${purposeDeleteIconClass(apt.purpose)}`} /> <Trash2 className={`w-4 h-4 ${purposeDeleteIconClass(apt.purpose)}`} />
</button> </button>
)} )}
</div> </button>
); );
})} })}
</div> </div>

View File

@@ -10,11 +10,12 @@ export const APPOINTMENT_PURPOSE_LABEL: Record<AppointmentPurpose, string> = {
/** Background + border for blocks / legend (matches reference palette). */ /** Background + border for blocks / legend (matches reference palette). */
export const APPOINTMENT_PURPOSE_STYLES: Record<AppointmentPurpose, string> = { export const APPOINTMENT_PURPOSE_STYLES: Record<AppointmentPurpose, string> = {
consultation: 'bg-violet-500/25 border-violet-400/50 text-violet-100', consultation:
filling: 'bg-orange-500/25 border-orange-400/50 text-orange-100', 'bg-purpose-consultation-bg border-purpose-consultation-border text-purpose-consultation-fg',
endo: 'bg-red-500/25 border-red-400/50 text-red-100', filling: 'bg-purpose-filling-bg border-purpose-filling-border text-purpose-filling-fg',
visit: 'bg-sky-500/25 border-sky-400/50 text-sky-100', endo: 'bg-purpose-endo-bg border-purpose-endo-border text-purpose-endo-fg',
hygiene: 'bg-lime-500/20 border-lime-400/45 text-lime-100', visit: 'bg-purpose-visit-bg border-purpose-visit-border text-purpose-visit-fg',
hygiene: 'bg-purpose-hygiene-bg border-purpose-hygiene-border text-purpose-hygiene-fg',
}; };
export function purposeStyle(purpose: string): string { export function purposeStyle(purpose: string): string {
@@ -26,13 +27,13 @@ export function purposeStyle(purpose: string): string {
export function purposeDeleteIconClass(purpose: string): string { export function purposeDeleteIconClass(purpose: string): string {
const p = purpose as AppointmentPurpose; const p = purpose as AppointmentPurpose;
const map: Record<AppointmentPurpose, string> = { const map: Record<AppointmentPurpose, string> = {
consultation: '!text-violet-400 group-hover:!text-violet-300', consultation: '!text-purpose-consultation-fg',
filling: '!text-orange-400 group-hover:!text-orange-300', filling: '!text-purpose-filling-fg',
endo: '!text-red-400 group-hover:!text-red-300', endo: '!text-purpose-endo-fg',
visit: '!text-sky-400 group-hover:!text-sky-300', visit: '!text-purpose-visit-fg',
hygiene: '!text-lime-600 group-hover:!text-lime-500', hygiene: '!text-purpose-hygiene-fg',
}; };
return map[p] ?? '!text-text-muted group-hover:!text-text-secondary'; return map[p] ?? '!text-text-muted';
} }
/** Small swatch for legend (background + border only). */ /** Small swatch for legend (background + border only). */

View File

@@ -14,10 +14,10 @@ interface BadgeProps {
} }
const variantStyles: Record<BadgeVariant, string> = { const variantStyles: Record<BadgeVariant, string> = {
success: 'bg-emerald-900/30 text-emerald-300 border-emerald-700/60', success: 'bg-badge-success-bg text-badge-success-fg border-badge-success-border',
warning: 'bg-amber-900/30 text-amber-300 border-amber-700/60', warning: 'bg-badge-warning-bg text-badge-warning-fg border-badge-warning-border',
danger: 'bg-red-950/30 text-red-300 border-red-700/60', danger: 'bg-badge-danger-bg text-badge-danger-fg border-badge-danger-border',
default: 'bg-background-secondary text-text-secondary border-border', default: 'bg-badge-default-bg text-badge-default-fg border-badge-default-border',
}; };
/** Explicit width + height so every row matches; flex centers label optically. */ /** Explicit width + height so every row matches; flex centers label optically. */

View File

@@ -0,0 +1,36 @@
import type { ElementType, ReactNode } from 'react';
type CardPadding = 'none' | 'sm' | 'md' | 'lg';
type CardProps<T extends ElementType = 'div'> = {
children: ReactNode;
padding?: CardPadding;
as?: T;
className?: string;
} & Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'children' | 'className'>;
const paddingClassMap: Record<CardPadding, string> = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
};
export function Card<T extends ElementType = 'div'>({
children,
as,
className = '',
padding = 'md',
...props
}: CardProps<T>) {
const Component = as ?? 'div';
return (
<Component
className={`rounded-[var(--radius-lg)] border border-card-border bg-card text-card-foreground ${paddingClassMap[padding]} ${className}`}
{...props}
>
{children}
</Component>
);
}

View File

@@ -1,19 +1,17 @@
'use client'; 'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronLeft, ChevronRight } from 'lucide-react';
import { addCalendarDays, compareLocalDayStart } from '@/lib/appointmentTime'; import { addCalendarDays } from '@/lib/appointmentTime';
interface ScheduleDayPickerProps { interface ScheduleDayPickerProps {
value: Date; value: Date;
onChange: (day: Date) => void; onChange: (day: Date) => void;
/** Inclusive minimum calendar day (typically today at local midnight). */ /** Optional lower bound; picker navigation is unrestricted for history browsing. */
minDate: Date; minDate?: Date;
label?: string; label?: string;
} }
export function ScheduleDayPicker({ value, onChange, minDate, label = 'Schedule date' }: ScheduleDayPickerProps) { export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) {
const canGoPrev = compareLocalDayStart(value, minDate) > 0;
const labelText = value.toLocaleDateString(undefined, { const labelText = value.toLocaleDateString(undefined, {
weekday: 'short', weekday: 'short',
month: 'short', month: 'short',
@@ -27,9 +25,8 @@ export function ScheduleDayPicker({ value, onChange, minDate, label = 'Schedule
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]"> <div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]">
<button <button
type="button" type="button"
disabled={!canGoPrev}
onClick={() => onChange(addCalendarDays(value, -1))} onClick={() => onChange(addCalendarDays(value, -1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 disabled:opacity-35 disabled:pointer-events-none focus:outline-none focus:ring-2 focus:ring-primary/35" className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label="Previous day" aria-label="Previous day"
> >
<ChevronLeft className="h-4 w-4 icon-flat" /> <ChevronLeft className="h-4 w-4 icon-flat" />

View File

@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import type { BadgeVariant } from '@/components/ui/common/Badge';
interface ToastProps {
children: ReactNode;
variant?: BadgeVariant;
className?: string;
}
const variantStyles: Record<BadgeVariant, string> = {
success: 'bg-badge-success-bg text-badge-success-fg border-badge-success-border',
warning: 'bg-badge-warning-bg text-badge-warning-fg border-badge-warning-border',
danger: 'bg-badge-danger-bg text-badge-danger-fg border-badge-danger-border',
default: 'bg-badge-default-bg text-badge-default-fg border-badge-default-border',
};
export function Toast({ children, variant = 'default', className = '' }: ToastProps) {
return (
<div
role="status"
className={`w-full rounded-[var(--radius-md)] border px-4 py-3 text-sm shadow-lg ${variantStyles[variant]} ${className}`}
>
{children}
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { CalendarDays } from 'lucide-react'; import { CalendarDays } from 'lucide-react';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { Card } from '@/components/ui/common/Card';
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker'; import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
import { startOfLocalDay } from '@/lib/appointmentTime'; import { startOfLocalDay } from '@/lib/appointmentTime';
import type { TreatmentAppointment } from '@/types/treatment'; import type { TreatmentAppointment } from '@/types/treatment';
@@ -32,7 +33,7 @@ export function AppointmentsStrip({
}: AppointmentsStripProps) { }: AppointmentsStripProps) {
if (stripHidden) { if (stripHidden) {
return ( return (
<div className="surface-card p-3 flex items-center justify-between gap-3 flex-wrap"> <Card className="flex items-center justify-between gap-3 flex-wrap" padding="sm">
<p className="text-sm text-text-secondary">Appointments are hidden.</p> <p className="text-sm text-text-secondary">Appointments are hidden.</p>
<button <button
type="button" type="button"
@@ -41,12 +42,12 @@ export function AppointmentsStrip({
> >
Show appointments Show appointments
</button> </button>
</div> </Card>
); );
} }
return ( return (
<div className="surface-card p-4 space-y-4"> <Card className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap"> <div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<CalendarDays className="w-5 h-5 text-text-muted shrink-0 icon-flat" aria-hidden /> <CalendarDays className="w-5 h-5 text-text-muted shrink-0 icon-flat" aria-hidden />
@@ -89,12 +90,14 @@ export function AppointmentsStrip({
})}`; })}`;
const palette = purposeStyle(a.purpose); const palette = purposeStyle(a.purpose);
return ( return (
<button <Card
as="button"
key={a.id} key={a.id}
type="button" type="button"
onClick={() => onSelectAppointment(a.id)} onClick={() => onSelectAppointment(a.id)}
padding="none"
className={` className={`
text-left rounded-[var(--radius-sm)] border px-3 py-2 min-w-[200px] max-w-[280px] transition-shadow min-h-[52px] text-left rounded-[var(--radius-sm)] px-3 py-2 min-w-[200px] max-w-[280px] transition-shadow min-h-[52px]
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${palette} ${palette}
${sel ? 'ring-2 ring-primary ring-offset-2 ring-offset-background-secondary shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]' : 'hover:brightness-110'} ${sel ? 'ring-2 ring-primary ring-offset-2 ring-offset-background-secondary shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]' : 'hover:brightness-110'}
@@ -105,10 +108,10 @@ export function AppointmentsStrip({
{a.patientFirstName} {a.patientLastName} {a.patientFirstName} {a.patientLastName}
</p> </p>
<p className="text-[11px] opacity-90 capitalize mt-0.5">{a.purpose}</p> <p className="text-[11px] opacity-90 capitalize mt-0.5">{a.purpose}</p>
</button> </Card>
); );
})} })}
</div> </div>
</div> </Card>
); );
} }

View File

@@ -5,6 +5,14 @@ import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } f
import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph'; import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph';
import type { FdiToothId } from '@/types/treatment'; 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<FdiToothId>([
'13', '12', '11', '21', '22', '23',
'43', '42', '41', '31', '32', '33',
]);
function quadrantMirrored(fdi: FdiToothId): boolean { function quadrantMirrored(fdi: FdiToothId): boolean {
const q = fdi[0]; const q = fdi[0];
return q === '2' || q === '3'; return q === '2' || q === '3';
@@ -18,36 +26,121 @@ interface FdiToothChartProps {
export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartProps) { export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartProps) {
const uid = useId().replace(/:/g, ''); const uid = useId().replace(/:/g, '');
const archPeak = 10;
const Row = ({ teeth }: { teeth: FdiToothId[] }) => ( const TOOTH_TWEAKS: Record<FdiToothId, { glyph: string; offset: number; rotate: number }> = {
<div className="flex flex-wrap justify-center gap-x-1 gap-y-2"> '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 }) => (
<div className="flex flex-wrap justify-center gap-x-1 gap-y-1">
{teeth.map((fdi, i) => { {teeth.map((fdi, i) => {
const kind = getToothShapeKind(fdi); const kind = getToothShapeKind(fdi);
const gid = `${uid}-g-${fdi}-${i}`; const gid = `${uid}-g-${fdi}-${i}`;
const isSel = selected.has(fdi); 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 (
<div key={fdi} className="flex flex-col items-center w-[2.25rem]"> <div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
<button <div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
type="button" <button
disabled={disabled} type="button"
onClick={() => onToggle(fdi)} disabled={disabled}
className={` onClick={() => onToggle(fdi)}
rounded-[var(--radius-sm)] p-0.5 transition-transform style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 className={`
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'} rounded-[var(--radius-sm)] p-0.5 transition-transform
`} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
aria-pressed={isSel} ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
aria-label={`FDI tooth ${fdi}${isSel ? ', selected' : ''}`} `}
> aria-pressed={isSel}
<ToothGlyph kind={kind} gradientId={gid} selected={isSel} mirrored={quadrantMirrored(fdi)} /> aria-label={`FDI tooth ${fdi}${isSel ? ', selected' : ''}`}
</button> >
<span <ToothGlyph
className={`text-[10px] tabular-nums leading-none mt-0.5 font-medium ${ fdi={fdi}
isSel ? 'text-primary' : 'text-text-muted' kind={kind}
}`} gradientId={gid}
> selected={isSel}
{fdi} upper={upper}
</span> mirrored={quadrantMirrored(fdi)}
upsideDown={upper}
className={tweak?.glyph ?? size.glyph}
/>
</button>
</div>
</div> </div>
); );
})} })}
@@ -56,28 +149,77 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
return ( return (
<div className="surface-card p-4 space-y-6"> <div className="surface-card p-4 space-y-6">
<div className="flex items-start justify-between gap-3 flex-wrap"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h3 className="text-sm font-semibold text-text-primary">FDI tooth chart</h3> <h3 className="text-sm font-semibold text-text-primary">FDI tooth chart</h3>
<p className="text-xs text-text-muted mt-0.5"> <p className="text-xs text-text-muted mt-0.5">
Tap teeth to multi-select (FDI). Selection applies to the active treatment record until you save. Tap teeth to multi-select (FDI). Selection applies to the active treatment record until you save.
</p> </p>
</div> </div>
<p className="text-xs text-text-secondary tabular-nums"> <div className="flex flex-col gap-2 sm:items-end w-full sm:max-w-xs">
Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')} <p className="text-xs text-text-secondary tabular-nums text-right">
</p> Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')}
</p>
</div>
</div> </div>
<div className="space-y-6"> <p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">Upper arch</p>
<div>
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-2 text-center">Upper arch</p> <div className="relative isolate py-1">
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} /> <div
</div> className="pointer-events-none absolute left-1/2 top-3 bottom-3 w-px -translate-x-1/2 bg-border/70"
<div className="border-t border-border/60 pt-4"> aria-hidden
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-2 text-center">Lower arch</p> />
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} /> <div className="relative z-10 space-y-0">
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
<div className={`flex justify-center gap-x-1 ${TOOTH_NUMBER_GAP}`}>
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi);
const isSel = selected.has(fdi);
return (
<span
key={`u-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
>
{fdi}
</span>
);
})}
</div>
<div
className="my-4 h-px w-full max-w-[min(100%,42rem)] mx-auto bg-border/70"
role="separator"
aria-hidden
/>
<div className="my-4 pt-1">
<div className="flex justify-center gap-x-1">
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi);
const isSel = selected.has(fdi);
return (
<span
key={`l-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
>
{fdi}
</span>
);
})}
</div>
<div className={TOOTH_NUMBER_GAP}>
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} />
</div>
</div>
</div> </div>
</div> </div>
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">Lower arch</p>
</div> </div>
); );
} }

View File

@@ -6,9 +6,16 @@ import type { PastTreatment } from '@/types/treatment';
interface PastTreatmentsPanelProps { interface PastTreatmentsPanelProps {
items: PastTreatment[]; items: PastTreatment[];
loading?: boolean; loading?: boolean;
selectedTreatmentId?: string | null;
onSelectTreatment?: (treatment: PastTreatment) => void;
} }
export function PastTreatmentsPanel({ items, loading }: PastTreatmentsPanelProps) { export function PastTreatmentsPanel({
items,
loading,
selectedTreatmentId,
onSelectTreatment,
}: PastTreatmentsPanelProps) {
return ( return (
<div className="surface-card p-4 space-y-3"> <div className="surface-card p-4 space-y-3">
<div> <div>
@@ -28,7 +35,11 @@ export function PastTreatmentsPanel({ items, loading }: PastTreatmentsPanelProps
{items.map((t) => ( {items.map((t) => (
<article <article
key={t.id} key={t.id}
className="border border-border/70 rounded-[var(--radius-md)] p-3 bg-background-secondary/40" className={`border rounded-[var(--radius-md)] p-3 ${
selectedTreatmentId === t.id
? 'border-primary/70 bg-primary-soft/35'
: 'border-border/70 bg-background-secondary/40'
}`}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-text-primary">{t.title}</p> <p className="text-sm font-medium text-text-primary">{t.title}</p>
@@ -43,6 +54,8 @@ export function PastTreatmentsPanel({ items, loading }: PastTreatmentsPanelProps
{t.records.map((r) => ( {t.records.map((r) => (
<li key={r.id}> <li key={r.id}>
<span className="text-text-primary font-medium">Record: </span> <span className="text-text-primary font-medium">Record: </span>
<span className="capitalize">{r.treatmentType}</span>
{' | '}
{r.teeth.length > 0 ? `Teeth ${[...r.teeth].sort().join(', ')}` : 'No teeth tagged'} {r.teeth.length > 0 ? `Teeth ${[...r.teeth].sort().join(', ')}` : 'No teeth tagged'}
{r.notes ? `${r.notes}` : ''} {r.notes ? `${r.notes}` : ''}
</li> </li>
@@ -50,6 +63,18 @@ export function PastTreatmentsPanel({ items, loading }: PastTreatmentsPanelProps
</ul> </ul>
)} )}
{onSelectTreatment && (
<div className="mt-3 pt-2 border-t border-border/50 flex justify-end">
<button
type="button"
onClick={() => onSelectTreatment(t)}
className="text-xs text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 rounded-[var(--radius-sm)] px-1"
>
Review details
</button>
</div>
)}
{t.documents.length > 0 && ( {t.documents.length > 0 && (
<div className="mt-3 pt-2 border-t border-border/50"> <div className="mt-3 pt-2 border-t border-border/50">
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1.5">Attachments</p> <p className="text-[11px] uppercase tracking-wide text-text-muted mb-1.5">Attachments</p>

View File

@@ -1,140 +1,105 @@
'use client'; 'use client';
import { memo } from 'react'; import { memo, type ReactNode } from 'react';
import type { FdiToothId } from '@/types/treatment';
import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta'; import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta';
import { getToothPathModel, type ToothPathModel } from '@/components/ui/treatment/toothPathModel';
interface ToothGlyphProps { interface ToothGlyphProps {
fdi: FdiToothId;
kind: ToothShapeKind; kind: ToothShapeKind;
gradientId: string; gradientId: string;
selected: boolean; selected: boolean;
upper?: boolean;
mirrored?: boolean; mirrored?: boolean;
upsideDown?: boolean;
className?: string;
} }
/** Stylized single tooth for FDI chart (outline + radial fill). Crown above, root(s) below. */ function filledSilhouette(
model: ToothPathModel,
crownFill: string,
rootFill: string,
stroke: string,
strokeW: number,
): ReactNode {
return (
<>
<path d={model.crown} fill={crownFill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
{model.roots.map((d, i) => (
<path key={i} d={d} fill={rootFill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
))}
</>
);
}
function detailOnly(model: ToothPathModel, stroke: string, strokeW: number): ReactNode {
if (!model.detailStroke) return null;
const { d, widthScale } = model.detailStroke;
return <path d={d} fill="none" stroke={stroke} strokeWidth={strokeW * widthScale} strokeLinecap="round" />;
}
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({ export const ToothGlyph = memo(function ToothGlyph({
fdi,
kind, kind,
gradientId, gradientId,
selected, selected,
upper,
mirrored, mirrored,
upsideDown,
className = 'w-8 h-[4.85rem]',
}: ToothGlyphProps) { }: ToothGlyphProps) {
const stroke = selected ? 'var(--color-primary)' : '#1e293b'; const model: ToothPathModel | null = getToothPathModel(fdi, kind, upper);
const strokeW = selected ? 2.4 : 1.35;
const filter = selected ? 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' : undefined; const filter = selected ? 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' : undefined;
const inner = () => { if (!model) {
switch (kind) { return (
case 'incisor': <svg viewBox="0 0 36 78" className={`${className} shrink-0`} aria-hidden>
return ( <text x="4" y="40" fontSize="8" fill="currentColor" opacity="0.35">
<> ?
<path </text>
d="M10 7h16q3 0 3 3v22q0 3-3 3h-16q-3 0-3-3v-22q0-3 3-3z" </svg>
fill={`url(#${gradientId})`} );
stroke={stroke} }
strokeWidth={strokeW}
strokeLinejoin="round" const body = renderClinicalFill(model, selected, gradientId);
/>
<path
d="M14 35v26q4 10 8 0v-26"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
</>
);
case 'canine':
return (
<>
<path
d="M18 6 L26 14 L24 35 H12 L10 14 Z"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
<path
d="M14 35 L12 62 Q18 74 24 62 L22 35 Z"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
</>
);
case 'premolar':
return (
<>
<path
d="M8 12 Q18 6 28 12 L30 22 Q31 28 26 34 H10 Q5 28 6 22 Z"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
<path
d="M11 34 L10 58 Q18 68 26 58 L25 34 Q18 30 11 34 Z"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
</>
);
case 'molar':
return (
<>
<path
d="M6 14 Q10 8 14 10 Q18 7 22 10 Q26 8 30 14 L31 26 Q32 32 27 36 H9 Q4 32 5 26 Z"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
<path
d="M10 36 L8 56 Q14 66 18 58 Q22 66 28 56 L26 36 Q18 32 10 36 Z"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
<path
d="M12 58 Q18 72 24 58"
fill="none"
stroke={stroke}
strokeWidth={strokeW * 0.85}
strokeLinecap="round"
/>
</>
);
default:
return null;
}
};
return ( return (
<svg <svg
viewBox="0 0 36 78" viewBox="0 0 36 78"
className="w-8 h-[4.85rem] shrink-0" className={`${className} shrink-0`}
style={{ filter }} style={{ filter }}
aria-hidden aria-hidden
> >
<defs> <defs>
<radialGradient id={gradientId} cx="45%" cy="35%" r="65%"> <radialGradient id={gradientId} cx="45%" cy="35%" r="65%">
<stop <stop offset="0%" stopColor={selected ? '#cffafe' : '#ffffff'} />
offset="0%" <stop offset="55%" stopColor={selected ? '#5eead4' : '#e0f2fe'} />
stopColor={selected ? '#cffafe' : '#ffffff'} <stop offset="100%" stopColor={selected ? '#0e7490' : '#93c5fd'} />
/>
<stop
offset="55%"
stopColor={selected ? '#5eead4' : '#e0f2fe'}
/>
<stop
offset="100%"
stopColor={selected ? '#0e7490' : '#93c5fd'}
/>
</radialGradient> </radialGradient>
</defs> </defs>
<g transform={mirrored ? 'translate(36 0) scale(-1 1)' : undefined}>{inner()}</g> <g
transform={[
mirrored ? 'translate(36 0) scale(-1 1)' : '',
upsideDown ? 'translate(0 78) scale(1 -1)' : '',
]
.filter(Boolean)
.join(' ') || undefined}
>
{body}
</g>
</svg> </svg>
); );
}); });

View File

@@ -6,6 +6,9 @@ import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel'; import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { Checkbox } from '@/components/ui/common/Checkbox'; import { Checkbox } from '@/components/ui/common/Checkbox';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/common/Button';
import { Dropdown } from '@/components/ui/common/Dropdown';
import { SearchBar } from '@/components/ui/common/SearchBar';
import { Toast } from '@/components/ui/common/Toast';
import { isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime'; import { isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime';
import { import {
fetchLinkedOrganizations, fetchLinkedOrganizations,
@@ -32,6 +35,7 @@ function newRecord(): TreatmentRecordDraft {
typeof crypto !== 'undefined' && 'randomUUID' in crypto typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID() ? crypto.randomUUID()
: `rec-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, : `rec-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: 'consultation',
teeth: [], teeth: [],
comment: '', comment: '',
attachmentMetas: [], attachmentMetas: [],
@@ -74,6 +78,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [saveBusy, setSaveBusy] = useState(false); const [saveBusy, setSaveBusy] = useState(false);
const [sendBusyId, setSendBusyId] = useState<string | null>(null); const [sendBusyId, setSendBusyId] = useState<string | null>(null);
const [banner, setBanner] = useState<string | null>(null); const [banner, setBanner] = useState<string | null>(null);
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
const [reviewTreatment, setReviewTreatment] = useState<PastTreatment | null>(null);
const selectedAppointment = useMemo( const selectedAppointment = useMemo(
() => appointments.find((a) => a.id === selectedAppointmentId) ?? null, () => appointments.find((a) => a.id === selectedAppointmentId) ?? null,
@@ -86,6 +93,48 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
); );
const selectedTeethSet = useMemo(() => new Set(activeRecord.teeth), [activeRecord.teeth]); 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<PastTreatment | null>(() => {
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<TreatmentRecordDraft['treatmentType'], string> = {
consultation: '#ddd6fe',
filling: '#fed7aa',
endo: '#fecaca',
visit: '#bae6fd',
hygiene: '#d9f99d',
};
return map[activeRecord.treatmentType];
}, [activeRecord.treatmentType]);
useEffect(() => { useEffect(() => {
setSelectionLocked(false); setSelectionLocked(false);
@@ -159,6 +208,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const first = newRecord(); const first = newRecord();
setRecords([first]); setRecords([first]);
setActiveRecordId(first.clientId); setActiveRecordId(first.clientId);
setOrganizationSearch('');
setReviewTreatment(null);
}, [selectedAppointment?.id]); }, [selectedAppointment?.id]);
const fixActiveAfterRecordsChange = useCallback((next: TreatmentRecordDraft[]) => { const fixActiveAfterRecordsChange = useCallback((next: TreatmentRecordDraft[]) => {
@@ -250,6 +301,10 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
r.clientId === record.clientId ? { ...r, sentAt: new Date().toISOString() } : 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).'); setBanner('Record sent to selected organizations (mock).');
} finally { } finally {
setSendBusyId(null); setSendBusyId(null);
@@ -271,7 +326,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
} }
return ( return (
<div className="space-y-6"> <div className="relative space-y-6 pb-24">
<header className="space-y-1"> <header className="space-y-1">
<h1 className="text-2xl font-semibold text-text-primary">Treatment</h1> <h1 className="text-2xl font-semibold text-text-primary">Treatment</h1>
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
@@ -291,12 +346,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
loading={apptsLoading} loading={apptsLoading}
/> />
{banner && (
<div className="rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-4 py-2 text-sm text-text-primary">
{banner}
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-[minmax(280px,380px)_minmax(0,1fr)] gap-6 items-start"> <div className="grid grid-cols-1 xl:grid-cols-[minmax(280px,380px)_minmax(0,1fr)] gap-6 items-start">
<div className="space-y-4"> <div className="space-y-4">
{selectedAppointment ? ( {selectedAppointment ? (
@@ -316,7 +365,58 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
</div> </div>
)} )}
<PastTreatmentsPanel items={history} loading={historyLoading} /> <PastTreatmentsPanel
items={history}
loading={historyLoading}
selectedTreatmentId={reviewTreatment?.id ?? null}
onSelectTreatment={setReviewTreatment}
/>
<div className="surface-card p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-text-primary">Treatment review</h3>
<Button
type="button"
variant="primary"
disabled={!selectedAppointment}
onClick={() => setReviewTreatment(currentDraftPreview)}
>
Preview current treatment
</Button>
</div>
{!reviewTreatment && (
<p className="text-sm text-text-muted">
Select a treatment from history, or preview the current draft.
</p>
)}
{reviewTreatment && (
<div className="border border-border/70 rounded-[var(--radius-md)] p-3 bg-background-secondary/40 space-y-2">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-text-primary">{reviewTreatment.title}</p>
<span className="text-xs text-text-muted tabular-nums">
{new Date(reviewTreatment.treatmentAt).toLocaleDateString()}
</span>
</div>
<p className="text-xs text-text-secondary capitalize">Status: {reviewTreatment.status}</p>
<div className="space-y-2">
{reviewTreatment.records.map((r, idx) => (
<div key={r.id} className="rounded-[var(--radius-sm)] border border-border/60 px-2.5 py-2">
<p className="text-xs text-text-primary font-medium">Record {idx + 1}</p>
<p className="text-xs text-text-secondary capitalize mt-1">
Type: {r.treatmentType}
</p>
<p className="text-xs text-text-secondary">
Teeth: {r.teeth.length ? [...r.teeth].sort().join(', ') : 'None selected'}
</p>
{r.notes && <p className="text-xs text-text-muted mt-1">Notes: {r.notes}</p>}
</div>
))}
</div>
<p className="text-xs text-text-muted">
Attachments: {reviewTreatment.documents.length}
</p>
</div>
)}
</div>
</div> </div>
<div className="space-y-4 min-w-0"> <div className="space-y-4 min-w-0">
@@ -391,6 +491,30 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
/> />
</label> </label>
<div>
<Dropdown
label="Treatment type"
value={activeRecord.treatmentType}
onChange={(e) => {
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 }}
>
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }} className="capitalize">consultation</option>
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }} className="capitalize">filling</option>
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }} className="capitalize">endo</option>
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }} className="capitalize">visit</option>
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }} className="capitalize">hygiene</option>
</Dropdown>
</div>
<div> <div>
<p className="text-xs font-medium text-text-secondary mb-2">Attachments (mock)</p> <p className="text-xs font-medium text-text-secondary mb-2">Attachments (mock)</p>
<input <input
@@ -430,12 +554,42 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<p className="text-xs font-medium text-text-secondary mb-2"> <p className="text-xs font-medium text-text-secondary mb-2">
Send this record to linked organizations Send this record to linked organizations
</p> </p>
<div className="space-y-2 mb-2">
<SearchBar
value={organizationSearch}
onChange={setOrganizationSearch}
placeholder="Search active organizations..."
/>
{recentOrganizations.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-text-muted">Recent:</span>
{recentOrganizations.map((o) => (
<button
key={o.id}
type="button"
onClick={() => {
setRecords((prev) =>
prev.map((r) => {
if (r.clientId !== activeRecordId || r.sentAt) return r;
if (r.sendToOrganizationIds.includes(o.id)) return r;
return { ...r, sendToOrganizationIds: [...r.sendToOrganizationIds, o.id] };
}),
);
}}
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
>
{o.name}
</button>
))}
</div>
)}
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{orgs.map((o) => ( {filteredOrganizations.map((o) => (
<Checkbox <Checkbox
key={o.id} key={o.id}
checked={activeRecord.sendToOrganizationIds.includes(o.id)} checked={activeRecord.sendToOrganizationIds.includes(o.id)}
disabled={!o.active || !selectedAppointment || Boolean(activeRecord.sentAt)} disabled={!selectedAppointment || Boolean(activeRecord.sentAt)}
onChange={(checked) => { onChange={(checked) => {
setRecords((prev) => setRecords((prev) =>
prev.map((r) => { prev.map((r) => {
@@ -450,6 +604,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
label={`${o.name}${o.active ? '' : ' (inactive)'}`} label={`${o.name}${o.active ? '' : ' (inactive)'}`}
/> />
))} ))}
{filteredOrganizations.length === 0 && (
<p className="text-xs text-text-muted">No active organization matches your search.</p>
)}
</div> </div>
</div> </div>
@@ -489,6 +646,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
</div> </div>
</div> </div>
</div> </div>
{banner && (
<div className="fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none">
<div className="pointer-events-auto w-full">
<Toast variant="success">{banner}</Toast>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -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;
}
}

View File

@@ -9,6 +9,8 @@ export interface CreateAppointmentBody {
purpose: string; purpose: string;
} }
export type UpdateAppointmentBody = Partial<CreateAppointmentBody>;
export const appointmentsApi = { export const appointmentsApi = {
columnProviders: async (): Promise<{ success: boolean; data: AppointmentColumnProvider[] }> => { columnProviders: async (): Promise<{ success: boolean; data: AppointmentColumnProvider[] }> => {
const response = await apiClient.get('/appointments/column-providers'); const response = await apiClient.get('/appointments/column-providers');
@@ -25,6 +27,14 @@ export const appointmentsApi = {
return response.data; return response.data;
}, },
update: async (
id: string,
body: UpdateAppointmentBody,
): Promise<{ success: boolean; data: AppointmentRecord }> => {
const response = await apiClient.patch(`/appointments/${id}`, body);
return response.data;
},
remove: async (id: string): Promise<{ success: boolean }> => { remove: async (id: string): Promise<{ success: boolean }> => {
const response = await apiClient.delete(`/appointments/${id}`); const response = await apiClient.delete(`/appointments/${id}`);
return response.data; return response.data;

View File

@@ -108,6 +108,7 @@ const MOCK_HISTORY: Record<string, PastTreatment[]> = {
records: [ records: [
{ {
id: 'ptr-1', id: 'ptr-1',
treatmentType: 'endo',
teeth: ['45'] as FdiToothId[], teeth: ['45'] as FdiToothId[],
notes: 'Instrumented and temporized.', notes: 'Instrumented and temporized.',
}, },
@@ -136,6 +137,7 @@ const MOCK_HISTORY: Record<string, PastTreatment[]> = {
records: [ records: [
{ {
id: 'ptr-2', id: 'ptr-2',
treatmentType: 'filling',
teeth: ['14', '15'] as FdiToothId[], teeth: ['14', '15'] as FdiToothId[],
notes: 'Composite restoration.', notes: 'Composite restoration.',
}, },
@@ -157,7 +159,7 @@ const MOCK_HISTORY: Record<string, PastTreatment[]> = {
title: 'Hygiene visit', title: 'Hygiene visit',
treatmentAt: addCalendarDays(new Date(), -21).toISOString(), treatmentAt: addCalendarDays(new Date(), -21).toISOString(),
status: 'completed', status: 'completed',
records: [{ id: 'ptr-a', teeth: [], notes: 'Scale & polish.' }], records: [{ id: 'ptr-a', treatmentType: 'hygiene', teeth: [], notes: 'Scale & polish.' }],
documents: [], documents: [],
}, },
], ],

View File

@@ -18,6 +18,40 @@
--color-border: #d2dcec; --color-border: #d2dcec;
--color-border-strong: #aec0de; --color-border-strong: #aec0de;
--color-card-background: #ffffff;
--color-card-foreground: #0f172a;
--color-card-muted: #475569;
--color-card-border: #d2dcec;
--color-badge-default-bg: #edf3fb;
--color-badge-default-fg: #1e293b;
--color-badge-default-border: #c9d8ec;
--color-badge-success-bg: #e6f7ef;
--color-badge-success-fg: #14532d;
--color-badge-success-border: #98d8b5;
--color-badge-warning-bg: #fff4df;
--color-badge-warning-fg: #92400e;
--color-badge-warning-border: #f6c88b;
--color-badge-danger-bg: #ffe8e8;
--color-badge-danger-fg: #991b1b;
--color-badge-danger-border: #f2b0b0;
--color-purpose-consultation-bg: #f1eafe;
--color-purpose-consultation-fg: #5b21b6;
--color-purpose-consultation-border: #cfb8f7;
--color-purpose-filling-bg: #fff1e5;
--color-purpose-filling-fg: #9a3412;
--color-purpose-filling-border: #f8c9a6;
--color-purpose-endo-bg: #ffe9e9;
--color-purpose-endo-fg: #991b1b;
--color-purpose-endo-border: #f4b6b6;
--color-purpose-visit-bg: #e7f4ff;
--color-purpose-visit-fg: #0c4a6e;
--color-purpose-visit-border: #abd8f3;
--color-purpose-hygiene-bg: #effadf;
--color-purpose-hygiene-fg: #3f6212;
--color-purpose-hygiene-border: #cbe9a1;
--color-primary: #009cae; --color-primary: #009cae;
--color-primary-contrast: #031014; --color-primary-contrast: #031014;
--color-primary-soft: rgba(0, 156, 174, 0.16); --color-primary-soft: rgba(0, 156, 174, 0.16);
@@ -42,6 +76,40 @@
--color-border: #29456a; --color-border: #29456a;
--color-border-strong: #3b5f8f; --color-border-strong: #3b5f8f;
--color-card-background: #14253d;
--color-card-foreground: #f5f9ff;
--color-card-muted: #b6c6dd;
--color-card-border: #29456a;
--color-badge-default-bg: #1b2f4e;
--color-badge-default-fg: #b6c6dd;
--color-badge-default-border: #3b5f8f;
--color-badge-success-bg: rgba(6, 78, 59, 0.45);
--color-badge-success-fg: #86efac;
--color-badge-success-border: rgba(21, 128, 61, 0.5);
--color-badge-warning-bg: rgba(120, 53, 15, 0.48);
--color-badge-warning-fg: #fcd34d;
--color-badge-warning-border: rgba(180, 83, 9, 0.55);
--color-badge-danger-bg: rgba(127, 29, 29, 0.42);
--color-badge-danger-fg: #fca5a5;
--color-badge-danger-border: rgba(185, 28, 28, 0.52);
--color-purpose-consultation-bg: rgba(139, 92, 246, 0.25);
--color-purpose-consultation-fg: #ddd6fe;
--color-purpose-consultation-border: rgba(167, 139, 250, 0.5);
--color-purpose-filling-bg: rgba(249, 115, 22, 0.25);
--color-purpose-filling-fg: #fed7aa;
--color-purpose-filling-border: rgba(251, 146, 60, 0.5);
--color-purpose-endo-bg: rgba(239, 68, 68, 0.25);
--color-purpose-endo-fg: #fecaca;
--color-purpose-endo-border: rgba(248, 113, 113, 0.5);
--color-purpose-visit-bg: rgba(14, 165, 233, 0.25);
--color-purpose-visit-fg: #bae6fd;
--color-purpose-visit-border: rgba(56, 189, 248, 0.5);
--color-purpose-hygiene-bg: rgba(132, 204, 22, 0.2);
--color-purpose-hygiene-fg: #d9f99d;
--color-purpose-hygiene-border: rgba(163, 230, 53, 0.45);
--color-primary: #09a9bc; --color-primary: #09a9bc;
--color-primary-contrast: #001117; --color-primary-contrast: #001117;
--color-primary-soft: rgba(9, 169, 188, 0.2); --color-primary-soft: rgba(9, 169, 188, 0.2);
@@ -66,6 +134,40 @@
--color-border: #29456a; --color-border: #29456a;
--color-border-strong: #3b5f8f; --color-border-strong: #3b5f8f;
--color-card-background: #14253d;
--color-card-foreground: #f5f9ff;
--color-card-muted: #b6c6dd;
--color-card-border: #29456a;
--color-badge-default-bg: #1b2f4e;
--color-badge-default-fg: #b6c6dd;
--color-badge-default-border: #3b5f8f;
--color-badge-success-bg: rgba(6, 78, 59, 0.45);
--color-badge-success-fg: #86efac;
--color-badge-success-border: rgba(21, 128, 61, 0.5);
--color-badge-warning-bg: rgba(120, 53, 15, 0.48);
--color-badge-warning-fg: #fcd34d;
--color-badge-warning-border: rgba(180, 83, 9, 0.55);
--color-badge-danger-bg: rgba(127, 29, 29, 0.42);
--color-badge-danger-fg: #fca5a5;
--color-badge-danger-border: rgba(185, 28, 28, 0.52);
--color-purpose-consultation-bg: rgba(139, 92, 246, 0.25);
--color-purpose-consultation-fg: #ddd6fe;
--color-purpose-consultation-border: rgba(167, 139, 250, 0.5);
--color-purpose-filling-bg: rgba(249, 115, 22, 0.25);
--color-purpose-filling-fg: #fed7aa;
--color-purpose-filling-border: rgba(251, 146, 60, 0.5);
--color-purpose-endo-bg: rgba(239, 68, 68, 0.25);
--color-purpose-endo-fg: #fecaca;
--color-purpose-endo-border: rgba(248, 113, 113, 0.5);
--color-purpose-visit-bg: rgba(14, 165, 233, 0.25);
--color-purpose-visit-fg: #bae6fd;
--color-purpose-visit-border: rgba(56, 189, 248, 0.5);
--color-purpose-hygiene-bg: rgba(132, 204, 22, 0.2);
--color-purpose-hygiene-fg: #d9f99d;
--color-purpose-hygiene-border: rgba(163, 230, 53, 0.45);
--color-primary: #09a9bc; --color-primary: #09a9bc;
--color-primary-contrast: #001117; --color-primary-contrast: #001117;
--color-primary-soft: rgba(9, 169, 188, 0.2); --color-primary-soft: rgba(9, 169, 188, 0.2);
@@ -89,6 +191,37 @@
--color-border: var(--color-border); --color-border: var(--color-border);
--color-border-strong: var(--color-border-strong); --color-border-strong: var(--color-border-strong);
--color-card: var(--color-card-background);
--color-card-foreground: var(--color-card-foreground);
--color-card-muted: var(--color-card-muted);
--color-card-border: var(--color-card-border);
--color-badge-default-bg: var(--color-badge-default-bg);
--color-badge-default-fg: var(--color-badge-default-fg);
--color-badge-default-border: var(--color-badge-default-border);
--color-badge-success-bg: var(--color-badge-success-bg);
--color-badge-success-fg: var(--color-badge-success-fg);
--color-badge-success-border: var(--color-badge-success-border);
--color-badge-warning-bg: var(--color-badge-warning-bg);
--color-badge-warning-fg: var(--color-badge-warning-fg);
--color-badge-warning-border: var(--color-badge-warning-border);
--color-badge-danger-bg: var(--color-badge-danger-bg);
--color-badge-danger-fg: var(--color-badge-danger-fg);
--color-badge-danger-border: var(--color-badge-danger-border);
--color-purpose-consultation-bg: var(--color-purpose-consultation-bg);
--color-purpose-consultation-fg: var(--color-purpose-consultation-fg);
--color-purpose-consultation-border: var(--color-purpose-consultation-border);
--color-purpose-filling-bg: var(--color-purpose-filling-bg);
--color-purpose-filling-fg: var(--color-purpose-filling-fg);
--color-purpose-filling-border: var(--color-purpose-filling-border);
--color-purpose-endo-bg: var(--color-purpose-endo-bg);
--color-purpose-endo-fg: var(--color-purpose-endo-fg);
--color-purpose-endo-border: var(--color-purpose-endo-border);
--color-purpose-visit-bg: var(--color-purpose-visit-bg);
--color-purpose-visit-fg: var(--color-purpose-visit-fg);
--color-purpose-visit-border: var(--color-purpose-visit-border);
--color-purpose-hygiene-bg: var(--color-purpose-hygiene-bg);
--color-purpose-hygiene-fg: var(--color-purpose-hygiene-fg);
--color-purpose-hygiene-border: var(--color-purpose-hygiene-border);
--color-primary: var(--color-primary); --color-primary: var(--color-primary);
--color-primary-contrast: var(--color-primary-contrast); --color-primary-contrast: var(--color-primary-contrast);
--color-primary-soft: var(--color-primary-soft); --color-primary-soft: var(--color-primary-soft);
@@ -115,8 +248,9 @@ body {
} }
.surface-card { .surface-card {
background: color-mix(in srgb, var(--color-background-card) 92%, transparent); background: color-mix(in srgb, var(--color-card-background) 92%, transparent);
border: 1px solid var(--color-border); border: 1px solid var(--color-card-border);
color: var(--color-card-foreground);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }

View File

@@ -51,8 +51,19 @@ export interface TreatmentAttachmentMeta {
sizeBytes: number; sizeBytes: number;
} }
export const TREATMENT_TYPES = [
'consultation',
'filling',
'endo',
'visit',
'hygiene',
] as const;
export type TreatmentType = (typeof TREATMENT_TYPES)[number];
export interface PastTreatmentRecord { export interface PastTreatmentRecord {
id: string; id: string;
treatmentType: TreatmentType;
teeth: FdiToothId[]; teeth: FdiToothId[];
notes?: string | null; notes?: string | null;
} }
@@ -75,6 +86,7 @@ export interface LinkedOrganizationOption {
export interface TreatmentRecordDraft { export interface TreatmentRecordDraft {
clientId: string; clientId: string;
treatmentType: TreatmentType;
teeth: FdiToothId[]; teeth: FdiToothId[];
comment: string; comment: string;
attachmentMetas: TreatmentAttachmentMeta[]; attachmentMetas: TreatmentAttachmentMeta[];