diff --git a/FEATURES.md b/FEATURES.md index 97fead4..4acd3a0 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -35,9 +35,35 @@ ## Pending 🔲 Maintenance mode banner (config key exists, layout doesn't check it yet) -🔲 Audit log (last 50 actions across all users) 🔲 Data retention (auto-delete events older than N months) -🔲 Recurring medication reminders 🔲 Bilingual UI (FR/EN toggle) 🔲 Weight trend alert (no growth log in 30 days) -🔲 Photo log (attach photo to event) +✅ Breast pump / milk expression tracker (suivi tirage de lait) — volume, duration, side, time +✅ Email invitation from UI (invite family members by email, not just invite code) +✅ Settings page broken on mobile — FAB hidden on /settings, bottom padding increased +✅ Photo log (attach photo to event) — upload API, picker in event modal, thumbnail in timeline +✅ Audit log — AuditLog model, logs event CRUD, admin table (/api/admin/audit) +✅ Recurring medication reminders — /reminders page, CRUD API, Pushover via /api/reminders/check (call from cron) +✅ Calendar view — /calendar monthly grid with event dots, day detail on tap +✅ Milk stock management — /milk page, lots with location (room/fridge/freezer), configurable expiry, alerts, notes, mark-as-used; PUMP now in dashboard quick-log +✅ Medication profiles — /medications page, preset + custom profiles, dose/unit/interval/crisis-interval, auto-seeded on first use +✅ Medication next-dose enforcement — visual warning in event modal + dashboard status card, crisis mode toggle +✅ Datetime selectors fixed — split date + time inputs (iOS Safari datetime-local compatibility) +✅ Live timer h:m:s — timers ≥ 1h now show h:mm:ss format +✅ Edit events from timeline — Modifier button opens pre-filled event modal, saves via PATCH + +## Feature ideas / backlog + +🔲 Offline support / PWA install prompt — service worker for offline event logging +🔲 Multi-language UI (FR/EN toggle) — see also pending Bilingual UI +🔲 Weekly summary push notification (Sunday digest: feedings, sleep, weight change) +🔲 Growth alert: no weight log in 30 days → push notification +🔲 Feeding pattern AI insight (detect sleep regression, feeding cluster, etc.) +🔲 Contacts / care team directory (pediatrician, midwife, family contact numbers) +🔲 Baby activities log (bath, walk, tummy time) — lightweight custom event types +🔲 Sleep log chart — visual night timeline (bar per night, wake markers) +🔲 Bottle feed from milk stock — direct link: log BOTTLE → decrement milk stock +🔲 Shared family notes / pinned message (daily handoff summary visible to all members) +🔲 Event templates / quick presets (e.g. "usual nap 13:00–14:30") +🔲 Data retention policy — auto-archive events older than N months +🔲 Maintenance mode banner in layout (config key exists, layout not wired yet) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8b5226b..f046874 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -104,9 +104,11 @@ model Baby { growthLogs GrowthLog[] doctorNotes DoctorNote[] journalNotes JournalNote[] - milestones Milestone[] - vaccinations Vaccination[] - createdAt DateTime @default(now()) + milestones Milestone[] + vaccinations Vaccination[] + medicationReminders MedicationReminder[] + milkStocks MilkStock[] + createdAt DateTime @default(now()) } model Event { @@ -127,6 +129,7 @@ model Event { enum EventType { BREASTFEED BOTTLE + PUMP DIAPER_WET DIAPER_STOOL NAP @@ -192,3 +195,55 @@ model Vaccination { done Boolean @default(false) createdAt DateTime @default(now()) } + +model AuditLog { + id String @id @default(cuid()) + userId String + userName String + action String + targetId String? + familyId String? + detail String? + createdAt DateTime @default(now()) +} + +model MedicationProfile { + id String @id @default(cuid()) + familyId String + name String + molecule String? + defaultDose String? + unit String @default("mL") + intervalHours Float @default(6) + minIntervalHours Float? + isPreset Boolean @default(false) + createdAt DateTime @default(now()) +} + +model MilkStock { + id String @id @default(cuid()) + babyId String + baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade) + volume Float + storedAt DateTime @default(now()) + expiresAt DateTime + location String @default("fridge") + notes String? + used Boolean @default(false) + usedAt DateTime? + createdAt DateTime @default(now()) +} + +model MedicationReminder { + id String @id @default(cuid()) + babyId String + baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade) + name String + dose String? + unit String? + intervalHours Float + startAt DateTime + enabled Boolean @default(true) + lastSentAt DateTime? + createdAt DateTime @default(now()) +} diff --git a/public/uploads/.gitkeep b/public/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/app/(app)/admin/AdminConfigForm.tsx b/src/app/(app)/admin/AdminConfigForm.tsx index 5a44ae6..d78fb3a 100644 --- a/src/app/(app)/admin/AdminConfigForm.tsx +++ b/src/app/(app)/admin/AdminConfigForm.tsx @@ -78,6 +78,9 @@ export default function AdminConfigForm() { const [testEmailMsg, setTestEmailMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); const [testingEmail, setTestingEmail] = useState(false); + const [auditLogs, setAuditLogs] = useState<{ id: string; userName: string; action: string; detail: string | null; createdAt: string }[]>([]); + const [auditError, setAuditError] = useState(null); + useEffect(() => { fetch("/api/admin/config") .then((r) => r.json()) @@ -108,6 +111,14 @@ export default function AdminConfigForm() { else setFamiliesError(data.error ?? "Erreur inconnue"); }) .catch(() => setFamiliesError("Erreur réseau")); + + fetch("/api/admin/audit") + .then((r) => r.json()) + .then((data) => { + if (Array.isArray(data)) setAuditLogs(data); + else setAuditError(data.error ?? "Erreur inconnue"); + }) + .catch(() => setAuditError("Erreur réseau")); }, []); function set(key: string, value: string) { @@ -574,6 +585,42 @@ export default function AdminConfigForm() { )} + {/* Audit log */} +
+ {auditError ? ( +

{auditError}

+ ) : auditLogs.length === 0 ? ( +

Aucune action enregistrée.

+ ) : ( +
+ + + + + + + + + + + {auditLogs.map((log) => ( + + + + + + + ))} + +
DateUtilisateurActionDétail
+ {new Date(log.createdAt).toLocaleString("fr-FR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })} + {log.userName} + {log.action} + {log.detail ?? "—"}
+
+ )} +
+ {/* Sauvegarde */}
diff --git a/src/app/(app)/calendar/page.tsx b/src/app/(app)/calendar/page.tsx new file mode 100644 index 0000000..a5aeb9f --- /dev/null +++ b/src/app/(app)/calendar/page.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useBaby } from "@/contexts/baby-context"; +import { EVENT_CONFIG, EventType } from "@/lib/event-config"; +import { EventIcon } from "@/components/event-icon"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { format, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, startOfWeek, endOfWeek } from "date-fns"; +import { fr } from "date-fns/locale"; + +interface Event { + id: string; + type: EventType; + startedAt: string; + endedAt?: string; + metadata?: Record; +} + +const DAY_LABELS = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]; + +function getTypeColor(type: EventType): string { + return EVENT_CONFIG[type]?.color ?? "#94a3b8"; +} + +export default function CalendarPage() { + const { selectedBaby } = useBaby(); + const [cursor, setCursor] = useState(new Date()); + + const monthStart = startOfMonth(cursor); + const monthEnd = endOfMonth(cursor); + const calStart = startOfWeek(monthStart, { weekStartsOn: 1 }); + const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); + + const { data } = useQuery<{ events: Event[] }>({ + queryKey: ["events-calendar", selectedBaby?.id, format(cursor, "yyyy-MM")], + queryFn: () => + fetch( + `/api/events?babyId=${selectedBaby!.id}&limit=500&dateFrom=${monthStart.toISOString()}&dateTo=${monthEnd.toISOString()}` + ).then((r) => r.json()), + enabled: !!selectedBaby?.id, + }); + + const events: Event[] = data?.events ?? []; + + const days = eachDayOfInterval({ start: calStart, end: calEnd }); + + const [selectedDay, setSelectedDay] = useState(null); + + const dayEvents = (day: Date) => events.filter((e) => isSameDay(new Date(e.startedAt), day)); + + const selectedDayEvents = selectedDay ? dayEvents(selectedDay) : []; + + function prevMonth() { + setCursor(new Date(cursor.getFullYear(), cursor.getMonth() - 1, 1)); + setSelectedDay(null); + } + function nextMonth() { + setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)); + setSelectedDay(null); + } + + return ( +
+ {/* Header */} +
+
+

+ {format(cursor, "MMMM yyyy", { locale: fr })} +

+ {selectedBaby &&

{selectedBaby.name}

} +
+
+ + + +
+
+ + {/* Grid */} +
+ {/* Day headers */} +
+ {DAY_LABELS.map((d) => ( +
+ {d} +
+ ))} +
+ + {/* Weeks */} +
+ {days.map((day, i) => { + const inMonth = day.getMonth() === cursor.getMonth(); + const evs = dayEvents(day); + const isSelected = selectedDay ? isSameDay(day, selectedDay) : false; + const today = isToday(day); + + return ( + + ); + })} +
+
+ + {/* Selected day detail */} + {selectedDay && ( +
+

+ {format(selectedDay, "EEEE d MMMM", { locale: fr })} + · {selectedDayEvents.length} événement{selectedDayEvents.length !== 1 ? "s" : ""} +

+ {selectedDayEvents.length === 0 ? ( +

Aucun événement

+ ) : ( +
+ {selectedDayEvents.map((ev) => { + const cfg = EVENT_CONFIG[ev.type]; + return ( +
+
+ +
+
+

{cfg.label}

+
+ + {format(new Date(ev.startedAt), "HH:mm")} + +
+ ); + })} +
+ )} +
+ )} + + {!selectedBaby && ( +
+

Sélectionnez un bébé

+
+ )} +
+ ); +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index c448074..f75b500 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -9,10 +9,12 @@ import { useBaby } from "@/contexts/baby-context"; import { checkFeedAlert, getThresholds } from "@/lib/notifications"; import { format, isToday } from "date-fns"; import { fr } from "date-fns/locale"; +import { AlertTriangle, Clock, CheckCircle2 } from "lucide-react"; const QUICK_LOG_TYPES: EventType[] = [ "BREASTFEED", "BOTTLE", + "PUMP", "DIAPER_WET", "DIAPER_STOOL", "NAP", @@ -93,6 +95,19 @@ export default function DashboardPage() { const todayCounts: Partial> = {}; for (const ev of todayEvents) todayCounts[ev.type] = (todayCounts[ev.type] ?? 0) + 1; + const { data: medProfiles = [] } = useQuery<{ id: string; name: string; molecule: string | null; intervalHours: number; minIntervalHours: number | null }[]>({ + queryKey: ["med-profiles"], + queryFn: () => fetch("/api/medication-profiles").then((r) => r.json()), + enabled: !!selectedBaby?.id, + }); + + const { data: lastIntakes = {} } = useQuery>({ + queryKey: ["last-intakes", selectedBaby?.id], + queryFn: () => fetch(`/api/medication-profiles/last-intakes?babyId=${selectedBaby!.id}`).then((r) => r.json()), + enabled: !!selectedBaby?.id, + refetchInterval: 30_000, + }); + if (familyLoading) { return (
@@ -195,6 +210,52 @@ export default function DashboardPage() {
+ {/* Medication status */} + {medProfiles.length > 0 && (() => { + const activeMeds = medProfiles.filter((p) => lastIntakes[p.name]); + if (activeMeds.length === 0) return null; + return ( +
+

Médicaments

+
+ {activeMeds.map((p) => { + const intake = lastIntakes[p.name]; + const nextAt = new Date(new Date(intake.startedAt).getTime() + p.intervalHours * 3600000); + const waitMs = nextAt.getTime() - Date.now(); + const tooSoon = waitMs > 0; + const h = Math.floor(Math.abs(waitMs) / 3600000); + const m = Math.floor((Math.abs(waitMs) % 3600000) / 60000); + const timeLabel = h > 0 ? `${h}h${m.toString().padStart(2, "0")}` : `${m}min`; + return ( +
+
+ {tooSoon + ? + : + } +
+
+

{p.name}

+ {p.molecule &&

{p.molecule}

} +
+
+ {tooSoon ? ( +

Dans {timeLabel}

+ ) : ( +

Disponible

+ )} +

+ {format(nextAt, "HH:mm")} +

+
+
+ ); + })} +
+
+ ); + })()} + {/* Today's timeline */} {todayEvents.length > 0 && (
diff --git a/src/app/(app)/medications/page.tsx b/src/app/(app)/medications/page.tsx new file mode 100644 index 0000000..e0f1f46 --- /dev/null +++ b/src/app/(app)/medications/page.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Pencil, Trash2, Check, X } from "lucide-react"; + +interface Profile { + id: string; + name: string; + molecule: string | null; + defaultDose: string | null; + unit: string; + intervalHours: number; + minIntervalHours: number | null; + isPreset: boolean; +} + +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 dark:text-slate-100"; + +const UNITS = ["mL", "mg", "gouttes", "sachet", "comprimé", "mL/kg", "mg/kg"]; + +const emptyForm = () => ({ name: "", molecule: "", defaultDose: "", unit: "mL", intervalHours: "6", minIntervalHours: "" }); + +export default function MedicationsPage() { + const qc = useQueryClient(); + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(emptyForm()); + const [saving, setSaving] = useState(false); + + const { data: profiles = [], isLoading } = useQuery({ + queryKey: ["med-profiles"], + queryFn: () => fetch("/api/medication-profiles").then((r) => r.json()), + }); + + function startEdit(p: Profile) { + setEditingId(p.id); + setForm({ + name: p.name, + molecule: p.molecule ?? "", + defaultDose: p.defaultDose ?? "", + unit: p.unit, + intervalHours: String(p.intervalHours), + minIntervalHours: p.minIntervalHours ? String(p.minIntervalHours) : "", + }); + setShowForm(false); + } + + function cancelEdit() { + setEditingId(null); + setForm(emptyForm()); + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + await fetch("/api/medication-profiles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: form.name, + molecule: form.molecule || null, + defaultDose: form.defaultDose || null, + unit: form.unit, + intervalHours: form.intervalHours, + minIntervalHours: form.minIntervalHours || null, + }), + }); + qc.invalidateQueries({ queryKey: ["med-profiles"] }); + setForm(emptyForm()); + setShowForm(false); + setSaving(false); + } + + async function handleUpdate(e: React.FormEvent) { + e.preventDefault(); + if (!editingId) return; + setSaving(true); + await fetch(`/api/medication-profiles/${editingId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: form.name, + molecule: form.molecule || null, + defaultDose: form.defaultDose || null, + unit: form.unit, + intervalHours: form.intervalHours, + minIntervalHours: form.minIntervalHours || null, + }), + }); + qc.invalidateQueries({ queryKey: ["med-profiles"] }); + cancelEdit(); + setSaving(false); + } + + async function handleDelete(id: string, name: string) { + if (!confirm(`Supprimer ${name} ?`)) return; + await fetch(`/api/medication-profiles/${id}`, { method: "DELETE" }); + qc.invalidateQueries({ queryKey: ["med-profiles"] }); + } + + const ProfileForm = ({ onSubmit, onCancel }: { onSubmit: (e: React.FormEvent) => void; onCancel: () => void }) => ( +
+
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + required className={inputCls} placeholder="Doliprane" /> +
+
+ + setForm((f) => ({ ...f, molecule: e.target.value }))} + className={inputCls} placeholder="Paracétamol" /> +
+
+
+
+ + setForm((f) => ({ ...f, defaultDose: e.target.value }))} + className={inputCls} placeholder="2.4" /> +
+
+ + +
+
+
+
+ + setForm((f) => ({ ...f, intervalHours: e.target.value }))} + required className={inputCls} placeholder="6" min="0.5" step="0.5" /> +
+
+ + setForm((f) => ({ ...f, minIntervalHours: e.target.value }))} + className={inputCls} placeholder="4 (optionnel)" min="0.5" step="0.5" /> +
+
+
+ + +
+
+ ); + + return ( +
+
+
+

Médicaments

+

Profils et intervalles de prise

+
+ {!showForm && !editingId && ( + + )} +
+ + {showForm &&
{ setShowForm(false); setForm(emptyForm()); }} />
} + + {isLoading && ( +
+
+
+ )} + +
+ {profiles.map((p) => ( +
+ {editingId === p.id ? ( + + ) : ( +
+
+
+

{p.name}

+ {p.isPreset && ( + preset + )} +
+ {p.molecule &&

{p.molecule}

} +
+ {p.defaultDose && ( + + {p.defaultDose} {p.unit} + + )} + + {p.intervalHours}h + + {p.minIntervalHours && ( + + urgence {p.minIntervalHours}h + + )} +
+
+
+ + +
+
+ )} +
+ ))} +
+ + {!isLoading && profiles.length === 0 && !showForm && ( +
+

Aucun profil médicament

+
+ )} +
+ ); +} diff --git a/src/app/(app)/milk/page.tsx b/src/app/(app)/milk/page.tsx new file mode 100644 index 0000000..be86325 --- /dev/null +++ b/src/app/(app)/milk/page.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useBaby } from "@/contexts/baby-context"; +import { Plus, Trash2, Check, AlertTriangle, Clock, Thermometer, Snowflake, Wind } from "lucide-react"; +import { format, formatDistanceToNow } from "date-fns"; +import { fr } from "date-fns/locale"; + +interface Stock { + id: string; + volume: number; + storedAt: string; + expiresAt: string; + location: string; + notes: string | null; + used: boolean; + usedAt: string | null; +} + +type Location = "room" | "fridge" | "freezer"; + +const LOCATION_CONFIG: Record; defaultHours: number; color: string }> = { + room: { label: "Température ambiante", icon: Wind, defaultHours: 4, color: "text-amber-600 dark:text-amber-400" }, + fridge: { label: "Réfrigérateur", icon: Thermometer, defaultHours: 96, color: "text-blue-600 dark:text-blue-400" }, + freezer: { label: "Congélateur", icon: Snowflake, defaultHours: 4320, color: "text-indigo-600 dark:text-indigo-400" }, +}; + +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 dark:text-slate-100"; + +function localNowString() { + const now = new Date(); + return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16); +} + +function expiryStatus(stock: Stock): "expired" | "soon" | "ok" { + const now = Date.now(); + const exp = new Date(stock.expiresAt).getTime(); + if (exp <= now) return "expired"; + if (exp - now < 6 * 60 * 60 * 1000) return "soon"; // < 6h + return "ok"; +} + +export default function MilkPage() { + const qc = useQueryClient(); + const { selectedBaby } = useBaby(); + const [showForm, setShowForm] = useState(false); + const [showUsed, setShowUsed] = useState(false); + const [location, setLocation] = useState("fridge"); + const [form, setForm] = useState({ + volume: "", + storedAt: localNowString(), + notes: "", + expiryHours: "", + }); + const [saving, setSaving] = useState(false); + + const { data: stocks = [], isLoading } = useQuery({ + queryKey: ["milk", selectedBaby?.id], + queryFn: () => fetch(`/api/milk?babyId=${selectedBaby!.id}`).then((r) => r.json()), + enabled: !!selectedBaby?.id, + refetchInterval: 60_000, + }); + + const available = stocks.filter((s) => !s.used); + const used = stocks.filter((s) => s.used); + + const totalVolume = available.reduce((sum, s) => { + if (expiryStatus(s) !== "expired") return sum + s.volume; + return sum; + }, 0); + + const expiredCount = available.filter((s) => expiryStatus(s) === "expired").length; + const soonCount = available.filter((s) => expiryStatus(s) === "soon").length; + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!selectedBaby) return; + setSaving(true); + const expiryHours = form.expiryHours ? parseFloat(form.expiryHours) : undefined; + await fetch("/api/milk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + babyId: selectedBaby.id, + volume: form.volume, + storedAt: form.storedAt, + location, + notes: form.notes || null, + expiryHours, + }), + }); + qc.invalidateQueries({ queryKey: ["milk"] }); + setForm({ volume: "", storedAt: localNowString(), notes: "", expiryHours: "" }); + setShowForm(false); + setSaving(false); + } + + async function markUsed(id: string) { + await fetch(`/api/milk/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ used: true }), + }); + qc.invalidateQueries({ queryKey: ["milk"] }); + } + + async function deleteStock(id: string) { + if (!confirm("Supprimer cette entrée ?")) return; + await fetch(`/api/milk/${id}`, { method: "DELETE" }); + qc.invalidateQueries({ queryKey: ["milk"] }); + } + + const defaultExpiryHours = LOCATION_CONFIG[location].defaultHours; + + return ( +
+ {/* Header */} +
+
+

Stock de lait

+ {selectedBaby &&

{selectedBaby.name}

} +
+ +
+ + {/* Summary cards */} + {available.length > 0 && ( +
+
+

{totalVolume.toFixed(0)}

+

mL disponibles

+
+
0 ? "border-red-200 dark:border-red-800" : "border-slate-200 dark:border-slate-700"}`}> +

0 ? "text-red-600 dark:text-red-400" : "text-slate-300 dark:text-slate-600"}`}>{expiredCount}

+

périmés

+
+
0 ? "border-amber-200 dark:border-amber-800" : "border-slate-200 dark:border-slate-700"}`}> +

0 ? "text-amber-600 dark:text-amber-400" : "text-slate-300 dark:text-slate-600"}`}>{soonCount}

