cd16c354c0
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.
519 lines
18 KiB
TypeScript
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'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>
|
|
);
|
|
}
|