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,354 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useBaby } from "@/contexts/baby-context";
|
||||
import {
|
||||
format,
|
||||
subDays,
|
||||
addDays,
|
||||
isToday,
|
||||
isYesterday,
|
||||
isTomorrow,
|
||||
isFuture,
|
||||
startOfDay,
|
||||
parseISO,
|
||||
} from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { ChevronLeft, ChevronRight, CalendarDays, Save, Trash2 } from "lucide-react";
|
||||
|
||||
interface JournalNote {
|
||||
id: string;
|
||||
babyId: string;
|
||||
userId: string;
|
||||
date: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: { id: string; name: string };
|
||||
}
|
||||
|
||||
function formatDateLabel(date: Date): string {
|
||||
if (isToday(date)) return "Aujourd'hui";
|
||||
if (isYesterday(date)) return "Hier";
|
||||
if (isTomorrow(date)) return "Demain";
|
||||
return format(date, "d MMMM yyyy", { locale: fr });
|
||||
}
|
||||
|
||||
function toDateString(date: Date): string {
|
||||
return format(date, "yyyy-MM-dd");
|
||||
}
|
||||
|
||||
export default function NotesPage() {
|
||||
const { data: session } = useSession();
|
||||
const { selectedBaby } = useBaby();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [currentDate, setCurrentDate] = useState<Date>(startOfDay(new Date()));
|
||||
const [draftContent, setDraftContent] = useState<string>("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const babyId = selectedBaby?.id;
|
||||
const dateStr = toDateString(currentDate);
|
||||
|
||||
const userRole = (session?.user as { role?: string } | undefined)?.role;
|
||||
const isReadonly = userRole === "READONLY";
|
||||
|
||||
// Query for current day note
|
||||
const { data: dayNotes, isLoading: isDayLoading } = useQuery<JournalNote[]>({
|
||||
queryKey: ["journal", babyId, dateStr],
|
||||
queryFn: () =>
|
||||
fetch(`/api/journal?babyId=${babyId}&date=${dateStr}`).then((r) => r.json()),
|
||||
enabled: !!babyId,
|
||||
});
|
||||
|
||||
const currentNote = dayNotes?.[0] ?? null;
|
||||
|
||||
// Query for recent notes (last 10 days that have notes)
|
||||
const { data: recentNotes } = useQuery<JournalNote[]>({
|
||||
queryKey: ["journal-recent", babyId],
|
||||
queryFn: () =>
|
||||
fetch(`/api/journal?babyId=${babyId}`).then((r) => r.json()),
|
||||
enabled: !!babyId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Build list of last 10 unique days with notes, excluding current date
|
||||
const recentDays: { dateStr: string; note: JournalNote }[] = (() => {
|
||||
if (!recentNotes) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: { dateStr: string; note: JournalNote }[] = [];
|
||||
for (const note of recentNotes) {
|
||||
const ds = toDateString(new Date(note.date));
|
||||
if (ds === dateStr) continue;
|
||||
if (!seen.has(ds)) {
|
||||
seen.add(ds);
|
||||
result.push({ dateStr: ds, note });
|
||||
}
|
||||
if (result.length >= 10) break;
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
// Sync draft content when note or date changes
|
||||
useEffect(() => {
|
||||
if (isDayLoading) return;
|
||||
setDraftContent(currentNote?.content ?? "");
|
||||
setSaveError(null);
|
||||
}, [currentNote?.id, dateStr, isDayLoading]);
|
||||
|
||||
function goToPrevDay() {
|
||||
setCurrentDate((d) => startOfDay(subDays(d, 1)));
|
||||
}
|
||||
|
||||
function goToNextDay() {
|
||||
const next = startOfDay(addDays(currentDate, 1));
|
||||
if (isFuture(next)) return;
|
||||
setCurrentDate(next);
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
setCurrentDate(startOfDay(new Date()));
|
||||
}
|
||||
|
||||
const canGoNext = !isToday(currentDate);
|
||||
|
||||
async function handleSave() {
|
||||
if (!babyId || isReadonly) return;
|
||||
const trimmed = draftContent.trim();
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
if (currentNote) {
|
||||
const res = await fetch(`/api/journal/${currentNote.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: trimmed }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Erreur lors de la sauvegarde");
|
||||
} else {
|
||||
if (!trimmed) return;
|
||||
const res = await fetch("/api/journal", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ babyId, date: dateStr, content: trimmed }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Erreur lors de la sauvegarde");
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["journal", babyId, dateStr] });
|
||||
qc.invalidateQueries({ queryKey: ["journal-recent", babyId] });
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : "Erreur inconnue");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlur() {
|
||||
if (isReadonly) return;
|
||||
const trimmed = draftContent.trim();
|
||||
// Only auto-save if content changed from what's stored
|
||||
const stored = currentNote?.content ?? "";
|
||||
if (trimmed === stored) return;
|
||||
// Don't auto-save empty content if there's no existing note
|
||||
if (!trimmed && !currentNote) return;
|
||||
await handleSave();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!currentNote || isReadonly) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await fetch(`/api/journal/${currentNote.id}`, { method: "DELETE" });
|
||||
setDraftContent("");
|
||||
qc.invalidateQueries({ queryKey: ["journal", babyId, dateStr] });
|
||||
qc.invalidateQueries({ queryKey: ["journal-recent", babyId] });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToDate(ds: string) {
|
||||
setCurrentDate(startOfDay(parseISO(ds)));
|
||||
}
|
||||
|
||||
const hasUnsavedChanges = draftContent.trim() !== (currentNote?.content ?? "").trim();
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Journal
|
||||
</h1>
|
||||
{selectedBaby && (
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500 mt-1">
|
||||
Notes quotidiennes pour {selectedBaby.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedBaby ? (
|
||||
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
|
||||
Sélectionnez un bébé pour voir le journal.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Date Navigation */}
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={goToPrevDay}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400 transition-colors"
|
||||
aria-label="Jour précédent"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<span className="text-lg font-semibold text-slate-900 dark:text-slate-100 capitalize">
|
||||
{formatDateLabel(currentDate)}
|
||||
</span>
|
||||
{!isToday(currentDate) && (
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
{format(currentDate, "EEEE d MMMM yyyy", { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{!isToday(currentDate) && (
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-indigo-500 dark:text-indigo-400 transition-colors"
|
||||
aria-label="Aujourd'hui"
|
||||
title="Aujourd'hui"
|
||||
>
|
||||
<CalendarDays className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={goToNextDay}
|
||||
disabled={!canGoNext}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="Jour suivant"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note Editor / Viewer */}
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-6">
|
||||
{isDayLoading ? (
|
||||
<div className="h-32 flex items-center justify-center">
|
||||
<div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : isReadonly ? (
|
||||
<div className="min-h-[8rem]">
|
||||
{currentNote ? (
|
||||
<p className="text-slate-700 dark:text-slate-300 whitespace-pre-wrap leading-relaxed">
|
||||
{currentNote.content}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-slate-400 dark:text-slate-500 italic">
|
||||
Aucune note pour ce jour.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draftContent}
|
||||
onChange={(e) => setDraftContent(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder="Notes du jour..."
|
||||
rows={6}
|
||||
className="w-full resize-none bg-transparent text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 text-sm leading-relaxed focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{currentNote && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{saveError && (
|
||||
<span className="text-xs text-red-500">{saveError}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasUnsavedChanges || (!draftContent.trim() && !currentNote)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{isSaving ? "Sauvegarde…" : "Sauvegarder"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{currentNote && (
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-2">
|
||||
Dernière modification le{" "}
|
||||
{format(new Date(currentNote.updatedAt), "d MMM yyyy à HH:mm", { locale: fr })}
|
||||
{currentNote.user?.name ? ` par ${currentNote.user.name}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Notes */}
|
||||
{recentDays.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">
|
||||
Notes récentes
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{recentDays.map(({ dateStr: ds, note }) => {
|
||||
const d = parseISO(ds);
|
||||
return (
|
||||
<button
|
||||
key={ds}
|
||||
onClick={() => jumpToDate(ds)}
|
||||
className="text-left bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 hover:border-indigo-300 dark:hover:border-indigo-600 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-indigo-600 dark:text-indigo-400 mb-1 capitalize">
|
||||
{formatDateLabel(d)}
|
||||
{!isYesterday(d) && !isToday(d) && (
|
||||
<span className="text-slate-400 dark:text-slate-500 font-normal ml-1">
|
||||
· {format(d, "d MMMM", { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300 line-clamp-2 leading-relaxed">
|
||||
{note.content}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-400 flex-shrink-0 mt-0.5 transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user