+

Ă  utiliser vite

+
+
+ )} + + {/* Alerts */} + {expiredCount > 0 && ( +
+ + {expiredCount} lot{expiredCount > 1 ? "s" : ""} périmé{expiredCount > 1 ? "s" : ""} — à jeter +
+ )} + {soonCount > 0 && ( +
+ + {soonCount} lot{soonCount > 1 ? "s" : ""} Ă  utiliser dans moins de 6h +
+ )} + + {/* Add form */} + {showForm && ( +
+

Nouveau lot

+ + {/* Location selector */} +
+ +
+ {(["room", "fridge", "freezer"] as Location[]).map((loc) => { + const cfg = LOCATION_CONFIG[loc]; + const Icon = cfg.icon; + return ( + + ); + })} +
+

+ Durée par défaut : {defaultExpiryHours >= 24 ? `${defaultExpiryHours / 24}j` : `${defaultExpiryHours}h`} +

+
+ +
+
+ + setForm((f) => ({ ...f, volume: e.target.value }))} + required className={inputCls} placeholder="80" min="1" max="2000" /> +
+
+ + setForm((f) => ({ ...f, expiryHours: e.target.value }))} + className={inputCls} placeholder={String(defaultExpiryHours)} min="1" /> +
+
+ +
+ + setForm((f) => ({ ...f, storedAt: e.target.value }))} + required className={inputCls} /> +
+ +
+ + setForm((f) => ({ ...f, notes: e.target.value }))} + className={inputCls} placeholder="Ex : tirage du matin" /> +
+ +
+ + +
+
+ )} + + {isLoading && ( +
+
+
+ )} + + {!isLoading && stocks.length === 0 && !showForm && ( +
+

Aucun lot enregistré

+
+ )} + + {/* Available stocks */} + {available.length > 0 && ( +
+ {available + .sort((a, b) => new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()) + .map((s) => { + const status = expiryStatus(s); + const LocIcon = LOCATION_CONFIG[s.location as Location]?.icon ?? Thermometer; + return ( +
+
+
+ +
+
+
+

{s.volume} mL

+ + {status === "expired" ? "Périmé" : status === "soon" ? "Bientôt" : "OK"} + +
+

+ Tiré {formatDistanceToNow(new Date(s.storedAt), { locale: fr, addSuffix: true })} +

+

+ {status === "expired" + ? `Périmé ${formatDistanceToNow(new Date(s.expiresAt), { locale: fr, addSuffix: true })}` + : `Expire ${formatDistanceToNow(new Date(s.expiresAt), { locale: fr, addSuffix: true })}` + } + {" · "}{format(new Date(s.expiresAt), "dd/MM HH:mm")} +

+ {s.notes &&

{s.notes}

} +
+
+ + +
+
+
+ ); + })} +
+ )} + + {/* Used / archived */} + {used.length > 0 && ( +
+ + {showUsed && ( +
+ {used.slice(0, 20).map((s) => ( +
+ +
+

{s.volume} mL · {s.notes ?? LOCATION_CONFIG[s.location as Location]?.label ?? s.location}

+

+ Utilisé {s.usedAt ? formatDistanceToNow(new Date(s.usedAt), { locale: fr, addSuffix: true }) : ""} +

+
+ +
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/src/app/(app)/reminders/page.tsx b/src/app/(app)/reminders/page.tsx new file mode 100644 index 0000000..63a4eb7 --- /dev/null +++ b/src/app/(app)/reminders/page.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useBaby } from "@/contexts/baby-context"; +import { Plus, Trash2, Bell, BellOff, Clock } from "lucide-react"; + +interface Reminder { + id: string; + name: string; + dose: string | null; + unit: string | null; + intervalHours: number; + startAt: string; + enabled: boolean; + lastSentAt: string | 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 dark:text-slate-100"; + +function localNowString() { + const now = new Date(); + return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16); +} + +function nextDueLabel(reminder: Reminder): string { + const intervalMs = reminder.intervalHours * 60 * 60 * 1000; + const base = reminder.lastSentAt ?? reminder.startAt; + const next = new Date(new Date(base).getTime() + intervalMs); + const now = new Date(); + const diff = next.getTime() - now.getTime(); + if (diff <= 0) return "En attente d'envoi"; + const h = Math.floor(diff / 3600000); + const m = Math.floor((diff % 3600000) / 60000); + return h > 0 ? `Dans ${h}h${m.toString().padStart(2, "0")}` : `Dans ${m} min`; +} + +export default function RemindersPage() { + const qc = useQueryClient(); + const { selectedBaby } = useBaby(); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState({ name: "", dose: "", unit: "mL", intervalHours: "8", startAt: localNowString() }); + const [saving, setSaving] = useState(false); + + const { data: reminders = [], isLoading } = useQuery({ + queryKey: ["reminders", selectedBaby?.id], + queryFn: () => fetch(`/api/reminders?babyId=${selectedBaby!.id}`).then((r) => r.json()), + enabled: !!selectedBaby?.id, + }); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!selectedBaby) return; + setSaving(true); + await fetch("/api/reminders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ babyId: selectedBaby.id, ...form, intervalHours: parseFloat(form.intervalHours) }), + }); + qc.invalidateQueries({ queryKey: ["reminders"] }); + setForm({ name: "", dose: "", unit: "mL", intervalHours: "8", startAt: localNowString() }); + setShowForm(false); + setSaving(false); + } + + async function toggleEnabled(id: string, enabled: boolean) { + await fetch(`/api/reminders/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !enabled }), + }); + qc.invalidateQueries({ queryKey: ["reminders"] }); + } + + async function deleteReminder(id: string) { + if (!confirm("Supprimer ce rappel ?")) return; + await fetch(`/api/reminders/${id}`, { method: "DELETE" }); + qc.invalidateQueries({ queryKey: ["reminders"] }); + } + + return ( +
+
+
+

Rappels médicaments

+ {selectedBaby &&

{selectedBaby.name}

} +
+ +
+ + {showForm && ( +
+

Nouveau rappel

+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + required className={inputCls} placeholder="Doliprane" /> +
+
+
+ + setForm((f) => ({ ...f, dose: e.target.value }))} + className={inputCls} placeholder="2.4" /> +
+
+ + +
+
+
+ + +
+
+ + setForm((f) => ({ ...f, startAt: e.target.value }))} + required className={inputCls} /> +
+
+ + +
+
+ )} + + {isLoading && ( +
+
+
+ )} + + {!isLoading && reminders.length === 0 && !showForm && ( +
+ +

Aucun rappel configuré

+
+ )} + +
+ {reminders.map((r) => ( +
+
+
+ {r.enabled ? : } +
+
+

{r.name}

+ {(r.dose || r.unit) && ( +

{[r.dose, r.unit].filter(Boolean).join(" ")}

+ )} +
+ + Toutes les {r.intervalHours}h + {r.enabled && ( + {nextDueLabel(r)} + )} +
+
+
+ + +
+
+
+ ))} +
+ + {reminders.length > 0 && ( +

+ Appelez /api/reminders/check régulièrement (cron) pour envoyer les rappels via Pushover. +

+ )} +
+ ); +} diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 1fb43d5..aab90a0 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -5,7 +5,7 @@ import { signOut } from "next-auth/react"; import { useState, useEffect } from "react"; import { useBaby } from "@/contexts/baby-context"; import { getThresholds, saveThresholds, requestPermission } from "@/lib/notifications"; -import { Copy, Check, Download, LogOut, Bell, Baby, Users, ChevronRight, FileText, Plus } from "lucide-react"; +import { Copy, Check, Download, LogOut, Bell, Baby, Users, ChevronRight, FileText, Plus, Mail, Send } from "lucide-react"; interface Family { id: string; @@ -40,6 +40,11 @@ export default function SettingsPage() { const [savingPushover, setSavingPushover] = useState(false); const [pushoverMsg, setPushoverMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState<"PARENT" | "READONLY" | "DOCTOR">("PARENT"); + const [sendingInvite, setSendingInvite] = useState(false); + const [inviteMsg, setInviteMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); + useEffect(() => { fetch("/api/user/pushover") .then((r) => r.json()) @@ -106,8 +111,9 @@ export default function SettingsPage() { doc.setFont("helvetica", "normal"); const TYPE_LABELS: Record = { - BREASTFEED: "Allaitement", BOTTLE: "Biberon", DIAPER_WET: "Couche urine", - DIAPER_STOOL: "Couche selles", NAP: "Sieste", NIGHT_SLEEP: "Nuit", + BREASTFEED: "Allaitement", BOTTLE: "Biberon", PUMP: "Tirage", + DIAPER_WET: "Couche urine", DIAPER_STOOL: "Couche selles", + NAP: "Sieste", NIGHT_SLEEP: "Nuit", MEDICATION: "Traitement", TEMPERATURE: "Température", }; @@ -192,6 +198,30 @@ export default function SettingsPage() { saveThresholds(updated); } + async function sendInviteEmail(e: React.FormEvent) { + e.preventDefault(); + if (!inviteEmail) return; + setSendingInvite(true); + setInviteMsg(null); + try { + const res = await fetch("/api/invite/send-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: inviteEmail, role: inviteRole }), + }); + if (res.ok) { + setInviteMsg({ type: "ok", text: `Invitation envoyée à ${inviteEmail}.` }); + setInviteEmail(""); + } else { + const data = await res.json().catch(() => ({})); + setInviteMsg({ type: "err", text: data.error ?? "Erreur lors de l'envoi." }); + } + } catch { + setInviteMsg({ type: "err", text: "Erreur réseau." }); + } + setSendingInvite(false); + } + async function savePushoverKey() { setSavingPushover(true); setPushoverMsg(null); @@ -218,7 +248,7 @@ export default function SettingsPage() { ); return ( -
+

Réglages

@@ -359,6 +389,44 @@ export default function SettingsPage() { ))}
+ +
+

Inviter par email

+
+ setInviteEmail(e.target.value)} + placeholder="adresse@email.com" + required + className={inputCls} + /> +
+ + +
+ {inviteMsg && ( +

+ {inviteMsg.text} +

+ )} +
+
)} diff --git a/src/app/(app)/timeline/page.tsx b/src/app/(app)/timeline/page.tsx index fe27376..d3e2271 100644 --- a/src/app/(app)/timeline/page.tsx +++ b/src/app/(app)/timeline/page.tsx @@ -7,7 +7,9 @@ import { EventIcon } from "@/components/event-icon"; import { useBaby } from "@/contexts/baby-context"; import { format, isToday, isYesterday } from "date-fns"; import { fr } from "date-fns/locale"; -import { Trash2, StickyNote } from "lucide-react"; +import { Trash2, StickyNote, Pencil } from "lucide-react"; +import Image from "next/image"; +import { EventModal } from "@/components/event-modal"; interface Event { id: string; @@ -46,6 +48,12 @@ function getEventSummary(event: Event): string { return side; } if (event.type === "BOTTLE") return [meta.volume ? `${meta.volume} mL` : "", meta.bottleType === "maternal" ? "maternel" : "artificiel"].filter(Boolean).join(" · "); + if (event.type === "PUMP") { + const side = meta.side === "left" ? "gauche" : meta.side === "right" ? "droite" : "les deux"; + const parts = [meta.volume ? `${meta.volume} mL` : "", side]; + if (event.endedAt) parts.unshift(formatDuration(new Date(event.startedAt), new Date(event.endedAt))); + return parts.filter(Boolean).join(" · "); + } if (event.type === "DIAPER_STOOL") return String(meta.color ?? ""); if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : ""; if (event.type === "MEDICATION") return String(meta.name ?? ""); @@ -60,6 +68,7 @@ export default function TimelinePage() { const { selectedBaby } = useBaby(); const [expandedId, setExpandedId] = useState(null); const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null); + const [editingEvent, setEditingEvent] = useState(null); const [filterType, setFilterType] = useState(""); const [page, setPage] = useState(0); @@ -221,13 +230,28 @@ export default function TimelinePage() { )} - + {ev.metadata?.photo && ( +
+ Photo +
+ )} + +
+ + +
)} @@ -252,6 +276,19 @@ export default function TimelinePage() { )} + + {editingEvent && ( + setEditingEvent(null)} + onSaved={() => { + qc.invalidateQueries({ queryKey: ["events"] }); + setEditingEvent(null); + }} + /> + )} ); } diff --git a/src/app/api/admin/audit/route.ts b/src/app/api/admin/audit/route.ts new file mode 100644 index 0000000..0b52def --- /dev/null +++ b/src/app/api/admin/audit/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.email) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + if (session.user.email !== process.env.SUPERADMIN_EMAIL) { + return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + } + + const logs = await prisma.auditLog.findMany({ + orderBy: { createdAt: "desc" }, + take: 100, + }); + + return NextResponse.json(logs); +} diff --git a/src/app/api/events/[id]/route.ts b/src/app/api/events/[id]/route.ts index 5e313f2..0ce380f 100644 --- a/src/app/api/events/[id]/route.ts +++ b/src/app/api/events/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { logAudit } from "@/lib/audit"; export async function PATCH( req: Request, @@ -25,6 +26,7 @@ export async function PATCH( include: { user: { select: { id: true, name: true } } }, }); + await logAudit(session, { action: "event.update", targetId: id }); return NextResponse.json(event); } @@ -37,5 +39,6 @@ export async function DELETE( const { id } = await params; await prisma.event.delete({ where: { id } }); + await logAudit(session, { action: "event.delete", targetId: id }); return new NextResponse(null, { status: 204 }); } diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index 2967bc5..8c224d6 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { logAudit } from "@/lib/audit"; export async function GET(req: Request) { const session = await auth(); @@ -63,5 +64,7 @@ export async function POST(req: Request) { include: { user: { select: { id: true, name: true } } }, }); + await logAudit(session, { action: "event.create", targetId: event.id, detail: `${type} pour bébé ${babyId}` }); + return NextResponse.json(event, { status: 201 }); } diff --git a/src/app/api/invite/send-email/route.ts b/src/app/api/invite/send-email/route.ts new file mode 100644 index 0000000..d63cc27 --- /dev/null +++ b/src/app/api/invite/send-email/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { sendEmail } from "@/lib/email"; + +const VALID_ROLES = ["PARENT", "READONLY", "DOCTOR"] as const; +type Role = (typeof VALID_ROLES)[number]; + +const ROLE_LABELS: Record = { + PARENT: "Parent", + READONLY: "Lecture seule", + DOCTOR: "Médecin", +}; + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const { email, role } = await req.json(); + if (!email || !role || !VALID_ROLES.includes(role)) { + return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + include: { family: true }, + }); + + if (!user?.family) { + return NextResponse.json({ error: "Famille introuvable" }, { status: 404 }); + } + + const origin = req.headers.get("origin") ?? process.env.NEXTAUTH_URL ?? ""; + const inviteUrl = `${origin}/auth/invite/${user.family.inviteCode}?role=${role}`; + const assignedRole = role as Role; + + try { + await sendEmail({ + to: email, + subject: `Invitation Grow — ${user.family.name}`, + html: ` +
+

Grow — Invitation

+

${user.name} vous invite Ă  rejoindre la famille ${user.family.name} sur Grow.

+

Votre rĂ´le : ${ROLE_LABELS[assignedRole]}

+ + Rejoindre la famille + +

Ou copiez ce lien : ${inviteUrl}

+
+ `, + text: `${user.name} vous invite à rejoindre ${user.family.name} sur Grow.\n\nRôle : ${ROLE_LABELS[assignedRole]}\n\nLien : ${inviteUrl}`, + }); + } catch { + return NextResponse.json({ error: "Envoi email impossible — vérifiez la config email." }, { status: 500 }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/medication-profiles/[id]/route.ts b/src/app/api/medication-profiles/[id]/route.ts new file mode 100644 index 0000000..f649d98 --- /dev/null +++ b/src/app/api/medication-profiles/[id]/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const { id } = await params; + const body = await req.json(); + + const profile = await prisma.medicationProfile.update({ + where: { id }, + data: { + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.molecule !== undefined ? { molecule: body.molecule } : {}), + ...(body.defaultDose !== undefined ? { defaultDose: body.defaultDose } : {}), + ...(body.unit !== undefined ? { unit: body.unit } : {}), + ...(body.intervalHours !== undefined ? { intervalHours: parseFloat(body.intervalHours) } : {}), + ...(body.minIntervalHours !== undefined ? { minIntervalHours: body.minIntervalHours ? parseFloat(body.minIntervalHours) : null } : {}), + }, + }); + + return NextResponse.json(profile); +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const { id } = await params; + await prisma.medicationProfile.delete({ where: { id } }); + return new NextResponse(null, { status: 204 }); +} diff --git a/src/app/api/medication-profiles/last-intakes/route.ts b/src/app/api/medication-profiles/last-intakes/route.ts new file mode 100644 index 0000000..7f99069 --- /dev/null +++ b/src/app/api/medication-profiles/last-intakes/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// Returns the most recent MEDICATION event per medication name for a given baby. +// Used to compute next-allowed-dose times in the UI. +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const { searchParams } = new URL(req.url); + const babyId = searchParams.get("babyId"); + if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + + // Fetch last 100 medication events — enough to find latest per med + const events = await prisma.event.findMany({ + where: { babyId, type: "MEDICATION" }, + orderBy: { startedAt: "desc" }, + take: 100, + select: { id: true, startedAt: true, metadata: true }, + }); + + // Deduplicate: keep only the latest per medication name + const latest: Record = {}; + for (const ev of events) { + const meta = ev.metadata as Record | null; + const name = String(meta?.name ?? ""); + if (name && !latest[name]) { + latest[name] = { + id: ev.id, + startedAt: ev.startedAt, + dose: meta?.dose ? String(meta.dose) : undefined, + unit: meta?.unit ? String(meta.unit) : undefined, + }; + } + } + + return NextResponse.json(latest); +} diff --git a/src/app/api/medication-profiles/route.ts b/src/app/api/medication-profiles/route.ts new file mode 100644 index 0000000..b178127 --- /dev/null +++ b/src/app/api/medication-profiles/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { MEDICATION_PRESETS } from "@/lib/medication-presets"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { familyId: true } }); + if (!user?.familyId) return NextResponse.json([]); + + const profiles = await prisma.medicationProfile.findMany({ + where: { familyId: user.familyId }, + orderBy: [{ isPreset: "desc" }, { name: "asc" }], + }); + + // Auto-seed presets for this family if none exist yet + if (profiles.length === 0) { + await prisma.medicationProfile.createMany({ + data: MEDICATION_PRESETS.map((p) => ({ + familyId: user.familyId!, + name: p.name, + molecule: p.molecule, + defaultDose: p.defaultDose ?? null, + unit: p.unit, + intervalHours: p.intervalHours, + minIntervalHours: p.minIntervalHours, + isPreset: true, + })), + }); + const seeded = await prisma.medicationProfile.findMany({ + where: { familyId: user.familyId }, + orderBy: [{ isPreset: "desc" }, { name: "asc" }], + }); + return NextResponse.json(seeded); + } + + return NextResponse.json(profiles); +} + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { familyId: true } }); + if (!user?.familyId) return NextResponse.json({ error: "Famille introuvable" }, { status: 400 }); + + const body = await req.json(); + const { name, molecule, defaultDose, unit, intervalHours, minIntervalHours } = body; + if (!name || !intervalHours) return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); + + const profile = await prisma.medicationProfile.create({ + data: { + familyId: user.familyId, + name, + molecule: molecule ?? null, + defaultDose: defaultDose ?? null, + unit: unit ?? "mL", + intervalHours: parseFloat(intervalHours), + minIntervalHours: minIntervalHours ? parseFloat(minIntervalHours) : null, + isPreset: false, + }, + }); + + return NextResponse.json(profile, { status: 201 }); +} diff --git a/src/app/api/milk/[id]/route.ts b/src/app/api/milk/[id]/route.ts new file mode 100644 index 0000000..f2ffde6 --- /dev/null +++ b/src/app/api/milk/[id]/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const { id } = await params; + const body = await req.json(); + + const data: Record = {}; + if (body.volume !== undefined) data.volume = parseFloat(body.volume); + if (body.notes !== undefined) data.notes = body.notes; + if (body.location !== undefined) data.location = body.location; + if (body.expiresAt !== undefined) data.expiresAt = new Date(body.expiresAt); + if (body.used !== undefined) { + data.used = body.used; + data.usedAt = body.used ? new Date() : null; + } + + const stock = await prisma.milkStock.update({ where: { id }, data }); + return NextResponse.json(stock); +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const { id } = await params; + await prisma.milkStock.delete({ where: { id } }); + return new NextResponse(null, { status: 204 }); +} diff --git a/src/app/api/milk/route.ts b/src/app/api/milk/route.ts new file mode 100644 index 0000000..83ae692 --- /dev/null +++ b/src/app/api/milk/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// Default expiry hours by location +const EXPIRY_HOURS: Record = { + room: 4, + fridge: 96, // 4 days + freezer: 4320, // 6 months +}; + +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const { searchParams } = new URL(req.url); + const babyId = searchParams.get("babyId"); + if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + + const stocks = await prisma.milkStock.findMany({ + where: { babyId }, + orderBy: { storedAt: "desc" }, + }); + + return NextResponse.json(stocks); +} + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const body = await req.json(); + const { babyId, volume, storedAt, location, notes, expiryHours } = body; + + if (!babyId || !volume) { + return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); + } + + const loc = location ?? "fridge"; + const storedDate = storedAt ? new Date(storedAt) : new Date(); + const hours = expiryHours ?? EXPIRY_HOURS[loc] ?? EXPIRY_HOURS.fridge; + const expiresAt = new Date(storedDate.getTime() + hours * 60 * 60 * 1000); + + const stock = await prisma.milkStock.create({ + data: { + babyId, + volume: parseFloat(volume), + storedAt: storedDate, + expiresAt, + location: loc, + notes: notes ?? null, + }, + }); + + return NextResponse.json(stock, { status: 201 }); +} diff --git a/src/app/api/reminders/[id]/route.ts b/src/app/api/reminders/[id]/route.ts new file mode 100644 index 0000000..1f61a2b --- /dev/null +++ b/src/app/api/reminders/[id]/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const { id } = await params; + const body = await req.json(); + + const reminder = await prisma.medicationReminder.update({ + where: { id }, + data: { + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.dose !== undefined ? { dose: body.dose } : {}), + ...(body.unit !== undefined ? { unit: body.unit } : {}), + ...(body.intervalHours !== undefined ? { intervalHours: parseFloat(body.intervalHours) } : {}), + ...(body.startAt !== undefined ? { startAt: new Date(body.startAt) } : {}), + ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), + }, + }); + + return NextResponse.json(reminder); +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const { id } = await params; + await prisma.medicationReminder.delete({ where: { id } }); + return new NextResponse(null, { status: 204 }); +} diff --git a/src/app/api/reminders/check/route.ts b/src/app/api/reminders/check/route.ts new file mode 100644 index 0000000..91f1033 --- /dev/null +++ b/src/app/api/reminders/check/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { notifyFamilyFeedAlert } from "@/lib/push-notify"; + +export async function POST() { + const now = new Date(); + + const reminders = await prisma.medicationReminder.findMany({ + where: { enabled: true }, + include: { baby: { include: { family: true } } }, + }); + + let sent = 0; + + for (const reminder of reminders) { + const intervalMs = reminder.intervalHours * 60 * 60 * 1000; + const nextDue = new Date( + (reminder.lastSentAt ?? reminder.startAt).getTime() + intervalMs + ); + + if (now >= nextDue) { + const dosePart = reminder.dose ? ` — ${reminder.dose}${reminder.unit ? " " + reminder.unit : ""}` : ""; + const message = `Rappel médicament : ${reminder.name}${dosePart} pour ${reminder.baby.name}`; + + try { + await notifyFamilyFeedAlert(reminder.baby.familyId, message); + await prisma.medicationReminder.update({ + where: { id: reminder.id }, + data: { lastSentAt: now }, + }); + sent++; + } catch { + // continue with other reminders even if one fails + } + } + } + + return NextResponse.json({ checked: reminders.length, sent }); +} diff --git a/src/app/api/reminders/route.ts b/src/app/api/reminders/route.ts new file mode 100644 index 0000000..62e1a42 --- /dev/null +++ b/src/app/api/reminders/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const { searchParams } = new URL(req.url); + const babyId = searchParams.get("babyId"); + if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + + const reminders = await prisma.medicationReminder.findMany({ + where: { babyId }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json(reminders); +} + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const body = await req.json(); + const { babyId, name, dose, unit, intervalHours, startAt } = body; + + if (!babyId || !name || !intervalHours || !startAt) { + return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); + } + + const reminder = await prisma.medicationReminder.create({ + data: { + babyId, + name, + dose: dose ?? null, + unit: unit ?? null, + intervalHours: parseFloat(intervalHours), + startAt: new Date(startAt), + }, + }); + + return NextResponse.json(reminder, { status: 201 }); +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts new file mode 100644 index 0000000..9739855 --- /dev/null +++ b/src/app/api/upload/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { writeFile } from "fs/promises"; +import { join } from "path"; +import { randomUUID } from "crypto"; + +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]; +const MAX_SIZE = 5 * 1024 * 1024; // 5 MB + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const formData = await req.formData(); + const file = formData.get("file") as File | null; + + if (!file) return NextResponse.json({ error: "Fichier manquant" }, { status: 400 }); + if (!ALLOWED_TYPES.includes(file.type)) return NextResponse.json({ error: "Type de fichier non supporté" }, { status: 400 }); + if (file.size > MAX_SIZE) return NextResponse.json({ error: "Fichier trop volumineux (max 5 MB)" }, { status: 400 }); + + const ext = file.type.split("/")[1].replace("jpeg", "jpg"); + const filename = `${randomUUID()}.${ext}`; + const buffer = Buffer.from(await file.arrayBuffer()); + + const uploadDir = join(process.cwd(), "public", "uploads"); + await writeFile(join(uploadDir, filename), buffer); + + return NextResponse.json({ url: `/uploads/${filename}` }); +} diff --git a/src/components/event-icon.tsx b/src/components/event-icon.tsx index f60f8c2..4dace9b 100644 --- a/src/components/event-icon.tsx +++ b/src/components/event-icon.tsx @@ -7,6 +7,7 @@ import { Moon, Pill, Thermometer, + Milk, LucideProps, } from "lucide-react"; @@ -19,6 +20,7 @@ const ICONS: Record> = { Moon, Pill, Thermometer, + Milk, }; interface EventIconProps extends LucideProps { diff --git a/src/components/event-modal.tsx b/src/components/event-modal.tsx index 5f1f8e5..57ca2e8 100644 --- a/src/components/event-modal.tsx +++ b/src/components/event-modal.tsx @@ -3,13 +3,23 @@ import { useState, useEffect, useRef } from "react"; import { EVENT_CONFIG, EventType } from "@/lib/event-config"; import { EventIcon } from "@/components/event-icon"; -import { X } from "lucide-react"; +import { X, Camera, Loader2, AlertTriangle, Zap, Clock } from "lucide-react"; +import Image from "next/image"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; interface Props { type: EventType; babyId: string; onClose: () => void; onSaved: () => void; + initialEvent?: { + id: string; + startedAt: string; + endedAt?: string; + notes?: string; + metadata?: Record; + }; } interface FormData { @@ -19,6 +29,8 @@ interface FormData { breastSide?: "left" | "right" | "both"; volume?: string; bottleType?: "maternal" | "formula"; + pumpSide?: "left" | "right" | "both"; + pumpVolume?: string; stoolColor?: string; stoolAmount?: "small" | "medium" | "large"; medName?: string; @@ -28,6 +40,24 @@ interface FormData { tempUnit?: "C" | "F"; } +interface MedProfile { + id: string; + name: string; + molecule: string | null; + defaultDose: string | null; + unit: string; + intervalHours: number; + minIntervalHours: number | null; + isPreset: boolean; +} + +interface LastIntake { + id: string; + startedAt: string; + dose?: string; + unit?: string; +} + const STOOL_COLORS = [ { value: "jaune", label: "Jaune", bg: "#fef3c7", dot: "#f59e0b" }, { value: "vert", label: "Vert", bg: "#d1fae5", dot: "#10b981" }, @@ -42,19 +72,41 @@ function localNowString() { return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16); } -export function EventModal({ type, babyId, onClose, onSaved }: Props) { +function msToLabel(ms: number): string { + if (ms <= 0) return "maintenant"; + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + if (h > 0) return `${h}h${m.toString().padStart(2, "0")}`; + return `${m}min`; +} + +function toLocal(iso: string) { + return new Date(new Date(iso).getTime() - new Date(iso).getTimezoneOffset() * 60000).toISOString().slice(0, 16); +} + +export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Props) { const config = EVENT_CONFIG[type]; const TIMER_KEY = `grow_timer_${type}_${babyId}`; + const isEditing = !!initialEvent; + + const meta = initialEvent?.metadata ?? {}; const [form, setForm] = useState({ - startedAt: localNowString(), - endedAt: "", - notes: "", - breastSide: "left", - bottleType: "maternal", - stoolColor: "jaune", - stoolAmount: "medium", - tempUnit: "C", + startedAt: initialEvent ? toLocal(initialEvent.startedAt) : localNowString(), + endedAt: initialEvent?.endedAt ? toLocal(initialEvent.endedAt) : "", + notes: initialEvent?.notes ?? "", + breastSide: (meta.breastSide as "left" | "right" | "both") ?? "left", + bottleType: (meta.bottleType as "maternal" | "formula") ?? "maternal", + pumpSide: (meta.side as "left" | "right" | "both") ?? "both", + pumpVolume: meta.volume != null ? String(meta.volume) : undefined, + volume: meta.volume != null && type === "BOTTLE" ? String(meta.volume) : undefined, + stoolColor: (meta.color as string) ?? "jaune", + stoolAmount: (meta.amount as "small" | "medium" | "large") ?? "medium", + medName: (meta.name as string) ?? undefined, + medDose: (meta.dose as string) ?? undefined, + medUnit: (meta.unit as string) ?? undefined, + temperature: meta.value != null ? String(meta.value) : undefined, + tempUnit: (meta.unit as "C" | "F") ?? "C", }); const [timerRunning, setTimerRunning] = useState(false); @@ -63,6 +115,39 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) { const startTimeRef = useRef(null); const [loading, setLoading] = useState(false); const [validationError, setValidationError] = useState(""); + const [photoUrl, setPhotoUrl] = useState(null); + const [photoUploading, setPhotoUploading] = useState(false); + + // Medication profile state + const [profiles, setProfiles] = useState([]); + const [lastIntakes, setLastIntakes] = useState>({}); + const [selectedProfileId, setSelectedProfileId] = useState(null); + const [crisisMode, setCrisisMode] = useState(false); + + const selectedProfile = profiles.find((p) => p.id === selectedProfileId) ?? null; + + useEffect(() => { + if (type !== "MEDICATION") return; + Promise.all([ + fetch("/api/medication-profiles").then((r) => r.json()), + fetch(`/api/medication-profiles/last-intakes?babyId=${babyId}`).then((r) => r.json()), + ]).then(([profs, intakes]) => { + if (Array.isArray(profs)) setProfiles(profs); + if (intakes && typeof intakes === "object") setLastIntakes(intakes); + }); + }, [type, babyId]); + + // Auto-fill dose/unit when profile selected + useEffect(() => { + if (!selectedProfile) return; + setForm((f) => ({ + ...f, + medName: selectedProfile.name, + medDose: selectedProfile.defaultDose ?? f.medDose ?? "", + medUnit: selectedProfile.unit, + })); + setCrisisMode(false); + }, [selectedProfile?.id]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!config.hasTimer) return; @@ -99,9 +184,47 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) { } function formatTimer(s: number) { - return `${Math.floor(s / 60).toString().padStart(2, "0")}:${(s % 60).toString().padStart(2, "0")}`; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`; + return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`; } + async function handlePhotoChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setPhotoUploading(true); + const fd = new FormData(); + fd.append("file", file); + const res = await fetch("/api/upload", { method: "POST", body: fd }); + if (res.ok) { + const data = await res.json(); + setPhotoUrl(data.url); + } + setPhotoUploading(false); + } + + // Compute next-dose status for selected profile + function getMedStatus(): { tooSoon: boolean; nextAt: Date | null; waitMs: number; intervalUsed: number } | null { + if (!selectedProfile) return null; + const intake = lastIntakes[selectedProfile.name]; + if (!intake) return null; + + const intervalUsed = crisisMode && selectedProfile.minIntervalHours + ? selectedProfile.minIntervalHours + : selectedProfile.intervalHours; + + const lastAt = new Date(intake.startedAt).getTime(); + const nextAt = new Date(lastAt + intervalUsed * 3600000); + const waitMs = nextAt.getTime() - Date.now(); + + return { tooSoon: waitMs > 0, nextAt, waitMs, intervalUsed }; + } + + const medStatus = getMedStatus(); + const hasCrisisOption = !!(selectedProfile?.minIntervalHours && selectedProfile.minIntervalHours < selectedProfile.intervalHours); + async function handleSave() { setValidationError(""); if (type === "TEMPERATURE" && form.temperature) { @@ -117,29 +240,59 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) { const v = parseFloat(form.volume); if (v < 0 || v > 500) { setValidationError("Volume entre 0 et 500 mL"); return; } } + if (type === "PUMP" && form.pumpVolume) { + const v = parseFloat(form.pumpVolume); + if (v < 0 || v > 1000) { setValidationError("Volume entre 0 et 1000 mL"); return; } + } setLoading(true); const metadata: Record = {}; if (type === "BREASTFEED") metadata.breastSide = form.breastSide; if (type === "BOTTLE") { metadata.volume = form.volume ? parseFloat(form.volume) : null; metadata.bottleType = form.bottleType; } + if (type === "PUMP") { metadata.side = form.pumpSide; metadata.volume = form.pumpVolume ? parseFloat(form.pumpVolume) : null; } if (type === "DIAPER_STOOL") { metadata.color = form.stoolColor; metadata.amount = form.stoolAmount; } - if (type === "MEDICATION") { metadata.name = form.medName; metadata.dose = form.medDose; metadata.unit = form.medUnit; } + if (type === "MEDICATION") { + metadata.name = form.medName; + metadata.dose = form.medDose; + metadata.unit = form.medUnit; + if (selectedProfile) { + metadata.profileId = selectedProfile.id; + metadata.intervalHours = crisisMode && selectedProfile.minIntervalHours + ? selectedProfile.minIntervalHours + : selectedProfile.intervalHours; + if (crisisMode) metadata.crisis = true; + } + } if (type === "TEMPERATURE") { metadata.value = form.temperature ? parseFloat(form.temperature) : null; metadata.unit = form.tempUnit; } + if (photoUrl) metadata.photo = photoUrl; const toUTC = (local: string) => local ? new Date(local).toISOString() : null; localStorage.removeItem(TIMER_KEY); - await fetch("/api/events", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - babyId, - type, - startedAt: toUTC(form.startedAt), - endedAt: form.endedAt ? toUTC(form.endedAt) : null, - notes: form.notes || null, - metadata: Object.keys(metadata).length > 0 ? metadata : null, - }), - }); + if (isEditing) { + await fetch(`/api/events/${initialEvent!.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + startedAt: toUTC(form.startedAt), + endedAt: form.endedAt ? toUTC(form.endedAt) : null, + notes: form.notes || null, + metadata: Object.keys(metadata).length > 0 ? metadata : null, + }), + }); + } else { + await fetch("/api/events", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + babyId, + type, + startedAt: toUTC(form.startedAt), + endedAt: form.endedAt ? toUTC(form.endedAt) : null, + notes: form.notes || null, + metadata: Object.keys(metadata).length > 0 ? metadata : null, + }), + }); + } setLoading(false); onSaved(); onClose(); @@ -161,7 +314,9 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
-

