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:
@@ -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">
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user