feat: pump tracker, photo log, milk stock, medication profiles, calendar, reminders, audit log, edit events

New pages:
- /calendar — monthly grid with event dots, day detail on tap
- /medications — CRUD for medication profiles (presets + custom, dose/unit/intervals/crisis mode)
- /milk — milk stock management with location-based expiry, alerts, mark-as-used
- /reminders — recurring medication reminder CRUD with Pushover notifications

New APIs:
- /api/medication-profiles — profiles CRUD + last-intakes endpoint (auto-seeds 10 presets per family)
- /api/milk — milk stock CRUD with auto-calculated expiry
- /api/reminders + /api/reminders/check — reminder CRUD + cron-callable check endpoint
- /api/upload — multipart photo upload to public/uploads/
- /api/invite/send-email — email invitation with family invite link
- /api/admin/audit — last 100 audit log entries (superadmin only)

Event modal improvements:
- PUMP event type (side selector + volume + timer)
- MEDICATION: profile chip picker, next-dose timing status, crisis mode toggle
- Photo attachment (upload + thumbnail preview)
- Datetime inputs split into date + time (iOS Safari datetime-local fix)
- Edit mode via initialEvent prop (pre-fills all fields, saves via PATCH)
- Timer now shows h:mm:ss when >= 1 hour

Timeline:
- Modify button opens pre-filled edit modal per event
- Photo thumbnail in expanded view

Dashboard:
- PUMP in quick-log
- Medication status card (too-soon / available with next-dose time)

