f5e0f581af
- 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>
313 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|