Initial commit — Grow baby tracker

Next.js 16 App Router, Prisma + PostgreSQL, NextAuth v5 JWT.

Features: dashboard quick-log, timeline, growth charts (WHO percentiles),
stats, journal/notes, doctor notes, milestones, vaccinations, settings,
superadmin panel. Mobile-first with sidebar nav + bottom nav + quick-add FAB.
Dark mode, PWA push notifications, multi-family invite system.

Docker: multi-stage Dockerfile + docker-compose with postgres service.
This commit is contained in:
2026-06-13 01:52:46 +02:00
commit cd16c354c0
94 changed files with 14354 additions and 0 deletions
+616
View File
@@ -0,0 +1,616 @@
"use client";
import { useState, useEffect } from "react";
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 dark:text-slate-100";
const labelCls = "block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden mb-4">
<div className="px-4 py-3 border-b border-slate-100 dark:border-slate-700">
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">{title}</h2>
</div>
<div className="p-4 space-y-4">{children}</div>
</div>
);
}
interface UserRow {
id: string;
name: string;
email: string;
role: "PARENT" | "READONLY" | "DOCTOR";
isSuperAdmin: boolean;
familyId: string | null;
family: { name: string } | null;
createdAt: string;
}
interface FamilyRow {
id: string;
name: string;
inviteCode: string;
createdAt: string;
_count: {
users: number;
babies: number;
};
}
interface Stats {
userCount: number;
familyCount: number;
babyCount: number;
eventCount: number;
growthLogCount: number;
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-indigo-600 dark:text-indigo-400">{value}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{label}</p>
</div>
);
}
export default function AdminConfigForm() {
const [config, setConfig] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const [stats, setStats] = useState<Stats | null>(null);
const [statsError, setStatsError] = useState<string | null>(null);
const [users, setUsers] = useState<UserRow[]>([]);
const [usersError, setUsersError] = useState<string | null>(null);
const [forceLogoutMsgs, setForceLogoutMsgs] = useState<Record<string, { type: "ok" | "err"; text: string }>>({});
const [families, setFamilies] = useState<FamilyRow[]>([]);
const [familiesError, setFamiliesError] = useState<string | null>(null);
const [resetInviteMsgs, setResetInviteMsgs] = useState<Record<string, { type: "ok" | "err"; text: string }>>({});
const [testPushMsg, setTestPushMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const [testingPush, setTestingPush] = useState(false);
const [testEmailMsg, setTestEmailMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const [testingEmail, setTestingEmail] = useState(false);
useEffect(() => {
fetch("/api/admin/config")
.then((r) => r.json())
.then((data) => {
if (!data.error) setConfig(data);
});
fetch("/api/admin/stats")
.then((r) => r.json())
.then((data) => {
if (data.error) setStatsError(data.error);
else setStats(data);
})
.catch(() => setStatsError("Erreur réseau"));
fetch("/api/admin/users")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setUsers(data);
else setUsersError(data.error ?? "Erreur inconnue");
})
.catch(() => setUsersError("Erreur réseau"));
fetch("/api/admin/families")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setFamilies(data);
else setFamiliesError(data.error ?? "Erreur inconnue");
})
.catch(() => setFamiliesError("Erreur réseau"));
}, []);
function set(key: string, value: string) {
setConfig((prev) => ({ ...prev, [key]: value }));
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
try {
const res = await fetch("/api/admin/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
allow_registration: config.allow_registration ?? "true",
maintenance_mode: config.maintenance_mode ?? "false",
email_provider: config.email_provider ?? "",
mailgun_api_key: config.mailgun_api_key ?? "",
mailgun_domain: config.mailgun_domain ?? "",
mailgun_from: config.mailgun_from ?? "",
smtp_host: config.smtp_host ?? "",
smtp_port: config.smtp_port ?? "",
smtp_user: config.smtp_user ?? "",
smtp_pass: config.smtp_pass ?? "",
smtp_from: config.smtp_from ?? "",
pushover_app_token: config.pushover_app_token ?? "",
}),
});
if (res.ok) {
setMsg({ type: "ok", text: "Configuration sauvegardée." });
} else {
setMsg({ type: "err", text: "Erreur lors de la sauvegarde." });
}
} catch {
setMsg({ type: "err", text: "Erreur réseau." });
}
setSaving(false);
}
async function handleRoleChange(userId: string, role: string) {
const res = await fetch(`/api/admin/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role }),
});
if (res.ok) {
setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, role: role as UserRow["role"] } : u)));
}
}
async function handleDeleteUser(userId: string, name: string) {
if (!confirm(`Supprimer l'utilisateur "${name}" ? Cette action est irréversible.`)) return;
const res = await fetch(`/api/admin/users/${userId}`, { method: "DELETE" });
if (res.ok) {
setUsers((prev) => prev.filter((u) => u.id !== userId));
} else {
const data = await res.json();
alert(data.error ?? "Erreur lors de la suppression");
}
}
async function handleTestPush() {
setTestingPush(true);
setTestPushMsg(null);
try {
const res = await fetch("/api/admin/test-push", { method: "POST" });
const data = await res.json();
if (data.ok) setTestPushMsg({ type: "ok", text: "Notification envoyée avec succès." });
else setTestPushMsg({ type: "err", text: data.error ?? "Erreur inconnue" });
} catch {
setTestPushMsg({ type: "err", text: "Erreur réseau." });
}
setTestingPush(false);
}
async function handleForceLogout(userId: string) {
const res = await fetch(`/api/admin/users/${userId}/force-logout`, { method: "POST" });
const data = await res.json();
if (res.ok) {
setForceLogoutMsgs((prev) => ({ ...prev, [userId]: { type: "ok", text: "Déconnecté." } }));
} else {
setForceLogoutMsgs((prev) => ({ ...prev, [userId]: { type: "err", text: data.error ?? "Erreur inconnue" } }));
}
}
async function handleResetInvite(familyId: string) {
const res = await fetch(`/api/admin/family/${familyId}/reset-invite`, { method: "POST" });
const data = await res.json();
if (res.ok && data.inviteCode) {
setFamilies((prev) =>
prev.map((f) => (f.id === familyId ? { ...f, inviteCode: data.inviteCode } : f))
);
setResetInviteMsgs((prev) => ({ ...prev, [familyId]: { type: "ok", text: "Lien réinitialisé." } }));
} else {
setResetInviteMsgs((prev) => ({ ...prev, [familyId]: { type: "err", text: data.error ?? "Erreur inconnue" } }));
}
}
async function handleTestEmail() {
setTestingEmail(true);
setTestEmailMsg(null);
try {
const res = await fetch("/api/admin/test-email", { method: "POST" });
const data = await res.json();
if (data.ok) setTestEmailMsg({ type: "ok", text: "Email envoyé avec succès." });
else setTestEmailMsg({ type: "err", text: data.error ?? "Erreur inconnue" });
} catch {
setTestEmailMsg({ type: "err", text: "Erreur réseau." });
}
setTestingEmail(false);
}
const provider = config.email_provider ?? "none";
const allowRegistration = config.allow_registration !== "false";
const maintenanceMode = config.maintenance_mode === "true";
const inlineMsgCls = (type: "ok" | "err") =>
`mt-2 px-3 py-2 rounded-lg text-xs font-medium ${
type === "ok"
? "bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-400 border border-green-200 dark:border-green-800"
: "bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800"
}`;
return (
<form onSubmit={handleSave}>
{/* Stats */}
<Section title="Statistiques">
{statsError ? (
<p className="text-sm text-red-500">{statsError}</p>
) : stats ? (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-5">
<StatCard label="Utilisateurs" value={stats.userCount} />
<StatCard label="Familles" value={stats.familyCount} />
<StatCard label="Bébés" value={stats.babyCount} />
<StatCard label="Événements" value={stats.eventCount} />
<StatCard label="Courbe de croissance" value={stats.growthLogCount} />
</div>
) : (
<p className="text-sm text-slate-400 dark:text-slate-500">Chargement</p>
)}
</Section>
{/* Access */}
<Section title="Accès">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Inscriptions ouvertes</p>
<p className="text-xs text-slate-400 dark:text-slate-500">Autoriser la création de nouveaux comptes</p>
</div>
<button
type="button"
onClick={() => set("allow_registration", allowRegistration ? "false" : "true")}
className={`w-10 h-5 rounded-full transition relative overflow-hidden flex-shrink-0 ${allowRegistration ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-700"}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${allowRegistration ? "translate-x-[18px]" : "translate-x-0"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Mode maintenance</p>
<p className="text-xs text-slate-400 dark:text-slate-500">Afficher un message de maintenance aux utilisateurs</p>
</div>
<button
type="button"
onClick={() => set("maintenance_mode", maintenanceMode ? "false" : "true")}
className={`w-10 h-5 rounded-full transition relative overflow-hidden flex-shrink-0 ${maintenanceMode ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-700"}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${maintenanceMode ? "translate-x-[18px]" : "translate-x-0"}`} />
</button>
</div>
</Section>
{/* Email */}
<Section title="Email">
<div>
<label className={labelCls}>Fournisseur</label>
<select value={provider} onChange={(e) => set("email_provider", e.target.value)} className={inputCls}>
<option value="none">Désactivé</option>
<option value="mailgun">Mailgun</option>
<option value="smtp">SMTP</option>
</select>
</div>
{provider === "mailgun" && (
<>
<div>
<label className={labelCls}>Clé API Mailgun</label>
<input
type="password"
value={config.mailgun_api_key ?? ""}
onChange={(e) => set("mailgun_api_key", e.target.value)}
className={inputCls}
placeholder="key-••••••••••••••••••••••••••••••••"
/>
</div>
<div>
<label className={labelCls}>Domaine Mailgun</label>
<input
type="text"
value={config.mailgun_domain ?? ""}
onChange={(e) => set("mailgun_domain", e.target.value)}
className={inputCls}
placeholder="mg.exemple.com"
/>
</div>
<div>
<label className={labelCls}>Adresse expéditeur</label>
<input
type="text"
value={config.mailgun_from ?? ""}
onChange={(e) => set("mailgun_from", e.target.value)}
className={inputCls}
placeholder="Grow <noreply@exemple.com>"
/>
</div>
</>
)}
{provider === "smtp" && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className={labelCls}>Hôte SMTP</label>
<input
type="text"
value={config.smtp_host ?? ""}
onChange={(e) => set("smtp_host", e.target.value)}
className={inputCls}
placeholder="smtp.exemple.com"
/>
</div>
<div>
<label className={labelCls}>Port</label>
<input
type="text"
value={config.smtp_port ?? ""}
onChange={(e) => set("smtp_port", e.target.value)}
className={inputCls}
placeholder="587"
/>
</div>
</div>
<div>
<label className={labelCls}>Utilisateur</label>
<input
type="text"
value={config.smtp_user ?? ""}
onChange={(e) => set("smtp_user", e.target.value)}
className={inputCls}
/>
</div>
<div>
<label className={labelCls}>Mot de passe</label>
<input
type="password"
value={config.smtp_pass ?? ""}
onChange={(e) => set("smtp_pass", e.target.value)}
className={inputCls}
/>
</div>
<div>
<label className={labelCls}>Adresse expéditeur</label>
<input
type="text"
value={config.smtp_from ?? ""}
onChange={(e) => set("smtp_from", e.target.value)}
className={inputCls}
placeholder="Grow <noreply@exemple.com>"
/>
</div>
</>
)}
</Section>
{/* Pushover */}
<Section title="Notifications push">
<div>
<label className={labelCls}>Token application Pushover</label>
<input
type="password"
value={config.pushover_app_token ?? ""}
onChange={(e) => set("pushover_app_token", e.target.value)}
className={inputCls}
placeholder="a••••••••••••••••••••••••••••••"
/>
<p className="mt-1.5 text-xs text-slate-400 dark:text-slate-500">
Créez une application sur pushover.net pour obtenir ce token.
</p>
</div>
</Section>
{/* Tests */}
<Section title="Tests">
<div className="space-y-3">
<div>
<button
type="button"
onClick={handleTestPush}
disabled={testingPush}
className="px-4 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 text-sm rounded-lg font-medium transition disabled:opacity-50"
>
{testingPush ? "Envoi…" : "Tester Pushover"}
</button>
{testPushMsg && <p className={inlineMsgCls(testPushMsg.type)}>{testPushMsg.text}</p>}
</div>
<div>
<button
type="button"
onClick={handleTestEmail}
disabled={testingEmail}
className="px-4 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 text-sm rounded-lg font-medium transition disabled:opacity-50"
>
{testingEmail ? "Envoi…" : "Tester l'email"}
</button>
{testEmailMsg && <p className={inlineMsgCls(testEmailMsg.type)}>{testEmailMsg.text}</p>}
</div>
</div>
</Section>
{/* Utilisateurs */}
<Section title="Utilisateurs">
{usersError ? (
<p className="text-sm text-red-500">{usersError}</p>
) : users.length === 0 ? (
<p className="text-sm text-slate-400 dark:text-slate-500">Chargement</p>
) : (
<div className="overflow-x-auto -mx-4 px-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 dark:border-slate-700">
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Nom</th>
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Email</th>
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Famille</th>
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Rôle</th>
<th className="pb-2 text-xs font-medium text-slate-400 dark:text-slate-500" />
</tr>
</thead>
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
{users.map((u) => (
<tr key={u.id}>
<td className="py-2 pr-3 font-medium text-slate-800 dark:text-slate-100 whitespace-nowrap">
{u.name}
{u.isSuperAdmin && (
<span className="ml-1.5 text-xs bg-indigo-100 dark:bg-indigo-900 text-indigo-600 dark:text-indigo-400 px-1.5 py-0.5 rounded font-medium">
superadmin
</span>
)}
</td>
<td className="py-2 pr-3 text-slate-500 dark:text-slate-400 whitespace-nowrap">{u.email}</td>
<td className="py-2 pr-3 text-slate-500 dark:text-slate-400 whitespace-nowrap">
{u.family?.name ?? <span className="text-slate-300 dark:text-slate-600"></span>}
</td>
<td className="py-2 pr-3">
{u.isSuperAdmin ? (
<span className="text-slate-400 dark:text-slate-500 text-xs"></span>
) : (
<select
value={u.role}
onChange={(e) => handleRoleChange(u.id, e.target.value)}
className="px-2 py-1 rounded-md border border-slate-200 dark:border-slate-600 text-xs bg-white dark:bg-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400"
>
<option value="PARENT">Parent</option>
<option value="READONLY">Lecture seule</option>
<option value="DOCTOR">Médecin</option>
</select>
)}
</td>
<td className="py-2 text-right">
{!u.isSuperAdmin && (
<div className="flex items-center justify-end gap-1.5">
{forceLogoutMsgs[u.id] ? (
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${
forceLogoutMsgs[u.id].type === "ok"
? "text-green-600 dark:text-green-400"
: "text-red-500 dark:text-red-400"
}`}
>
{forceLogoutMsgs[u.id].text}
</span>
) : (
<button
type="button"
onClick={() => handleForceLogout(u.id)}
className="px-2 py-1 text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-950 rounded transition"
>
Déconnecter
</button>
)}
<button
type="button"
onClick={() => handleDeleteUser(u.id, u.name)}
className="px-2 py-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 rounded transition"
>
Supprimer
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
{/* Familles */}
<Section title="Familles">
{familiesError ? (
<p className="text-sm text-red-500">{familiesError}</p>
) : families.length === 0 ? (
<p className="text-sm text-slate-400 dark:text-slate-500">Chargement</p>
) : (
<div className="overflow-x-auto -mx-4 px-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 dark:border-slate-700">
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Nom</th>
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Membres</th>
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Bébés</th>
<th className="text-left pb-2 pr-3 text-xs font-medium text-slate-400 dark:text-slate-500">Code d&apos;invitation</th>
<th className="pb-2 text-xs font-medium text-slate-400 dark:text-slate-500" />
</tr>
</thead>
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
{families.map((f) => (
<tr key={f.id}>
<td className="py-2 pr-3 font-medium text-slate-800 dark:text-slate-100 whitespace-nowrap">{f.name}</td>
<td className="py-2 pr-3 text-slate-500 dark:text-slate-400">{f._count.users}</td>
<td className="py-2 pr-3 text-slate-500 dark:text-slate-400">{f._count.babies}</td>
<td className="py-2 pr-3">
<span className="font-mono text-xs text-slate-500 dark:text-slate-400 truncate max-w-[10rem] inline-block align-bottom">
{f.inviteCode}
</span>
</td>
<td className="py-2 text-right whitespace-nowrap">
{resetInviteMsgs[f.id] ? (
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${
resetInviteMsgs[f.id].type === "ok"
? "text-green-600 dark:text-green-400"
: "text-red-500 dark:text-red-400"
}`}
>
{resetInviteMsgs[f.id].text}
</span>
) : (
<button
type="button"
onClick={() => handleResetInvite(f.id)}
className="px-2 py-1 text-xs text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300 hover:bg-indigo-50 dark:hover:bg-indigo-950 rounded transition"
>
Réinitialiser le lien
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
{/* Sauvegarde */}
<Section title="Sauvegarde">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Exporter les données</p>
<p className="text-xs text-slate-400 dark:text-slate-500">Télécharger une sauvegarde JSON complète</p>
</div>
<a
href="/api/admin/backup"
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 text-sm rounded-lg font-medium transition"
>
Télécharger
</a>
</div>
</Section>
{msg && (
<div
className={`mb-4 px-4 py-3 rounded-lg text-sm font-medium ${
msg.type === "ok"
? "bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-400 border border-green-200 dark:border-green-800"
: "bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800"
}`}
>
{msg.text}
</div>
)}
<button
type="submit"
disabled={saving}
className="w-full py-2.5 bg-indigo-600 text-white text-sm rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 transition"
>
{saving ? "Sauvegarde..." : "Sauvegarder"}
</button>
</form>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import AdminConfigForm from "./AdminConfigForm";
export default async function AdminPage() {
const session = await auth();
if (!session?.user?.email) redirect("/auth/login");
if (session.user.email !== process.env.SUPERADMIN_EMAIL) redirect("/dashboard");
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-1">Administration</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-8">Configuration système</p>
<AdminConfigForm />
</div>
);
}
+238
View File
@@ -0,0 +1,238 @@
"use client";
import { useState, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EVENT_CONFIG, EventType, formatTimeAgo, formatDuration } from "@/lib/event-config";
import { EventModal } from "@/components/event-modal";
import { EventIcon } from "@/components/event-icon";
import { useBaby } from "@/contexts/baby-context";
import { checkFeedAlert, getThresholds } from "@/lib/notifications";
import { format, isToday } from "date-fns";
import { fr } from "date-fns/locale";
const QUICK_LOG_TYPES: EventType[] = [
"BREASTFEED",
"BOTTLE",
"DIAPER_WET",
"DIAPER_STOOL",
"NAP",
"NIGHT_SLEEP",
"MEDICATION",
"TEMPERATURE",
];
interface Event {
id: string;
type: EventType;
startedAt: string;
endedAt?: string;
notes?: string;
metadata?: Record<string, unknown>;
user: { id: string; name: string };
}
function getBabyAge(birthDate: string): string {
const birth = new Date(birthDate);
const now = new Date();
const days = Math.floor((now.getTime() - birth.getTime()) / 86400000);
if (days < 7) return `${days} jour${days !== 1 ? "s" : ""}`;
const weeks = Math.floor(days / 7);
if (weeks < 8) return `${weeks} semaine${weeks !== 1 ? "s" : ""}`;
const months = Math.floor(days / 30);
return `${months} mois`;
}
function getEventSummary(event: Event): string {
const meta = event.metadata ?? {};
if (event.type === "BREASTFEED") {
const side = meta.breastSide === "left" ? "gauche" : meta.breastSide === "right" ? "droite" : "les deux";
if (event.endedAt) return `${formatDuration(new Date(event.startedAt), new Date(event.endedAt))} · ${side}`;
return side;
}
if (event.type === "BOTTLE") {
return [meta.volume ? `${meta.volume} mL` : "", meta.bottleType === "maternal" ? "maternel" : "artificiel"].filter(Boolean).join(" · ");
}
if (event.type === "DIAPER_STOOL") return String(meta.color ?? "");
if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
if (event.type === "MEDICATION") return String(meta.name ?? "");
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
}
return "";
}
export default function DashboardPage() {
const qc = useQueryClient();
const [activeModal, setActiveModal] = useState<EventType | null>(null);
const { selectedBaby, isLoading: familyLoading } = useBaby();
const { data: eventsData } = useQuery({
queryKey: ["events", selectedBaby?.id],
queryFn: () => fetch(`/api/events?babyId=${selectedBaby!.id}&limit=50`).then((r) => r.json()),
enabled: !!selectedBaby?.id,
refetchInterval: 10_000,
});
const events: Event[] = eventsData?.events ?? [];
useEffect(() => {
const lastFeed = events.find((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds());
const id = setInterval(() => {
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds());
}, 5 * 60 * 1000);
return () => clearInterval(id);
}, [events]);
const lastByType: Partial<Record<EventType, Event>> = {};
for (const ev of events) {
if (!lastByType[ev.type]) lastByType[ev.type] = ev;
}
const todayEvents = events.filter((e) => isToday(new Date(e.startedAt)));
const todayCounts: Partial<Record<EventType, number>> = {};
for (const ev of todayEvents) todayCounts[ev.type] = (todayCounts[ev.type] ?? 0) + 1;
if (familyLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!selectedBaby) {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center">
<p className="text-slate-500 mb-4">Aucun bébé enregistré.</p>
<a href="/setup" className="bg-indigo-600 text-white px-5 py-2.5 rounded-lg font-medium hover:bg-indigo-700 transition text-sm">
Ajouter un bébé
</a>
</div>
);
}
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Page header */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">{selectedBaby.name}</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-0.5">{getBabyAge(selectedBaby.birthDate)}</p>
</div>
<div className="text-right">
<p className="text-slate-400 dark:text-slate-500 text-xs">Aujourd&apos;hui</p>
<p className="text-slate-700 dark:text-slate-200 font-medium text-sm capitalize">
{format(new Date(), "EEEE d MMMM", { locale: fr })}
</p>
</div>
</div>
{/* Today's summary counters */}
<div className="grid grid-cols-4 gap-3 mb-6">
{(["BREASTFEED", "BOTTLE", "DIAPER_WET", "DIAPER_STOOL"] as EventType[]).map((t) => {
const cfg = EVENT_CONFIG[t];
return (
<div key={t} className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-3 text-center">
<EventIcon name={cfg.icon} className={`w-5 h-5 mx-auto mb-1.5 ${cfg.colorClass}`} />
<div className="text-2xl font-bold text-slate-900 dark:text-slate-100">{todayCounts[t] ?? 0}</div>
<div className="text-xs text-slate-400 dark:text-slate-500 mt-0.5 leading-tight">{cfg.label.split(" ")[0]}</div>
</div>
);
})}
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* Last events */}
<div>
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Dernières activités</h2>
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{(["BREASTFEED", "BOTTLE", "DIAPER_WET", "DIAPER_STOOL", "NAP"] as EventType[]).map((t) => {
const last = lastByType[t];
const cfg = EVENT_CONFIG[t];
return (
<div key={t} className="flex items-center gap-3 px-4 py-3">
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0`}>
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</div>
{last && getEventSummary(last) && (
<div className="text-xs text-slate-400 dark:text-slate-500 truncate">{getEventSummary(last)}</div>
)}
</div>
<div className="text-xs text-slate-400 dark:text-slate-500 flex-shrink-0">
{last ? formatTimeAgo(new Date(last.startedAt)) : "—"}
</div>
</div>
);
})}
</div>
</div>
{/* Quick log */}
<div>
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Enregistrer</h2>
<div className="grid grid-cols-4 md:grid-cols-4 gap-2">
{QUICK_LOG_TYPES.map((type) => {
const cfg = EVENT_CONFIG[type];
return (
<button
key={type}
onClick={() => setActiveModal(type)}
className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 hover:border-slate-300 hover:shadow-sm rounded-xl p-3 flex flex-col items-center gap-2 transition active:scale-95"
>
<div className={`w-9 h-9 rounded-lg ${cfg.bgClass} flex items-center justify-center`}>
<EventIcon name={cfg.icon} className={`w-5 h-5 ${cfg.colorClass}`} />
</div>
<span className="text-[10px] font-medium text-slate-600 dark:text-slate-300 text-center leading-tight">
{cfg.label}
</span>
</button>
);
})}
</div>
</div>
</div>
{/* Today's timeline */}
{todayEvents.length > 0 && (
<div className="mt-6">
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">
Aujourd&apos;hui · {todayEvents.length} événement{todayEvents.length > 1 ? "s" : ""}
</h2>
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{todayEvents.slice(0, 10).map((ev) => {
const cfg = EVENT_CONFIG[ev.type];
return (
<div key={ev.id} className="flex items-center gap-3 px-4 py-3">
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0`}>
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</span>
{getEventSummary(ev) && (
<span className="text-xs text-slate-400 dark:text-slate-500 ml-2">{getEventSummary(ev)}</span>
)}
</div>
<span className="text-xs text-slate-400 dark:text-slate-500 flex-shrink-0 font-mono">
{format(new Date(ev.startedAt), "HH:mm")}
</span>
</div>
);
})}
</div>
</div>
)}
{activeModal && (
<EventModal
type={activeModal}
babyId={selectedBaby.id}
onClose={() => setActiveModal(null)}
onSaved={() => qc.invalidateQueries({ queryKey: ["events"] })}
/>
)}
</div>
);
}
+223
View File
@@ -0,0 +1,223 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useBaby } from "@/contexts/baby-context";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Trash2, Stethoscope, Plus, Loader2 } from "lucide-react";
interface DoctorNote {
id: string;
content: string;
date: string;
createdAt: string;
userId: string;
user: { id: string; name: string };
}
export default function DoctorNotesPage() {
const { selectedBaby, isLoading: familyLoading } = useBaby();
const { data: session } = useSession();
const qc = useQueryClient();
const userRole = (session?.user as { role?: string })?.role;
const userId = session?.user?.id;
const isDoctor = userRole === "DOCTOR";
const today = new Date().toISOString().slice(0, 10);
const [content, setContent] = useState("");
const [date, setDate] = useState(today);
const [formError, setFormError] = useState<string | null>(null);
const { data: notes = [], isLoading: notesLoading } = useQuery<DoctorNote[]>({
queryKey: ["doctor-notes", selectedBaby?.id],
queryFn: () =>
fetch(`/api/doctor-notes?babyId=${selectedBaby!.id}`).then((r) => r.json()),
enabled: !!selectedBaby?.id,
});
const createMutation = useMutation({
mutationFn: (body: { babyId: string; content: string; date: string }) =>
fetch("/api/doctor-notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(async (r) => {
if (!r.ok) {
const data = await r.json();
throw new Error(data.error ?? "Erreur lors de la création");
}
return r.json();
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["doctor-notes", selectedBaby?.id] });
setContent("");
setDate(today);
setFormError(null);
},
onError: (err: Error) => {
setFormError(err.message);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) =>
fetch(`/api/doctor-notes/${id}`, { method: "DELETE" }).then(async (r) => {
if (!r.ok) {
const data = await r.json();
throw new Error(data.error ?? "Erreur lors de la suppression");
}
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["doctor-notes", selectedBaby?.id] });
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selectedBaby?.id || !content.trim() || !date) return;
createMutation.mutate({ babyId: selectedBaby.id, content: content.trim(), date });
}
if (familyLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!selectedBaby) {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center">
<p className="text-slate-500 dark:text-slate-400 mb-4">Aucun bébé enregistré.</p>
<a
href="/setup"
className="bg-indigo-600 text-white px-5 py-2.5 rounded-lg font-medium hover:bg-indigo-700 transition text-sm"
>
Ajouter un bébé
</a>
</div>
);
}
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-9 h-9 bg-emerald-100 dark:bg-emerald-900 rounded-xl flex items-center justify-center flex-shrink-0">
<Stethoscope className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Observations médicales
</h1>
</div>
{/* Add form — DOCTOR only */}
{isDoctor && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-6">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">
Ajouter une observation
</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">
Date
</label>
<input
type="date"
value={date}
max={today}
onChange={(e) => setDate(e.target.value)}
required
className="w-full rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-slate-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:focus:ring-emerald-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">
Observation
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Décrivez votre observation médicale…"
required
rows={4}
className="w-full rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-slate-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:focus:ring-emerald-400 resize-none"
/>
</div>
{formError && (
<p className="text-xs text-red-600 dark:text-red-400">{formError}</p>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={createMutation.isPending || !content.trim()}
className="inline-flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-2 rounded-lg transition"
>
{createMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
Enregistrer
</button>
</div>
</form>
</div>
)}
{/* Notes list */}
{notesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-6 h-6 border-2 border-emerald-600 border-t-transparent rounded-full animate-spin" />
</div>
) : notes.length === 0 ? (
<div className="text-center py-12">
<div className="w-12 h-12 bg-slate-100 dark:bg-slate-800 rounded-xl flex items-center justify-center mx-auto mb-3">
<Stethoscope className="w-6 h-6 text-slate-400 dark:text-slate-500" />
</div>
<p className="text-slate-500 dark:text-slate-400 text-sm">
Aucune observation médicale enregistrée
</p>
</div>
) : (
<div className="flex flex-col gap-3">
{notes.map((note) => (
<div
key={note.id}
className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4"
>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
{format(new Date(note.date), "d MMMM yyyy", { locale: fr })}
</span>
<span className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full border bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800">
<Stethoscope className="w-3 h-3" />
{note.user.name ?? "Médecin"}
</span>
</div>
{isDoctor && note.userId === userId && (
<button
onClick={() => deleteMutation.mutate(note.id)}
disabled={deleteMutation.isPending}
title="Supprimer"
className="text-slate-400 dark:text-slate-500 hover:text-red-500 dark:hover:text-red-400 transition flex-shrink-0 disabled:opacity-40"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<p className="text-sm text-slate-600 dark:text-slate-300 whitespace-pre-wrap leading-relaxed">
{note.content}
</p>
</div>
))}
</div>
)}
</div>
);
}
+478
View File
@@ -0,0 +1,478 @@
"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 } 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);
}
function buildWeightChartData(logs: GrowthLog[], baby: BabyShape, allLogs: GrowthLog[]) {
const actualPoints = logs
.filter((l) => l.weight)
.map((l) => ({
ageWeeks: getAgeInWeeks(new Date(baby.birthDate), new Date(l.date)),
weight: l.weight! / 1000,
}));
if (actualPoints.length === 0) return [];
const maxWeek = Math.max(...actualPoints.map((p) => p.ageWeeks));
// minWeek: either earliest in filtered set, or 0 if showing all
const allPoints = allLogs.filter((l) => l.weight).map((l) =>
getAgeInWeeks(new Date(baby.birthDate), new Date(l.date))
);
const globalMin = allPoints.length ? Math.min(...allPoints) : 0;
const localMin = Math.min(...actualPoints.map((p) => p.ageWeeks));
// Only clip if the range is meaningfully narrower than global
const minWeek = localMin > globalMin ? Math.max(0, localMin - 1) : 0;
const whoAges = Object.keys(WHO_WEIGHT_PERCENTILES)
.map(Number)
.filter((a) => a >= minWeek && a <= maxWeek + 2)
.sort((a, b) => a - b);
const mergedMap: Record<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); });
mergedMap[ageWeeks] = row;
}
for (const pt of actualPoints) {
mergedMap[pt.ageWeeks] = { ...(mergedMap[pt.ageWeeks] ?? { ageWeeks: pt.ageWeeks }), actualWeight: pt.weight };
}
return Object.values(mergedMap).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: getAgeInWeeks(new Date(baby.birthDate), new Date(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 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">Semaine {label}</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: "",
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"] });
}
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>
<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>
{/* 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 (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</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 > 1 && (
<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 P3P97</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" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={(v) => `S${v}`} 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"} />
))}
<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 > 1 && (
<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" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={(v) => `S${v}`} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} />
<Tooltip formatter={(val) => [`${val} cm`, "Taille"]} 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 > 1 && (
<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" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={(v) => `S${v}`} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} />
<Tooltip formatter={(val) => [`${val} cm`, "PC"]} 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>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { SidebarNav, BottomNav } from "@/components/nav";
import { BabyProvider } from "@/contexts/baby-context";
import { QuickAddFab } from "@/components/quick-add-fab";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user) redirect("/auth/login");
return (
<BabyProvider>
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<SidebarNav />
<main className="app-main min-h-screen">
{children}
</main>
<BottomNav />
<QuickAddFab />
</div>
</BabyProvider>
);
}
+440
View File
@@ -0,0 +1,440 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useBaby } from "@/contexts/baby-context";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import {
Plus,
Pencil,
Trash2,
Star,
Smile,
MessageCircle,
Baby,
Footprints,
Cake,
Droplets,
Eye,
Hand,
Moon,
type LucideIcon,
} from "lucide-react";
interface Milestone {
id: string;
babyId: string;
title: string;
date: string;
notes: string | null;
icon: string | null;
createdAt: string;
}
const ICON_MAP: Record<string, LucideIcon> = {
Smile,
MessageCircle,
Baby,
Footprints,
Cake,
Droplets,
Eye,
Hand,
Moon,
Star,
};
const PRESET_ICONS: { key: string; Icon: LucideIcon; label: string }[] = [
{ key: "Smile", Icon: Smile, label: "Premier sourire" },
{ key: "MessageCircle", Icon: MessageCircle, label: "Premier mot" },
{ key: "Baby", Icon: Baby, label: "Tient assis" },
{ key: "Footprints", Icon: Footprints, label: "Premier pas" },
{ key: "Cake", Icon: Cake, label: "Anniversaire" },
{ key: "Droplets", Icon: Droplets, label: "Boit seul" },
{ key: "Eye", Icon: Eye, label: "Suit du regard" },
{ key: "Hand", Icon: Hand, label: "Saisit objet" },
{ key: "Moon", Icon: Moon, label: "Fait ses nuits" },
{ key: "Star", Icon: Star, label: "Autre" },
];
function MilestoneIcon({ icon }: { icon: string | null; className?: string }) {
const Icon = icon && ICON_MAP[icon] ? ICON_MAP[icon] : Star;
return <Icon className="w-5 h-5 text-indigo-500 dark:text-indigo-400" />;
}
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";
const emptyForm = { icon: "", title: "", date: "", notes: "" };
type FormState = typeof emptyForm;
function groupByMonth(milestones: Milestone[]) {
const groups: { key: string; items: Milestone[] }[] = [];
const seen: Record<string, number> = {};
for (const m of milestones) {
const key = format(new Date(m.date), "MMMM yyyy", { locale: fr });
if (seen[key] === undefined) {
seen[key] = groups.length;
groups.push({ key, items: [] });
}
groups[seen[key]].items.push(m);
}
return groups;
}
function IconPicker({
value,
onChange,
}: {
value: string;
onChange: (key: string, label: string) => void;
}) {
return (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">
Icône
</label>
<div className="grid grid-cols-2 gap-2">
{PRESET_ICONS.map(({ key, Icon, label }) => (
<button
key={key}
type="button"
onClick={() => onChange(key, label)}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg border text-sm transition ${
value === key
? "border-indigo-500 bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300"
: "border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:border-indigo-300 dark:hover:border-indigo-700 hover:bg-slate-50 dark:hover:bg-slate-800"
}`}
>
<span className="w-5 h-5 flex items-center justify-center flex-shrink-0">
<Icon
className={`w-4 h-4 ${
value === key
? "text-indigo-600 dark:text-indigo-400"
: "text-slate-400 dark:text-slate-500"
}`}
/>
</span>
<span className="text-xs font-medium truncate">{label}</span>
</button>
))}
</div>
</div>
);
}
function MilestoneFormCard({
form,
setForm,
onSubmit,
onCancel,
saving,
heading,
}: {
form: FormState;
setForm: (fn: (f: FormState) => FormState) => void;
onSubmit: (e: React.FormEvent) => void;
onCancel: () => void;
saving: boolean;
heading: string;
}) {
return (
<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-800 dark:text-slate-200 mb-4">
{heading}
</h3>
<form onSubmit={onSubmit} className="space-y-4">
<IconPicker
value={form.icon}
onChange={(key, label) =>
setForm((f) => ({ ...f, icon: key, title: f.title || label }))
}
/>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Titre <span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
required
placeholder="Ex : Premier sourire"
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date <span className="text-red-400">*</span>
</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
required
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Notes{" "}
<span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<textarea
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={3}
placeholder="Un souvenir à garder…"
className={inputCls + " resize-none"}
/>
</div>
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={onCancel}
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 hover:bg-slate-50 dark:hover:bg-slate-700 transition"
>
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 disabled:opacity-60"
>
{saving ? "Enregistrement…" : "Enregistrer"}
</button>
</div>
</form>
</div>
);
}
export default function MilestonesPage() {
const qc = useQueryClient();
const { selectedBaby: baby } = useBaby();
const { data: session } = useSession();
const isReadonly =
(session?.user as { role?: string } | undefined)?.role === "READONLY";
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState(emptyForm);
const [editSaving, setEditSaving] = useState(false);
const { data: milestones = [] } = useQuery<Milestone[]>({
queryKey: ["milestones", baby?.id],
queryFn: () =>
fetch(`/api/milestones?babyId=${baby!.id}`).then((r) => r.json()),
enabled: !!baby?.id,
});
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!baby) return;
setSaving(true);
await fetch("/api/milestones", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId: baby.id,
title: form.title,
date: new Date(form.date).toISOString(),
notes: form.notes || null,
icon: form.icon || null,
}),
});
qc.invalidateQueries({ queryKey: ["milestones", baby.id] });
setSaving(false);
setShowForm(false);
setForm(emptyForm);
}
function startEdit(m: Milestone) {
setShowForm(false);
setEditingId(m.id);
setEditForm({
icon: m.icon ?? "",
title: m.title,
date: format(new Date(m.date), "yyyy-MM-dd"),
notes: m.notes ?? "",
});
}
async function handleEditSave(e: React.FormEvent) {
e.preventDefault();
if (!baby || !editingId) return;
setEditSaving(true);
await fetch(`/api/milestones/${editingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: editForm.title,
date: new Date(editForm.date).toISOString(),
notes: editForm.notes || null,
icon: editForm.icon || null,
}),
});
qc.invalidateQueries({ queryKey: ["milestones", baby.id] });
setEditSaving(false);
setEditingId(null);
}
async function handleDelete(id: string) {
if (!baby) return;
if (!confirm("Supprimer cette étape ?")) return;
await fetch(`/api/milestones/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["milestones", baby.id] });
}
const groups = groupByMonth(milestones);
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Étapes clés
</h1>
{baby && (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
{baby.name}
</p>
)}
</div>
{!isReadonly && (
<button
onClick={() => {
setShowForm((v) => !v);
setForm(emptyForm);
setEditingId(null);
}}
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" />
<span className="hidden sm:inline">Ajouter une étape</span>
<span className="sm:hidden">Ajouter</span>
</button>
)}
</div>
{/* Add form */}
{showForm && !isReadonly && (
<div className="mb-6">
<MilestoneFormCard
form={form}
setForm={setForm}
onSubmit={handleSave}
onCancel={() => setShowForm(false)}
saving={saving}
heading="Nouvelle étape"
/>
</div>
)}
{/* Empty state */}
{milestones.length === 0 && !showForm && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-16 h-16 bg-indigo-50 dark:bg-indigo-950 rounded-2xl flex items-center justify-center mb-4">
<Star className="w-8 h-8 text-indigo-300 dark:text-indigo-600" />
</div>
<p className="text-base font-medium text-slate-700 dark:text-slate-300 mb-1">
Aucune étape enregistrée
</p>
<p className="text-sm text-slate-400 dark:text-slate-500 mb-4 max-w-xs">
Capturez les grands moments premier sourire, premiers pas,
premier mot
</p>
{!isReadonly && (
<button
onClick={() => setShowForm(true)}
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:underline"
>
Ajouter la première étape
</button>
)}
</div>
)}
{/* Milestone list grouped by month */}
{milestones.length > 0 && (
<div className="space-y-8">
{groups.map(({ key, items }) => (
<div key={key}>
<div className="flex items-center gap-3 mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 capitalize">
{key}
</span>
<div className="flex-1 h-px bg-slate-100 dark:bg-slate-800" />
</div>
<div className="space-y-3">
{items.map((m) =>
editingId === m.id ? (
<MilestoneFormCard
key={m.id}
form={editForm}
setForm={setEditForm}
onSubmit={handleEditSave}
onCancel={() => setEditingId(null)}
saving={editSaving}
heading="Modifier l'étape"
/>
) : (
<div
key={m.id}
className="group flex items-start gap-4 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl px-4 py-4 hover:border-indigo-200 dark:hover:border-indigo-800 transition-colors"
>
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-indigo-50 dark:bg-indigo-950 flex items-center justify-center">
<MilestoneIcon icon={m.icon} />
</div>
<div className="flex-1 min-w-0 pt-0.5">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{m.title}
</p>
<p className="text-xs text-indigo-500 dark:text-indigo-400 font-medium mt-0.5 capitalize">
{format(new Date(m.date), "EEEE d MMMM yyyy", {
locale: fr,
})}
</p>
{m.notes && (
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400 leading-relaxed">
{m.notes}
</p>
)}
</div>
{!isReadonly && (
<div className="flex gap-1 flex-shrink-0 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity pt-0.5">
<button
onClick={() => startEdit(m)}
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-4 h-4" />
</button>
<button
onClick={() => handleDelete(m.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-4 h-4" />
</button>
</div>
)}
</div>
)
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
+354
View File
@@ -0,0 +1,354 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useBaby } from "@/contexts/baby-context";
import {
format,
subDays,
addDays,
isToday,
isYesterday,
isTomorrow,
isFuture,
startOfDay,
parseISO,
} from "date-fns";
import { fr } from "date-fns/locale";
import { ChevronLeft, ChevronRight, CalendarDays, Save, Trash2 } from "lucide-react";
interface JournalNote {
id: string;
babyId: string;
userId: string;
date: string;
content: string;
createdAt: string;
updatedAt: string;
user: { id: string; name: string };
}
function formatDateLabel(date: Date): string {
if (isToday(date)) return "Aujourd'hui";
if (isYesterday(date)) return "Hier";
if (isTomorrow(date)) return "Demain";
return format(date, "d MMMM yyyy", { locale: fr });
}
function toDateString(date: Date): string {
return format(date, "yyyy-MM-dd");
}
export default function NotesPage() {
const { data: session } = useSession();
const { selectedBaby } = useBaby();
const qc = useQueryClient();
const [currentDate, setCurrentDate] = useState<Date>(startOfDay(new Date()));
const [draftContent, setDraftContent] = useState<string>("");
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const babyId = selectedBaby?.id;
const dateStr = toDateString(currentDate);
const userRole = (session?.user as { role?: string } | undefined)?.role;
const isReadonly = userRole === "READONLY";
// Query for current day note
const { data: dayNotes, isLoading: isDayLoading } = useQuery<JournalNote[]>({
queryKey: ["journal", babyId, dateStr],
queryFn: () =>
fetch(`/api/journal?babyId=${babyId}&date=${dateStr}`).then((r) => r.json()),
enabled: !!babyId,
});
const currentNote = dayNotes?.[0] ?? null;
// Query for recent notes (last 10 days that have notes)
const { data: recentNotes } = useQuery<JournalNote[]>({
queryKey: ["journal-recent", babyId],
queryFn: () =>
fetch(`/api/journal?babyId=${babyId}`).then((r) => r.json()),
enabled: !!babyId,
staleTime: 30_000,
});
// Build list of last 10 unique days with notes, excluding current date
const recentDays: { dateStr: string; note: JournalNote }[] = (() => {
if (!recentNotes) return [];
const seen = new Set<string>();
const result: { dateStr: string; note: JournalNote }[] = [];
for (const note of recentNotes) {
const ds = toDateString(new Date(note.date));
if (ds === dateStr) continue;
if (!seen.has(ds)) {
seen.add(ds);
result.push({ dateStr: ds, note });
}
if (result.length >= 10) break;
}
return result;
})();
// Sync draft content when note or date changes
useEffect(() => {
if (isDayLoading) return;
setDraftContent(currentNote?.content ?? "");
setSaveError(null);
}, [currentNote?.id, dateStr, isDayLoading]);
function goToPrevDay() {
setCurrentDate((d) => startOfDay(subDays(d, 1)));
}
function goToNextDay() {
const next = startOfDay(addDays(currentDate, 1));
if (isFuture(next)) return;
setCurrentDate(next);
}
function goToToday() {
setCurrentDate(startOfDay(new Date()));
}
const canGoNext = !isToday(currentDate);
async function handleSave() {
if (!babyId || isReadonly) return;
const trimmed = draftContent.trim();
setIsSaving(true);
setSaveError(null);
try {
if (currentNote) {
const res = await fetch(`/api/journal/${currentNote.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: trimmed }),
});
if (!res.ok) throw new Error("Erreur lors de la sauvegarde");
} else {
if (!trimmed) return;
const res = await fetch("/api/journal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ babyId, date: dateStr, content: trimmed }),
});
if (!res.ok) throw new Error("Erreur lors de la sauvegarde");
}
qc.invalidateQueries({ queryKey: ["journal", babyId, dateStr] });
qc.invalidateQueries({ queryKey: ["journal-recent", babyId] });
} catch (e) {
setSaveError(e instanceof Error ? e.message : "Erreur inconnue");
} finally {
setIsSaving(false);
}
}
async function handleBlur() {
if (isReadonly) return;
const trimmed = draftContent.trim();
// Only auto-save if content changed from what's stored
const stored = currentNote?.content ?? "";
if (trimmed === stored) return;
// Don't auto-save empty content if there's no existing note
if (!trimmed && !currentNote) return;
await handleSave();
}
async function handleDelete() {
if (!currentNote || isReadonly) return;
setIsDeleting(true);
try {
await fetch(`/api/journal/${currentNote.id}`, { method: "DELETE" });
setDraftContent("");
qc.invalidateQueries({ queryKey: ["journal", babyId, dateStr] });
qc.invalidateQueries({ queryKey: ["journal-recent", babyId] });
} finally {
setIsDeleting(false);
}
}
function jumpToDate(ds: string) {
setCurrentDate(startOfDay(parseISO(ds)));
}
const hasUnsavedChanges = draftContent.trim() !== (currentNote?.content ?? "").trim();
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Journal
</h1>
{selectedBaby && (
<p className="text-sm text-slate-400 dark:text-slate-500 mt-1">
Notes quotidiennes pour {selectedBaby.name}
</p>
)}
</div>
{!selectedBaby ? (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
Sélectionnez un bébé pour voir le journal.
</div>
) : (
<>
{/* Date Navigation */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
<div className="flex items-center justify-between gap-2">
<button
onClick={goToPrevDay}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400 transition-colors"
aria-label="Jour précédent"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="flex-1 flex flex-col items-center gap-1">
<span className="text-lg font-semibold text-slate-900 dark:text-slate-100 capitalize">
{formatDateLabel(currentDate)}
</span>
{!isToday(currentDate) && (
<span className="text-xs text-slate-400 dark:text-slate-500">
{format(currentDate, "EEEE d MMMM yyyy", { locale: fr })}
</span>
)}
</div>
<div className="flex items-center gap-1">
{!isToday(currentDate) && (
<button
onClick={goToToday}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-indigo-500 dark:text-indigo-400 transition-colors"
aria-label="Aujourd'hui"
title="Aujourd'hui"
>
<CalendarDays className="w-5 h-5" />
</button>
)}
<button
onClick={goToNextDay}
disabled={!canGoNext}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
aria-label="Jour suivant"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
{/* Note Editor / Viewer */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-6">
{isDayLoading ? (
<div className="h-32 flex items-center justify-center">
<div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div>
) : isReadonly ? (
<div className="min-h-[8rem]">
{currentNote ? (
<p className="text-slate-700 dark:text-slate-300 whitespace-pre-wrap leading-relaxed">
{currentNote.content}
</p>
) : (
<p className="text-slate-400 dark:text-slate-500 italic">
Aucune note pour ce jour.
</p>
)}
</div>
) : (
<>
<textarea
ref={textareaRef}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
onBlur={handleBlur}
placeholder="Notes du jour..."
rows={6}
className="w-full resize-none bg-transparent text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 text-sm leading-relaxed focus:outline-none"
/>
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2">
{currentNote && (
<button
onClick={handleDelete}
disabled={isDeleting}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg transition-colors disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
Supprimer
</button>
)}
</div>
<div className="flex items-center gap-2">
{saveError && (
<span className="text-xs text-red-500">{saveError}</span>
)}
<button
onClick={handleSave}
disabled={isSaving || !hasUnsavedChanges || (!draftContent.trim() && !currentNote)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
<Save className="w-3.5 h-3.5" />
{isSaving ? "Sauvegarde…" : "Sauvegarder"}
</button>
</div>
</div>
{currentNote && (
<p className="text-xs text-slate-400 dark:text-slate-500 mt-2">
Dernière modification le{" "}
{format(new Date(currentNote.updatedAt), "d MMM yyyy à HH:mm", { locale: fr })}
{currentNote.user?.name ? ` par ${currentNote.user.name}` : ""}
</p>
)}
</>
)}
</div>
{/* Recent Notes */}
{recentDays.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">
Notes récentes
</h2>
<div className="flex flex-col gap-3">
{recentDays.map(({ dateStr: ds, note }) => {
const d = parseISO(ds);
return (
<button
key={ds}
onClick={() => jumpToDate(ds)}
className="text-left bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 hover:border-indigo-300 dark:hover:border-indigo-600 hover:shadow-sm transition-all group"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-indigo-600 dark:text-indigo-400 mb-1 capitalize">
{formatDateLabel(d)}
{!isYesterday(d) && !isToday(d) && (
<span className="text-slate-400 dark:text-slate-500 font-normal ml-1">
· {format(d, "d MMMM", { locale: fr })}
</span>
)}
</p>
<p className="text-sm text-slate-700 dark:text-slate-300 line-clamp-2 leading-relaxed">
{note.content}
</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-400 flex-shrink-0 mt-0.5 transition-colors" />
</div>
</button>
);
})}
</div>
</div>
)}
</>
)}
</div>
);
}
+493
View File
@@ -0,0 +1,493 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { signOut } from "next-auth/react";
import { useState, useEffect } from "react";
import { useBaby } from "@/contexts/baby-context";
import { getThresholds, saveThresholds, requestPermission } from "@/lib/notifications";
import { Copy, Check, Download, LogOut, Bell, Baby, Users, ChevronRight, FileText, Plus } from "lucide-react";
interface Family {
id: string;
name: string;
inviteCode: string;
babies: { id: string; name: string; birthDate: string; gender?: string }[];
users: { id: string; name: string; email: string; role: string }[];
}
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 dark:text-slate-100";
export default function SettingsPage() {
const qc = useQueryClient();
const { selectedBaby, setSelectedBabyId } = useBaby();
const [copiedRole, setCopiedRole] = useState<string | null>(null);
const [exportLoading, setExportLoading] = useState(false);
const [pdfLoading, setPdfLoading] = useState(false);
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [editingBaby, setEditingBaby] = useState(false);
const [babyForm, setBabyForm] = useState({ name: "", birthDate: "", gender: "" });
const [savingBaby, setSavingBaby] = useState(false);
const [thresholds, setThresholdsState] = useState(getThresholds);
const [notifPermission, setNotifPermission] = useState<boolean>(
typeof window !== "undefined" && Notification.permission === "granted"
);
const [pushoverKey, setPushoverKey] = useState("");
const [savingPushover, setSavingPushover] = useState(false);
const [pushoverMsg, setPushoverMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
useEffect(() => {
fetch("/api/user/pushover")
.then((r) => r.json())
.then((data) => { if (data.pushoverUserKey) setPushoverKey(data.pushoverUserKey); });
}, []);
const { data: family } = useQuery<Family>({
queryKey: ["family"],
queryFn: () => fetch("/api/baby").then((r) => r.json()),
});
const origin = typeof window !== "undefined" ? window.location.origin : "";
async function copyInvite(role: string) {
if (!family) return;
const url = `${origin}/auth/invite/${family.inviteCode}?role=${role}`;
await navigator.clipboard.writeText(url);
setCopiedRole(role);
setTimeout(() => setCopiedRole(null), 2000);
}
async function handlePdfExport() {
if (!selectedBaby) return;
setPdfLoading(true);
const { default: jsPDF } = await import("jspdf");
const params = new URLSearchParams({ babyId: selectedBaby.id, limit: "500" });
if (dateFrom) params.set("dateFrom", new Date(dateFrom).toISOString());
if (dateTo) params.set("dateTo", new Date(dateTo).toISOString());
const res = await fetch(`/api/export?${params}&format=json`);
const events: Array<{ type: string; startedAt: string; endedAt?: string; notes?: string }> = await res.json().catch(() => []);
const doc = new jsPDF({ unit: "mm", format: "a4" });
const pageW = doc.internal.pageSize.getWidth();
let y = 20;
doc.setFontSize(20);
doc.setFont("helvetica", "bold");
doc.text("Grow — Rapport", 20, y);
y += 8;
doc.setFontSize(11);
doc.setFont("helvetica", "normal");
doc.setTextColor(100, 116, 139);
const rangeLabel = dateFrom || dateTo
? `${dateFrom ? new Date(dateFrom).toLocaleDateString("fr-FR") : "début"}${dateTo ? new Date(dateTo).toLocaleDateString("fr-FR") : "aujourd'hui"}`
: "Toutes les données";
doc.text(`${selectedBaby.name} · ${rangeLabel}`, 20, y);
y += 12;
doc.setDrawColor(226, 232, 240);
doc.line(20, y, pageW - 20, y);
y += 8;
doc.setFontSize(9);
doc.setTextColor(15, 23, 42);
doc.setFont("helvetica", "bold");
doc.text("Date & Heure", 20, y);
doc.text("Type", 75, y);
doc.text("Détails", 115, y);
y += 5;
doc.setDrawColor(226, 232, 240);
doc.line(20, y, pageW - 20, y);
y += 4;
doc.setFont("helvetica", "normal");
const TYPE_LABELS: Record<string, string> = {
BREASTFEED: "Allaitement", BOTTLE: "Biberon", DIAPER_WET: "Couche urine",
DIAPER_STOOL: "Couche selles", NAP: "Sieste", NIGHT_SLEEP: "Nuit",
MEDICATION: "Traitement", TEMPERATURE: "Température",
};
const rows = Array.isArray(events) ? events : [];
for (const ev of rows) {
if (y > 275) { doc.addPage(); y = 20; }
const dt = new Date(ev.startedAt).toLocaleString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
const label = TYPE_LABELS[ev.type] ?? ev.type;
const detail = ev.notes ?? "";
doc.setTextColor(100, 116, 139);
doc.text(dt, 20, y);
doc.setTextColor(15, 23, 42);
doc.text(label, 75, y);
if (detail) doc.text(detail.slice(0, 40), 115, y);
y += 5.5;
}
doc.save(`grow-${selectedBaby.name}-rapport.pdf`);
setPdfLoading(false);
}
async function handleExport() {
if (!selectedBaby) return;
setExportLoading(true);
const params = new URLSearchParams({ babyId: selectedBaby.id });
if (dateFrom) params.set("dateFrom", new Date(dateFrom).toISOString());
if (dateTo) params.set("dateTo", new Date(dateTo).toISOString());
const res = await fetch(`/api/export?${params}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `grow-export-${selectedBaby.name}.csv`;
a.click();
URL.revokeObjectURL(url);
setExportLoading(false);
}
function startEditBaby() {
if (!selectedBaby) return;
setBabyForm({
name: selectedBaby.name,
birthDate: selectedBaby.birthDate.slice(0, 10),
gender: selectedBaby.gender ?? "",
});
setEditingBaby(true);
}
async function saveBaby(e: React.FormEvent) {
e.preventDefault();
if (!selectedBaby) return;
setSavingBaby(true);
await fetch(`/api/baby/${selectedBaby.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(babyForm),
});
qc.invalidateQueries({ queryKey: ["family"] });
setSavingBaby(false);
setEditingBaby(false);
}
async function enableNotifications() {
const granted = await requestPermission();
setNotifPermission(granted);
if (granted) {
const updated = { ...thresholds, enabled: true };
setThresholdsState(updated);
saveThresholds(updated);
}
}
function updateThreshold(hours: number) {
const updated = { ...thresholds, feedAlertHours: hours };
setThresholdsState(updated);
saveThresholds(updated);
}
function toggleNotifications(enabled: boolean) {
const updated = { ...thresholds, enabled };
setThresholdsState(updated);
saveThresholds(updated);
}
async function savePushoverKey() {
setSavingPushover(true);
setPushoverMsg(null);
try {
const res = await fetch("/api/user/pushover", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pushoverUserKey: pushoverKey }),
});
setPushoverMsg(res.ok ? { type: "ok", text: "Clé sauvegardée." } : { type: "err", text: "Erreur lors de la sauvegarde." });
} catch {
setPushoverMsg({ type: "err", text: "Erreur réseau." });
}
setSavingPushover(false);
}
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
<div className="bg-white dark:bg-slate-900 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">
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">{title}</h2>
</div>
{children}
</div>
);
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-6">Réglages</h1>
<div className="space-y-4">
{/* Baby info */}
{selectedBaby && (
<Section title="Bébé">
{editingBaby ? (
<form onSubmit={saveBaby} className="p-4 space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Prénom</label>
<input type="text" value={babyForm.name} onChange={(e) => setBabyForm((f) => ({ ...f, name: e.target.value }))}
required className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Date de naissance</label>
<input type="date" value={babyForm.birthDate} onChange={(e) => setBabyForm((f) => ({ ...f, birthDate: e.target.value }))}
required className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Genre</label>
<select value={babyForm.gender} onChange={(e) => setBabyForm((f) => ({ ...f, gender: e.target.value }))} className={inputCls}>
<option value="">Non précisé</option>
<option value="boy">Garçon</option>
<option value="girl">Fille</option>
</select>
</div>
<div className="flex gap-2">
<button type="button" onClick={() => setEditingBaby(false)} className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-400">Annuler</button>
<button type="submit" disabled={savingBaby} className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium">
{savingBaby ? "..." : "Enregistrer"}
</button>
</div>
</form>
) : (
<button onClick={startEditBaby} className="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-800 transition text-left">
<div className="w-10 h-10 bg-indigo-50 dark:bg-indigo-950 rounded-xl flex items-center justify-center flex-shrink-0">
<Baby className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">{selectedBaby.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{new Date(selectedBaby.birthDate).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" })}
</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-400 dark:text-slate-500" />
</button>
)}
</Section>
)}
{/* Add baby */}
<Section title="Bébés">
<div className="divide-y divide-slate-100 dark:divide-slate-700">
{family?.babies.map((b) => (
<div key={b.id} className="flex items-center gap-3 px-4 py-3">
<div className="w-8 h-8 bg-indigo-50 dark:bg-indigo-950 rounded-full flex items-center justify-center">
<Baby className="w-4 h-4 text-indigo-600 dark:text-indigo-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{b.name}</p>
<p className="text-xs text-slate-400 dark:text-slate-500">
{new Date(b.birthDate).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" })}
</p>
</div>
</div>
))}
<div className="px-4 py-3">
<a
href="/setup"
className="flex items-center gap-2 text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 font-medium transition"
>
<Plus className="w-3.5 h-3.5" />
Ajouter un bébé
</a>
</div>
</div>
</Section>
{/* Family */}
{family && (
<Section title={`Famille · ${family.users.length} membre${family.users.length > 1 ? "s" : ""}`}>
{family.users.map((u) => (
<div key={u.id} className="flex items-center gap-3 px-4 py-3 border-b border-slate-100 dark:border-slate-700 last:border-0">
<div className="w-8 h-8 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center text-xs font-bold text-slate-600 dark:text-slate-300">
{u.name[0].toUpperCase()}
</div>
<div className="flex-1">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{u.name}</p>
<p className="text-xs text-slate-400 dark:text-slate-500">{u.email}</p>
</div>
{u.role && (
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-medium">
{u.role === "PARENT" ? "Parent" : u.role === "READONLY" ? "Lecture" : u.role === "DOCTOR" ? "Médecin" : u.role}
</span>
)}
</div>
))}
<div className="px-4 py-3 border-t border-slate-100 dark:border-slate-700">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-3">Inviter un membre</p>
<div className="space-y-2">
{([
{
role: "PARENT",
label: "Parent",
desc: "Accès complet — enregistrer, modifier, supprimer tous les événements",
color: "border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-950",
textColor: "text-indigo-700 dark:text-indigo-300",
},
{
role: "READONLY",
label: "Lecture seule",
desc: "Voir le journal et les courbes, sans pouvoir modifier quoi que ce soit",
color: "border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800",
textColor: "text-slate-700 dark:text-slate-300",
},
{
role: "DOCTOR",
label: "Médecin",
desc: "Voir toutes les données + ajouter des notes et observations cliniques",
color: "border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950",
textColor: "text-emerald-700 dark:text-emerald-300",
},
] as const).map(({ role, label, desc, color, textColor }) => (
<button
key={role}
onClick={() => copyInvite(role)}
className={`w-full flex items-center gap-3 px-3 py-2.5 border rounded-xl transition hover:opacity-90 active:scale-[0.99] text-left ${color}`}
>
<div className="flex-1 min-w-0">
<p className={`text-xs font-semibold ${textColor}`}>{label}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 leading-snug">{desc}</p>
</div>
<div className={`flex-shrink-0 flex items-center gap-1.5 text-xs font-medium ${textColor}`}>
{copiedRole === role
? <><Check className="w-3.5 h-3.5" />Copié</>
: <><Copy className="w-3.5 h-3.5" />Copier</>}
</div>
</button>
))}
</div>
</div>
</Section>
)}
{/* Notifications */}
<Section title="Notifications">
<div className="p-4 space-y-4">
{!notifPermission ? (
<div className="flex items-start gap-3">
<Bell className="w-5 h-5 text-slate-400 dark:text-slate-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-slate-700 dark:text-slate-200 font-medium mb-1">Activer les notifications</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-3">Recevez une alerte si bébé n&apos;a pas mangé depuis X heures.</p>
<button onClick={enableNotifications}
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg font-medium hover:bg-indigo-700 transition">
Activer
</button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Alertes activées</p>
<p className="text-xs text-slate-400 dark:text-slate-500">Rappel si pas de repas depuis</p>
</div>
<button
onClick={() => toggleNotifications(!thresholds.enabled)}
className={`w-10 h-5 rounded-full transition relative overflow-hidden ${thresholds.enabled ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-700"}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${thresholds.enabled ? "translate-x-[18px]" : "translate-x-0"}`} />
</button>
</div>
{thresholds.enabled && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Délai (heures)</label>
<select
value={thresholds.feedAlertHours}
onChange={(e) => updateThreshold(parseInt(e.target.value))}
className={inputCls}
>
{[2, 2.5, 3, 3.5, 4, 5].map((h) => (
<option key={h} value={h}>{h}h</option>
))}
</select>
</div>
)}
</div>
)}
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Pushover</p>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Votre clé utilisateur Pushover</label>
<div className="flex gap-2">
<input
type="password"
value={pushoverKey}
onChange={(e) => setPushoverKey(e.target.value)}
placeholder="u••••••••••••••••••••••••••••••"
className={inputCls}
/>
<button
type="button"
onClick={savePushoverKey}
disabled={savingPushover}
className="flex-shrink-0 px-3 py-2 bg-indigo-600 text-white text-sm rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 transition"
>
{savingPushover ? "..." : "Sauvegarder"}
</button>
</div>
<p className="mt-1.5 text-xs text-slate-400 dark:text-slate-500">Obtenez votre clé sur pushover.net</p>
{pushoverMsg && (
<p className={`mt-2 text-xs font-medium ${pushoverMsg.type === "ok" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}`}>
{pushoverMsg.text}
</p>
)}
</div>
</div>
</div>
</Section>
{/* Export */}
{selectedBaby && (
<Section title="Exporter les données">
<div className="p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Depuis</label>
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Jusqu&apos;au</label>
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} className={inputCls} />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleExport}
disabled={exportLoading}
className="flex items-center gap-2 justify-center py-2.5 bg-slate-800 dark:bg-slate-700 text-white text-sm rounded-lg font-medium hover:bg-slate-900 dark:hover:bg-slate-600 disabled:opacity-50 transition"
>
<Download className="w-4 h-4" />
{exportLoading ? "..." : "CSV"}
</button>
<button
onClick={handlePdfExport}
disabled={pdfLoading}
className="flex items-center gap-2 justify-center py-2.5 bg-indigo-600 text-white text-sm rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 transition"
>
<FileText className="w-4 h-4" />
{pdfLoading ? "..." : "PDF"}
</button>
</div>
</div>
</Section>
)}
{/* Sign out */}
<button
onClick={() => signOut({ callbackUrl: "/auth/login" })}
className="flex items-center gap-2 w-full px-4 py-3 border border-red-200 dark:border-red-900 text-red-600 dark:text-red-400 rounded-xl text-sm font-medium hover:bg-red-50 dark:hover:bg-red-950 transition"
>
<LogOut className="w-4 h-4" />
Déconnexion
</button>
<p className="text-center text-xs text-slate-400 dark:text-slate-600">Grow · Suivi nouveau-</p>
</div>
</div>
);
}
+249
View File
@@ -0,0 +1,249 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { Leaf, Baby, Ruler } from "lucide-react";
const inputCls = "w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm";
const labelCls = "block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5";
export default function SetupPage() {
const router = useRouter();
const qc = useQueryClient();
const [step, setStep] = useState<"baby" | "measurements">("baby");
const [babyId, setBabyId] = useState("");
const [birthDate, setBirthDate] = useState("");
const [form, setForm] = useState({ name: "", birthDate: "", gender: "" });
const [measurements, setMeasurements] = useState({ weight: "", height: "", headCirc: "" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleBabySubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/baby", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Erreur");
setLoading(false);
return;
}
const baby = await res.json();
setBabyId(baby.id);
setBirthDate(form.birthDate);
setLoading(false);
setStep("measurements");
}
async function handleMeasurementsSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const hasAny = measurements.weight || measurements.height || measurements.headCirc;
if (hasAny) {
await fetch("/api/growth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId,
date: birthDate,
weight: measurements.weight ? parseFloat(measurements.weight) * 1000 : undefined,
height: measurements.height ? parseFloat(measurements.height) : undefined,
headCirc: measurements.headCirc ? parseFloat(measurements.headCirc) : undefined,
notes: "Mesures à la naissance",
}),
});
}
await qc.invalidateQueries({ queryKey: ["family"] });
router.push("/dashboard");
}
function skipMeasurements() {
qc.invalidateQueries({ queryKey: ["family"] });
router.push("/dashboard");
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Leaf className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Bienvenue !</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
{step === "baby" ? "Ajoutons votre bébé pour commencer" : "Mesures initiales (optionnel)"}
</p>
</div>
{/* Step indicator */}
<div className="flex items-center gap-2 justify-center mb-6">
<div className={`w-2 h-2 rounded-full transition-colors ${step === "baby" ? "bg-indigo-600" : "bg-indigo-600"}`} />
<div className={`w-8 h-0.5 transition-colors ${step === "measurements" ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-700"}`} />
<div className={`w-2 h-2 rounded-full transition-colors ${step === "measurements" ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-700"}`} />
</div>
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm p-8">
{step === "baby" && (
<>
<div className="flex items-center gap-3 mb-6">
<div className="w-9 h-9 bg-indigo-50 dark:bg-indigo-950 rounded-xl flex items-center justify-center">
<Baby className="w-5 h-5 text-indigo-600" />
</div>
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">Informations bébé</h2>
</div>
<form onSubmit={handleBabySubmit} className="space-y-4">
<div>
<label className={labelCls}>Prénom de bébé</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
className={inputCls}
placeholder="Léo"
/>
</div>
<div>
<label className={labelCls}>Date de naissance</label>
<input
type="date"
value={form.birthDate}
onChange={(e) => setForm({ ...form, birthDate: e.target.value })}
required
className={inputCls}
/>
</div>
<div>
<label className={labelCls}>Genre</label>
<div className="grid grid-cols-3 gap-2">
{[
{ value: "boy", label: "Garçon" },
{ value: "girl", label: "Fille" },
{ value: "", label: "Non précisé" },
].map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => setForm({ ...form, gender: value })}
className={`py-2.5 px-3 rounded-xl border text-xs font-medium transition ${
form.gender === value
? "border-indigo-500 bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300"
: "border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-500"
}`}
>
{label}
</button>
))}
</div>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-3 rounded-xl text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "Enregistrement..." : "Continuer"}
</button>
</form>
</>
)}
{step === "measurements" && (
<>
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 bg-indigo-50 dark:bg-indigo-950 rounded-xl flex items-center justify-center">
<Ruler className="w-5 h-5 text-indigo-600" />
</div>
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">Mesures à la naissance</h2>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-6">
Ces données seront enregistrées comme premier point sur la courbe de croissance.
</p>
<form onSubmit={handleMeasurementsSubmit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelCls}>Poids (kg)</label>
<input
type="number"
step="0.001"
min="0"
max="8"
value={measurements.weight}
onChange={(e) => setMeasurements({ ...measurements, weight: e.target.value })}
className={inputCls}
placeholder="3,400"
/>
</div>
<div>
<label className={labelCls}>Taille (cm)</label>
<input
type="number"
step="0.1"
min="0"
max="70"
value={measurements.height}
onChange={(e) => setMeasurements({ ...measurements, height: e.target.value })}
className={inputCls}
placeholder="50"
/>
</div>
<div>
<label className={labelCls}>PC (cm)</label>
<input
type="number"
step="0.1"
min="0"
max="50"
value={measurements.headCirc}
onChange={(e) => setMeasurements({ ...measurements, headCirc: e.target.value })}
className={inputCls}
placeholder="34"
/>
</div>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500">
PC = périmètre crânien. Tous les champs sont optionnels.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={skipMeasurements}
className="flex-1 py-3 border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 rounded-xl text-sm font-medium hover:bg-slate-50 dark:hover:bg-slate-700 transition"
>
Passer
</button>
<button
type="submit"
disabled={loading}
className="flex-1 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "..." : "Commencer le suivi"}
</button>
</div>
</form>
</>
)}
</div>
</div>
</div>
);
}
+303
View File
@@ -0,0 +1,303 @@
"use client";
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
LineChart, Line, CartesianGrid,
} from "recharts";
import { useBaby } from "@/contexts/baby-context";
import { format, subDays, startOfDay, endOfDay } from "date-fns";
import { fr } from "date-fns/locale";
import { EventType } from "@/lib/event-config";
interface Event {
id: string;
type: EventType;
startedAt: string;
endedAt?: string;
}
function buildWeeklyData(events: Event[], days: number) {
const labelFormat = days <= 7 ? "EEE" : "d/M";
return Array.from({ length: days }, (_, i) => {
const d = subDays(new Date(), days - 1 - i);
const start = startOfDay(d).getTime();
const end = endOfDay(d).getTime();
const dayEvents = events.filter((e) => {
const t = new Date(e.startedAt).getTime();
return t >= start && t <= end;
});
const feeds = dayEvents.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
const diapers = dayEvents.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL").length;
const sleepMins = dayEvents
.filter((e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt)
.reduce((acc, e) => acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000, 0);
return { label: format(d, labelFormat, { locale: fr }), feeds, diapers, sleep: Math.round(sleepMins / 60 * 10) / 10 };
});
}
function formatDuration(totalMins: number): string {
const h = Math.floor(totalMins / 60);
const m = Math.round(totalMins % 60);
return `${h}h ${m.toString().padStart(2, "0")}min`;
}
interface SleepStats {
totalHours: number;
avgPerDay: number;
longestStretchMins: number;
}
function computeSleepStats(events: Event[], days: number): SleepStats {
const sleepEvents = events.filter(
(e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt
);
const durations = sleepEvents.map(
(e) => (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000
);
const totalMins = durations.reduce((acc, d) => acc + d, 0);
const longestStretchMins = durations.length > 0 ? Math.max(...durations) : 0;
return {
totalHours: Math.round((totalMins / 60) * 10) / 10,
avgPerDay: Math.round((totalMins / 60 / days) * 10) / 10,
longestStretchMins: Math.round(longestStretchMins),
};
}
interface FeedingStats {
breastfeedMinutes: number;
bottleCount: number;
}
function computeFeedingStats(events: Event[]): FeedingStats {
const breastfeedEvents = events.filter(
(e) => e.type === "BREASTFEED" && e.endedAt
);
const breastfeedMinutes = Math.round(
breastfeedEvents.reduce(
(acc, e) =>
acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000,
0
)
);
const bottleCount = events.filter((e) => e.type === "BOTTLE").length;
return { breastfeedMinutes, bottleCount };
}
function computeFeedInterval(events: Event[]): string {
const feeds = events
.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE")
.map((e) => new Date(e.startedAt).getTime())
.sort((a, b) => a - b);
if (feeds.length < 2) return "—";
const intervals = feeds.slice(1).map((t, i) => t - feeds[i]);
const avgMs = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const hours = Math.floor(avgMs / 3600000);
const mins = Math.round((avgMs % 3600000) / 60000);
return `${hours}h${mins.toString().padStart(2, "0")}`;
}
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;
}
const DAY_OPTIONS = [7, 14, 30, 90] as const;
type DaysOption = typeof DAY_OPTIONS[number];
function daysLabel(days: DaysOption): string {
if (days === 7) return "7j";
if (days === 14) return "14j";
if (days === 30) return "30j";
return "90j";
}
function periodSubtitle(days: DaysOption): string {
if (days === 7) return "7 derniers jours";
if (days === 14) return "14 derniers jours";
if (days === 30) return "30 derniers jours";
return "90 derniers jours";
}
export default function StatsPage() {
const { selectedBaby: baby } = useBaby();
const isDark = useDarkMode();
const [days, setDays] = useState<DaysOption>(7);
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 cursorFill = isDark ? "rgba(255,255,255,0.04)" : "#f8fafc";
const { data: eventsData } = useQuery({
queryKey: ["events", baby?.id, "stats", days],
queryFn: () =>
fetch(`/api/events?babyId=${baby!.id}&limit=500&dateFrom=${subDays(new Date(), days - 1).toISOString()}`).then((r) => r.json()),
enabled: !!baby?.id,
});
const events: Event[] = eventsData?.events ?? [];
const weeklyData = buildWeeklyData(events, days);
const feedInterval = computeFeedInterval(events);
const sleepStats = computeSleepStats(events, days);
const feedingStats = computeFeedingStats(events);
const today = events.filter((e) => new Date(e.startedAt).toDateString() === new Date().toDateString());
const todayFeeds = today.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
const todayDiapers = today.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL").length;
const todaySleepMins = today
.filter((e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt)
.reduce((acc, e) => acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000, 0);
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Statistiques</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{periodSubtitle(days)}</p>
</div>
<div className="flex gap-1.5">
{DAY_OPTIONS.map((opt) => (
<button
key={opt}
onClick={() => setDays(opt)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition ${
days === opt
? "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"
}`}
>
{daysLabel(opt)}
</button>
))}
</div>
</div>
{/* Today summary */}
<div className="grid grid-cols-3 gap-3 mb-6">
{[
{ label: "Repas", value: todayFeeds, color: "text-rose-600", bg: "bg-rose-50 dark:bg-rose-950", border: "border-rose-100 dark:border-rose-900" },
{ label: "Couches", value: todayDiapers, color: "text-amber-600", bg: "bg-amber-50 dark:bg-amber-950", border: "border-amber-100 dark:border-amber-900" },
{ label: "Sommeil", value: `${Math.round(todaySleepMins / 60 * 10) / 10}h`, color: "text-violet-600", bg: "bg-violet-50 dark:bg-violet-950", border: "border-violet-100 dark:border-violet-900" },
].map(({ label, value, color, bg, border }) => (
<div key={label} className={`${bg} border ${border} rounded-xl p-4 text-center`}>
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">{label} · aujourd&apos;hui</div>
</div>
))}
</div>
{/* Feed interval */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Intervalle moyen entre les repas</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{periodSubtitle(days)}</p>
</div>
<span className="text-2xl font-bold text-indigo-600 font-mono">{feedInterval}</span>
</div>
{/* Sleep summary */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-3">Résumé du sommeil</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">{periodSubtitle(days)}</p>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Total</p>
<p className="text-lg font-bold text-violet-600 leading-tight">
{formatDuration(sleepStats.totalHours * 60)}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Moy. / jour</p>
<p className="text-lg font-bold text-violet-600 leading-tight">
{formatDuration(sleepStats.avgPerDay * 60)}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Plus longue nuit</p>
<p className="text-lg font-bold text-violet-600 leading-tight">
{sleepStats.longestStretchMins > 0 ? formatDuration(sleepStats.longestStretchMins) : "—"}
</p>
</div>
</div>
</div>
{/* Feeding summary */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-3">Résumé des repas</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">{periodSubtitle(days)}</p>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Durée allaitement</p>
<p className="text-lg font-bold text-rose-600 leading-tight">
{feedingStats.breastfeedMinutes > 0 ? formatDuration(feedingStats.breastfeedMinutes) : "—"}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Biberons</p>
<p className="text-lg font-bold text-rose-600 leading-tight">{feedingStats.bottleCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Intervalle moy.</p>
<p className="text-lg font-bold text-rose-600 leading-tight font-mono">{feedInterval}</p>
</div>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
{/* Feeds */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Repas par jour</h3>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={weeklyData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<Tooltip formatter={(val) => [val, "Repas"]} contentStyle={tooltipStyle} cursor={{ fill: cursorFill }} />
<Bar dataKey="feeds" fill="#4f46e5" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Diapers */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Couches par jour</h3>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={weeklyData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<Tooltip formatter={(val) => [val, "Couches"]} contentStyle={tooltipStyle} cursor={{ fill: cursorFill }} />
<Bar dataKey="diapers" fill="#f59e0b" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Sleep */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Sommeil par jour (heures)</h3>
<ResponsiveContainer width="100%" height={150}>
<LineChart data={weeklyData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<Tooltip formatter={(val) => [`${val}h`, "Sommeil"]} contentStyle={tooltipStyle} />
<Line dataKey="sleep" stroke="#7c3aed" strokeWidth={2} dot={{ r: 4, fill: "#7c3aed", strokeWidth: 2, stroke: "#fff" }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
+257
View File
@@ -0,0 +1,257 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EVENT_CONFIG, EventType, formatDuration } from "@/lib/event-config";
import { EventIcon } from "@/components/event-icon";
import { useBaby } from "@/contexts/baby-context";
import { format, isToday, isYesterday } from "date-fns";
import { fr } from "date-fns/locale";
import { Trash2, StickyNote } from "lucide-react";
interface Event {
id: string;
type: EventType;
startedAt: string;
endedAt?: string;
notes?: string;
metadata?: Record<string, unknown>;
user: { id: string; name: string };
}
const PAGE_SIZE = 50;
function groupByDay(events: Event[]): Record<string, Event[]> {
const groups: Record<string, Event[]> = {};
for (const ev of events) {
const key = format(new Date(ev.startedAt), "yyyy-MM-dd");
if (!groups[key]) groups[key] = [];
groups[key].push(ev);
}
return groups;
}
function dayLabel(key: string): string {
const d = new Date(key);
if (isToday(d)) return "Aujourd'hui";
if (isYesterday(d)) return "Hier";
return format(d, "EEEE d MMMM", { locale: fr });
}
function getEventSummary(event: Event): string {
const meta = event.metadata ?? {};
if (event.type === "BREASTFEED") {
const side = meta.breastSide === "left" ? "gauche" : meta.breastSide === "right" ? "droite" : "les deux";
if (event.endedAt) return `${formatDuration(new Date(event.startedAt), new Date(event.endedAt))} · ${side}`;
return side;
}
if (event.type === "BOTTLE") return [meta.volume ? `${meta.volume} mL` : "", meta.bottleType === "maternal" ? "maternel" : "artificiel"].filter(Boolean).join(" · ");
if (event.type === "DIAPER_STOOL") return String(meta.color ?? "");
if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
if (event.type === "MEDICATION") return String(meta.name ?? "");
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
}
return "";
}
export default function TimelinePage() {
const qc = useQueryClient();
const { selectedBaby } = useBaby();
const [expandedId, setExpandedId] = useState<string | null>(null);
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null);
const [filterType, setFilterType] = useState<EventType | "">("");
const [page, setPage] = useState(0);
const { data: eventsData, isLoading } = useQuery({
queryKey: ["events", selectedBaby?.id, filterType, page],
queryFn: () =>
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=${PAGE_SIZE}&offset=${page * PAGE_SIZE}${filterType ? `&type=${filterType}` : ""}`).then(
(r) => r.json()
),
enabled: !!selectedBaby?.id,
refetchInterval: 10_000,
});
const events: Event[] = eventsData?.events ?? [];
const total: number = eventsData?.total ?? 0;
const hasMore = (page + 1) * PAGE_SIZE < total;
const grouped = groupByDay(events);
const days = Object.keys(grouped).sort((a, b) => b.localeCompare(a));
async function deleteEvent(id: string) {
await fetch(`/api/events/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["events"] });
}
async function saveNotes(id: string, notes: string) {
await fetch(`/api/events/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes }),
});
qc.invalidateQueries({ queryKey: ["events"] });
setEditingNotes(null);
}
function handleFilterChange(t: EventType | "") {
setFilterType(t);
setPage(0);
}
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-4">
Journal{selectedBaby ? `${selectedBaby.name}` : ""}
</h1>
{/* Filter chips */}
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
<button
onClick={() => handleFilterChange("")}
className={`flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-medium transition border ${
filterType === "" ? "bg-indigo-600 text-white border-indigo-600" : "bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500"
}`}
>
Tout {total > 0 && <span className="opacity-70 ml-0.5">({total})</span>}
</button>
{(Object.keys(EVENT_CONFIG) as EventType[]).map((t) => (
<button
key={t}
onClick={() => handleFilterChange(filterType === t ? "" : t)}
className={`flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition border ${
filterType === t ? "bg-indigo-600 text-white border-indigo-600" : "bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500"
}`}
>
<EventIcon name={EVENT_CONFIG[t].icon} className={`w-3 h-3 ${filterType === t ? "text-white" : EVENT_CONFIG[t].colorClass}`} />
{EVENT_CONFIG[t].label}
</button>
))}
</div>
</div>
{isLoading && (
<div className="flex justify-center py-16">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
)}
<div className="space-y-6">
{days.map((day) => (
<div key={day}>
<div className="flex items-center gap-3 mb-3">
<span className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide capitalize">{dayLabel(day)}</span>
<div className="flex-1 h-px bg-slate-200 dark:bg-slate-700" />
<span className="text-xs text-slate-400 dark:text-slate-500">{grouped[day].length}</span>
</div>
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{grouped[day].map((ev) => {
const cfg = EVENT_CONFIG[ev.type];
const isExpanded = expandedId === ev.id;
const summary = getEventSummary(ev);
return (
<div key={ev.id}>
<button
className="w-full text-left flex items-center gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
onClick={() => setExpandedId(isExpanded ? null : ev.id)}
>
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0`}>
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</span>
{summary && <span className="text-xs text-slate-400 dark:text-slate-500 ml-2">{summary}</span>}
{ev.notes && <p className="text-xs text-slate-400 dark:text-slate-500 truncate mt-0.5 italic">{ev.notes}</p>}
</div>
<span className="text-xs text-slate-400 dark:text-slate-500 flex-shrink-0 font-mono">
{format(new Date(ev.startedAt), "HH:mm")}
</span>
</button>
{isExpanded && (
<div className="px-4 pb-4 bg-slate-50 dark:bg-slate-800/60 border-t border-slate-100 dark:border-slate-700 space-y-3 pt-3">
<div className="grid grid-cols-3 gap-4 text-xs">
<div>
<p className="text-slate-400 dark:text-slate-500 mb-0.5">Début</p>
<p className="font-mono font-medium text-slate-700 dark:text-slate-200">{format(new Date(ev.startedAt), "HH:mm")}</p>
</div>
{ev.endedAt && (
<div>
<p className="text-slate-400 dark:text-slate-500 mb-0.5">Fin</p>
<p className="font-mono font-medium text-slate-700 dark:text-slate-200">{format(new Date(ev.endedAt), "HH:mm")}</p>
</div>
)}
<div>
<p className="text-slate-400 dark:text-slate-500 mb-0.5">Noté par</p>
<p className="font-medium text-slate-700 dark:text-slate-200">{ev.user.name}</p>
</div>
</div>
{editingNotes?.id === ev.id ? (
<div className="space-y-2">
<textarea
value={editingNotes.notes}
onChange={(e) => setEditingNotes({ id: ev.id, notes: e.target.value })}
rows={2}
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 resize-none bg-white dark:bg-slate-700 dark:text-slate-100"
placeholder="Observations..."
autoFocus
/>
<div className="flex gap-2">
<button onClick={() => saveNotes(ev.id, editingNotes.notes)}
className="flex-1 py-1.5 bg-indigo-600 text-white text-xs rounded-lg font-medium">
Sauvegarder
</button>
<button onClick={() => setEditingNotes(null)}
className="py-1.5 px-3 border border-slate-200 dark:border-slate-600 text-xs rounded-lg text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-700">
Annuler
</button>
</div>
</div>
) : (
<button
onClick={() => setEditingNotes({ id: ev.id, notes: ev.notes ?? "" })}
className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition"
>
<StickyNote className="w-3.5 h-3.5" />
<span>{ev.notes ? ev.notes : "Ajouter une note..."}</span>
</button>
)}
<button
onClick={() => { if (confirm("Supprimer cet événement ?")) deleteEvent(ev.id); }}
className="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-600 transition"
>
<Trash2 className="w-3.5 h-3.5" />
Supprimer
</button>
</div>
)}
</div>
);
})}
</div>
</div>
))}
{hasMore && (
<button
onClick={() => setPage((p) => p + 1)}
className="w-full py-2.5 border border-slate-200 dark:border-slate-700 rounded-xl text-sm text-slate-600 dark:text-slate-300 hover:border-indigo-300 dark:hover:border-indigo-500 hover:text-indigo-600 dark:hover:text-indigo-400 bg-white dark:bg-slate-800 transition"
>
Charger plus · {total - (page + 1) * PAGE_SIZE} restants
</button>
)}
{!isLoading && events.length === 0 && (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<p className="text-sm">Aucun événement enregistré</p>
</div>
)}
</div>
</div>
);
}
+518
View File
@@ -0,0 +1,518 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useBaby } from "@/contexts/baby-context";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Plus, Pencil, Trash2, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle } from "lucide-react";
interface Vaccination {
id: string;
babyId: string;
name: string;
date: string | null;
dueDate: string | null;
notes: string | null;
done: boolean;
createdAt: string;
}
const PRESET_VACCINES = [
"BCG",
"Hépatite B",
"DTPolio",
"Pentavalent",
"Pneumocoque",
"Méningocoque C",
"ROR",
"Varicelle",
"Hépatite A",
];
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";
const emptyForm = { name: "", dueDate: "", date: "", notes: "" };
function formatDate(dateStr: string) {
return format(new Date(dateStr), "d MMM yyyy", { locale: fr });
}
function isOverdue(v: Vaccination): boolean {
if (v.done || !v.dueDate) return false;
return new Date(v.dueDate) < new Date(new Date().toDateString());
}
export default function VaccinationsPage() {
const qc = useQueryClient();
const { selectedBaby: baby } = useBaby();
const { data: session } = useSession();
const isReadonly = (session?.user as { role?: string } | undefined)?.role === "READONLY";
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState(emptyForm);
const [editSaving, setEditSaving] = useState(false);
const [showDone, setShowDone] = useState(false);
const [togglingId, setTogglingId] = useState<string | null>(null);
const { data: vaccinations = [] } = useQuery<Vaccination[]>({
queryKey: ["vaccinations", baby?.id],
queryFn: () =>
fetch(`/api/vaccinations?babyId=${baby!.id}`).then((r) => r.json()),
enabled: !!baby?.id,
});
const upcoming = vaccinations.filter((v) => !v.done);
const done = vaccinations.filter((v) => v.done);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!baby) return;
setSaving(true);
await fetch("/api/vaccinations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId: baby.id,
name: form.name,
dueDate: form.dueDate ? new Date(form.dueDate).toISOString() : null,
date: form.date ? new Date(form.date).toISOString() : null,
notes: form.notes || null,
}),
});
qc.invalidateQueries({ queryKey: ["vaccinations", baby.id] });
setSaving(false);
setShowForm(false);
setForm(emptyForm);
}
function startEdit(v: Vaccination) {
setEditingId(v.id);
setEditForm({
name: v.name,
dueDate: v.dueDate ? format(new Date(v.dueDate), "yyyy-MM-dd") : "",
date: v.date ? format(new Date(v.date), "yyyy-MM-dd") : "",
notes: v.notes ?? "",
});
}
async function handleEditSave(e: React.FormEvent, id: string) {
e.preventDefault();
if (!baby) return;
setEditSaving(true);
await fetch(`/api/vaccinations/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: editForm.name,
dueDate: editForm.dueDate ? new Date(editForm.dueDate).toISOString() : null,
date: editForm.date ? new Date(editForm.date).toISOString() : null,
notes: editForm.notes || null,
}),
});
qc.invalidateQueries({ queryKey: ["vaccinations", baby.id] });
setEditSaving(false);
setEditingId(null);
}
async function handleDelete(id: string) {
if (!baby) return;
if (!confirm("Supprimer ce vaccin ?")) return;
await fetch(`/api/vaccinations/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["vaccinations", baby.id] });
}
async function handleToggleDone(v: Vaccination) {
if (!baby || isReadonly) return;
setTogglingId(v.id);
const nowDone = !v.done;
await fetch(`/api/vaccinations/${v.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
done: nowDone,
date: nowDone && !v.date ? new Date().toISOString() : v.date ?? undefined,
}),
});
qc.invalidateQueries({ queryKey: ["vaccinations", baby.id] });
setTogglingId(null);
}
function VaccinCard({ v }: { v: Vaccination }) {
const overdue = isOverdue(v);
const toggling = togglingId === v.id;
if (editingId === v.id) {
return (
<div className="bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<form onSubmit={(e) => handleEditSave(e, v.id)} className="space-y-3">
{/* Preset chips */}
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">
Vaccin
</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{PRESET_VACCINES.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setEditForm((f) => ({ ...f, name: preset }))}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition ${
editForm.name === preset
? "bg-indigo-600 text-white border-indigo-600"
: "border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-indigo-400 dark:hover:border-indigo-500"
}`}
>
{preset}
</button>
))}
</div>
<input
type="text"
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
required
placeholder="Nom du vaccin"
className={inputCls}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date prévue
</label>
<input
type="date"
value={editForm.dueDate}
onChange={(e) => setEditForm((f) => ({ ...f, dueDate: e.target.value }))}
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date d&apos;administration
</label>
<input
type="date"
value={editForm.date}
onChange={(e) => setEditForm((f) => ({ ...f, date: e.target.value }))}
className={inputCls}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Notes <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<textarea
value={editForm.notes}
onChange={(e) => setEditForm((f) => ({ ...f, notes: e.target.value }))}
rows={2}
className={inputCls + " resize-none"}
/>
</div>
<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 hover:bg-slate-100 dark:hover:bg-slate-700 transition"
>
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 disabled:opacity-60"
>
{editSaving ? "..." : "Enregistrer"}
</button>
</div>
</form>
</div>
);
}
return (
<div
className={`bg-white dark:bg-slate-800 border rounded-xl px-4 py-3.5 flex items-start gap-3 group transition ${
overdue
? "border-red-200 dark:border-red-800"
: "border-slate-200 dark:border-slate-700"
}`}
>
{/* Done toggle */}
{!isReadonly ? (
<button
onClick={() => handleToggleDone(v)}
disabled={toggling}
className="mt-0.5 flex-shrink-0 text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 disabled:opacity-50 transition"
aria-label={v.done ? "Marquer non fait" : "Marquer fait"}
>
{v.done ? (
<CheckSquare className="w-5 h-5" />
) : (
<Square className="w-5 h-5" />
)}
</button>
) : (
<span className="mt-0.5 flex-shrink-0 text-slate-300 dark:text-slate-600">
{v.done ? (
<CheckSquare className="w-5 h-5" />
) : (
<Square className="w-5 h-5" />
)}
</span>
)}
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{v.name}
</p>
{overdue && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400">
<AlertCircle className="w-3 h-3" />
En retard
</span>
)}
</div>
{v.dueDate && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Prévu le{" "}
<span className={overdue ? "text-red-500 dark:text-red-400 font-medium" : ""}>
{formatDate(v.dueDate)}
</span>
</p>
)}
{v.date && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-0.5">
Administré le {formatDate(v.date)}
</p>
)}
{v.notes && (
<p className="mt-1.5 text-sm text-slate-500 dark:text-slate-400 leading-relaxed">
{v.notes}
</p>
)}
</div>
{/* Actions */}
{!isReadonly && (
<div className="flex gap-1 flex-shrink-0 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(v)}
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"
aria-label="Modifier"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(v.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"
aria-label="Supprimer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
}
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Vaccinations
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
{baby?.name}
</p>
</div>
{!isReadonly && (
<button
onClick={() => {
setShowForm(!showForm);
setForm(emptyForm);
}}
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" />
Ajouter un vaccin
</button>
)}
</div>
{/* Add form */}
{showForm && !isReadonly && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 mb-6">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">
Nouveau vaccin
</h3>
<form onSubmit={handleSave} className="space-y-4">
{/* Preset chips */}
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">
Vaccin <span className="text-red-400">*</span>
</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{PRESET_VACCINES.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setForm((f) => ({ ...f, name: preset }))}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition ${
form.name === preset
? "bg-indigo-600 text-white border-indigo-600"
: "border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-indigo-400 dark:hover:border-indigo-500"
}`}
>
{preset}
</button>
))}
</div>
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
placeholder="Ou saisir un nom personnalisé"
className={inputCls}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date prévue
</label>
<input
type="date"
value={form.dueDate}
onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))}
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date d&apos;administration
</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
className={inputCls}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Notes <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<textarea
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={3}
placeholder="Réactions, lot, remarques..."
className={inputCls + " resize-none"}
/>
</div>
<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 hover:bg-slate-50 dark:hover:bg-slate-700 transition"
>
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 disabled:opacity-60"
>
{saving ? "Enregistrement..." : "Enregistrer"}
</button>
</div>
</form>
</div>
)}
{/* Empty state */}
{vaccinations.length === 0 && !showForm && (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<p className="text-4xl mb-3">💉</p>
<p className="text-sm">Aucun vaccin enregistré</p>
{!isReadonly && (
<button
onClick={() => setShowForm(true)}
className="mt-3 text-indigo-600 font-medium text-sm hover:underline"
>
Ajouter le premier vaccin
</button>
)}
</div>
)}
{/* À venir section */}
{upcoming.length > 0 && (
<div className="mb-6">
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
À venir
<span className="ml-2 text-slate-300 dark:text-slate-600 font-normal normal-case tracking-normal">
({upcoming.length})
</span>
</h2>
<div className="space-y-2">
{upcoming.map((v) => (
<VaccinCard key={v.id} v={v} />
))}
</div>
</div>
)}
{/* Faits section */}
{done.length > 0 && (
<div>
<button
onClick={() => setShowDone((s) => !s)}
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 hover:text-slate-600 dark:hover:text-slate-300 transition"
>
{showDone ? (
<ChevronUp className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5" />
)}
Faits
<span className="font-normal normal-case tracking-normal text-slate-300 dark:text-slate-600">
({done.length})
</span>
</button>
{showDone && (
<div className="space-y-2">
{done.map((v) => (
<VaccinCard key={v.id} v={v} />
))}
</div>
)}
</div>
)}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const [families, babies, events, growthLogs, journalNotes, milestones] = await Promise.all([
prisma.family.findMany({
include: { users: { select: { id: true, name: true, email: true, role: true, isSuperAdmin: true, createdAt: true } } },
}),
prisma.baby.findMany(),
prisma.event.findMany({ orderBy: { createdAt: "desc" }, take: 10000 }),
prisma.growthLog.findMany(),
prisma.journalNote.findMany(),
prisma.milestone.findMany(),
]);
const payload = {
exportedAt: new Date().toISOString(),
families,
babies,
events,
growthLogs,
journalNotes,
milestones,
};
const filename = `grow-backup-${new Date().toISOString().slice(0, 10)}.json`;
return new NextResponse(JSON.stringify(payload, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
const ALLOWED_KEYS = [
"email_provider",
"mailgun_api_key",
"mailgun_domain",
"mailgun_from",
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_pass",
"smtp_from",
"pushover_app_token",
"allow_registration",
"maintenance_mode",
];
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const configs = await prisma.systemConfig.findMany();
const result: Record<string, string> = {};
for (const c of configs) result[c.key] = c.value;
return NextResponse.json(result);
}
export async function POST(req: Request) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const body: Record<string, string> = await req.json();
await Promise.all(
Object.entries(body)
.filter(([key]) => ALLOWED_KEYS.includes(key))
.map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value },
})
)
);
return NextResponse.json({ ok: true });
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const families = await prisma.family.findMany({
select: {
id: true,
name: true,
inviteCode: true,
createdAt: true,
_count: {
select: {
users: true,
babies: true,
},
},
},
orderBy: { createdAt: "asc" },
});
return NextResponse.json(families);
}
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
function generateInviteCode(): string {
return crypto.randomUUID().replace(/-/g, "").slice(0, 25);
}
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const { id } = await params;
const inviteCode = generateInviteCode();
await prisma.family.update({
where: { id },
data: { inviteCode },
});
return NextResponse.json({ inviteCode });
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const [userCount, familyCount, babyCount, eventCount, growthLogCount] = await Promise.all([
prisma.user.count(),
prisma.family.count(),
prisma.baby.count(),
prisma.event.count(),
prisma.growthLog.count(),
]);
return NextResponse.json({ userCount, familyCount, babyCount, eventCount, growthLogCount });
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function POST() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
if (!session.user?.email) {
return NextResponse.json({ error: "Email de l'administrateur introuvable" }, { status: 400 });
}
try {
await sendEmail({
to: session.user!.email as string,
subject: "Test email — Grow",
html: "<p>La configuration email fonctionne correctement.</p>",
text: "La configuration email fonctionne correctement.",
});
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
return NextResponse.json({ error: message }, { status: 502 });
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user!.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function POST() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const [appTokenRow, user] = await Promise.all([
prisma.systemConfig.findUnique({ where: { key: "pushover_app_token" } }),
prisma.user.findUnique({ where: { id: session.user!.id }, select: { pushoverUserKey: true } }),
]);
const appToken = appTokenRow?.value ?? process.env.PUSHOVER_APP_TOKEN ?? "";
const userKey = user?.pushoverUserKey ?? "";
if (!appToken) {
return NextResponse.json({ error: "Token application Pushover non configuré" }, { status: 400 });
}
if (!userKey) {
return NextResponse.json({ error: "Clé utilisateur Pushover non configurée" }, { status: 400 });
}
const formData = new FormData();
formData.append("token", appToken);
formData.append("user", userKey);
formData.append("title", "Grow — Test de notification");
formData.append("message", "La configuration Pushover fonctionne correctement.");
const res = await fetch("https://api.pushover.net/1/messages.json", {
method: "POST",
body: formData,
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: `Erreur Pushover : ${text}` }, { status: 502 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const { id } = await params;
if (id === session.user?.id) {
return NextResponse.json({ error: "Impossible de vous déconnecter vous-même" }, { status: 400 });
}
await prisma.user.update({
where: { id },
data: { forcedLogoutAt: new Date() },
});
return NextResponse.json({ ok: true });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
const VALID_ROLES = ["PARENT", "READONLY", "DOCTOR"] as const;
type ValidRole = typeof VALID_ROLES[number];
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const { id } = await params;
const body: { role?: string } = await req.json();
if (!body.role || !(VALID_ROLES as readonly string[]).includes(body.role)) {
return NextResponse.json({ error: "Rôle invalide" }, { status: 400 });
}
const updated = await prisma.user.update({
where: { id },
data: { role: body.role as ValidRole },
select: { id: true, name: true, email: true, role: true },
});
return NextResponse.json(updated);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const { id } = await params;
if (id === session.user?.id) {
return NextResponse.json({ error: "Impossible de supprimer votre propre compte" }, { status: 400 });
}
await prisma.user.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
role: true,
isSuperAdmin: true,
familyId: true,
family: { select: { name: true } },
createdAt: true,
},
orderBy: { createdAt: "asc" },
});
return NextResponse.json(users);
}
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { sendEmail, resetPasswordEmail } from "@/lib/email";
export async function POST(req: Request) {
const { email } = await req.json();
if (!email) return NextResponse.json({ error: "Email requis" }, { status: 400 });
const user = await prisma.user.findUnique({ where: { email } });
// Always return success to prevent user enumeration
if (!user) return NextResponse.json({ ok: true });
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
const reset = await prisma.passwordResetToken.create({
data: { userId: user.id, expiresAt },
});
const origin = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const resetUrl = `${origin}/auth/reset-password?token=${reset.token}`;
try {
const { html, text } = resetPasswordEmail(user.name, resetUrl);
await sendEmail({ to: user.email, subject: "Réinitialisation de votre mot de passe — Grow", html, text });
} catch (err) {
console.error("Email send failed:", err);
// Still succeed silently to avoid leaking info
}
return NextResponse.json({ ok: true });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export async function POST(req: Request) {
const { token, password } = await req.json();
if (!token || !password || password.length < 8) {
return NextResponse.json({ error: "Données invalides" }, { status: 400 });
}
const reset = await prisma.passwordResetToken.findUnique({ where: { token } });
if (!reset || reset.used || reset.expiresAt < new Date()) {
return NextResponse.json({ error: "Lien expiré ou invalide" }, { status: 400 });
}
const hashed = await bcrypt.hash(password, 12);
await Promise.all([
prisma.user.update({ where: { id: reset.userId }, data: { password: hashed } }),
prisma.passwordResetToken.update({ where: { id: reset.id }, data: { used: true } }),
]);
return NextResponse.json({ ok: true });
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const { name, birthDate, gender } = await req.json();
const baby = await prisma.baby.update({
where: { id },
data: {
...(name ? { name } : {}),
...(birthDate ? { birthDate: new Date(birthDate) } : {}),
...(gender !== undefined ? { gender } : {}),
},
});
return NextResponse.json(baby);
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
family: {
include: {
babies: true,
users: { select: { id: true, name: true, email: true, role: true } },
},
},
},
});
return NextResponse.json(user?.family ?? null);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { name, birthDate, gender } = await req.json();
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.familyId) return NextResponse.json({ error: "Aucune famille" }, { status: 400 });
const baby = await prisma.baby.create({
data: { name, birthDate: new Date(birthDate), gender, familyId: user.familyId },
});
return NextResponse.json(baby);
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const userRole = (session.user as { role?: string }).role;
if (userRole !== "DOCTOR") {
return NextResponse.json({ error: "Réservé aux médecins" }, { status: 403 });
}
const { id } = await params;
const note = await prisma.doctorNote.findUnique({ where: { id } });
if (!note) {
return NextResponse.json({ error: "Observation introuvable" }, { status: 404 });
}
if (note.userId !== session.user.id) {
return NextResponse.json({ error: "Vous ne pouvez supprimer que vos propres observations" }, { status: 403 });
}
await prisma.doctorNote.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const notes = await prisma.doctorNote.findMany({
where: { babyId },
orderBy: { date: "desc" },
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(notes);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const userRole = (session.user as { role?: string }).role;
if (userRole !== "DOCTOR") {
return NextResponse.json({ error: "Réservé aux médecins" }, { status: 403 });
}
const body = await req.json();
const { babyId, content, date } = body;
if (!babyId || !content || !date) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const noteDate = new Date(date);
noteDate.setHours(12, 0, 0, 0);
const note = await prisma.doctorNote.create({
data: {
babyId,
userId: session.user.id,
content,
date: noteDate,
},
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(note, { status: 201 });
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const body = await req.json();
const event = await prisma.event.update({
where: { id },
data: {
...(body.notes !== undefined ? { notes: body.notes } : {}),
...(body.startedAt ? { startedAt: new Date(body.startedAt) } : {}),
...(body.endedAt !== undefined
? { endedAt: body.endedAt ? new Date(body.endedAt) : null }
: {}),
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
},
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(event);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
await prisma.event.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+67
View File
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
const limit = parseInt(searchParams.get("limit") ?? "50");
const offset = parseInt(searchParams.get("offset") ?? "0");
const type = searchParams.get("type");
const dateFrom = searchParams.get("dateFrom");
const dateTo = searchParams.get("dateTo");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const where: Record<string, unknown> = { babyId };
if (type) where.type = type;
if (dateFrom || dateTo) {
where.startedAt = {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
...(dateTo ? { lte: new Date(dateTo) } : {}),
};
}
const [events, total] = await Promise.all([
prisma.event.findMany({
where,
orderBy: { startedAt: "desc" },
take: limit,
skip: offset,
include: { user: { select: { id: true, name: true } } },
}),
prisma.event.count({ where }),
]);
return NextResponse.json({ events, total });
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const body = await req.json();
const { babyId, type, startedAt, endedAt, notes, metadata } = body;
if (!babyId || !type || !startedAt) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const event = await prisma.event.create({
data: {
babyId,
userId: session.user.id,
type,
startedAt: new Date(startedAt),
endedAt: endedAt ? new Date(endedAt) : null,
notes: notes ?? null,
metadata: metadata ?? null,
},
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(event, { status: 201 });
}
+71
View File
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { EVENT_CONFIG, EventType } from "@/lib/event-config";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
const dateFrom = searchParams.get("dateFrom");
const dateTo = searchParams.get("dateTo");
const outputFormat = searchParams.get("format");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const where: Record<string, unknown> = { babyId };
if (dateFrom || dateTo) {
where.startedAt = {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
...(dateTo ? { lte: new Date(dateTo) } : {}),
};
}
const events = await prisma.event.findMany({
where,
orderBy: { startedAt: "asc" },
include: { user: { select: { name: true } } },
});
if (outputFormat === "json") {
return NextResponse.json(events.map((e) => ({
type: e.type,
startedAt: e.startedAt,
endedAt: e.endedAt,
notes: e.notes,
metadata: e.metadata,
user: e.user.name,
})));
}
const rows = [
["Date", "Heure", "Type", "Durée (min)", "Noté par", "Notes", "Détails"].join(","),
...events.map((e) => {
const duration =
e.endedAt
? Math.round((e.endedAt.getTime() - e.startedAt.getTime()) / 60000)
: "";
const details = e.metadata ? JSON.stringify(e.metadata).replace(/"/g, "'") : "";
return [
format(e.startedAt, "dd/MM/yyyy", { locale: fr }),
format(e.startedAt, "HH:mm"),
EVENT_CONFIG[e.type as EventType]?.label ?? e.type,
duration,
e.user.name,
`"${(e.notes ?? "").replace(/"/g, "'")}"`,
`"${details}"`,
].join(",");
}),
].join("\n");
return new NextResponse(rows, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="grow-export-${format(new Date(), "yyyy-MM-dd")}.csv"`,
},
});
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
const { id } = await params;
const { date, weight, height, headCirc, notes } = await req.json();
const log = await prisma.growthLog.update({
where: { id },
data: {
...(date ? { date: new Date(date) } : {}),
...(weight !== undefined ? { weight: weight ? weight * 1000 : null } : {}),
...(height !== undefined ? { height: height ?? null } : {}),
...(headCirc !== undefined ? { headCirc: headCirc ?? null } : {}),
...(notes !== undefined ? { notes: notes || null } : {}),
},
});
return NextResponse.json(log);
}
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
const { id } = await params;
await prisma.growthLog.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const logs = await prisma.growthLog.findMany({
where: { babyId },
orderBy: { date: "asc" },
});
return NextResponse.json(logs);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { babyId, date, weight, height, headCirc, notes } = await req.json();
if (!babyId || !date) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const log = await prisma.growthLog.create({
data: {
babyId,
date: new Date(date),
weight: weight ?? null,
height: height ?? null,
headCirc: headCirc ?? null,
notes: notes ?? null,
},
});
return NextResponse.json(log, { status: 201 });
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
const VALID_ROLES = ["PARENT", "READONLY", "DOCTOR"] as const;
type Role = (typeof VALID_ROLES)[number];
export async function POST(req: Request) {
const { name, email, password, inviteCode, role } = await req.json();
if (!name || !email || !password || !inviteCode) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const assignedRole: Role = VALID_ROLES.includes(role) ? role : "PARENT";
const family = await prisma.family.findUnique({ where: { inviteCode } });
if (!family) {
return NextResponse.json({ error: "Code d'invitation invalide" }, { status: 404 });
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "Email déjà utilisé" }, { status: 409 });
}
const hashed = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { name, email, password: hashed, familyId: family.id, role: assignedRole },
});
return NextResponse.json({ id: user.id, email: user.email, name: user.name, role: user.role });
}
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const body = await req.json();
if (body.content === undefined) {
return NextResponse.json({ error: "content requis" }, { status: 400 });
}
const existing = await prisma.journalNote.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: "Note introuvable" }, { status: 404 });
const userRole = (session.user as { role?: string }).role;
const isOwner = existing.userId === session.user.id;
const isParent = userRole === "PARENT";
if (!isOwner && !isParent) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const note = await prisma.journalNote.update({
where: { id },
data: { content: body.content },
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(note);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const existing = await prisma.journalNote.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: "Note introuvable" }, { status: 404 });
const userRole = (session.user as { role?: string }).role;
const isOwner = existing.userId === session.user.id;
const isParent = userRole === "PARENT";
if (!isOwner && !isParent) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
await prisma.journalNote.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
const date = searchParams.get("date");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const where: Record<string, unknown> = { babyId };
if (date) {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
const end = new Date(date);
end.setHours(23, 59, 59, 999);
where.date = { gte: start, lte: end };
}
const notes = await prisma.journalNote.findMany({
where,
orderBy: { date: "desc" },
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(notes);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const userRole = (session.user as { role?: string }).role;
if (userRole === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const body = await req.json();
const { babyId, date, content } = body;
if (!babyId || !date || !content) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const noteDate = new Date(date);
noteDate.setHours(12, 0, 0, 0);
const note = await prisma.journalNote.create({
data: {
babyId,
userId: session.user.id,
date: noteDate,
content,
},
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(note, { status: 201 });
}
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const { id } = await params;
const body = await req.json();
const milestone = await prisma.milestone.update({
where: { id },
data: {
...(body.title !== undefined ? { title: body.title } : {}),
...(body.date !== undefined ? { date: new Date(body.date) } : {}),
...(body.notes !== undefined ? { notes: body.notes } : {}),
...(body.icon !== undefined ? { icon: body.icon } : {}),
},
});
return NextResponse.json(milestone);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const { id } = await params;
await prisma.milestone.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const milestones = await prisma.milestone.findMany({
where: { babyId },
orderBy: { date: "desc" },
});
return NextResponse.json(milestones);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const body = await req.json();
const { babyId, title, date, notes, icon } = body;
if (!babyId || !title || !date) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const milestone = await prisma.milestone.create({
data: {
babyId,
title,
date: new Date(date),
notes: notes ?? null,
icon: icon ?? null,
},
});
return NextResponse.json(milestone, { status: 201 });
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { notifyFamilyFeedAlert } from "@/lib/push-notify";
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { babyId, thresholdHours } = await req.json();
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const baby = await prisma.baby.findUnique({
where: { id: babyId },
select: { name: true, familyId: true },
});
if (!baby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
const lastFeed = await prisma.event.findFirst({
where: { babyId, type: { in: ["BREASTFEED", "BOTTLE"] } },
orderBy: { startedAt: "desc" },
});
if (!lastFeed) {
return NextResponse.json({ sent: false, reason: "no_feed_data" });
}
const hoursAgo = (Date.now() - lastFeed.startedAt.getTime()) / 3600000;
const threshold = thresholdHours ?? 3;
if (hoursAgo < threshold) {
return NextResponse.json({ sent: false, reason: "threshold_not_reached", hoursAgo });
}
const hStr = Math.floor(hoursAgo);
const mStr = Math.round((hoursAgo % 1) * 60);
const message = `${baby.name} n'a pas mangé depuis ${hStr}h${mStr.toString().padStart(2, "0")} — pensez à la prochaine tétée.`;
try {
await notifyFamilyFeedAlert(baby.familyId, message);
return NextResponse.json({ sent: true, hoursAgo });
} catch (err) {
return NextResponse.json({ sent: false, error: String(err) }, { status: 500 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const { name, email, password } = await req.json();
if (!name || !email || !password) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
// Check if registration is disabled (superadmin can always register)
const isSuperAdminEmail = !!process.env.SUPERADMIN_EMAIL && email === process.env.SUPERADMIN_EMAIL;
if (!isSuperAdminEmail) {
const regConfig = await prisma.systemConfig.findUnique({ where: { key: "allow_registration" } });
if (regConfig && regConfig.value === "false") {
return NextResponse.json({ error: "Les inscriptions sont désactivées." }, { status: 403 });
}
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "Email déjà utilisé" }, { status: 409 });
}
const hashed = await bcrypt.hash(password, 12);
const family = await prisma.family.create({
data: { name: `Famille de ${name}` },
});
const isSuperAdmin = !!process.env.SUPERADMIN_EMAIL && email === process.env.SUPERADMIN_EMAIL;
const user = await prisma.user.create({
data: { name, email, password: hashed, familyId: family.id, isSuperAdmin },
});
return NextResponse.json({ id: user.id, email: user.email, name: user.name });
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { pushoverUserKey: true } });
return NextResponse.json({ pushoverUserKey: user?.pushoverUserKey ?? "" });
}
export async function PATCH(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { pushoverUserKey } = await req.json();
await prisma.user.update({ where: { id: session.user.id }, data: { pushoverUserKey: pushoverUserKey || null } });
return NextResponse.json({ ok: true });
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const { id } = await params;
const body = await req.json();
const vaccination = await prisma.vaccination.update({
where: { id },
data: {
...(body.name !== undefined ? { name: body.name } : {}),
...(body.date !== undefined
? { date: body.date ? new Date(body.date) : null }
: {}),
...(body.dueDate !== undefined
? { dueDate: body.dueDate ? new Date(body.dueDate) : null }
: {}),
...(body.notes !== undefined ? { notes: body.notes } : {}),
...(body.done !== undefined ? { done: body.done } : {}),
},
});
return NextResponse.json(vaccination);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const { id } = await params;
await prisma.vaccination.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId)
return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const vaccinations = await prisma.vaccination.findMany({
where: { babyId },
orderBy: [{ dueDate: "asc" }, { date: "asc" }],
});
// Move nulls last for dueDate
const withDue = vaccinations.filter((v) => v.dueDate !== null);
const withoutDue = vaccinations.filter((v) => v.dueDate === null);
return NextResponse.json([...withDue, ...withoutDue]);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const body = await req.json();
const { babyId, name, date, dueDate, notes } = body;
if (!babyId || !name) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const vaccination = await prisma.vaccination.create({
data: {
babyId,
name,
date: date ? new Date(date) : null,
dueDate: dueDate ? new Date(dueDate) : null,
notes: notes ?? null,
},
});
return NextResponse.json(vaccination, { status: 201 });
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Leaf } from "lucide-react";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
setSent(true);
setLoading(false);
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Leaf className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Grow</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Réinitialisation du mot de passe</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm p-8">
{sent ? (
<div className="text-center space-y-4">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto">
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-slate-700 dark:text-slate-300 text-sm">
Si un compte existe pour cet email, vous recevrez un lien de réinitialisation dans quelques minutes.
</p>
<Link href="/auth/login" className="inline-block text-indigo-600 dark:text-indigo-400 font-medium hover:underline text-sm">
Retour à la connexion
</Link>
</div>
) : (
<>
<p className="text-slate-500 dark:text-slate-400 text-sm mb-6">
Entrez votre adresse email et nous vous enverrons un lien pour réinitialiser votre mot de passe.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="vous@exemple.com"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "Envoi..." : "Envoyer le lien"}
</button>
</form>
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-6">
<Link href="/auth/login" className="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">
Retour à la connexion
</Link>
</p>
</>
)}
</div>
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useState, use, Suspense } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Leaf, Check } from "lucide-react";
const ROLE_META: Record<string, { label: string; description: string; color: string }> = {
PARENT: {
label: "Accès parent",
description: "toutes les fonctionnalités",
color: "bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800",
},
READONLY: {
label: "Accès consultation",
description: "lecture seule",
color: "bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-700",
},
DOCTOR: {
label: "Accès médecin",
description: "consultation + notes cliniques",
color: "bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
},
};
const inputCls =
"w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm";
function InviteForm({ code }: { code: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const role = searchParams.get("role") ?? "PARENT";
const [form, setForm] = useState({ name: "", email: "", password: "" });
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const meta = ROLE_META[role] ?? ROLE_META.PARENT;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/invite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, inviteCode: code, role }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Erreur lors de l'inscription");
setLoading(false);
return;
}
await signIn("credentials", {
email: form.email,
password: form.password,
redirect: false,
});
router.push("/dashboard");
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4">
<div className="w-full max-w-md">
{/* Brand */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-12 h-12 bg-indigo-600 rounded-2xl mb-4">
<Leaf className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Grow</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Suivi nouveau-</p>
</div>
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 p-8">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-1">
Rejoindre la famille
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-5">
Créez votre compte pour accéder à l&apos;espace partagé.
</p>
{/* Role badge */}
<div className={`flex items-center gap-2 px-3 py-2.5 rounded-xl border mb-6 ${meta.color}`}>
<Check className="w-4 h-4 flex-shrink-0" />
<span className="text-sm font-medium">
{meta.label} <span className="font-normal opacity-75">· {meta.description}</span>
</span>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Prénom
</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
className={inputCls}
placeholder="Thomas"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Email
</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
className={inputCls}
placeholder="vous@exemple.com"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Mot de passe
</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
minLength={8}
className={inputCls}
placeholder="8 caractères minimum"
/>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-900 px-4 py-3 rounded-xl text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 dark:disabled:bg-indigo-900 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "Rejoindre..." : "Rejoindre la famille"}
</button>
</form>
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-6">
Déjà un compte ?{" "}
<Link href="/auth/login" className="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">
Se connecter
</Link>
</p>
</div>
</div>
</div>
);
}
export default function InvitePage({ params }: { params: Promise<{ code: string }> }) {
const { code } = use(params);
return (
<Suspense fallback={null}>
<InviteForm code={code} />
</Suspense>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Leaf } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
setError("Email ou mot de passe incorrect");
setLoading(false);
} else {
router.push("/dashboard");
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Leaf className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Grow</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Suivi de votre nouveau-</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="vous@exemple.com"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400">
Mot de passe
</label>
<Link href="/auth/forgot-password" className="text-xs text-slate-400 dark:text-slate-500 hover:text-indigo-600">
Mot de passe oublié ?
</Link>
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="••••••••"
/>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800 px-4 py-3 rounded-xl text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "Connexion..." : "Se connecter"}
</button>
</form>
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-6">
Pas encore de compte ?{" "}
<Link href="/auth/register" className="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">
Créer un compte
</Link>
</p>
</div>
</div>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Leaf } from "lucide-react";
export default function RegisterPage() {
const router = useRouter();
const [form, setForm] = useState({ name: "", email: "", password: "" });
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Erreur lors de l'inscription");
setLoading(false);
return;
}
await signIn("credentials", {
email: form.email,
password: form.password,
redirect: false,
});
router.push("/setup");
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Leaf className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Grow</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Créez votre compte</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Prénom</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="Marie"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Email</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="vous@exemple.com"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Mot de passe</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
minLength={8}
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="8 caractères minimum"
/>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800 px-4 py-3 rounded-xl text-sm">{error}</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "Création..." : "Créer mon compte"}
</button>
</form>
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-6">
Déjà un compte ?{" "}
<Link href="/auth/login" className="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">
Se connecter
</Link>
</p>
</div>
</div>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { Suspense, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Leaf } from "lucide-react";
function ResetForm() {
const params = useSearchParams();
const token = params.get("token") ?? "";
const router = useRouter();
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password !== confirm) {
setError("Les mots de passe ne correspondent pas");
return;
}
if (password.length < 8) {
setError("8 caractères minimum");
return;
}
setLoading(true);
setError("");
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error);
setLoading(false);
return;
}
setDone(true);
}
if (done) {
return (
<div className="text-center space-y-4">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto">
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-slate-700 dark:text-slate-300 text-sm">
Votre mot de passe a é réinitialisé avec succès.
</p>
<Link href="/auth/login" className="inline-block text-indigo-600 dark:text-indigo-400 font-medium hover:underline text-sm">
Se connecter
</Link>
</div>
);
}
if (!token) {
return (
<div className="text-center space-y-4">
<div className="w-12 h-12 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center mx-auto">
<svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-slate-700 dark:text-slate-300 text-sm">
Ce lien de réinitialisation est invalide ou a expiré.
</p>
<Link href="/auth/forgot-password" className="inline-block text-indigo-600 dark:text-indigo-400 font-medium hover:underline text-sm">
Demander un nouveau lien
</Link>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Nouveau mot de passe
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="8 caractères minimum"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Confirmer le mot de passe
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
autoComplete="new-password"
className="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm"
placeholder="••••••••"
/>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800 px-4 py-3 rounded-xl text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white font-semibold py-3 rounded-xl transition text-sm"
>
{loading ? "Réinitialisation..." : "Réinitialiser le mot de passe"}
</button>
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-2">
<Link href="/auth/login" className="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">
Retour à la connexion
</Link>
</p>
</form>
);
}
export default function ResetPasswordPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Leaf className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Grow</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Nouveau mot de passe</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm p-8">
<Suspense
fallback={
<div className="flex justify-center py-8">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
}
>
<ResetForm />
</Suspense>
</div>
</div>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+67
View File
@@ -0,0 +1,67 @@
@import "tailwindcss";
@variant dark (&:is(.dark *));
:root {
--bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-light: #f1f5f9;
--text: #0f172a;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--primary: #4f46e5;
--primary-light: #eef2ff;
--primary-fg: #ffffff;
--sidebar-width: 220px;
}
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-border: var(--border);
--color-primary: var(--primary);
}
* {
box-sizing: border-box;
}
html {
color-scheme: light dark;
}
.dark {
--bg: #0f172a;
--surface: #1e293b;
--border: #334155;
--border-light: #1e293b;
--text: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--primary: #6366f1;
--primary-light: #1e1b4b;
--primary-fg: #ffffff;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
transition: background-color 0.15s, color 0.15s;
}
button, a {
-webkit-tap-highlight-color: transparent;
}
@media (min-width: 768px) {
.app-main { margin-left: var(--sidebar-width); }
}
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { Providers } from "@/components/providers";
export const metadata: Metadata = {
title: "Grow — Suivi bébé",
description: "Suivez les activités de votre nouveau-né",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Grow",
},
};
export const viewport: Viewport = {
themeColor: "#4f46e5",
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem('grow_theme')||'auto';var d=t==='dark'||(t==='auto'&&window.matchMedia('(prefers-color-scheme: dark)').matches);if(d)document.documentElement.classList.add('dark')}catch(e){}` }} />
</head>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
export default async function Home() {
const session = await auth();
if (session?.user) {
redirect("/dashboard");
}
redirect("/auth/login");
}
+32
View File
@@ -0,0 +1,32 @@
import {
Heart,
GlassWater,
Droplets,
CircleDot,
Sunset,
Moon,
Pill,
Thermometer,
LucideProps,
} from "lucide-react";
const ICONS: Record<string, React.FC<LucideProps>> = {
Heart,
GlassWater,
Droplets,
CircleDot,
Sunset,
Moon,
Pill,
Thermometer,
};
interface EventIconProps extends LucideProps {
name: string;
}
export function EventIcon({ name, ...props }: EventIconProps) {
const Icon = ICONS[name];
if (!Icon) return null;
return <Icon {...props} />;
}
+356
View File
@@ -0,0 +1,356 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { EVENT_CONFIG, EventType } from "@/lib/event-config";
import { EventIcon } from "@/components/event-icon";
import { X } from "lucide-react";
interface Props {
type: EventType;
babyId: string;
onClose: () => void;
onSaved: () => void;
}
interface FormData {
startedAt: string;
endedAt: string;
notes: string;
breastSide?: "left" | "right" | "both";
volume?: string;
bottleType?: "maternal" | "formula";
stoolColor?: string;
stoolAmount?: "small" | "medium" | "large";
medName?: string;
medDose?: string;
medUnit?: string;
temperature?: string;
tempUnit?: "C" | "F";
}
const STOOL_COLORS = [
{ value: "jaune", label: "Jaune", bg: "#fef3c7", dot: "#f59e0b" },
{ value: "vert", label: "Vert", bg: "#d1fae5", dot: "#10b981" },
{ value: "marron", label: "Marron", bg: "#fef3c7", dot: "#92400e" },
{ value: "orange", label: "Orange", bg: "#ffedd5", dot: "#f97316" },
{ value: "noir", label: "Méconium", bg: "#f1f5f9", dot: "#1e293b" },
{ value: "rouge", label: "Rouge", bg: "#fee2e2", dot: "#ef4444" },
];
function localNowString() {
const now = new Date();
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
}
export function EventModal({ type, babyId, onClose, onSaved }: Props) {
const config = EVENT_CONFIG[type];
const TIMER_KEY = `grow_timer_${type}_${babyId}`;
const [form, setForm] = useState<FormData>({
startedAt: localNowString(),
endedAt: "",
notes: "",
breastSide: "left",
bottleType: "maternal",
stoolColor: "jaune",
stoolAmount: "medium",
tempUnit: "C",
});
const [timerRunning, setTimerRunning] = useState(false);
const [timerSeconds, setTimerSeconds] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef<Date | null>(null);
const [loading, setLoading] = useState(false);
const [validationError, setValidationError] = useState("");
useEffect(() => {
if (!config.hasTimer) return;
const saved = localStorage.getItem(TIMER_KEY);
if (saved) {
const savedStart = new Date(saved);
const elapsed = Math.floor((Date.now() - savedStart.getTime()) / 1000);
startTimeRef.current = savedStart;
setTimerSeconds(elapsed);
setTimerRunning(true);
timerRef.current = setInterval(() => setTimerSeconds((s) => s + 1), 1000);
}
return () => { if (timerRef.current) clearInterval(timerRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function startTimer() {
const start = new Date();
startTimeRef.current = start;
localStorage.setItem(TIMER_KEY, start.toISOString());
setTimerRunning(true);
timerRef.current = setInterval(() => setTimerSeconds((s) => s + 1), 1000);
}
function stopTimer() {
if (timerRef.current) clearInterval(timerRef.current);
localStorage.removeItem(TIMER_KEY);
setTimerRunning(false);
if (startTimeRef.current) {
const endedAt = new Date();
const toLocal = (d: Date) => new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
setForm((f) => ({ ...f, startedAt: toLocal(startTimeRef.current!), endedAt: toLocal(endedAt) }));
}
}
function formatTimer(s: number) {
return `${Math.floor(s / 60).toString().padStart(2, "0")}:${(s % 60).toString().padStart(2, "0")}`;
}
async function handleSave() {
setValidationError("");
if (type === "TEMPERATURE" && form.temperature) {
const t = parseFloat(form.temperature);
const min = form.tempUnit === "C" ? 35 : 95;
const max = form.tempUnit === "C" ? 42 : 107.6;
if (t < min || t > max) {
setValidationError(`Température hors plage (${min}${max}°${form.tempUnit})`);
return;
}
}
if (type === "BOTTLE" && form.volume) {
const v = parseFloat(form.volume);
if (v < 0 || v > 500) { setValidationError("Volume entre 0 et 500 mL"); return; }
}
setLoading(true);
const metadata: Record<string, unknown> = {};
if (type === "BREASTFEED") metadata.breastSide = form.breastSide;
if (type === "BOTTLE") { metadata.volume = form.volume ? parseFloat(form.volume) : null; metadata.bottleType = form.bottleType; }
if (type === "DIAPER_STOOL") { metadata.color = form.stoolColor; metadata.amount = form.stoolAmount; }
if (type === "MEDICATION") { metadata.name = form.medName; metadata.dose = form.medDose; metadata.unit = form.medUnit; }
if (type === "TEMPERATURE") { metadata.value = form.temperature ? parseFloat(form.temperature) : null; metadata.unit = form.tempUnit; }
const toUTC = (local: string) => local ? new Date(local).toISOString() : null;
localStorage.removeItem(TIMER_KEY);
await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId,
type,
startedAt: toUTC(form.startedAt),
endedAt: form.endedAt ? toUTC(form.endedAt) : null,
notes: form.notes || null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
}),
});
setLoading(false);
onSaved();
onClose();
}
const inputCls = "w-full px-3 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:border-transparent transition bg-white dark:bg-slate-700 dark:text-slate-100";
const segmentBase = "flex-1 py-2 text-xs font-medium rounded-lg border transition text-center";
const segmentActive = "border-indigo-500 bg-indigo-50 text-indigo-700";
const segmentInactive = "border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:border-slate-300";
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4">
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-md bg-white dark:bg-slate-800 rounded-2xl shadow-2xl z-10 max-h-[92vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-xl ${config.bgClass} flex items-center justify-center`}>
<EventIcon name={config.icon} className={`w-5 h-5 ${config.colorClass}`} />
</div>
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">{config.label}</h2>
</div>
<button onClick={onClose} className="w-8 h-8 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 flex items-center justify-center text-slate-400 transition">
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-4 space-y-4">
{/* Timer */}
{config.hasTimer && (
<div className="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4 text-center">
<div className="text-4xl font-mono font-bold text-slate-800 dark:text-slate-100 mb-3 tabular-nums">
{formatTimer(timerSeconds)}
</div>
<button
type="button"
onClick={timerRunning ? stopTimer : startTimer}
className={`px-8 py-2.5 rounded-lg font-medium text-sm text-white transition ${
timerRunning ? "bg-red-500 hover:bg-red-600" : "bg-indigo-600 hover:bg-indigo-700"
}`}
>
{timerRunning ? "Arrêter" : "Démarrer"}
</button>
</div>
)}
{/* Breastfeed: side */}
{type === "BREASTFEED" && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Sein</label>
<div className="flex gap-2">
{(["left", "right", "both"] as const).map((side) => (
<button key={side} type="button" onClick={() => setForm((f) => ({ ...f, breastSide: side }))}
className={`${segmentBase} ${form.breastSide === side ? segmentActive : segmentInactive}`}>
{side === "left" ? "Gauche" : side === "right" ? "Droite" : "Les deux"}
</button>
))}
</div>
</div>
)}
{/* Bottle */}
{type === "BOTTLE" && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Volume (mL)</label>
<input type="number" value={form.volume ?? ""} onChange={(e) => setForm((f) => ({ ...f, volume: e.target.value }))}
className={inputCls} placeholder="80" min="0" max="300" />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Type de lait</label>
<div className="flex gap-2">
{(["maternal", "formula"] as const).map((bt) => (
<button key={bt} type="button" onClick={() => setForm((f) => ({ ...f, bottleType: bt }))}
className={`${segmentBase} ${form.bottleType === bt ? segmentActive : segmentInactive}`}>
{bt === "maternal" ? "Maternel" : "Artificiel"}
</button>
))}
</div>
</div>
</div>
)}
{/* Diaper stool */}
{type === "DIAPER_STOOL" && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Couleur</label>
<div className="grid grid-cols-3 gap-2">
{STOOL_COLORS.map(({ value, label, bg, dot }) => (
<button key={value} type="button" onClick={() => setForm((f) => ({ ...f, stoolColor: value }))}
className={`flex items-center gap-2 px-2.5 py-2 rounded-lg border text-xs font-medium transition ${
form.stoolColor === value ? "border-indigo-500 ring-1 ring-indigo-300" : "border-slate-200 dark:border-slate-600 hover:border-slate-300"
}`} style={{ background: bg }}>
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: dot }} />
{label}
</button>
))}
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Quantité</label>
<div className="flex gap-2">
{(["small", "medium", "large"] as const).map((amt) => (
<button key={amt} type="button" onClick={() => setForm((f) => ({ ...f, stoolAmount: amt }))}
className={`${segmentBase} ${form.stoolAmount === amt ? segmentActive : segmentInactive}`}>
{amt === "small" ? "Peu" : amt === "medium" ? "Moyen" : "Beaucoup"}
</button>
))}
</div>
</div>
</div>
)}
{/* Medication */}
{type === "MEDICATION" && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Traitement</label>
<input type="text" value={form.medName ?? ""} onChange={(e) => setForm((f) => ({ ...f, medName: e.target.value }))}
className={inputCls} placeholder="Doliprane" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Dose</label>
<input type="text" value={form.medDose ?? ""} onChange={(e) => setForm((f) => ({ ...f, medDose: e.target.value }))}
className={inputCls} placeholder="2.4" />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Unité</label>
<select value={form.medUnit ?? "mL"} onChange={(e) => setForm((f) => ({ ...f, medUnit: e.target.value }))}
className={inputCls}>
<option value="mL">mL</option>
<option value="mg">mg</option>
<option value="gouttes">gouttes</option>
</select>
</div>
</div>
</div>
)}
{/* Temperature */}
{type === "TEMPERATURE" && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Température</label>
<div className="flex gap-2">
<input type="number" value={form.temperature ?? ""} onChange={(e) => setForm((f) => ({ ...f, temperature: e.target.value }))}
className={`${inputCls} flex-1`} placeholder="37.5" step="0.1" min="35" max="42" />
<div className="flex gap-1">
{(["C", "F"] as const).map((u) => (
<button key={u} type="button" onClick={() => setForm((f) => ({ ...f, tempUnit: u }))}
className={`px-3 py-2.5 rounded-lg border text-sm font-semibold transition ${
form.tempUnit === u ? segmentActive : segmentInactive
}`}>
°{u}
</button>
))}
</div>
</div>
</div>
)}
{/* Time fields */}
<div className={`grid ${config.hasDuration ? "grid-cols-2" : "grid-cols-1"} gap-3`}>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
{config.hasDuration ? "Début" : "Heure"}
</label>
<input type="datetime-local" value={form.startedAt}
onChange={(e) => setForm((f) => ({ ...f, startedAt: e.target.value }))}
className={inputCls} />
</div>
{config.hasDuration && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Fin</label>
<input type="datetime-local" value={form.endedAt}
onChange={(e) => setForm((f) => ({ ...f, endedAt: e.target.value }))}
className={inputCls} />
</div>
)}
</div>
{/* Notes */}
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Notes <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<textarea value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={2} className={`${inputCls} resize-none`} placeholder="Observations..." />
</div>
{validationError && (
<div className="px-3 py-2 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg text-xs text-red-600 dark:text-red-400">
{validationError}
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1 pb-1">
<button type="button" onClick={onClose}
className="flex-1 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 text-sm font-medium hover:bg-slate-50 dark:hover:bg-slate-700 transition">
Annuler
</button>
<button type="button" onClick={handleSave} disabled={loading}
className="flex-[2] py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white text-sm font-medium transition">
{loading ? "Enregistrement..." : "Enregistrer"}
</button>
</div>
</div>
</div>
</div>
);
}
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession, signOut } from "next-auth/react";
import { useBaby } from "@/contexts/baby-context";
import {
LayoutGrid,
ScrollText,
BarChart3,
TrendingUp,
Settings,
Leaf,
Sun,
Moon,
ShieldCheck,
LogOut,
ChevronDown,
Star,
NotebookPen,
Stethoscope,
Syringe,
MoreHorizontal,
X,
} from "lucide-react";
const LINKS = [
{ href: "/dashboard", label: "Accueil", Icon: LayoutGrid },
{ href: "/timeline", label: "Journal", Icon: ScrollText },
{ href: "/stats", label: "Statistiques", Icon: BarChart3 },
{ href: "/growth", label: "Courbes", Icon: TrendingUp },
{ href: "/milestones", label: "Étapes", Icon: Star },
{ href: "/notes", label: "Notes", Icon: NotebookPen },
{ href: "/doctor-notes", label: "Médecin", Icon: Stethoscope },
{ href: "/vaccinations", label: "Vaccinations", Icon: Syringe },
{ href: "/settings", label: "Réglages", Icon: Settings },
];
const PRIMARY_HREFS = ["/dashboard", "/timeline", "/growth", "/settings"];
const PRIMARY_LINKS = LINKS.filter((l) => PRIMARY_HREFS.includes(l.href));
const SECONDARY_LINKS = LINKS.filter((l) => !PRIMARY_HREFS.includes(l.href));
type ThemeMode = "auto" | "light" | "dark";
function applyTheme(mode: ThemeMode) {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const isDark = mode === "dark" || (mode === "auto" && prefersDark);
document.documentElement.classList.toggle("dark", isDark);
}
function ThemeToggle() {
const [mode, setMode] = useState<ThemeMode>("auto");
useEffect(() => {
const stored = (localStorage.getItem("grow_theme") ?? "auto") as ThemeMode;
setMode(stored);
applyTheme(stored);
// Re-apply when system preference changes (only matters in auto mode)
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => {
const current = (localStorage.getItem("grow_theme") ?? "auto") as ThemeMode;
if (current === "auto") applyTheme("auto");
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
function cycle() {
const next: ThemeMode = mode === "auto" ? "light" : mode === "light" ? "dark" : "auto";
setMode(next);
if (next === "auto") localStorage.removeItem("grow_theme");
else localStorage.setItem("grow_theme", next);
applyTheme(next);
}
return (
<button
onClick={cycle}
title={mode === "auto" ? "Thème : automatique" : mode === "light" ? "Thème : clair" : "Thème : sombre"}
className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition"
>
{mode === "dark" ? <Sun className="w-4 h-4" /> : mode === "light" ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4 opacity-50" />}
</button>
);
}
export function SidebarNav() {
const pathname = usePathname();
const { data: session } = useSession();
const isSuperAdmin = (session?.user as { isSuperAdmin?: boolean })?.isSuperAdmin;
const { babies, selectedBaby, setSelectedBabyId } = useBaby();
return (
<aside className="hidden md:flex flex-col w-[220px] border-r border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 fixed left-0 top-0 h-screen z-20">
{/* Brand */}
<div className="px-5 py-4 border-b border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-indigo-600 rounded-lg flex items-center justify-center flex-shrink-0">
<Leaf className="w-4 h-4 text-white" />
</div>
<span className="font-semibold text-slate-900 dark:text-slate-100 text-base">Grow</span>
</div>
</div>
{/* Baby switcher */}
{babies.length > 0 && (
<div className="px-3 py-2 border-b border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg">
<div className="w-6 h-6 bg-indigo-100 dark:bg-indigo-950 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-[10px] font-bold text-indigo-600 dark:text-indigo-400 leading-none">
{selectedBaby?.name[0]}
</span>
</div>
{babies.length === 1 ? (
<span className="text-sm font-medium text-slate-700 dark:text-slate-200 truncate">
{selectedBaby?.name}
</span>
) : (
<div className="flex items-center flex-1 min-w-0">
<select
value={selectedBaby?.id ?? ""}
onChange={(e) => setSelectedBabyId(e.target.value)}
className="flex-1 min-w-0 text-sm font-medium text-slate-700 dark:text-slate-200 bg-transparent border-none outline-none cursor-pointer appearance-none"
>
{babies.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 text-slate-400 flex-shrink-0 pointer-events-none" />
</div>
)}
</div>
</div>
)}
<nav className="flex-1 px-3 py-4 space-y-0.5">
{LINKS.map(({ href, label, Icon }) => {
const active = pathname === href;
return (
<Link
key={href}
href={href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
active
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
}`}
>
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? "text-indigo-600" : "text-slate-400"}`} />
{label}
</Link>
);
})}
{isSuperAdmin && (
<Link
href="/admin"
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
pathname === "/admin"
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
}`}
>
<ShieldCheck className={`w-4 h-4 flex-shrink-0 ${pathname === "/admin" ? "text-indigo-600" : "text-slate-400"}`} />
Admin
</Link>
)}
</nav>
<div className="px-3 py-3 border-t border-slate-200 dark:border-slate-700 flex items-center gap-2">
<ThemeToggle />
<button
onClick={() => signOut({ callbackUrl: "/auth/login" })}
title="Déconnexion"
className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-700 dark:hover:text-slate-300 transition"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</aside>
);
}
export function BottomNav() {
const pathname = usePathname();
const { data: session } = useSession();
const isSuperAdmin = (session?.user as { isSuperAdmin?: boolean })?.isSuperAdmin;
const [drawerOpen, setDrawerOpen] = useState(false);
const secondaryLinks = isSuperAdmin
? [...SECONDARY_LINKS, { href: "/admin", label: "Admin", Icon: ShieldCheck }]
: SECONDARY_LINKS;
const secondaryActive = secondaryLinks.some((l) => pathname === l.href);
return (
<>
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-700 z-50 pb-[env(safe-area-inset-bottom)]">
<div className="flex items-center justify-around px-2 pt-2 pb-2">
{PRIMARY_LINKS.map(({ href, label, Icon }) => {
const active = pathname === href;
return (
<Link
key={href}
href={href}
className={`flex flex-col items-center gap-1 px-2 py-1 rounded-lg min-w-[52px] transition-colors ${
active ? "text-indigo-600 dark:text-indigo-400" : "text-slate-400 dark:text-slate-500"
}`}
>
<Icon className="w-5 h-5" />
<span className="text-[10px] font-medium">{label}</span>
</Link>
);
})}
<button
onClick={() => setDrawerOpen(true)}
className={`flex flex-col items-center gap-1 px-2 py-1 rounded-lg min-w-[52px] transition-colors ${
secondaryActive ? "text-indigo-600 dark:text-indigo-400" : "text-slate-400 dark:text-slate-500"
}`}
>
<MoreHorizontal className="w-5 h-5" />
<span className="text-[10px] font-medium">Plus</span>
</button>
</div>
</nav>
{/* Drawer overlay */}
{drawerOpen && (
<div className="md:hidden fixed inset-0 z-50 flex flex-col justify-end">
{/* Semi-transparent backdrop */}
<div
className="absolute inset-0 bg-black/40"
onClick={() => setDrawerOpen(false)}
/>
{/* Drawer sheet */}
<div className="relative bg-white dark:bg-slate-900 rounded-t-2xl pb-[env(safe-area-inset-bottom)] z-10">
{/* Header */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">Plus</span>
<button
onClick={() => setDrawerOpen(false)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Grid of secondary links */}
<div className="grid grid-cols-3 gap-2 px-4 pb-4">
{secondaryLinks.map(({ href, label, Icon }) => {
const active = pathname === href;
return (
<Link
key={href}
href={href}
onClick={() => setDrawerOpen(false)}
className={`flex flex-col items-center gap-1.5 px-2 py-3 rounded-xl transition-colors ${
active
? "bg-indigo-50 dark:bg-indigo-950 text-indigo-600 dark:text-indigo-400"
: "text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800"
}`}
>
<Icon className="w-5 h-5" />
<span className="text-[10px] font-medium text-center leading-tight">{label}</span>
</Link>
);
})}
</div>
</div>
</div>
)}
</>
);
}
// Legacy export for compatibility
export { BottomNav as Nav };
+24
View File
@@ -0,0 +1,24 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SessionProvider } from "next-auth/react";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 10 * 1000,
},
},
})
);
return (
<SessionProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</SessionProvider>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useBaby } from "@/contexts/baby-context";
import { EVENT_CONFIG, EventType } from "@/lib/event-config";
import { EventIcon } from "@/components/event-icon";
import { EventModal } from "@/components/event-modal";
import { Plus, X } from "lucide-react";
const QUICK_TYPES: EventType[] = [
"BREASTFEED",
"BOTTLE",
"DIAPER_WET",
"DIAPER_STOOL",
"NAP",
"NIGHT_SLEEP",
"MEDICATION",
"TEMPERATURE",
];
export function QuickAddFab() {
const qc = useQueryClient();
const { selectedBaby } = useBaby();
const [sheetOpen, setSheetOpen] = useState(false);
const [activeModal, setActiveModal] = useState<EventType | null>(null);
if (!selectedBaby) return null;
function selectType(type: EventType) {
setSheetOpen(false);
setActiveModal(type);
}
return (
<>
{/* FAB — only on mobile, hidden on md+ (sidebar handles desktop) */}
<button
onClick={() => setSheetOpen((v) => !v)}
aria-label="Enregistrer une action"
className="md:hidden fixed bottom-[calc(env(safe-area-inset-bottom)+4.5rem)] right-4 z-40 w-14 h-14 rounded-full bg-indigo-600 text-white shadow-lg flex items-center justify-center active:scale-95 transition-transform"
>
{sheetOpen ? <X className="w-6 h-6" /> : <Plus className="w-6 h-6" />}
</button>
{/* Sheet */}
{sheetOpen && (
<div className="md:hidden fixed inset-0 z-30 flex flex-col justify-end">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40"
onClick={() => setSheetOpen(false)}
/>
{/* Panel */}
<div className="relative bg-white dark:bg-slate-900 rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+5rem)] z-10">
<div className="px-4 pt-4 pb-3 flex items-center justify-between">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
Enregistrer
</span>
<button
onClick={() => setSheetOpen(false)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-4 gap-2 px-4 pb-2">
{QUICK_TYPES.map((type) => {
const cfg = EVENT_CONFIG[type];
return (
<button
key={type}
onClick={() => selectType(type)}
className="flex flex-col items-center gap-2 py-3 px-1 rounded-xl hover:bg-slate-50 dark:hover:bg-slate-800 active:scale-95 transition-transform"
>
<div
className={`w-10 h-10 rounded-xl ${cfg.bgClass} flex items-center justify-center`}
>
<EventIcon name={cfg.icon} className={`w-5 h-5 ${cfg.colorClass}`} />
</div>
<span className="text-[10px] font-medium text-slate-600 dark:text-slate-300 text-center leading-tight">
{cfg.label}
</span>
</button>
);
})}
</div>
</div>
</div>
)}
{/* Event modal (rendered outside sheet so it persists after sheet closes) */}
{activeModal && (
<EventModal
type={activeModal}
babyId={selectedBaby.id}
onClose={() => setActiveModal(null)}
onSaved={() => {
qc.invalidateQueries({ queryKey: ["events"] });
setActiveModal(null);
}}
/>
)}
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
interface Baby {
id: string;
name: string;
birthDate: string;
gender?: string;
}
interface Family {
id: string;
name: string;
inviteCode: string;
babies: Baby[];
users: { id: string; name: string; email: string }[];
}
interface BabyContextValue {
family: Family | null;
babies: Baby[];
selectedBaby: Baby | null;
setSelectedBabyId: (id: string) => void;
isLoading: boolean;
}
const BabyContext = createContext<BabyContextValue>({
family: null,
babies: [],
selectedBaby: null,
setSelectedBabyId: () => {},
isLoading: true,
});
export function BabyProvider({ children }: { children: ReactNode }) {
const [selectedBabyId, setSelectedBabyIdState] = useState<string | null>(null);
const { data: family, isLoading } = useQuery<Family>({
queryKey: ["family"],
queryFn: () => fetch("/api/baby").then((r) => r.json()),
staleTime: 30 * 1000,
});
const babies = family?.babies ?? [];
useEffect(() => {
if (babies.length === 0) return;
const saved = localStorage.getItem("grow_selected_baby");
const valid = babies.find((b) => b.id === saved);
if (valid) {
setSelectedBabyIdState(valid.id);
} else {
setSelectedBabyIdState(babies[0].id);
}
}, [babies]);
function setSelectedBabyId(id: string) {
setSelectedBabyIdState(id);
localStorage.setItem("grow_selected_baby", id);
}
const selectedBaby = babies.find((b) => b.id === selectedBabyId) ?? babies[0] ?? null;
return (
<BabyContext.Provider value={{ family: family ?? null, babies, selectedBaby, setSelectedBabyId, isLoading }}>
{children}
</BabyContext.Provider>
);
}
export function useBaby() {
return useContext(BabyContext);
}
+79
View File
@@ -0,0 +1,79 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
pages: {
signIn: "/auth/login",
},
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Mot de passe", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user?.password) return null;
const valid = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!valid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
familyId: user.familyId,
role: user.role,
isSuperAdmin: user.isSuperAdmin,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.familyId = (user as { familyId?: string | null }).familyId;
token.role = (user as { role?: string }).role ?? "PARENT";
token.isSuperAdmin = (user as { isSuperAdmin?: boolean }).isSuperAdmin ?? false;
}
if (!user && token.id) {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
select: { forcedLogoutAt: true },
});
if (dbUser?.forcedLogoutAt) {
const iat = typeof token.iat === "number" ? token.iat : 0;
if (dbUser.forcedLogoutAt.getTime() / 1000 > iat) {
return null;
}
}
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
(session.user as { familyId?: string | null }).familyId =
token.familyId as string | null;
(session.user as { role?: string }).role = token.role as string;
(session.user as { isSuperAdmin?: boolean }).isSuperAdmin = token.isSuperAdmin as boolean;
}
return session;
},
},
});
+78
View File
@@ -0,0 +1,78 @@
import { prisma } from "./prisma";
interface EmailPayload {
to: string;
subject: string;
html: string;
text?: string;
}
async function getConfig(key: string): Promise<string> {
const row = await prisma.systemConfig.findUnique({ where: { key } });
return row?.value ?? process.env[key.toUpperCase()] ?? "";
}
async function sendMailgun(payload: EmailPayload): Promise<void> {
const apiKey = await getConfig("mailgun_api_key");
const domain = await getConfig("mailgun_domain");
const from = await getConfig("mailgun_from");
if (!apiKey || !domain) throw new Error("Mailgun non configuré");
const formData = new FormData();
formData.append("from", from || `Grow <noreply@${domain}>`);
formData.append("to", payload.to);
formData.append("subject", payload.subject);
formData.append("html", payload.html);
if (payload.text) formData.append("text", payload.text);
const res = await fetch(`https://api.mailgun.net/v3/${domain}/messages`, {
method: "POST",
headers: { Authorization: `Basic ${Buffer.from(`api:${apiKey}`).toString("base64")}` },
body: formData,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Mailgun error: ${err}`);
}
}
async function sendSmtp(payload: EmailPayload): Promise<void> {
const host = await getConfig("smtp_host");
const port = parseInt(await getConfig("smtp_port") || "587");
const user = await getConfig("smtp_user");
const pass = await getConfig("smtp_pass");
const from = await getConfig("smtp_from");
if (!host) throw new Error("SMTP non configuré");
const nodemailer = await import("nodemailer");
const transport = nodemailer.default.createTransport({ host, port, auth: { user, pass } });
await transport.sendMail({ from, to: payload.to, subject: payload.subject, html: payload.html, text: payload.text });
}
export async function sendEmail(payload: EmailPayload): Promise<void> {
const provider = await getConfig("email_provider");
if (provider === "smtp") return sendSmtp(payload);
if (provider === "mailgun") return sendMailgun(payload);
// Fallback: check env vars directly
if (process.env.MAILGUN_API_KEY) return sendMailgun(payload);
if (process.env.SMTP_HOST) return sendSmtp(payload);
throw new Error("Aucun service email configuré");
}
export function resetPasswordEmail(name: string, resetUrl: string): { html: string; text: string } {
return {
text: `Bonjour ${name},\n\nCliquez sur ce lien pour réinitialiser votre mot de passe :\n${resetUrl}\n\nLien valide 1 heure.\n\nGrow`,
html: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
<h2 style="color:#4f46e5">Grow — Réinitialisation du mot de passe</h2>
<p>Bonjour ${name},</p>
<p>Cliquez sur le bouton ci-dessous pour réinitialiser votre mot de passe. Le lien est valide pendant <strong>1 heure</strong>.</p>
<a href="${resetUrl}" style="display:inline-block;margin:16px 0;padding:12px 24px;background:#4f46e5;color:#fff;border-radius:8px;text-decoration:none;font-weight:600">
Réinitialiser mon mot de passe
</a>
<p style="color:#64748b;font-size:13px">Si vous n'avez pas demandé cette réinitialisation, ignorez cet email.</p>
<p style="color:#64748b;font-size:13px">Ou copiez ce lien : ${resetUrl}</p>
</div>
`,
};
}
+127
View File
@@ -0,0 +1,127 @@
export type EventType =
| "BREASTFEED"
| "BOTTLE"
| "DIAPER_WET"
| "DIAPER_STOOL"
| "NAP"
| "NIGHT_SLEEP"
| "MEDICATION"
| "TEMPERATURE";
export const EVENT_CONFIG: Record<
EventType,
{
label: string;
icon: string; // lucide icon name
color: string; // hex for inline use
colorClass: string; // tailwind text color
bgClass: string; // tailwind bg (light)
borderClass: string; // tailwind border
hasDuration: boolean;
hasTimer: boolean;
}
> = {
BREASTFEED: {
label: "Allaitement",
icon: "Heart",
color: "#f43f5e",
colorClass: "text-rose-500",
bgClass: "bg-rose-50",
borderClass: "border-rose-200",
hasDuration: true,
hasTimer: true,
},
BOTTLE: {
label: "Biberon",
icon: "GlassWater",
color: "#3b82f6",
colorClass: "text-blue-500",
bgClass: "bg-blue-50",
borderClass: "border-blue-200",
hasDuration: false,
hasTimer: false,
},
DIAPER_WET: {
label: "Couche mouillée",
icon: "Droplets",
color: "#06b6d4",
colorClass: "text-cyan-500",
bgClass: "bg-cyan-50",
borderClass: "border-cyan-200",
hasDuration: false,
hasTimer: false,
},
DIAPER_STOOL: {
label: "Selles",
icon: "CircleDot",
color: "#d97706",
colorClass: "text-amber-600",
bgClass: "bg-amber-50",
borderClass: "border-amber-200",
hasDuration: false,
hasTimer: false,
},
NAP: {
label: "Sieste",
icon: "Sunset",
color: "#7c3aed",
colorClass: "text-violet-600",
bgClass: "bg-violet-50",
borderClass: "border-violet-200",
hasDuration: true,
hasTimer: true,
},
NIGHT_SLEEP: {
label: "Sommeil nocturne",
icon: "Moon",
color: "#4338ca",
colorClass: "text-indigo-700",
bgClass: "bg-indigo-50",
borderClass: "border-indigo-200",
hasDuration: true,
hasTimer: true,
},
MEDICATION: {
label: "Traitement",
icon: "Pill",
color: "#16a34a",
colorClass: "text-green-600",
bgClass: "bg-green-50",
borderClass: "border-green-200",
hasDuration: false,
hasTimer: false,
},
TEMPERATURE: {
label: "Température",
icon: "Thermometer",
color: "#dc2626",
colorClass: "text-red-600",
bgClass: "bg-red-50",
borderClass: "border-red-200",
hasDuration: false,
hasTimer: false,
},
};
export const ALL_EVENT_TYPES = Object.keys(EVENT_CONFIG) as EventType[];
export function formatDuration(startedAt: Date, endedAt: Date): string {
const ms = endedAt.getTime() - startedAt.getTime();
const totalMinutes = Math.floor(ms / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0) return `${hours}h${minutes.toString().padStart(2, "0")}`;
return `${minutes}min`;
}
export function formatTimeAgo(date: Date): string {
const now = new Date();
const ms = now.getTime() - date.getTime();
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `il y a ${days}j`;
if (hours > 0) return `il y a ${hours}h${(minutes % 60).toString().padStart(2, "0")}`;
if (minutes > 0) return `il y a ${minutes}min`;
return "à l'instant";
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
const STORAGE_KEY = "grow_notification_thresholds";
export interface NotificationThresholds {
feedAlertHours: number; // alert if no feed in X hours
enabled: boolean;
}
export function getThresholds(): NotificationThresholds {
if (typeof window === "undefined") return { feedAlertHours: 3, enabled: false };
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { feedAlertHours: 3, enabled: false };
return JSON.parse(raw);
}
export function saveThresholds(t: NotificationThresholds): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
}
export async function requestPermission(): Promise<boolean> {
if (!("Notification" in window)) return false;
if (Notification.permission === "granted") return true;
const result = await Notification.requestPermission();
return result === "granted";
}
export function sendNotification(title: string, body: string): void {
if (typeof window === "undefined") return;
if (Notification.permission !== "granted") return;
new Notification(title, {
body,
icon: "/icon.svg",
badge: "/icon.svg",
tag: "grow-alert",
});
}
export function checkFeedAlert(lastFeedAt: Date | null, thresholds: NotificationThresholds): void {
if (!thresholds.enabled || !lastFeedAt) return;
const hoursSince = (Date.now() - lastFeedAt.getTime()) / 3600000;
if (hoursSince >= thresholds.feedAlertHours) {
const h = Math.floor(hoursSince);
const m = Math.floor((hoursSince - h) * 60);
sendNotification(
"🍼 Rappel repas — Grow",
`Dernier repas il y a ${h}h${m.toString().padStart(2, "0")}. Heure de nourrir bébé ?`
);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
function createClient() {
const adapter = new PrismaPg(process.env.DATABASE_URL!);
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
}
export const prisma = globalForPrisma.prisma ?? createClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+31
View File
@@ -0,0 +1,31 @@
import { prisma } from "./prisma";
async function getConfig(key: string): Promise<string> {
const row = await prisma.systemConfig.findUnique({ where: { key } });
return row?.value ?? process.env[key.toUpperCase()] ?? "";
}
export async function sendPushover(userKey: string, message: string, title = "Grow"): Promise<void> {
const token = await getConfig("pushover_app_token");
if (!token) throw new Error("Pushover non configuré (PUSHOVER_APP_TOKEN manquant)");
const res = await fetch("https://api.pushover.net/1/messages.json", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, user: userKey, message, title }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(`Pushover error: ${JSON.stringify(err)}`);
}
}
export async function notifyFamilyFeedAlert(familyId: string, message: string): Promise<void> {
const users = await prisma.user.findMany({
where: { familyId, pushoverUserKey: { not: null } },
select: { pushoverUserKey: true },
});
await Promise.allSettled(
users.map((u) => sendPushover(u.pushoverUserKey!, message))
);
}
+53
View File
@@ -0,0 +1,53 @@
// WHO Child Growth Standards — Weight-for-age (girls & boys combined approximation)
// Values in kg, indexed by age in weeks (052)
export const WHO_WEIGHT_PERCENTILES: Record<number, number[]> = {
// [P3, P10, P25, P50, P75, P90, P97] in kg
0: [2.4, 2.6, 2.9, 3.3, 3.7, 4.0, 4.3],
1: [2.6, 2.9, 3.1, 3.5, 3.9, 4.3, 4.6],
2: [2.9, 3.2, 3.5, 4.0, 4.4, 4.8, 5.1],
4: [3.4, 3.8, 4.2, 4.8, 5.3, 5.8, 6.2],
6: [4.0, 4.4, 4.9, 5.5, 6.1, 6.6, 7.1],
8: [4.5, 5.0, 5.5, 6.2, 6.9, 7.5, 8.0],
10: [4.9, 5.4, 6.0, 6.7, 7.5, 8.1, 8.7],
12: [5.3, 5.8, 6.4, 7.2, 8.0, 8.7, 9.3],
16: [5.8, 6.4, 7.0, 7.8, 8.7, 9.4, 10.1],
20: [6.3, 6.9, 7.6, 8.5, 9.5, 10.2, 11.0],
24: [6.6, 7.3, 8.0, 9.0, 10.0, 10.9, 11.7],
28: [6.9, 7.6, 8.4, 9.4, 10.5, 11.4, 12.2],
32: [7.2, 7.9, 8.7, 9.8, 10.9, 11.8, 12.7],
36: [7.4, 8.1, 9.0, 10.1, 11.3, 12.3, 13.2],
40: [7.6, 8.3, 9.2, 10.4, 11.6, 12.7, 13.6],
44: [7.7, 8.5, 9.5, 10.7, 11.9, 13.0, 14.0],
48: [7.9, 8.7, 9.7, 10.9, 12.2, 13.3, 14.3],
52: [8.1, 8.9, 9.9, 11.2, 12.5, 13.6, 14.7],
};
export const PERCENTILE_LABELS = ["P3", "P10", "P25", "P50", "P75", "P90", "P97"];
export const PERCENTILE_COLORS = [
"#e2e8f0",
"#cbd5e1",
"#94a3b8",
"#64748b",
"#94a3b8",
"#cbd5e1",
"#e2e8f0",
];
export function getAgeInWeeks(birthDate: Date, measureDate: Date): number {
const ms = measureDate.getTime() - birthDate.getTime();
return Math.floor(ms / (7 * 24 * 60 * 60 * 1000));
}
export function interpolatePercentile(ageWeeks: number, percentileIndex: number): number | null {
const ages = Object.keys(WHO_WEIGHT_PERCENTILES).map(Number).sort((a, b) => a - b);
const lower = ages.filter((a) => a <= ageWeeks).at(-1);
const upper = ages.filter((a) => a >= ageWeeks)[0];
if (lower === undefined || upper === undefined) return null;
if (lower === upper) return WHO_WEIGHT_PERCENTILES[lower][percentileIndex];
const ratio = (ageWeeks - lower) / (upper - lower);
const lowerVal = WHO_WEIGHT_PERCENTILES[lower][percentileIndex];
const upperVal = WHO_WEIGHT_PERCENTILES[upper][percentileIndex];
return lowerVal + ratio * (upperVal - lowerVal);
}