Files
Grow/src/app/(app)/doctor-notes/page.tsx
T
arnaudne f5e0f581af feat: doctor visit log, schedule prediction, chart PDF, natural digest
- Doctor notes: add doctorName, reason, prescriptions, nextAppointmentAt fields (schema + API + UI)
- Dashboard: baby schedule prediction widget (avg nap/bedtime/first-feed over 14 days)
- Growth PDF: embed weight chart as image (SVG→Canvas capture) before data table
- Weekly digest: natural language push notification instead of bullet stats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 22:46:22 +02:00

313 lines
13 KiB
TypeScript

"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;
doctorName?: string | null;
reason?: string | null;
prescriptions?: string | null;
nextAppointmentAt?: string | null;
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 [doctorName, setDoctorName] = useState("");
const [reason, setReason] = useState("");
const [prescriptions, setPrescriptions] = useState("");
const [nextAppointmentAt, setNextAppointmentAt] = useState("");
const [formError, setFormError] = useState<string | null>(null);
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 { 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; doctorName?: string; reason?: string; prescriptions?: string; nextAppointmentAt?: 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);
setDoctorName("");
setReason("");
setPrescriptions("");
setNextAppointmentAt("");
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,
doctorName: doctorName || undefined,
reason: reason || undefined,
prescriptions: prescriptions || undefined,
nextAppointmentAt: nextAppointmentAt || undefined,
});
}
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-600 dark:text-slate-300 mb-1.5">
Médecin <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<input
type="text"
value={doctorName}
onChange={(e) => setDoctorName(e.target.value)}
placeholder="Dr. Dupont"
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Motif <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Visite de routine, fièvre..."
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Prescriptions <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<textarea
value={prescriptions}
onChange={(e) => setPrescriptions(e.target.value)}
rows={2}
placeholder="Doliprane 2.4% si fièvre..."
className={inputCls + " resize-none"}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Prochain rendez-vous <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<input
type="date"
value={nextAppointmentAt}
onChange={(e) => setNextAppointmentAt(e.target.value)}
className={inputCls}
/>
</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>
{note.doctorName && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
<span className="font-medium">Médecin :</span> {note.doctorName}
</p>
)}
{note.reason && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
<span className="font-medium">Motif :</span> {note.reason}
</p>
)}
{note.prescriptions && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 whitespace-pre-line">
<span className="font-medium">Prescriptions :</span> {note.prescriptions}
</p>
)}
{note.nextAppointmentAt && (
<p className="text-xs text-indigo-600 dark:text-indigo-400 mt-1 font-medium">
📅 Prochain RDV : {format(new Date(note.nextAppointmentAt), "d MMMM yyyy", { locale: fr })}
</p>
)}
</div>
))}
</div>
)}
</div>
);
}