254 lines
9.1 KiB
TypeScript
254 lines
9.1 KiB
TypeScript
import { useState } from "react";
|
||
import { cn } from "@/lib/utils";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Save } from "lucide-react";
|
||
|
||
type ToothCondition =
|
||
| "healthy"
|
||
| "cavity"
|
||
| "crown"
|
||
| "missing"
|
||
| "root-canal"
|
||
| "bridge"
|
||
| "implant"
|
||
| "fracture"
|
||
| "watch";
|
||
|
||
interface ToothState {
|
||
condition: ToothCondition;
|
||
notes: string;
|
||
}
|
||
|
||
const CONDITIONS: { value: ToothCondition; label: string; color: string; bg: string }[] = [
|
||
{ value: "healthy", label: "Healthy", color: "border-gray-300", bg: "bg-white" },
|
||
{ value: "cavity", label: "Cavity", color: "border-red-500", bg: "bg-red-400" },
|
||
{ value: "crown", label: "Crown", color: "border-yellow-500", bg: "bg-yellow-400" },
|
||
{ value: "missing", label: "Missing", color: "border-gray-400", bg: "bg-gray-300" },
|
||
{ value: "root-canal", label: "Root Canal", color: "border-blue-500", bg: "bg-blue-400" },
|
||
{ value: "bridge", label: "Bridge", color: "border-purple-500", bg: "bg-purple-400" },
|
||
{ value: "implant", label: "Implant", color: "border-green-500", bg: "bg-green-400" },
|
||
{ value: "fracture", label: "Fracture", color: "border-orange-500", bg: "bg-orange-400" },
|
||
{ value: "watch", label: "Watch", color: "border-yellow-300", bg: "bg-yellow-100" },
|
||
];
|
||
|
||
const TOOTH_NAMES: Record<number, string> = {
|
||
1: "UR 3rd Molar", 2: "UR 2nd Molar", 3: "UR 1st Molar",
|
||
4: "UR 2nd Premolar", 5: "UR 1st Premolar", 6: "UR Canine",
|
||
7: "UR Lat. Incisor", 8: "UR Cen. Incisor",
|
||
9: "UL Cen. Incisor", 10: "UL Lat. Incisor",
|
||
11: "UL Canine", 12: "UL 1st Premolar", 13: "UL 2nd Premolar",
|
||
14: "UL 1st Molar", 15: "UL 2nd Molar", 16: "UL 3rd Molar",
|
||
17: "LL 3rd Molar", 18: "LL 2nd Molar", 19: "LL 1st Molar",
|
||
20: "LL 2nd Premolar", 21: "LL 1st Premolar", 22: "LL Canine",
|
||
23: "LL Lat. Incisor", 24: "LL Cen. Incisor",
|
||
25: "LR Cen. Incisor", 26: "LR Lat. Incisor",
|
||
27: "LR Canine", 28: "LR 1st Premolar", 29: "LR 2nd Premolar",
|
||
30: "LR 1st Molar", 31: "LR 2nd Molar", 32: "LR 3rd Molar",
|
||
};
|
||
|
||
const initTeeth = (): Record<number, ToothState> => {
|
||
const s: Record<number, ToothState> = {};
|
||
for (let i = 1; i <= 32; i++) s[i] = { condition: "healthy", notes: "" };
|
||
return s;
|
||
};
|
||
|
||
const upperTeeth = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
|
||
const lowerTeeth = [32, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, 19, 18, 17];
|
||
|
||
function getConditionStyle(condition: ToothCondition) {
|
||
return CONDITIONS.find((c) => c.value === condition) ?? { value: "healthy" as ToothCondition, label: "Healthy", color: "border-gray-300", bg: "bg-white" };
|
||
}
|
||
|
||
interface ToothBoxProps {
|
||
number: number;
|
||
state: ToothState;
|
||
selected: boolean;
|
||
isUpper: boolean;
|
||
onClick: () => void;
|
||
}
|
||
|
||
function ToothBox({ number, state, selected, isUpper, onClick }: ToothBoxProps) {
|
||
const style = getConditionStyle(state.condition);
|
||
const isMissing = state.condition === "missing";
|
||
return (
|
||
<div className="flex flex-col items-center gap-0.5">
|
||
{isUpper && (
|
||
<span className="text-[10px] text-gray-500 font-mono leading-none">{number}</span>
|
||
)}
|
||
<button
|
||
onClick={onClick}
|
||
title={`#${number} – ${TOOTH_NAMES[number]}`}
|
||
className={cn(
|
||
"w-8 h-10 rounded border-2 transition-all duration-150 flex items-center justify-center relative",
|
||
style.bg,
|
||
style.color,
|
||
selected && "ring-2 ring-offset-1 ring-primary scale-110 z-10",
|
||
!selected && "hover:scale-105 hover:z-10",
|
||
isMissing && "opacity-50"
|
||
)}
|
||
>
|
||
{isMissing && (
|
||
<span className="text-gray-500 text-xs font-bold leading-none select-none">✕</span>
|
||
)}
|
||
</button>
|
||
{!isUpper && (
|
||
<span className="text-[10px] text-gray-500 font-mono leading-none">{number}</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function TeethChart() {
|
||
const [teeth, setTeeth] = useState(initTeeth);
|
||
const [selected, setSelected] = useState<number | null>(null);
|
||
const [draft, setDraft] = useState<ToothState>({ condition: "healthy", notes: "" });
|
||
|
||
const handleSelect = (n: number) => {
|
||
setSelected(n);
|
||
const t = teeth[n];
|
||
if (t) setDraft({ ...t });
|
||
};
|
||
|
||
const handleSave = () => {
|
||
if (selected === null) return;
|
||
setTeeth((prev) => ({ ...prev, [selected]: { ...draft } }));
|
||
};
|
||
|
||
const conditionCounts = CONDITIONS.slice(1).filter(
|
||
(c) => Object.values(teeth).some((t) => t.condition === c.value)
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Tooth chart */}
|
||
<div className="bg-gray-50 rounded-lg border p-4 overflow-x-auto">
|
||
<div className="flex items-center justify-between mb-3 min-w-max">
|
||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Patient's Right (R)</span>
|
||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Patient's Left (L)</span>
|
||
</div>
|
||
|
||
{/* Upper arch */}
|
||
<div className="mb-1 min-w-max">
|
||
<div className="flex gap-1 justify-center pb-1">
|
||
{upperTeeth.map((n) => (
|
||
<ToothBox
|
||
key={n}
|
||
number={n}
|
||
state={teeth[n]!}
|
||
selected={selected === n}
|
||
isUpper={true}
|
||
onClick={() => handleSelect(n)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Center divider */}
|
||
<div className="flex items-center gap-2 my-1 min-w-max">
|
||
<div className="flex-1 border-t-2 border-dashed border-gray-300" />
|
||
<span className="text-[10px] text-gray-400 font-semibold whitespace-nowrap px-1">OCCLUSAL</span>
|
||
<div className="flex-1 border-t-2 border-dashed border-gray-300" />
|
||
</div>
|
||
|
||
{/* Lower arch */}
|
||
<div className="mt-1 min-w-max">
|
||
<div className="flex gap-1 justify-center pt-1">
|
||
{lowerTeeth.map((n) => (
|
||
<ToothBox
|
||
key={n}
|
||
number={n}
|
||
state={teeth[n]!}
|
||
selected={selected === n}
|
||
isUpper={false}
|
||
onClick={() => handleSelect(n)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Legend */}
|
||
<div className="flex flex-wrap gap-2">
|
||
{CONDITIONS.map((c) => (
|
||
<div key={c.value} className="flex items-center gap-1.5">
|
||
<div className={cn("w-4 h-4 rounded border-2 flex-shrink-0", c.bg, c.color)} />
|
||
<span className="text-xs text-gray-600">{c.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Summary badges */}
|
||
{conditionCounts.length > 0 && (
|
||
<div className="flex flex-wrap gap-2">
|
||
{conditionCounts.map((c) => {
|
||
const count = Object.values(teeth).filter((t) => t.condition === c.value).length;
|
||
return (
|
||
<Badge key={c.value} variant="secondary" className="text-xs">
|
||
{c.label}: {count}
|
||
</Badge>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Selected tooth detail */}
|
||
{selected !== null && (
|
||
<div className="border rounded-lg p-4 bg-white space-y-3">
|
||
<div>
|
||
<p className="font-semibold text-sm text-gray-800">
|
||
Tooth #{selected} — {TOOTH_NAMES[selected]}
|
||
</p>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs text-gray-600">Condition</Label>
|
||
<Select
|
||
value={draft.condition}
|
||
onValueChange={(v) => setDraft((d) => ({ ...d, condition: v as ToothCondition }))}
|
||
>
|
||
<SelectTrigger className="h-9 text-sm">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{CONDITIONS.map((c) => (
|
||
<SelectItem key={c.value} value={c.value}>
|
||
<div className="flex items-center gap-2">
|
||
<div className={cn("w-3 h-3 rounded border flex-shrink-0", c.bg, c.color)} />
|
||
{c.label}
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs text-gray-600">Notes</Label>
|
||
<Textarea
|
||
className="text-sm resize-none h-9 min-h-0 py-1.5"
|
||
placeholder="Clinical notes..."
|
||
value={draft.notes}
|
||
onChange={(e) => setDraft((d) => ({ ...d, notes: e.target.value }))}
|
||
rows={1}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<Button size="sm" onClick={handleSave} className="gap-1.5">
|
||
<Save className="h-3.5 w-3.5" />
|
||
Save
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|