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,223 @@
|
||||
"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;
|
||||
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 [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
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 }) =>
|
||||
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);
|
||||
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 });
|
||||
}
|
||||
|
||||
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-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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user