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
+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>
)}
+45 -8
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,13 +230,28 @@ export default function TimelinePage() {
</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"
>
<Trash2 className="w-3.5 h-3.5" />
Supprimer
</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"
>
<Trash2 className="w-3.5 h-3.5" />
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>
);
}