Compare commits

..

3 Commits

Author SHA1 Message Date
b6b408e41e feature: FDI tooth chart UI finalized. 2026-05-08 12:42:01 +03:30
1a11323b74 feature: FDI tooth chart UI updated. 2026-05-08 03:58:53 +03:30
c2d490a5e7 feature: Preview added for treatments. 2026-05-07 14:20:50 +03:30
8 changed files with 597 additions and 155 deletions

View File

@@ -4,7 +4,7 @@ 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 } 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 {
@@ -46,6 +46,16 @@ export function AppointmentBookingModal({
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) {
@@ -175,12 +185,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

@@ -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,8 @@ 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 { isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime'; import { isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime';
import { import {
fetchLinkedOrganizations, fetchLinkedOrganizations,
@@ -32,6 +34,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 +77,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 +92,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 +207,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 +300,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);
@@ -316,7 +370,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 +496,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 +559,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 +609,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>

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

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

@@ -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[];