Files
DentalManagementMH06/apps/Frontend/src/components/chart/teeth-chart.tsx

254 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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