b40694d691
- nav: aria-label on baby switcher select - dashboard: active timers show baby name - growth: PDF export includes percentile band per weight row - settings: invite link note (permanent, reset if compromised) - timeline: select mode toggle shows "Annuler" when active - photos: delete photo from lightbox (PATCHes event metadata) - notes: invisible date input overlay for date-jump via calendar picker - IMPROVEMENTS.md: updated checkboxes, 8 remaining → 0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
572 lines
28 KiB
TypeScript
572 lines
28 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||
} from "recharts";
|
||
import {
|
||
WHO_WEIGHT_PERCENTILES, PERCENTILE_LABELS, getAgeInWeeks, interpolatePercentile,
|
||
} from "@/lib/who-percentiles";
|
||
import { useBaby } from "@/contexts/baby-context";
|
||
import { format, subDays } from "date-fns";
|
||
import { fr } from "date-fns/locale";
|
||
import { Plus, Pencil, Trash2, FileDown } from "lucide-react";
|
||
|
||
interface GrowthLog {
|
||
id: string;
|
||
date: string;
|
||
weight?: number;
|
||
height?: number;
|
||
headCirc?: number;
|
||
notes?: string;
|
||
}
|
||
|
||
interface BabyShape {
|
||
id: string;
|
||
name: string;
|
||
birthDate: string;
|
||
}
|
||
|
||
const WHO_CURVE_KEYS = ["p3", "p10", "p25", "p50", "p75", "p90", "p97"];
|
||
const WHO_COLORS = ["#e2e8f0", "#cbd5e1", "#94a3b8", "#475569", "#94a3b8", "#cbd5e1", "#e2e8f0"];
|
||
|
||
function useDarkMode() {
|
||
const [isDark, setIsDark] = useState(false);
|
||
useEffect(() => {
|
||
const check = () => setIsDark(document.documentElement.classList.contains("dark"));
|
||
check();
|
||
const obs = new MutationObserver(check);
|
||
obs.observe(document.documentElement, { attributeFilter: ["class"] });
|
||
return () => obs.disconnect();
|
||
}, []);
|
||
return isDark;
|
||
}
|
||
|
||
type TimeRange = "7j" | "4sem" | "3mois" | "tout";
|
||
|
||
function filterLogsByRange(logs: GrowthLog[], range: TimeRange): GrowthLog[] {
|
||
if (range === "tout") return logs;
|
||
const now = new Date();
|
||
let cutoff: Date;
|
||
if (range === "7j") cutoff = subDays(now, 7);
|
||
else if (range === "4sem") cutoff = subDays(now, 28);
|
||
else cutoff = subDays(now, 90);
|
||
return logs.filter((l) => new Date(l.date) >= cutoff);
|
||
}
|
||
|
||
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000;
|
||
function ageWeeksFloat(birthDate: string, measureDate: string): number {
|
||
return (new Date(measureDate).getTime() - new Date(birthDate).getTime()) / MS_PER_WEEK;
|
||
}
|
||
|
||
function buildWeightChartData(logs: GrowthLog[], baby: BabyShape, allLogs: GrowthLog[]) {
|
||
const actualPoints = logs
|
||
.filter((l) => l.weight)
|
||
.map((l) => ({ ageWeeks: ageWeeksFloat(baby.birthDate, l.date), weight: l.weight! / 1000 }));
|
||
|
||
if (actualPoints.length === 0) return [];
|
||
|
||
const maxWeek = Math.max(...actualPoints.map((p) => p.ageWeeks));
|
||
const globalMin = allLogs.filter((l) => l.weight).reduce((min, l) => {
|
||
const w = ageWeeksFloat(baby.birthDate, l.date);
|
||
return w < min ? w : min;
|
||
}, Infinity);
|
||
const localMin = Math.min(...actualPoints.map((p) => p.ageWeeks));
|
||
const minWeek = localMin > globalMin ? Math.max(0, localMin - 1) : 0;
|
||
|
||
const whoAges = Object.keys(WHO_WEIGHT_PERCENTILES)
|
||
.map(Number)
|
||
.filter((a) => a >= Math.floor(minWeek) && a <= Math.ceil(maxWeek) + 2)
|
||
.sort((a, b) => a - b);
|
||
|
||
// Use string keys so float actual-point ages don't collide with integer WHO ages
|
||
const rows = new Map<number, Record<string, number | null>>();
|
||
for (const ageWeeks of whoAges) {
|
||
const row: Record<string, number | null> = { ageWeeks };
|
||
PERCENTILE_LABELS.forEach((label, i) => { row[`p${label.slice(1)}`] = interpolatePercentile(ageWeeks, i); });
|
||
rows.set(ageWeeks, row);
|
||
}
|
||
for (const pt of actualPoints) {
|
||
const existing = rows.get(pt.ageWeeks) ?? { ageWeeks: pt.ageWeeks };
|
||
rows.set(pt.ageWeeks, { ...existing, actualWeight: pt.weight });
|
||
}
|
||
return Array.from(rows.values()).sort((a, b) => (a.ageWeeks as number) - (b.ageWeeks as number));
|
||
}
|
||
|
||
function buildSimpleChartData(logs: GrowthLog[], baby: BabyShape, field: "height" | "headCirc") {
|
||
return logs
|
||
.filter((l) => l[field])
|
||
.map((l) => ({ ageWeeks: ageWeeksFloat(baby.birthDate, l.date), value: l[field]! }))
|
||
.sort((a, b) => a.ageWeeks - b.ageWeeks);
|
||
}
|
||
|
||
const inputCls = "w-full px-3 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100";
|
||
|
||
function fmtAgeWeeks(weeks: number): string {
|
||
if (weeks === 0) return "Naiss.";
|
||
if (weeks < 4) return `${weeks}sem`;
|
||
const months = Math.round(weeks / 4.345);
|
||
if (months >= 12) return "1 an";
|
||
return `${months}m`;
|
||
}
|
||
|
||
function fmtAgeLabel(weeks: number): string {
|
||
if (weeks === 0) return "Naissance";
|
||
if (weeks < 4) return `${weeks} semaine${weeks > 1 ? "s" : ""}`;
|
||
const months = Math.floor(weeks / 4.345);
|
||
const remWeeks = Math.round(weeks - months * 4.345);
|
||
if (months >= 12) return "1 an";
|
||
if (remWeeks > 0) return `${months} mois ${remWeeks} sem.`;
|
||
return `${months} mois`;
|
||
}
|
||
|
||
function WeightTooltip(props: Record<string, unknown> & { style: React.CSSProperties }) {
|
||
const { active, payload, label, style } = props as {
|
||
active?: boolean;
|
||
payload?: { dataKey: string; value: number }[];
|
||
label?: number;
|
||
style: React.CSSProperties;
|
||
};
|
||
if (!active || !payload) return null;
|
||
const baby = payload.find((p) => p.dataKey === "actualWeight");
|
||
if (!baby) return null;
|
||
return (
|
||
<div style={style} className="px-3 py-2 rounded-lg text-xs">
|
||
<p className="text-slate-400 mb-1">{fmtAgeLabel(label ?? 0)}</p>
|
||
<p className="font-semibold text-indigo-500">Bébé : {baby.value.toFixed(3)} kg</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function GrowthPage() {
|
||
const qc = useQueryClient();
|
||
const { selectedBaby: baby } = useBaby();
|
||
const isDark = useDarkMode();
|
||
|
||
const tooltipStyle = {
|
||
borderRadius: 8,
|
||
border: `1px solid ${isDark ? "#334155" : "#e2e8f0"}`,
|
||
background: isDark ? "#1e293b" : "#fff",
|
||
color: isDark ? "#f1f5f9" : "#0f172a",
|
||
boxShadow: "0 4px 16px rgba(0,0,0,0.12)",
|
||
fontSize: 12,
|
||
};
|
||
const gridStroke = isDark ? "#1e293b" : "#f1f5f9";
|
||
const [showForm, setShowForm] = useState(false);
|
||
const [form, setForm] = useState({ date: "", weight: "", height: "", headCirc: "", notes: "" });
|
||
|
||
function openAddForm() {
|
||
const last = logs.at(-1);
|
||
setForm({
|
||
date: format(new Date(), "yyyy-MM-dd"),
|
||
weight: last?.weight ? (last.weight / 1000).toFixed(3) : "",
|
||
height: last?.height ? String(last.height) : "",
|
||
headCirc: last?.headCirc ? String(last.headCirc) : "",
|
||
notes: "",
|
||
});
|
||
setShowForm(true);
|
||
}
|
||
const [saving, setSaving] = useState(false);
|
||
const [timeRange, setTimeRange] = useState<TimeRange>("tout");
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [editForm, setEditForm] = useState({ date: "", weight: "", height: "", headCirc: "", notes: "" });
|
||
const [editSaving, setEditSaving] = useState(false);
|
||
|
||
const { data: logs = [] } = useQuery<GrowthLog[]>({
|
||
queryKey: ["growth", baby?.id],
|
||
queryFn: () => fetch(`/api/growth?babyId=${baby!.id}`).then((r) => r.json()),
|
||
enabled: !!baby?.id,
|
||
});
|
||
|
||
const filteredLogs = filterLogsByRange(logs, timeRange);
|
||
const weightData = baby ? buildWeightChartData(filteredLogs, baby, logs) : [];
|
||
const heightData = baby ? buildSimpleChartData(filteredLogs, baby, "height") : [];
|
||
const headData = baby ? buildSimpleChartData(filteredLogs, baby, "headCirc") : [];
|
||
|
||
async function handleSave(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setSaving(true);
|
||
await fetch("/api/growth", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
babyId: baby!.id,
|
||
date: new Date(form.date).toISOString(),
|
||
weight: form.weight ? parseFloat(form.weight) * 1000 : null,
|
||
height: form.height ? parseFloat(form.height) : null,
|
||
headCirc: form.headCirc ? parseFloat(form.headCirc) : null,
|
||
notes: form.notes || null,
|
||
}),
|
||
});
|
||
qc.invalidateQueries({ queryKey: ["growth"] });
|
||
setSaving(false);
|
||
setShowForm(false);
|
||
setForm({ date: "", weight: "", height: "", headCirc: "", notes: "" });
|
||
}
|
||
|
||
function startEdit(log: GrowthLog) {
|
||
setEditingId(log.id);
|
||
setEditForm({
|
||
date: format(new Date(log.date), "yyyy-MM-dd"),
|
||
weight: log.weight ? (log.weight / 1000).toFixed(3) : "",
|
||
height: log.height ? String(log.height) : "",
|
||
headCirc: log.headCirc ? String(log.headCirc) : "",
|
||
notes: log.notes ?? "",
|
||
});
|
||
}
|
||
|
||
async function handleEditSave(e: React.FormEvent, logId: string) {
|
||
e.preventDefault();
|
||
setEditSaving(true);
|
||
await fetch(`/api/growth/${logId}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
date: new Date(editForm.date).toISOString(),
|
||
weight: editForm.weight ? parseFloat(editForm.weight) * 1000 : null,
|
||
height: editForm.height ? parseFloat(editForm.height) : null,
|
||
headCirc: editForm.headCirc ? parseFloat(editForm.headCirc) : null,
|
||
notes: editForm.notes || null,
|
||
}),
|
||
});
|
||
qc.invalidateQueries({ queryKey: ["growth"] });
|
||
setEditSaving(false);
|
||
setEditingId(null);
|
||
}
|
||
|
||
async function handleDelete(logId: string) {
|
||
if (!confirm("Supprimer cette mesure ?")) return;
|
||
await fetch(`/api/growth/${logId}`, { method: "DELETE" });
|
||
qc.invalidateQueries({ queryKey: ["growth"] });
|
||
}
|
||
|
||
function exportPDF() {
|
||
function getPercentileBand(ageWeeks: number, weightKg: number): string {
|
||
const values = PERCENTILE_LABELS.map((_, i) => interpolatePercentile(ageWeeks, i) ?? 0);
|
||
if (weightKg < values[0]) return "< P3";
|
||
for (let i = 0; i < values.length - 1; i++) {
|
||
if (weightKg >= values[i] && weightKg < values[i + 1]) return `${PERCENTILE_LABELS[i]}–${PERCENTILE_LABELS[i + 1]}`;
|
||
}
|
||
return "> P97";
|
||
}
|
||
|
||
import("jspdf").then(({ default: jsPDF }) => {
|
||
const doc = new jsPDF();
|
||
const pageW = doc.internal.pageSize.getWidth();
|
||
|
||
doc.setFontSize(18);
|
||
doc.setTextColor(30, 41, 59);
|
||
doc.text("Courbe de croissance", 14, 20);
|
||
|
||
doc.setFontSize(11);
|
||
doc.setTextColor(100, 116, 139);
|
||
doc.text(`${baby!.name} · né${baby!.gender === "F" ? "e" : ""} le ${format(new Date(baby!.birthDate), "d MMMM yyyy", { locale: fr })}`, 14, 28);
|
||
doc.text(`Généré le ${format(new Date(), "d MMMM yyyy", { locale: fr })}`, 14, 34);
|
||
|
||
// Table header
|
||
const COL = [14, 55, 95, 130, 155];
|
||
const headers = ["Date", "Poids (kg)", "Taille (cm)", "PC (cm)", "Notes"];
|
||
let y = 46;
|
||
|
||
doc.setFillColor(238, 242, 255);
|
||
doc.rect(14, y - 5, pageW - 28, 9, "F");
|
||
doc.setFontSize(9);
|
||
doc.setTextColor(79, 70, 229);
|
||
headers.forEach((h, i) => doc.text(h, COL[i], y));
|
||
y += 8;
|
||
|
||
doc.setFontSize(9);
|
||
const sorted = [...logs].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||
sorted.forEach((log, idx) => {
|
||
if (y > 270) { doc.addPage(); y = 20; }
|
||
if (idx % 2 === 0) {
|
||
doc.setFillColor(248, 250, 252);
|
||
doc.rect(14, y - 4, pageW - 28, 7, "F");
|
||
}
|
||
doc.setTextColor(30, 41, 59);
|
||
doc.text(format(new Date(log.date), "d MMM yyyy", { locale: fr }), COL[0], y);
|
||
doc.text(log.weight ? (() => {
|
||
const ageW = getAgeInWeeks(new Date(baby!.birthDate), new Date(log.date));
|
||
const band = ageW >= 0 && ageW <= 104 ? ` (${getPercentileBand(ageW, log.weight! / 1000)})` : "";
|
||
return `${(log.weight! / 1000).toFixed(3)}${band}`;
|
||
})() : "—", COL[1], y);
|
||
doc.text(log.height ? String(log.height) : "—", COL[2], y);
|
||
doc.text(log.headCirc ? String(log.headCirc) : "—", COL[3], y);
|
||
if (log.notes) doc.text(log.notes.slice(0, 30), COL[4], y);
|
||
y += 7;
|
||
});
|
||
|
||
doc.save(`croissance-${baby!.name.toLowerCase()}.pdf`);
|
||
});
|
||
}
|
||
|
||
const latest = logs.at(-1);
|
||
|
||
const rangeOptions: { label: string; value: TimeRange }[] = [
|
||
{ label: "7j", value: "7j" },
|
||
{ label: "4 sem", value: "4sem" },
|
||
{ label: "3 mois", value: "3mois" },
|
||
{ label: "Tout", value: "tout" },
|
||
];
|
||
|
||
return (
|
||
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Courbes de croissance</h1>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{baby?.name}</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{logs.length > 0 && (
|
||
<button onClick={exportPDF}
|
||
className="flex items-center gap-2 px-3 py-2 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 text-sm font-medium rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition">
|
||
<FileDown className="w-4 h-4" />
|
||
PDF
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => showForm ? setShowForm(false) : openAddForm()}
|
||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Mesure
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Add measurement form */}
|
||
{showForm && (
|
||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 mb-5">
|
||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Nouvelle mesure</h3>
|
||
<form onSubmit={handleSave} className="space-y-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Date</label>
|
||
<input type="date" value={form.date} onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))} required className={inputCls} />
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Poids (kg)</label>
|
||
<input type="number" value={form.weight} onChange={(e) => setForm((f) => ({ ...f, weight: e.target.value }))}
|
||
step="0.001" min="1" max="30" placeholder="3.500" className={inputCls} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Taille (cm)</label>
|
||
<input type="number" value={form.height} onChange={(e) => setForm((f) => ({ ...f, height: e.target.value }))}
|
||
step="0.1" min="30" max="120" placeholder="50" className={inputCls} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">PC — Périmètre crânien (cm)</label>
|
||
<input type="number" value={form.headCirc} onChange={(e) => setForm((f) => ({ ...f, headCirc: e.target.value }))}
|
||
step="0.1" min="20" max="60" placeholder="34" className={inputCls} />
|
||
</div>
|
||
</div>
|
||
<input type="text" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||
placeholder="RDV pédiatre, contexte..." className={inputCls} />
|
||
<div className="flex gap-2">
|
||
<button type="button" onClick={() => setShowForm(false)} className="flex-1 py-2.5 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-300">Annuler</button>
|
||
<button type="submit" disabled={saving} className="flex-1 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition">
|
||
{saving ? "..." : "Enregistrer"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
{/* Latest values */}
|
||
{latest && (
|
||
<div className="grid grid-cols-3 gap-3 mb-5">
|
||
{latest.weight && (
|
||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 text-center">
|
||
<p className="text-xs text-slate-400 dark:text-slate-500 font-medium mb-1">Poids</p>
|
||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{(latest.weight / 1000).toFixed(3).replace(".", ",")} kg</p>
|
||
</div>
|
||
)}
|
||
{latest.height && (
|
||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 text-center">
|
||
<p className="text-xs text-slate-400 dark:text-slate-500 font-medium mb-1">Taille</p>
|
||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{latest.height} cm</p>
|
||
</div>
|
||
)}
|
||
{latest.headCirc && (
|
||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 text-center">
|
||
<p className="text-xs text-slate-400 dark:text-slate-500 font-medium mb-1">Périmètre crânien (PC)</p>
|
||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{latest.headCirc} cm</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Time range selector */}
|
||
{logs.length > 0 && (
|
||
<div className="flex gap-1.5 mb-5">
|
||
{rangeOptions.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
onClick={() => setTimeRange(opt.value)}
|
||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition ${
|
||
timeRange === opt.value
|
||
? "bg-indigo-600 text-white"
|
||
: "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600"
|
||
}`}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-4">
|
||
{/* Weight + WHO */}
|
||
{weightData.length > 0 && (
|
||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Poids (kg)</h3>
|
||
<div className="flex items-center gap-3 text-xs text-slate-400 dark:text-slate-500">
|
||
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-indigo-600 rounded" />Bébé</span>
|
||
<span className="flex items-center gap-1">
|
||
<span className="inline-block w-3 h-0.5 bg-slate-300 rounded" />
|
||
OMS P3–P97
|
||
<span className="text-slate-300 dark:text-slate-600 text-[10px]" title="P50 = médiane : 50% des bébés ont un poids inférieur. P3–P97 représente la plage normale OMS.">ⓘ</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<LineChart data={weightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||
<XAxis dataKey="ageWeeks" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={fmtAgeWeeks} axisLine={false} tickLine={false} />
|
||
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
|
||
<Tooltip content={(props) => <WeightTooltip {...props} style={tooltipStyle} />} />
|
||
{WHO_CURVE_KEYS.map((key, i) => (
|
||
<Line key={key} dataKey={key} stroke={WHO_COLORS[i]} strokeWidth={key === "p50" ? 1.5 : 1} dot={false} strokeDasharray={key === "p50" ? undefined : "3 2"} connectNulls />
|
||
))}
|
||
<Line dataKey="actualWeight" stroke="#4f46e5" strokeWidth={2.5} dot={{ r: 4, fill: "#4f46e5", strokeWidth: 2, stroke: "#fff" }} connectNulls={false} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
{/* Height */}
|
||
{heightData.length > 0 && (
|
||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
|
||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Taille (cm)</h3>
|
||
<ResponsiveContainer width="100%" height={160}>
|
||
<LineChart data={heightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||
<XAxis dataKey="ageWeeks" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={fmtAgeWeeks} axisLine={false} tickLine={false} />
|
||
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} />
|
||
<Tooltip formatter={(val) => [`${val} cm`, "Taille"]} labelFormatter={(v) => fmtAgeLabel(Number(v))} contentStyle={tooltipStyle} />
|
||
<Line dataKey="value" stroke="#0ea5e9" strokeWidth={2.5} dot={{ r: 4, fill: "#0ea5e9", strokeWidth: 2, stroke: "#fff" }} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)}
|
||
|
||
{/* Head circumference */}
|
||
{headData.length > 0 && (
|
||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
|
||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Périmètre crânien (cm)</h3>
|
||
<ResponsiveContainer width="100%" height={160}>
|
||
<LineChart data={headData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||
<XAxis dataKey="ageWeeks" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={fmtAgeWeeks} axisLine={false} tickLine={false} />
|
||
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} />
|
||
<Tooltip formatter={(val) => [`${val} cm`, "PC"]} labelFormatter={(v) => fmtAgeLabel(Number(v))} contentStyle={tooltipStyle} />
|
||
<Line dataKey="value" stroke="#10b981" strokeWidth={2.5} dot={{ r: 4, fill: "#10b981", strokeWidth: 2, stroke: "#fff" }} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* History */}
|
||
{logs.length > 0 && (
|
||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
|
||
<div className="px-4 py-3 border-b border-slate-100 dark:border-slate-700">
|
||
<h3 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Historique</h3>
|
||
</div>
|
||
<div className="divide-y divide-slate-100 dark:divide-slate-700">
|
||
{[...logs].reverse().map((log) => (
|
||
<div key={log.id}>
|
||
{editingId === log.id ? (
|
||
<div className="px-4 py-4 bg-slate-50 dark:bg-slate-900">
|
||
<form onSubmit={(e) => handleEditSave(e, log.id)} className="space-y-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Date</label>
|
||
<input type="date" value={editForm.date} onChange={(e) => setEditForm((f) => ({ ...f, date: e.target.value }))} required className={inputCls} />
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Poids (kg)</label>
|
||
<input type="number" value={editForm.weight} onChange={(e) => setEditForm((f) => ({ ...f, weight: e.target.value }))}
|
||
step="0.001" min="1" max="30" placeholder="3.500" className={inputCls} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Taille (cm)</label>
|
||
<input type="number" value={editForm.height} onChange={(e) => setEditForm((f) => ({ ...f, height: e.target.value }))}
|
||
step="0.1" min="30" max="120" placeholder="50" className={inputCls} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">PC (cm)</label>
|
||
<input type="number" value={editForm.headCirc} onChange={(e) => setEditForm((f) => ({ ...f, headCirc: e.target.value }))}
|
||
step="0.1" min="20" max="60" placeholder="34" className={inputCls} />
|
||
</div>
|
||
</div>
|
||
<input type="text" value={editForm.notes} onChange={(e) => setEditForm((f) => ({ ...f, notes: e.target.value }))}
|
||
placeholder="RDV pédiatre, contexte..." className={inputCls} />
|
||
<div className="flex gap-2">
|
||
<button type="button" onClick={() => setEditingId(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">Annuler</button>
|
||
<button type="submit" disabled={editSaving} className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition">
|
||
{editSaving ? "..." : "Enregistrer"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
) : (
|
||
<div className="px-4 py-3 flex items-center justify-between group">
|
||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||
{format(new Date(log.date), "d MMMM yyyy", { locale: fr })}
|
||
</span>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex gap-4 text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||
{log.weight && <span>{(log.weight / 1000).toFixed(3)} kg</span>}
|
||
{log.height && <span>{log.height} cm</span>}
|
||
{log.headCirc && <span>PC {log.headCirc} cm</span>}
|
||
</div>
|
||
<div className="flex gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
|
||
<button
|
||
onClick={() => startEdit(log)}
|
||
className="p-1.5 rounded-md text-slate-400 dark:text-slate-500 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-950 transition"
|
||
>
|
||
<Pencil className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(log.id)}
|
||
className="p-1.5 rounded-md text-slate-400 dark:text-slate-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{logs.length === 0 && !showForm && (
|
||
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
|
||
<p className="text-sm">Aucune mesure enregistrée</p>
|
||
<button onClick={openAddForm} className="mt-3 text-indigo-600 font-medium text-sm hover:underline">
|
||
Ajouter la première mesure
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|