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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 P3–P97</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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-né</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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-né</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'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>
|
||||
);
|
||||
}
|
||||
@@ -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-né</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 été 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 |
@@ -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; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user