{config.label}

+

+ {isEditing ? `Modifier · ${config.label}` : config.label} +

+ ))} + + +
+ + setForm((f) => ({ ...f, pumpVolume: e.target.value }))} + className={inputCls} placeholder="80" min="0" max="1000" /> +
+ + )} + {/* Diaper stool */} {type === "DIAPER_STOOL" && (
@@ -255,20 +432,141 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
)} - {/* Medication */} + {/* Medication — profile picker */} {type === "MEDICATION" && (
-
- - setForm((f) => ({ ...f, medName: e.target.value }))} - className={inputCls} placeholder="Doliprane" /> -
+ {/* Profile chips */} + {profiles.length > 0 && ( +
+ +
+ {profiles.map((p) => { + const intake = lastIntakes[p.name]; + const hasIntake = !!intake; + const intervalH = p.intervalHours; + const nextAt = hasIntake + ? new Date(new Date(intake.startedAt).getTime() + intervalH * 3600000) + : null; + const tooSoon = nextAt ? nextAt.getTime() > Date.now() : false; + + return ( + + ); + })} + +
+
+ )} + + {/* Selected profile info + timing */} + {selectedProfile && ( +
+ {selectedProfile.molecule && ( +

{selectedProfile.molecule}

+ )} + + {medStatus ? ( + medStatus.tooSoon ? ( +
+ +
+

+ Trop tôt — encore {msToLabel(medStatus.waitMs)} à attendre +

+

+ Prochaine dose : {format(medStatus.nextAt!, "HH:mm", { locale: fr })} + {" "}(intervalle {medStatus.intervalUsed}h) +

+
+
+ ) : ( +
+ +

+ Dernière prise : {msToLabel(Date.now() - new Date(lastIntakes[selectedProfile.name].startedAt).getTime())} — OK +

+
+ ) + ) : ( +

Première prise

+ )} + + {/* Crisis mode toggle */} + {hasCrisisOption && ( + + )} +
+ )} + + {/* Dose / unit */}
- - setForm((f) => ({ ...f, medDose: e.target.value }))} - className={inputCls} placeholder="2.4" /> + + {selectedProfileId === null ? ( + setForm((f) => ({ ...f, medName: e.target.value }))} + className={inputCls} placeholder="Doliprane" /> + ) : ( + setForm((f) => ({ ...f, medDose: e.target.value }))} + className={inputCls} placeholder={selectedProfile?.defaultDose ?? "—"} /> + )}
+
+ + {selectedProfileId === null ? ( + setForm((f) => ({ ...f, medDose: e.target.value }))} + className={inputCls} placeholder="2.4" /> + ) : ( + setForm((f) => ({ ...f, medUnit: e.target.value }))} + className={inputCls} placeholder="mL" /> + )} +
+
+ + {/* Free-text unit for custom */} + {selectedProfileId === null && (
-
+ )} )} @@ -303,22 +603,32 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) { )} - {/* Time fields */} -
+ {/* Time fields — split date + time for iOS Safari compatibility */} +
- setForm((f) => ({ ...f, startedAt: e.target.value }))} - className={inputCls} /> +
+ setForm((f) => ({ ...f, startedAt: `${e.target.value}T${f.startedAt.slice(11, 16) || "00:00"}` }))} + className={inputCls} /> + setForm((f) => ({ ...f, startedAt: `${f.startedAt.slice(0, 10)}T${e.target.value}` }))} + className={inputCls} /> +
{config.hasDuration && (
- setForm((f) => ({ ...f, endedAt: e.target.value }))} - className={inputCls} /> +
+ setForm((f) => ({ ...f, endedAt: `${e.target.value}T${f.endedAt.slice(11, 16) || "00:00"}` }))} + className={inputCls} /> + setForm((f) => ({ ...f, endedAt: `${f.endedAt.slice(0, 10) || new Date().toISOString().slice(0, 10)}T${e.target.value}` }))} + className={inputCls} /> +
)}
@@ -332,6 +642,34 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) { rows={2} className={`${inputCls} resize-none`} placeholder="Observations..." />
+ {/* Photo */} +
+ + {photoUrl ? ( +
+ Photo + +
+ ) : ( + + )} +
+ {validationError && (
{validationError} @@ -346,7 +684,7 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
diff --git a/src/components/nav.tsx b/src/components/nav.tsx index 2403360..e29f78f 100644 --- a/src/components/nav.tsx +++ b/src/components/nav.tsx @@ -23,17 +23,24 @@ import { Syringe, MoreHorizontal, X, + Bell, + CalendarDays, + Milk, } from "lucide-react"; const LINKS = [ { href: "/dashboard", label: "Accueil", Icon: LayoutGrid }, { href: "/timeline", label: "Journal", Icon: ScrollText }, + { href: "/calendar", label: "Calendrier", Icon: CalendarDays }, { href: "/stats", label: "Statistiques", Icon: BarChart3 }, { href: "/growth", label: "Courbes", Icon: TrendingUp }, + { href: "/milk", label: "Stock lait", Icon: Milk }, { href: "/milestones", label: "Étapes", Icon: Star }, { href: "/notes", label: "Notes", Icon: NotebookPen }, { href: "/doctor-notes", label: "Médecin", Icon: Stethoscope }, { href: "/vaccinations", label: "Vaccinations", Icon: Syringe }, + { href: "/medications", label: "Médic.", Icon: Stethoscope }, + { href: "/reminders", label: "Rappels", Icon: Bell }, { href: "/settings", label: "Réglages", Icon: Settings }, ]; diff --git a/src/components/quick-add-fab.tsx b/src/components/quick-add-fab.tsx index e98736c..cf77c8b 100644 --- a/src/components/quick-add-fab.tsx +++ b/src/components/quick-add-fab.tsx @@ -2,15 +2,19 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { usePathname } from "next/navigation"; import { useBaby } from "@/contexts/baby-context"; import { EVENT_CONFIG, EventType } from "@/lib/event-config"; import { EventIcon } from "@/components/event-icon"; import { EventModal } from "@/components/event-modal"; import { Plus, X } from "lucide-react"; +const FAB_HIDDEN_PATHS = ["/settings", "/admin"]; + const QUICK_TYPES: EventType[] = [ "BREASTFEED", "BOTTLE", + "PUMP", "DIAPER_WET", "DIAPER_STOOL", "NAP", @@ -22,10 +26,11 @@ const QUICK_TYPES: EventType[] = [ export function QuickAddFab() { const qc = useQueryClient(); const { selectedBaby } = useBaby(); + const pathname = usePathname(); const [sheetOpen, setSheetOpen] = useState(false); const [activeModal, setActiveModal] = useState(null); - if (!selectedBaby) return null; + if (!selectedBaby || FAB_HIDDEN_PATHS.includes(pathname)) return null; function selectType(type: EventType) { setSheetOpen(false); diff --git a/src/lib/audit.ts b/src/lib/audit.ts new file mode 100644 index 0000000..95c2800 --- /dev/null +++ b/src/lib/audit.ts @@ -0,0 +1,27 @@ +import { prisma } from "./prisma"; +import type { Session } from "next-auth"; + +interface AuditOptions { + action: string; + targetId?: string; + familyId?: string; + detail?: string; +} + +export async function logAudit(session: Session | null, opts: AuditOptions) { + if (!session?.user?.id) return; + try { + await prisma.auditLog.create({ + data: { + userId: session.user.id, + userName: session.user.name ?? "—", + action: opts.action, + targetId: opts.targetId ?? null, + familyId: opts.familyId ?? null, + detail: opts.detail ?? null, + }, + }); + } catch { + // audit failure must never break the main request + } +} diff --git a/src/lib/event-config.ts b/src/lib/event-config.ts index 1d84154..3212c72 100644 --- a/src/lib/event-config.ts +++ b/src/lib/event-config.ts @@ -1,6 +1,7 @@ export type EventType = | "BREASTFEED" | "BOTTLE" + | "PUMP" | "DIAPER_WET" | "DIAPER_STOOL" | "NAP" @@ -41,6 +42,16 @@ export const EVENT_CONFIG: Record< hasDuration: false, hasTimer: false, }, + PUMP: { + label: "Tirage", + icon: "Milk", + color: "#0891b2", + colorClass: "text-cyan-600", + bgClass: "bg-cyan-50", + borderClass: "border-cyan-200", + hasDuration: true, + hasTimer: true, + }, DIAPER_WET: { label: "Couche mouillée", icon: "Droplets", diff --git a/src/lib/medication-presets.ts b/src/lib/medication-presets.ts new file mode 100644 index 0000000..3a92c50 --- /dev/null +++ b/src/lib/medication-presets.ts @@ -0,0 +1,12 @@ +export const MEDICATION_PRESETS = [ + { name: "Doliprane", molecule: "Paracétamol 2.4%", defaultDose: "1", unit: "mL/kg", intervalHours: 6, minIntervalHours: 4 }, + { name: "Efferalgan", molecule: "Paracétamol 3%", defaultDose: "1", unit: "mL/kg", intervalHours: 6, minIntervalHours: 4 }, + { name: "Advil", molecule: "Ibuprofène 20mg/mL", defaultDose: "0.5", unit: "mL/kg", intervalHours: 6, minIntervalHours: 6 }, + { name: "Nurofen", molecule: "Ibuprofène 20mg/mL", defaultDose: "0.5", unit: "mL/kg", intervalHours: 6, minIntervalHours: 6 }, + { name: "Motilium", molecule: "Dompéridone", defaultDose: "0.25", unit: "mL/kg", intervalHours: 8, minIntervalHours: 8 }, + { name: "Toplexil", molecule: "Oxomémazine", defaultDose: null, unit: "mL", intervalHours: 6, minIntervalHours: 6 }, + { name: "Smecta", molecule: "Diosmectite", defaultDose: null, unit: "sachet", intervalHours: 8, minIntervalHours: 8 }, + { name: "Zyrtec", molecule: "Cétirizine", defaultDose: null, unit: "mL", intervalHours: 24, minIntervalHours: 24 }, + { name: "Solupred", molecule: "Prednisolone", defaultDose: null, unit: "mg/kg", intervalHours: 24, minIntervalHours: 24 }, + { name: "Amoxicilline", molecule: "Amoxicilline", defaultDose: null, unit: "mL", intervalHours: 8, minIntervalHours: 8 }, +] as const;