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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user