Files
Grow/src/app/(app)/vaccinations/page.tsx
T
arnaudne cd16c354c0 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.
2026-06-13 01:52:46 +02:00

519 lines
18 KiB
TypeScript

"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useBaby } from "@/contexts/baby-context";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Plus, Pencil, Trash2, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle } from "lucide-react";
interface Vaccination {
id: string;
babyId: string;
name: string;
date: string | null;
dueDate: string | null;
notes: string | null;
done: boolean;
createdAt: string;
}
const PRESET_VACCINES = [
"BCG",
"Hépatite B",
"DTPolio",
"Pentavalent",
"Pneumocoque",
"Méningocoque C",
"ROR",
"Varicelle",
"Hépatite A",
];
const inputCls =
"w-full px-3 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100";
const emptyForm = { name: "", dueDate: "", date: "", notes: "" };
function formatDate(dateStr: string) {
return format(new Date(dateStr), "d MMM yyyy", { locale: fr });
}
function isOverdue(v: Vaccination): boolean {
if (v.done || !v.dueDate) return false;
return new Date(v.dueDate) < new Date(new Date().toDateString());
}
export default function VaccinationsPage() {
const qc = useQueryClient();
const { selectedBaby: baby } = useBaby();
const { data: session } = useSession();
const isReadonly = (session?.user as { role?: string } | undefined)?.role === "READONLY";
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState(emptyForm);
const [editSaving, setEditSaving] = useState(false);
const [showDone, setShowDone] = useState(false);
const [togglingId, setTogglingId] = useState<string | null>(null);
const { data: vaccinations = [] } = useQuery<Vaccination[]>({
queryKey: ["vaccinations", baby?.id],
queryFn: () =>
fetch(`/api/vaccinations?babyId=${baby!.id}`).then((r) => r.json()),
enabled: !!baby?.id,
});
const upcoming = vaccinations.filter((v) => !v.done);
const done = vaccinations.filter((v) => v.done);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!baby) return;
setSaving(true);
await fetch("/api/vaccinations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId: baby.id,
name: form.name,
dueDate: form.dueDate ? new Date(form.dueDate).toISOString() : null,
date: form.date ? new Date(form.date).toISOString() : null,
notes: form.notes || null,
}),
});
qc.invalidateQueries({ queryKey: ["vaccinations", baby.id] });
setSaving(false);
setShowForm(false);
setForm(emptyForm);
}
function startEdit(v: Vaccination) {
setEditingId(v.id);
setEditForm({
name: v.name,
dueDate: v.dueDate ? format(new Date(v.dueDate), "yyyy-MM-dd") : "",
date: v.date ? format(new Date(v.date), "yyyy-MM-dd") : "",
notes: v.notes ?? "",
});
}
async function handleEditSave(e: React.FormEvent, id: string) {
e.preventDefault();
if (!baby) return;
setEditSaving(true);
await fetch(`/api/vaccinations/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: editForm.name,
dueDate: editForm.dueDate ? new Date(editForm.dueDate).toISOString() : null,
date: editForm.date ? new Date(editForm.date).toISOString() : null,
notes: editForm.notes || null,
}),
});
qc.invalidateQueries({ queryKey: ["vaccinations", baby.id] });
setEditSaving(false);
setEditingId(null);
}
async function handleDelete(id: string) {
if (!baby) return;
if (!confirm("Supprimer ce vaccin ?")) return;
await fetch(`/api/vaccinations/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["vaccinations", baby.id] });
}
async function handleToggleDone(v: Vaccination) {
if (!baby || isReadonly) return;
setTogglingId(v.id);
const nowDone = !v.done;
await fetch(`/api/vaccinations/${v.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
done: nowDone,
date: nowDone && !v.date ? new Date().toISOString() : v.date ?? undefined,
}),
});
qc.invalidateQueries({ queryKey: ["vaccinations", baby.id] });
setTogglingId(null);
}
function VaccinCard({ v }: { v: Vaccination }) {
const overdue = isOverdue(v);
const toggling = togglingId === v.id;
if (editingId === v.id) {
return (
<div className="bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<form onSubmit={(e) => handleEditSave(e, v.id)} className="space-y-3">
{/* Preset chips */}
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">
Vaccin
</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{PRESET_VACCINES.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setEditForm((f) => ({ ...f, name: preset }))}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition ${
editForm.name === preset
? "bg-indigo-600 text-white border-indigo-600"
: "border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-indigo-400 dark:hover:border-indigo-500"
}`}
>
{preset}
</button>
))}
</div>
<input
type="text"
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
required
placeholder="Nom du vaccin"
className={inputCls}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date prévue
</label>
<input
type="date"
value={editForm.dueDate}
onChange={(e) => setEditForm((f) => ({ ...f, dueDate: e.target.value }))}
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date d&apos;administration
</label>
<input
type="date"
value={editForm.date}
onChange={(e) => setEditForm((f) => ({ ...f, date: e.target.value }))}
className={inputCls}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Notes <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<textarea
value={editForm.notes}
onChange={(e) => setEditForm((f) => ({ ...f, notes: e.target.value }))}
rows={2}
className={inputCls + " resize-none"}
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setEditingId(null)}
className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition"
>
Annuler
</button>
<button
type="submit"
disabled={editSaving}
className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition disabled:opacity-60"
>
{editSaving ? "..." : "Enregistrer"}
</button>
</div>
</form>
</div>
);
}
return (
<div
className={`bg-white dark:bg-slate-800 border rounded-xl px-4 py-3.5 flex items-start gap-3 group transition ${
overdue
? "border-red-200 dark:border-red-800"
: "border-slate-200 dark:border-slate-700"
}`}
>
{/* Done toggle */}
{!isReadonly ? (
<button
onClick={() => handleToggleDone(v)}
disabled={toggling}
className="mt-0.5 flex-shrink-0 text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 disabled:opacity-50 transition"
aria-label={v.done ? "Marquer non fait" : "Marquer fait"}
>
{v.done ? (
<CheckSquare className="w-5 h-5" />
) : (
<Square className="w-5 h-5" />
)}
</button>
) : (
<span className="mt-0.5 flex-shrink-0 text-slate-300 dark:text-slate-600">
{v.done ? (
<CheckSquare className="w-5 h-5" />
) : (
<Square className="w-5 h-5" />
)}
</span>
)}
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{v.name}
</p>
{overdue && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400">
<AlertCircle className="w-3 h-3" />
En retard
</span>
)}
</div>
{v.dueDate && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Prévu le{" "}
<span className={overdue ? "text-red-500 dark:text-red-400 font-medium" : ""}>
{formatDate(v.dueDate)}
</span>
</p>
)}
{v.date && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-0.5">
Administré le {formatDate(v.date)}
</p>
)}
{v.notes && (
<p className="mt-1.5 text-sm text-slate-500 dark:text-slate-400 leading-relaxed">
{v.notes}
</p>
)}
</div>
{/* Actions */}
{!isReadonly && (
<div className="flex gap-1 flex-shrink-0 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(v)}
className="p-1.5 rounded-md text-slate-400 dark:text-slate-500 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-950 transition"
aria-label="Modifier"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(v.id)}
className="p-1.5 rounded-md text-slate-400 dark:text-slate-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition"
aria-label="Supprimer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
}
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Vaccinations
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
{baby?.name}
</p>
</div>
{!isReadonly && (
<button
onClick={() => {
setShowForm(!showForm);
setForm(emptyForm);
}}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition"
>
<Plus className="w-4 h-4" />
Ajouter un vaccin
</button>
)}
</div>
{/* Add form */}
{showForm && !isReadonly && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 mb-6">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">
Nouveau vaccin
</h3>
<form onSubmit={handleSave} className="space-y-4">
{/* Preset chips */}
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">
Vaccin <span className="text-red-400">*</span>
</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{PRESET_VACCINES.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setForm((f) => ({ ...f, name: preset }))}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition ${
form.name === preset
? "bg-indigo-600 text-white border-indigo-600"
: "border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-indigo-400 dark:hover:border-indigo-500"
}`}
>
{preset}
</button>
))}
</div>
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
placeholder="Ou saisir un nom personnalisé"
className={inputCls}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date prévue
</label>
<input
type="date"
value={form.dueDate}
onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))}
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Date d&apos;administration
</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
className={inputCls}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Notes <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<textarea
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={3}
placeholder="Réactions, lot, remarques..."
className={inputCls + " resize-none"}
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowForm(false)}
className="flex-1 py-2.5 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition"
>
Annuler
</button>
<button
type="submit"
disabled={saving}
className="flex-1 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition disabled:opacity-60"
>
{saving ? "Enregistrement..." : "Enregistrer"}
</button>
</div>
</form>
</div>
)}
{/* Empty state */}
{vaccinations.length === 0 && !showForm && (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<p className="text-4xl mb-3">💉</p>
<p className="text-sm">Aucun vaccin enregistré</p>
{!isReadonly && (
<button
onClick={() => setShowForm(true)}
className="mt-3 text-indigo-600 font-medium text-sm hover:underline"
>
Ajouter le premier vaccin
</button>
)}
</div>
)}
{/* À venir section */}
{upcoming.length > 0 && (
<div className="mb-6">
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
À venir
<span className="ml-2 text-slate-300 dark:text-slate-600 font-normal normal-case tracking-normal">
({upcoming.length})
</span>
</h2>
<div className="space-y-2">
{upcoming.map((v) => (
<VaccinCard key={v.id} v={v} />
))}
</div>
</div>
)}
{/* Faits section */}
{done.length > 0 && (
<div>
<button
onClick={() => setShowDone((s) => !s)}
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 hover:text-slate-600 dark:hover:text-slate-300 transition"
>
{showDone ? (
<ChevronUp className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5" />
)}
Faits
<span className="font-normal normal-case tracking-normal text-slate-300 dark:text-slate-600">
({done.length})
</span>
</button>
{showDone && (
<div className="space-y-2">
{done.map((v) => (
<VaccinCard key={v.id} v={v} />
))}
</div>
)}
</div>
)}
</div>
);
}