Schema additions: MedicationProfile, MedcationReminder, MilkStock, AuditLog models

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 15:01:38 +02:00
parent 091af949ee
commit 020539e2e2
31 changed files with 2206 additions and 63 deletions
+29 -3
View File
@@ -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:0014:30")
🔲 Data retention policy — auto-archive events older than N months
🔲 Maintenance mode banner in layout (config key exists, layout not wired yet)
+55
View File
@@ -106,6 +106,8 @@ model Baby {
journalNotes JournalNote[]
milestones Milestone[]
vaccinations Vaccination[]
medicationReminders MedicationReminder[]
milkStocks MilkStock[]
createdAt DateTime @default(now())
}
@@ -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())
}
View File
+47
View File
@@ -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<string | null>(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() {
)}
</Section>
{/* Audit log */}
<Section title="Journal d'audit">
{auditError ? (
<p className="text-xs text-red-500">{auditError}</p>
) : auditLogs.length === 0 ? (
<p className="text-xs text-slate-400 dark:text-slate-500">Aucune action enregistrée.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-100 dark:border-slate-700">
<th className="text-left pb-2 font-medium text-slate-400 dark:text-slate-500">Date</th>
<th className="text-left pb-2 font-medium text-slate-400 dark:text-slate-500">Utilisateur</th>
<th className="text-left pb-2 font-medium text-slate-400 dark:text-slate-500">Action</th>
<th className="text-left pb-2 font-medium text-slate-400 dark:text-slate-500">Détail</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
{auditLogs.map((log) => (
<tr key={log.id}>
<td className="py-1.5 text-slate-400 dark:text-slate-500 font-mono whitespace-nowrap pr-3">
{new Date(log.createdAt).toLocaleString("fr-FR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
</td>
<td className="py-1.5 text-slate-700 dark:text-slate-200 pr-3 whitespace-nowrap">{log.userName}</td>
<td className="py-1.5 pr-3">
<span className="px-1.5 py-0.5 rounded bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 font-mono">{log.action}</span>
</td>
<td className="py-1.5 text-slate-500 dark:text-slate-400 truncate max-w-[200px]">{log.detail ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
{/* Sauvegarde */}
<Section title="Sauvegarde">
<div className="flex items-center justify-between">
+187
View File
@@ -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<string, unknown>;
}
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<Date | null>(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 (
<div className="p-4 md:p-8 pb-28 md:pb-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 capitalize">
{format(cursor, "MMMM yyyy", { locale: fr })}
</h1>
{selectedBaby && <p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{selectedBaby.name}</p>}
</div>
<div className="flex items-center gap-1">
<button onClick={prevMonth}
className="w-9 h-9 rounded-lg flex items-center justify-center text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition">
<ChevronLeft className="w-5 h-5" />
</button>
<button onClick={() => { setCursor(new Date()); setSelectedDay(null); }}
className="px-3 py-1.5 text-xs font-medium text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition">
Aujourd&apos;hui
</button>
<button onClick={nextMonth}
className="w-9 h-9 rounded-lg flex items-center justify-center text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition">
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
{/* Grid */}
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-slate-100 dark:border-slate-700">
{DAY_LABELS.map((d) => (
<div key={d} className="py-2 text-center text-[10px] font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
{d}
</div>
))}
</div>
{/* Weeks */}
<div className="grid grid-cols-7">
{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 (
<button
key={i}
onClick={() => setSelectedDay(isSameDay(day, selectedDay ?? new Date(0)) ? null : day)}
className={`min-h-[64px] p-1.5 text-left border-b border-r border-slate-100 dark:border-slate-800 last:border-r-0 transition ${
isSelected
? "bg-indigo-50 dark:bg-indigo-950"
: "hover:bg-slate-50 dark:hover:bg-slate-800/50"
} ${(i + 1) % 7 === 0 ? "border-r-0" : ""}`}
>
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium mb-1 transition ${
today
? "bg-indigo-600 text-white"
: inMonth
? "text-slate-700 dark:text-slate-200"
: "text-slate-300 dark:text-slate-600"
}`}>
{format(day, "d")}
</div>
{/* Event dots */}
{evs.length > 0 && (
<div className="flex flex-wrap gap-0.5">
{evs.slice(0, 6).map((ev, j) => (
<span
key={j}
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: getTypeColor(ev.type) }}
/>
))}
{evs.length > 6 && (
<span className="text-[9px] text-slate-400 dark:text-slate-500 leading-none mt-0.5">+{evs.length - 6}</span>
)}
</div>
)}
</button>
);
})}
</div>
</div>
{/* Selected day detail */}
{selectedDay && (
<div className="mt-4">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3 capitalize">
{format(selectedDay, "EEEE d MMMM", { locale: fr })}
<span className="ml-2 text-slate-400 dark:text-slate-500 font-normal">· {selectedDayEvents.length} événement{selectedDayEvents.length !== 1 ? "s" : ""}</span>
</h2>
{selectedDayEvents.length === 0 ? (
<p className="text-sm text-slate-400 dark:text-slate-500 py-4 text-center">Aucun événement</p>
) : (
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl divide-y divide-slate-100 dark:divide-slate-800">
{selectedDayEvents.map((ev) => {
const cfg = EVENT_CONFIG[ev.type];
return (
<div key={ev.id} className="flex items-center gap-3 px-4 py-3">
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0`}>
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</p>
</div>
<span className="text-xs text-slate-400 dark:text-slate-500 font-mono flex-shrink-0">
{format(new Date(ev.startedAt), "HH:mm")}
</span>
</div>
);
})}
</div>
)}
</div>
)}
{!selectedBaby && (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<p className="text-sm">Sélectionnez un bébé</p>
</div>
)}
</div>
);
}
+61
View File
@@ -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<Record<EventType, number>> = {};
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<Record<string, { startedAt: string }>>({
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 (
<div className="flex items-center justify-center min-h-screen">
@@ -195,6 +210,52 @@ export default function DashboardPage() {
</div>
</div>
{/* Medication status */}
{medProfiles.length > 0 && (() => {
const activeMeds = medProfiles.filter((p) => lastIntakes[p.name]);
if (activeMeds.length === 0) return null;
return (
<div className="mt-6">
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Médicaments</h2>
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{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 (
<div key={p.id} className="flex items-center gap-3 px-4 py-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${tooSoon ? "bg-red-50 dark:bg-red-950" : "bg-green-50 dark:bg-green-950"}`}>
{tooSoon
? <AlertTriangle className="w-4 h-4 text-red-500" />
: <CheckCircle2 className="w-4 h-4 text-green-500" />
}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{p.name}</p>
{p.molecule && <p className="text-xs text-slate-400 dark:text-slate-500">{p.molecule}</p>}
</div>
<div className="text-right flex-shrink-0">
{tooSoon ? (
<p className="text-xs font-semibold text-red-600 dark:text-red-400">Dans {timeLabel}</p>
) : (
<p className="text-xs font-semibold text-green-600 dark:text-green-400">Disponible</p>
)}
<p className="text-[10px] text-slate-400 dark:text-slate-500 mt-0.5 flex items-center gap-1 justify-end">
<Clock className="w-2.5 h-2.5" />{format(nextAt, "HH:mm")}
</p>
</div>
</div>
);
})}
</div>
</div>
);
})()}
{/* Today's timeline */}
{todayEvents.length > 0 && (
<div className="mt-6">
+232
View File
@@ -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<string | null>(null);
const [form, setForm] = useState(emptyForm());
const [saving, setSaving] = useState(false);
const { data: profiles = [], isLoading } = useQuery<Profile[]>({
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 }) => (
<form onSubmit={onSubmit} className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Nom</label>
<input type="text" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required className={inputCls} placeholder="Doliprane" />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Molécule</label>
<input type="text" value={form.molecule} onChange={(e) => setForm((f) => ({ ...f, molecule: e.target.value }))}
className={inputCls} placeholder="Paracétamol" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Dose par défaut</label>
<input type="text" value={form.defaultDose} onChange={(e) => setForm((f) => ({ ...f, defaultDose: e.target.value }))}
className={inputCls} placeholder="2.4" />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Unité</label>
<select value={form.unit} onChange={(e) => setForm((f) => ({ ...f, unit: e.target.value }))} className={inputCls}>
{UNITS.map((u) => <option key={u} value={u}>{u}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Intervalle standard (h)</label>
<input type="number" value={form.intervalHours} onChange={(e) => setForm((f) => ({ ...f, intervalHours: e.target.value }))}
required className={inputCls} placeholder="6" min="0.5" step="0.5" />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Intervalle urgence (h)</label>
<input type="number" value={form.minIntervalHours} onChange={(e) => setForm((f) => ({ ...f, minIntervalHours: e.target.value }))}
className={inputCls} placeholder="4 (optionnel)" min="0.5" step="0.5" />
</div>
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={onCancel}
className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-400">
Annuler
</button>
<button type="submit" disabled={saving || !form.name}
className="flex-[2] py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium disabled:opacity-50">
{saving ? "..." : editingId ? "Mettre à jour" : "Ajouter"}
</button>
</div>
</form>
);
return (
<div className="p-4 md:p-8 pb-28 md:pb-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Médicaments</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Profils et intervalles de prise</p>
</div>
{!showForm && !editingId && (
<button onClick={() => setShowForm(true)}
className="flex items-center gap-2 px-3 py-2 bg-indigo-600 text-white rounded-xl text-sm font-medium hover:bg-indigo-700 transition">
<Plus className="w-4 h-4" />
Ajouter
</button>
)}
</div>
{showForm && <div className="mb-4"><ProfileForm onSubmit={handleCreate} onCancel={() => { setShowForm(false); setForm(emptyForm()); }} /></div>}
{isLoading && (
<div className="flex justify-center py-16">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
)}
<div className="space-y-2">
{profiles.map((p) => (
<div key={p.id}>
{editingId === p.id ? (
<ProfileForm onSubmit={handleUpdate} onCancel={cancelEdit} />
) : (
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl px-4 py-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">{p.name}</p>
{p.isPreset && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 font-medium">preset</span>
)}
</div>
{p.molecule && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{p.molecule}</p>}
<div className="flex flex-wrap gap-2 mt-1.5">
{p.defaultDose && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-800">
{p.defaultDose} {p.unit}
</span>
)}
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800">
{p.intervalHours}h
</span>
{p.minIntervalHours && (
<span className="text-xs px-2 py-0.5 rounded-full bg-orange-50 dark:bg-orange-950 text-orange-700 dark:text-orange-300 border border-orange-200 dark:border-orange-800">
urgence {p.minIntervalHours}h
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button onClick={() => startEdit(p)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950 transition">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleDelete(p.id, p.name)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950 transition">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
</div>
))}
</div>
{!isLoading && profiles.length === 0 && !showForm && (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<p className="text-sm">Aucun profil médicament</p>
</div>
)}
</div>
);
}
+349
View File
@@ -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<Location, { label: string; icon: React.FC<{ className?: string }>; 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<Location>("fridge");
const [form, setForm] = useState({
volume: "",
storedAt: localNowString(),
notes: "",
expiryHours: "",
});
const [saving, setSaving] = useState(false);
const { data: stocks = [], isLoading } = useQuery<Stock[]>({
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 (
<div className="p-4 md:p-8 pb-28 md:pb-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Stock de lait</h1>
{selectedBaby && <p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{selectedBaby.name}</p>}
</div>
<button
onClick={() => setShowForm((v) => !v)}
className="flex items-center gap-2 px-3 py-2 bg-cyan-600 text-white rounded-xl text-sm font-medium hover:bg-cyan-700 transition"
>
<Plus className="w-4 h-4" />
Ajouter
</button>
</div>
{/* Summary cards */}
{available.length > 0 && (
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-3 text-center">
<p className="text-xl font-bold text-cyan-600 dark:text-cyan-400">{totalVolume.toFixed(0)}</p>
<p className="text-[10px] text-slate-500 dark:text-slate-400 mt-0.5">mL disponibles</p>
</div>
<div className={`bg-white dark:bg-slate-900 border rounded-xl p-3 text-center ${expiredCount > 0 ? "border-red-200 dark:border-red-800" : "border-slate-200 dark:border-slate-700"}`}>
<p className={`text-xl font-bold ${expiredCount > 0 ? "text-red-600 dark:text-red-400" : "text-slate-300 dark:text-slate-600"}`}>{expiredCount}</p>
<p className="text-[10px] text-slate-500 dark:text-slate-400 mt-0.5">périmés</p>
</div>
<div className={`bg-white dark:bg-slate-900 border rounded-xl p-3 text-center ${soonCount > 0 ? "border-amber-200 dark:border-amber-800" : "border-slate-200 dark:border-slate-700"}`}>
<p className={`text-xl font-bold ${soonCount > 0 ? "text-amber-600 dark:text-amber-400" : "text-slate-300 dark:text-slate-600"}`}>{soonCount}</p>
<p className="text-[10px] text-slate-500 dark:text-slate-400 mt-0.5">à utiliser vite</p>
</div>
</div>
)}
{/* Alerts */}
{expiredCount > 0 && (
<div className="flex items-center gap-2 px-3 py-2.5 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-xl mb-4 text-sm text-red-700 dark:text-red-400">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span>{expiredCount} lot{expiredCount > 1 ? "s" : ""} périmé{expiredCount > 1 ? "s" : ""} à jeter</span>
</div>
)}
{soonCount > 0 && (
<div className="flex items-center gap-2 px-3 py-2.5 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-xl mb-4 text-sm text-amber-700 dark:text-amber-400">
<Clock className="w-4 h-4 flex-shrink-0" />
<span>{soonCount} lot{soonCount > 1 ? "s" : ""} à utiliser dans moins de 6h</span>
</div>
)}
{/* Add form */}
{showForm && (
<form onSubmit={handleCreate} className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4 space-y-3">
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Nouveau lot</p>
{/* Location selector */}
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Conservation</label>
<div className="flex gap-2">
{(["room", "fridge", "freezer"] as Location[]).map((loc) => {
const cfg = LOCATION_CONFIG[loc];
const Icon = cfg.icon;
return (
<button key={loc} type="button" onClick={() => {
setLocation(loc);
setForm((f) => ({ ...f, expiryHours: "" }));
}}
className={`flex-1 flex flex-col items-center gap-1 py-2 px-1 border rounded-xl text-xs font-medium transition ${
location === loc
? "border-indigo-500 bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300"
: "border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400"
}`}>
<Icon className={`w-4 h-4 ${location === loc ? "text-indigo-600 dark:text-indigo-400" : "text-slate-400"}`} />
{loc === "room" ? "Ambiant" : loc === "fridge" ? "Frigo" : "Congélo"}
</button>
);
})}
</div>
<p className="text-[10px] text-slate-400 dark:text-slate-500 mt-1">
Durée par défaut : {defaultExpiryHours >= 24 ? `${defaultExpiryHours / 24}j` : `${defaultExpiryHours}h`}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Volume (mL)</label>
<input type="number" value={form.volume} onChange={(e) => setForm((f) => ({ ...f, volume: e.target.value }))}
required className={inputCls} placeholder="80" min="1" max="2000" />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Durée péremption (h)</label>
<input type="number" value={form.expiryHours} onChange={(e) => setForm((f) => ({ ...f, expiryHours: e.target.value }))}
className={inputCls} placeholder={String(defaultExpiryHours)} min="1" />
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Date de tirage</label>
<input type="datetime-local" value={form.storedAt} onChange={(e) => setForm((f) => ({ ...f, storedAt: e.target.value }))}
required className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Notes <span className="text-slate-400 font-normal">(optionnel)</span></label>
<input type="text" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
className={inputCls} placeholder="Ex : tirage du matin" />
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={() => setShowForm(false)}
className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-400">
Annuler
</button>
<button type="submit" disabled={saving || !form.volume}
className="flex-[2] py-2 bg-cyan-600 text-white rounded-lg text-sm font-medium disabled:opacity-50">
{saving ? "..." : "Enregistrer"}
</button>
</div>
</form>
)}
{isLoading && (
<div className="flex justify-center py-16">
<div className="w-6 h-6 border-2 border-cyan-600 border-t-transparent rounded-full animate-spin" />
</div>
)}
{!isLoading && stocks.length === 0 && !showForm && (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<p className="text-sm">Aucun lot enregistré</p>
</div>
)}
{/* Available stocks */}
{available.length > 0 && (
<div className="space-y-2 mb-4">
{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 (
<div key={s.id} className={`bg-white dark:bg-slate-900 border rounded-xl p-4 transition ${
status === "expired" ? "border-red-200 dark:border-red-800" :
status === "soon" ? "border-amber-200 dark:border-amber-800" :
"border-slate-200 dark:border-slate-700"
}`}>
<div className="flex items-start gap-3">
<div className={`w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 ${
status === "expired" ? "bg-red-50 dark:bg-red-950" :
status === "soon" ? "bg-amber-50 dark:bg-amber-950" :
"bg-cyan-50 dark:bg-cyan-950"
}`}>
<LocIcon className={`w-4 h-4 ${
status === "expired" ? "text-red-500" :
status === "soon" ? "text-amber-500" :
LOCATION_CONFIG[s.location as Location]?.color ?? "text-cyan-600"
}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">{s.volume} mL</p>
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
status === "expired" ? "bg-red-100 dark:bg-red-950 text-red-600 dark:text-red-400" :
status === "soon" ? "bg-amber-100 dark:bg-amber-950 text-amber-600 dark:text-amber-400" :
"bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400"
}`}>
{status === "expired" ? "Périmé" : status === "soon" ? "Bientôt" : "OK"}
</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Tiré {formatDistanceToNow(new Date(s.storedAt), { locale: fr, addSuffix: true })}
</p>
<p className={`text-xs mt-0.5 ${status === "expired" ? "text-red-500" : status === "soon" ? "text-amber-500" : "text-slate-400 dark:text-slate-500"}`}>
{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")}
</p>
{s.notes && <p className="text-xs text-slate-400 dark:text-slate-500 italic mt-0.5">{s.notes}</p>}
</div>
<div className="flex flex-col gap-1.5 flex-shrink-0">
<button onClick={() => markUsed(s.id)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-950 transition"
title="Marquer comme utilisé">
<Check className="w-4 h-4" />
</button>
<button onClick={() => deleteStock(s.id)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950 transition"
title="Supprimer">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
{/* Used / archived */}
{used.length > 0 && (
<div className="mt-6">
<button
onClick={() => setShowUsed((v) => !v)}
className="flex items-center gap-2 text-xs font-medium text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 transition mb-3"
>
<span>{showUsed ? "▾" : "▸"}</span>
{used.length} lot{used.length > 1 ? "s" : ""} utilisé{used.length > 1 ? "s" : ""}
</button>
{showUsed && (
<div className="space-y-2">
{used.slice(0, 20).map((s) => (
<div key={s.id} className="bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 rounded-xl px-4 py-3 opacity-60 flex items-center gap-3">
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm text-slate-600 dark:text-slate-300">{s.volume} mL · {s.notes ?? LOCATION_CONFIG[s.location as Location]?.label ?? s.location}</p>
<p className="text-xs text-slate-400 dark:text-slate-500">
Utilisé {s.usedAt ? formatDistanceToNow(new Date(s.usedAt), { locale: fr, addSuffix: true }) : ""}
</p>
</div>
<button onClick={() => deleteStock(s.id)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-300 dark:text-slate-600 hover:text-red-400 transition">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
+203
View File
@@ -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<Reminder[]>({
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 (
<div className="p-4 md:p-8 pb-28 md:pb-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Rappels médicaments</h1>
{selectedBaby && <p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{selectedBaby.name}</p>}
</div>
<button
onClick={() => setShowForm((v) => !v)}
className="flex items-center gap-2 px-3 py-2 bg-indigo-600 text-white rounded-xl text-sm font-medium hover:bg-indigo-700 transition"
>
<Plus className="w-4 h-4" />
Nouveau
</button>
</div>
{showForm && (
<form onSubmit={handleCreate} className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4 space-y-3">
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Nouveau rappel</p>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Médicament</label>
<input type="text" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required className={inputCls} placeholder="Doliprane" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Dose</label>
<input type="text" value={form.dose} onChange={(e) => setForm((f) => ({ ...f, dose: e.target.value }))}
className={inputCls} placeholder="2.4" />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Unité</label>
<select value={form.unit} onChange={(e) => setForm((f) => ({ ...f, unit: e.target.value }))} className={inputCls}>
<option value="mL">mL</option>
<option value="mg">mg</option>
<option value="gouttes">gouttes</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Intervalle (heures)</label>
<select value={form.intervalHours} onChange={(e) => setForm((f) => ({ ...f, intervalHours: e.target.value }))} className={inputCls}>
{[4, 6, 8, 12, 24].map((h) => (
<option key={h} value={h}>{h}h</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Première prise</label>
<input type="datetime-local" value={form.startAt} onChange={(e) => setForm((f) => ({ ...f, startAt: e.target.value }))}
required className={inputCls} />
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={() => setShowForm(false)}
className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-400">
Annuler
</button>
<button type="submit" disabled={saving}
className="flex-[2] py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium disabled:opacity-50">
{saving ? "..." : "Créer"}
</button>
</div>
</form>
)}
{isLoading && (
<div className="flex justify-center py-16">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
)}
{!isLoading && reminders.length === 0 && !showForm && (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<Bell className="w-8 h-8 mx-auto mb-3 opacity-30" />
<p className="text-sm">Aucun rappel configuré</p>
</div>
)}
<div className="space-y-3">
{reminders.map((r) => (
<div key={r.id} className={`bg-white dark:bg-slate-900 border rounded-xl p-4 transition ${r.enabled ? "border-slate-200 dark:border-slate-700" : "border-slate-100 dark:border-slate-800 opacity-60"}`}>
<div className="flex items-start gap-3">
<div className={`w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 ${r.enabled ? "bg-green-50 dark:bg-green-950" : "bg-slate-100 dark:bg-slate-800"}`}>
{r.enabled ? <Bell className="w-4 h-4 text-green-600 dark:text-green-400" /> : <BellOff className="w-4 h-4 text-slate-400" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">{r.name}</p>
{(r.dose || r.unit) && (
<p className="text-xs text-slate-500 dark:text-slate-400">{[r.dose, r.unit].filter(Boolean).join(" ")}</p>
)}
<div className="flex items-center gap-1.5 mt-1">
<Clock className="w-3 h-3 text-slate-400" />
<span className="text-xs text-slate-400 dark:text-slate-500">Toutes les {r.intervalHours}h</span>
{r.enabled && (
<span className="text-xs text-indigo-600 dark:text-indigo-400 ml-2">{nextDueLabel(r)}</span>
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
onClick={() => toggleEnabled(r.id, r.enabled)}
className={`w-9 h-5 rounded-full transition relative overflow-hidden ${r.enabled ? "bg-green-500" : "bg-slate-200 dark:bg-slate-700"}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${r.enabled ? "translate-x-[14px]" : "translate-x-0"}`} />
</button>
<button onClick={() => deleteReminder(r.id)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950 transition">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
))}
</div>
{reminders.length > 0 && (
<p className="mt-6 text-center text-xs text-slate-400 dark:text-slate-500">
Appelez <code className="bg-slate-100 dark:bg-slate-800 px-1 py-0.5 rounded">/api/reminders/check</code> régulièrement (cron) pour envoyer les rappels via Pushover.
</p>
)}
</div>
);
}
+72 -4
View File
@@ -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<string, string> = {
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 (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<div className="p-4 md:p-8 pb-28 md:pb-8">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-6">Réglages</h1>
<div className="space-y-4">
@@ -359,6 +389,44 @@ export default function SettingsPage() {
</button>
))}
</div>
<div className="pt-3 border-t border-slate-100 dark:border-slate-700 mt-3">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-3">Inviter par email</p>
<form onSubmit={sendInviteEmail} className="space-y-2">
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="adresse@email.com"
required
className={inputCls}
/>
<div className="flex gap-2">
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as typeof inviteRole)}
className={`${inputCls} flex-1`}
>
<option value="PARENT">Parent</option>
<option value="READONLY">Lecture seule</option>
<option value="DOCTOR">Médecin</option>
</select>
<button
type="submit"
disabled={sendingInvite || !inviteEmail}
className="flex-shrink-0 flex items-center gap-1.5 px-3 py-2 bg-indigo-600 text-white text-sm rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 transition"
>
<Send className="w-3.5 h-3.5" />
{sendingInvite ? "..." : "Envoyer"}
</button>
</div>
{inviteMsg && (
<p className={`text-xs font-medium ${inviteMsg.type === "ok" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}`}>
{inviteMsg.text}
</p>
)}
</form>
</div>
</div>
</Section>
)}
+38 -1
View File
@@ -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<string | null>(null);
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null);
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [filterType, setFilterType] = useState<EventType | "">("");
const [page, setPage] = useState(0);
@@ -221,6 +230,20 @@ export default function TimelinePage() {
</button>
)}
{ev.metadata?.photo && (
<div className="relative w-full h-48 rounded-xl overflow-hidden border border-slate-200 dark:border-slate-600">
<Image src={String(ev.metadata.photo)} alt="Photo" fill className="object-cover" unoptimized />
</div>
)}
<div className="flex items-center gap-3">
<button
onClick={() => { setEditingEvent(ev); setExpandedId(null); }}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition"
>
<Pencil className="w-3.5 h-3.5" />
Modifier
</button>
<button
onClick={() => { if (confirm("Supprimer cet événement ?")) deleteEvent(ev.id); }}
className="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-600 transition"
@@ -229,6 +252,7 @@ export default function TimelinePage() {
Supprimer
</button>
</div>
</div>
)}
</div>
);
@@ -252,6 +276,19 @@ export default function TimelinePage() {
</div>
)}
</div>
{editingEvent && (
<EventModal
type={editingEvent.type}
babyId={selectedBaby!.id}
initialEvent={editingEvent}
onClose={() => setEditingEvent(null)}
onSaved={() => {
qc.invalidateQueries({ queryKey: ["events"] });
setEditingEvent(null);
}}
/>
)}
</div>
);
}
+18
View File
@@ -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);
}
+3
View File
@@ -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 });
}
+3
View File
@@ -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 });
}
+59
View File
@@ -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<Role, string> = {
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: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
<h2 style="color:#4f46e5">Grow — Invitation</h2>
<p><strong>${user.name}</strong> vous invite à rejoindre la famille <strong>${user.family.name}</strong> sur Grow.</p>
<p>Votre rôle : <strong>${ROLE_LABELS[assignedRole]}</strong></p>
<a href="${inviteUrl}" style="display:inline-block;margin:16px 0;padding:12px 24px;background:#4f46e5;color:#fff;border-radius:8px;text-decoration:none;font-weight:600">
Rejoindre la famille
</a>
<p style="color:#64748b;font-size:13px">Ou copiez ce lien : ${inviteUrl}</p>
</div>
`,
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 });
}
@@ -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 });
}
@@ -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<string, { id: string; startedAt: Date; dose?: string; unit?: string }> = {};
for (const ev of events) {
const meta = ev.metadata as Record<string, unknown> | 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);
}
+67
View File
@@ -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 });
}
+39
View File
@@ -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<string, unknown> = {};
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 });
}
+56
View File
@@ -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<string, number> = {
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 });
}
+40
View File
@@ -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 });
}
+39
View File
@@ -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 });
}
+44
View File
@@ -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 });
}
+29
View File
@@ -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}` });
}
+2
View File
@@ -7,6 +7,7 @@ import {
Moon,
Pill,
Thermometer,
Milk,
LucideProps,
} from "lucide-react";
@@ -19,6 +20,7 @@ const ICONS: Record<string, React.FC<LucideProps>> = {
Moon,
Pill,
Thermometer,
Milk,
};
interface EventIconProps extends LucideProps {
+364 -26
View File
@@ -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<string, unknown>;
};
}
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<FormData>({
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<Date | null>(null);
const [loading, setLoading] = useState(false);
const [validationError, setValidationError] = useState("");
const [photoUrl, setPhotoUrl] = useState<string | null>(null);
const [photoUploading, setPhotoUploading] = useState(false);
// Medication profile state
const [profiles, setProfiles] = useState<MedProfile[]>([]);
const [lastIntakes, setLastIntakes] = useState<Record<string, LastIntake>>({});
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(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<HTMLInputElement>) {
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,17 +240,46 @@ 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<string, unknown> = {};
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);
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" },
@@ -140,6 +292,7 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
metadata: Object.keys(metadata).length > 0 ? metadata : null,
}),
});
}
setLoading(false);
onSaved();
onClose();
@@ -161,7 +314,9 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
<div className={`w-9 h-9 rounded-xl ${config.bgClass} flex items-center justify-center`}>
<EventIcon name={config.icon} className={`w-5 h-5 ${config.colorClass}`} />
</div>
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">{config.label}</h2>
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">
{isEditing ? `Modifier · ${config.label}` : config.label}
</h2>
</div>
<button onClick={onClose} className="w-8 h-8 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 flex items-center justify-center text-slate-400 transition">
<X className="w-4 h-4" />
@@ -224,6 +379,28 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
</div>
)}
{/* Pump */}
{type === "PUMP" && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Sein(s)</label>
<div className="flex gap-2">
{(["left", "right", "both"] as const).map((side) => (
<button key={side} type="button" onClick={() => setForm((f) => ({ ...f, pumpSide: side }))}
className={`${segmentBase} ${form.pumpSide === side ? segmentActive : segmentInactive}`}>
{side === "left" ? "Gauche" : side === "right" ? "Droite" : "Les deux"}
</button>
))}
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Volume tiré (mL)</label>
<input type="number" value={form.pumpVolume ?? ""} onChange={(e) => setForm((f) => ({ ...f, pumpVolume: e.target.value }))}
className={inputCls} placeholder="80" min="0" max="1000" />
</div>
</div>
)}
{/* Diaper stool */}
{type === "DIAPER_STOOL" && (
<div className="space-y-3">
@@ -255,20 +432,141 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
</div>
)}
{/* Medication */}
{/* Medication — profile picker */}
{type === "MEDICATION" && (
<div className="space-y-3">
{/* Profile chips */}
{profiles.length > 0 && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Traitement</label>
<input type="text" value={form.medName ?? ""} onChange={(e) => setForm((f) => ({ ...f, medName: e.target.value }))}
className={inputCls} placeholder="Doliprane" />
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">Médicament</label>
<div className="flex flex-wrap gap-1.5">
{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 (
<button
key={p.id}
type="button"
onClick={() => setSelectedProfileId(selectedProfileId === p.id ? null : p.id)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl border text-xs font-medium transition ${
selectedProfileId === p.id
? "border-green-500 bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300"
: tooSoon
? "border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400"
: "border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-slate-300"
}`}
>
{tooSoon && selectedProfileId !== p.id && <span className="w-1.5 h-1.5 rounded-full bg-red-400 flex-shrink-0" />}
{p.name}
</button>
);
})}
<button
type="button"
onClick={() => setSelectedProfileId(null)}
className={`px-2.5 py-1.5 rounded-xl border text-xs font-medium transition ${
selectedProfileId === null
? "border-indigo-500 bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300"
: "border-slate-200 dark:border-slate-600 text-slate-400 hover:border-slate-300"
}`}
>
Autre
</button>
</div>
</div>
)}
{/* Selected profile info + timing */}
{selectedProfile && (
<div className={`rounded-xl p-3 space-y-2 ${
medStatus?.tooSoon
? "bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800"
: "bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800"
}`}>
{selectedProfile.molecule && (
<p className="text-xs text-slate-500 dark:text-slate-400">{selectedProfile.molecule}</p>
)}
{medStatus ? (
medStatus.tooSoon ? (
<div className="flex items-start gap-2">
<AlertTriangle className="w-3.5 h-3.5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-xs font-semibold text-red-700 dark:text-red-300">
Trop tôt encore {msToLabel(medStatus.waitMs)} à attendre
</p>
<p className="text-[10px] text-red-500 dark:text-red-400 mt-0.5">
Prochaine dose : {format(medStatus.nextAt!, "HH:mm", { locale: fr })}
{" "}(intervalle {medStatus.intervalUsed}h)
</p>
</div>
</div>
) : (
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
<p className="text-xs text-green-700 dark:text-green-300">
Dernière prise : {msToLabel(Date.now() - new Date(lastIntakes[selectedProfile.name].startedAt).getTime())} OK
</p>
</div>
)
) : (
<p className="text-xs text-green-700 dark:text-green-300">Première prise</p>
)}
{/* Crisis mode toggle */}
{hasCrisisOption && (
<button
type="button"
onClick={() => setCrisisMode((v) => !v)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition w-full justify-center ${
crisisMode
? "border-orange-400 bg-orange-50 dark:bg-orange-950 text-orange-700 dark:text-orange-300"
: "border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:border-orange-300"
}`}
>
<Zap className={`w-3 h-3 ${crisisMode ? "text-orange-500" : "text-slate-400"}`} />
Mode urgence ({selectedProfile.minIntervalHours}h au lieu de {selectedProfile.intervalHours}h)
</button>
)}
</div>
)}
{/* Dose / unit */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Dose</label>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
{selectedProfileId === null ? "Traitement" : "Dose"}
</label>
{selectedProfileId === null ? (
<input type="text" value={form.medName ?? ""} onChange={(e) => setForm((f) => ({ ...f, medName: e.target.value }))}
className={inputCls} placeholder="Doliprane" />
) : (
<input type="text" value={form.medDose ?? ""} onChange={(e) => setForm((f) => ({ ...f, medDose: e.target.value }))}
className={inputCls} placeholder={selectedProfile?.defaultDose ?? "—"} />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
{selectedProfileId === null ? "Dose" : "Unité"}
</label>
{selectedProfileId === null ? (
<input type="text" value={form.medDose ?? ""} onChange={(e) => setForm((f) => ({ ...f, medDose: e.target.value }))}
className={inputCls} placeholder="2.4" />
) : (
<input type="text" value={form.medUnit ?? ""} onChange={(e) => setForm((f) => ({ ...f, medUnit: e.target.value }))}
className={inputCls} placeholder="mL" />
)}
</div>
</div>
{/* Free-text unit for custom */}
{selectedProfileId === null && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Unité</label>
<select value={form.medUnit ?? "mL"} onChange={(e) => setForm((f) => ({ ...f, medUnit: e.target.value }))}
@@ -276,9 +574,11 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
<option value="mL">mL</option>
<option value="mg">mg</option>
<option value="gouttes">gouttes</option>
<option value="sachet">sachet</option>
<option value="comprimé">comprimé</option>
</select>
</div>
</div>
)}
</div>
)}
@@ -303,22 +603,32 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
</div>
)}
{/* Time fields */}
<div className={`grid ${config.hasDuration ? "grid-cols-2" : "grid-cols-1"} gap-3`}>
{/* Time fields — split date + time for iOS Safari compatibility */}
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
{config.hasDuration ? "Début" : "Heure"}
</label>
<input type="datetime-local" value={form.startedAt}
onChange={(e) => setForm((f) => ({ ...f, startedAt: e.target.value }))}
<div className="grid grid-cols-2 gap-2">
<input type="date" value={form.startedAt.slice(0, 10)}
onChange={(e) => setForm((f) => ({ ...f, startedAt: `${e.target.value}T${f.startedAt.slice(11, 16) || "00:00"}` }))}
className={inputCls} />
<input type="time" value={form.startedAt.slice(11, 16)}
onChange={(e) => setForm((f) => ({ ...f, startedAt: `${f.startedAt.slice(0, 10)}T${e.target.value}` }))}
className={inputCls} />
</div>
</div>
{config.hasDuration && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Fin</label>
<input type="datetime-local" value={form.endedAt}
onChange={(e) => setForm((f) => ({ ...f, endedAt: e.target.value }))}
<div className="grid grid-cols-2 gap-2">
<input type="date" value={form.endedAt.slice(0, 10)}
onChange={(e) => setForm((f) => ({ ...f, endedAt: `${e.target.value}T${f.endedAt.slice(11, 16) || "00:00"}` }))}
className={inputCls} />
<input type="time" value={form.endedAt.slice(11, 16)}
onChange={(e) => setForm((f) => ({ ...f, endedAt: `${f.endedAt.slice(0, 10) || new Date().toISOString().slice(0, 10)}T${e.target.value}` }))}
className={inputCls} />
</div>
</div>
)}
</div>
@@ -332,6 +642,34 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
rows={2} className={`${inputCls} resize-none`} placeholder="Observations..." />
</div>
{/* Photo */}
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Photo <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
{photoUrl ? (
<div className="relative w-full h-40 rounded-xl overflow-hidden border border-slate-200 dark:border-slate-600">
<Image src={photoUrl} alt="Photo" fill className="object-cover" unoptimized />
<button
type="button"
onClick={() => setPhotoUrl(null)}
className="absolute top-2 right-2 w-7 h-7 bg-black/50 rounded-full flex items-center justify-center text-white hover:bg-black/70 transition"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<label className="flex items-center gap-2 px-3 py-2.5 border border-dashed border-slate-300 dark:border-slate-600 rounded-xl cursor-pointer hover:border-indigo-400 dark:hover:border-indigo-500 transition text-slate-500 dark:text-slate-400 text-sm">
{photoUploading ? (
<><Loader2 className="w-4 h-4 animate-spin" />Envoi en cours...</>
) : (
<><Camera className="w-4 h-4" />Ajouter une photo</>
)}
<input type="file" accept="image/*" className="hidden" onChange={handlePhotoChange} disabled={photoUploading} />
</label>
)}
</div>
{validationError && (
<div className="px-3 py-2 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg text-xs text-red-600 dark:text-red-400">
{validationError}
@@ -346,7 +684,7 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
</button>
<button type="button" onClick={handleSave} disabled={loading}
className="flex-[2] py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white text-sm font-medium transition">
{loading ? "Enregistrement..." : "Enregistrer"}
{loading ? "..." : isEditing ? "Mettre à jour" : "Enregistrer"}
</button>
</div>
</div>
+7
View File
@@ -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 },
];
+6 -1
View File
@@ -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<EventType | null>(null);
if (!selectedBaby) return null;
if (!selectedBaby || FAB_HIDDEN_PATHS.includes(pathname)) return null;
function selectType(type: EventType) {
setSheetOpen(false);
+27
View File
@@ -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
}
}
+11
View File
@@ -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",
+12
View File
@@ -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;