Files
Grow/src/app/(app)/teeth/page.tsx
T
arnaudne cd3efa98fc feat: teeth tracker, feeding analysis, sidebar groups, timeline polish
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>
2026-06-15 21:51:32 +02:00

318 lines
12 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.
"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": "612 mois",
"2": "916 mois",
"3": "1623 mois",
"4": "1418 mois",
"5": "2331 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&apos;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>
);
}