cd3efa98fc
Features: - Teeth tracker: Tooth model + migration, API routes (GET/POST upsert/DELETE), page at /teeth with 20-tooth grid, progress bar, inline mark/unmark flow - Dashboard feeding analysis: avg interval + last feed + next expected time widget - Fix dashboard baby?.name crash (selectedBaby?.name) UI/UX: - Sidebar: grouped into Suivi / Santé / Vie du bébé / Outils sections - Nav: active highlight works on child routes (startsWith) - Timeline: filter chips sticky on mobile; skeleton cards replace spinner - Stats: 500-event limit warning banner - Medications: 3 states — "Dans Xh" (orange), "Disponible" (green), "En retard de Xh" (amber) - Notes: skip auto-save on empty/whitespace blur Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
318 lines
12 KiB
TypeScript
318 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useBaby } from "@/contexts/baby-context";
|
||
import { format } from "date-fns";
|
||
import { fr } from "date-fns/locale";
|
||
import { Check, Trash2, X } from "lucide-react";
|
||
|
||
interface Tooth {
|
||
id: string;
|
||
babyId: string;
|
||
code: string;
|
||
appearedAt: string;
|
||
notes: string | null;
|
||
}
|
||
|
||
const UPPER_CODES = ["u5l", "u4l", "u3l", "u2l", "u1l", "u1r", "u2r", "u3r", "u4r", "u5r"];
|
||
const LOWER_CODES = ["l5l", "l4l", "l3l", "l2l", "l1l", "l1r", "l2r", "l3r", "l4r", "l5r"];
|
||
|
||
const TOOTH_NAMES: Record<string, string> = {
|
||
"1": "Incisive centrale",
|
||
"2": "Incisive latérale",
|
||
"3": "Canine",
|
||
"4": "Première molaire",
|
||
"5": "Deuxième molaire",
|
||
};
|
||
|
||
const ERUPTION_AGES: Record<string, string> = {
|
||
"1": "6–12 mois",
|
||
"2": "9–16 mois",
|
||
"3": "16–23 mois",
|
||
"4": "14–18 mois",
|
||
"5": "23–31 mois",
|
||
};
|
||
|
||
function getToothNumber(code: string): string {
|
||
// code is like "u1l", "l3r" — digit is at index 1
|
||
return code[1];
|
||
}
|
||
|
||
function getToothLabel(code: string): string {
|
||
const num = getToothNumber(code);
|
||
const jaw = code[0] === "u" ? "sup." : "inf.";
|
||
const side = code[2] === "l" ? "gauche" : "droite";
|
||
return `${TOOTH_NAMES[num]} ${jaw} ${side}`;
|
||
}
|
||
|
||
const inputCls =
|
||
"w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100";
|
||
|
||
type ActiveState =
|
||
| { type: "form"; code: string; date: string; notes: string }
|
||
| { type: "detail"; code: string }
|
||
| null;
|
||
|
||
export default function TeethPage() {
|
||
const qc = useQueryClient();
|
||
const { selectedBaby: baby } = useBaby();
|
||
const [active, setActive] = useState<ActiveState>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [deleting, setDeleting] = useState<string | null>(null);
|
||
|
||
const { data: teeth = [] } = useQuery<Tooth[]>({
|
||
queryKey: ["teeth", baby?.id],
|
||
queryFn: () =>
|
||
fetch(`/api/teeth?babyId=${baby!.id}`).then((r) => r.json()),
|
||
enabled: !!baby?.id,
|
||
});
|
||
|
||
const appearedCodes = new Set(teeth.map((t) => t.code));
|
||
const count = appearedCodes.size;
|
||
|
||
function getToothData(code: string): Tooth | undefined {
|
||
return teeth.find((t) => t.code === code);
|
||
}
|
||
|
||
function handleToothClick(code: string) {
|
||
if (appearedCodes.has(code)) {
|
||
setActive(active?.type === "detail" && active.code === code ? null : { type: "detail", code });
|
||
} else {
|
||
const today = format(new Date(), "yyyy-MM-dd");
|
||
setActive(
|
||
active?.type === "form" && active.code === code
|
||
? null
|
||
: { type: "form", code, date: today, notes: "" }
|
||
);
|
||
}
|
||
}
|
||
|
||
async function handleSave(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!baby || active?.type !== "form") return;
|
||
setSaving(true);
|
||
await fetch("/api/teeth", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
babyId: baby.id,
|
||
code: active.code,
|
||
appearedAt: new Date(active.date).toISOString(),
|
||
notes: active.notes || null,
|
||
}),
|
||
});
|
||
qc.invalidateQueries({ queryKey: ["teeth", baby.id] });
|
||
setSaving(false);
|
||
setActive(null);
|
||
}
|
||
|
||
async function handleDelete(code: string) {
|
||
if (!baby) return;
|
||
setDeleting(code);
|
||
await fetch(`/api/teeth/${code}?babyId=${baby.id}`, { method: "DELETE" });
|
||
qc.invalidateQueries({ queryKey: ["teeth", baby.id] });
|
||
setDeleting(null);
|
||
setActive(null);
|
||
}
|
||
|
||
function ToothSquare({ code }: { code: string }) {
|
||
const appeared = appearedCodes.has(code);
|
||
const num = getToothNumber(code);
|
||
const isActive = active !== null && active.code === code;
|
||
|
||
return (
|
||
<button
|
||
onClick={() => handleToothClick(code)}
|
||
title={`${getToothLabel(code)} — ${ERUPTION_AGES[num]}`}
|
||
className={`
|
||
relative aspect-square w-full rounded-xl border-2 transition-all duration-150 flex flex-col items-center justify-center gap-0.5 text-xs font-semibold
|
||
${appeared
|
||
? "bg-emerald-50 dark:bg-emerald-950 border-emerald-400 dark:border-emerald-600 text-emerald-700 dark:text-emerald-300"
|
||
: "bg-slate-100 dark:bg-slate-800 border-dashed border-slate-300 dark:border-slate-600 text-slate-400 dark:text-slate-500"
|
||
}
|
||
${isActive ? "ring-2 ring-offset-1 ring-emerald-500 dark:ring-emerald-400" : "hover:border-emerald-400 dark:hover:border-emerald-500"}
|
||
`}
|
||
>
|
||
{appeared ? (
|
||
<>
|
||
<Check className="w-4 h-4" />
|
||
<span className="text-[10px] leading-tight">{num}</span>
|
||
</>
|
||
) : (
|
||
<span className="text-sm">{num}</span>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const activeToothLabel = active ? getToothLabel(active.code) : "";
|
||
const activeToothNum = active ? getToothNumber(active.code) : "";
|
||
|
||
return (
|
||
<div className="p-4 md:p-8 pb-28 md:pb-8 max-w-lg mx-auto">
|
||
{/* Header */}
|
||
<div className="mb-6">
|
||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Dents</h1>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{baby?.name}</p>
|
||
</div>
|
||
|
||
{/* Progress */}
|
||
<div className="mb-6">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||
{count} / 20 dents apparues
|
||
</span>
|
||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||
{Math.round((count / 20) * 100)} %
|
||
</span>
|
||
</div>
|
||
<div className="h-2.5 bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-emerald-500 rounded-full transition-all duration-500"
|
||
style={{ width: `${(count / 20) * 100}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Diagram */}
|
||
<div className="bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-700 p-4 space-y-3">
|
||
{/* Upper jaw label */}
|
||
<p className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-600 text-center">
|
||
Mâchoire supérieure
|
||
</p>
|
||
<div className="grid grid-cols-10 gap-1.5">
|
||
{UPPER_CODES.map((code) => (
|
||
<ToothSquare key={code} code={code} />
|
||
))}
|
||
</div>
|
||
|
||
<div className="border-t border-dashed border-slate-200 dark:border-slate-700" />
|
||
|
||
<div className="grid grid-cols-10 gap-1.5">
|
||
{LOWER_CODES.map((code) => (
|
||
<ToothSquare key={code} code={code} />
|
||
))}
|
||
</div>
|
||
{/* Lower jaw label */}
|
||
<p className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-600 text-center">
|
||
Mâchoire inférieure
|
||
</p>
|
||
</div>
|
||
|
||
{/* Legend */}
|
||
<div className="mt-3 flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400">
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="w-3.5 h-3.5 rounded border-2 border-dashed border-slate-300 dark:border-slate-600 inline-block" />
|
||
Non apparue
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="w-3.5 h-3.5 rounded border-2 border-emerald-400 bg-emerald-50 dark:bg-emerald-950 inline-block" />
|
||
Apparue
|
||
</span>
|
||
</div>
|
||
|
||
{/* Inline panel for form or detail */}
|
||
{active && (
|
||
<div className="mt-4 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div>
|
||
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||
{activeToothLabel}
|
||
</p>
|
||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">
|
||
Éruption typique : {ERUPTION_AGES[activeToothNum]}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setActive(null)}
|
||
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{active.type === "form" ? (
|
||
<form onSubmit={handleSave} className="space-y-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1">
|
||
Date d'apparition <span className="text-red-400">*</span>
|
||
</label>
|
||
<input
|
||
type="date"
|
||
required
|
||
value={active.date}
|
||
onChange={(e) =>
|
||
setActive((prev) =>
|
||
prev?.type === "form" ? { ...prev, date: e.target.value } : prev
|
||
)
|
||
}
|
||
className={inputCls}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1">
|
||
Notes <span className="text-slate-400 font-normal">(optionnel)</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={active.notes}
|
||
onChange={(e) =>
|
||
setActive((prev) =>
|
||
prev?.type === "form" ? { ...prev, notes: e.target.value } : prev
|
||
)
|
||
}
|
||
placeholder="Observations..."
|
||
className={inputCls}
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2 pt-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setActive(null)}
|
||
className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 transition"
|
||
>
|
||
Annuler
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={saving}
|
||
className="flex-1 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 transition disabled:opacity-60"
|
||
>
|
||
{saving ? "..." : "Enregistrer"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
) : (
|
||
(() => {
|
||
const tooth = getToothData(active.code);
|
||
if (!tooth) return null;
|
||
return (
|
||
<div className="space-y-2">
|
||
<p className="text-sm text-slate-700 dark:text-slate-200">
|
||
Apparue le{" "}
|
||
<span className="font-semibold">
|
||
{format(new Date(tooth.appearedAt), "d MMMM yyyy", { locale: fr })}
|
||
</span>
|
||
</p>
|
||
{tooth.notes && (
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">{tooth.notes}</p>
|
||
)}
|
||
<button
|
||
onClick={() => handleDelete(active.code)}
|
||
disabled={deleting === active.code}
|
||
className="flex items-center gap-2 mt-2 px-3 py-1.5 rounded-lg text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition disabled:opacity-50"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
{deleting === active.code ? "Suppression..." : "Supprimer"}
|
||
</button>
|
||
</div>
|
||
);
|
||
})()
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|