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:
+29
-3
@@ -35,9 +35,35 @@
|
||||
## Pending
|
||||
|
||||
🔲 Maintenance mode banner (config key exists, layout doesn't check it yet)
|
||||
🔲 Audit log (last 50 actions across all users)
|
||||
🔲 Data retention (auto-delete events older than N months)
|
||||
🔲 Recurring medication reminders
|
||||
🔲 Bilingual UI (FR/EN toggle)
|
||||
🔲 Weight trend alert (no growth log in 30 days)
|
||||
🔲 Photo log (attach photo to event)
|
||||
✅ Breast pump / milk expression tracker (suivi tirage de lait) — volume, duration, side, time
|
||||
✅ Email invitation from UI (invite family members by email, not just invite code)
|
||||
✅ Settings page broken on mobile — FAB hidden on /settings, bottom padding increased
|
||||
✅ Photo log (attach photo to event) — upload API, picker in event modal, thumbnail in timeline
|
||||
✅ Audit log — AuditLog model, logs event CRUD, admin table (/api/admin/audit)
|
||||
✅ Recurring medication reminders — /reminders page, CRUD API, Pushover via /api/reminders/check (call from cron)
|
||||
✅ Calendar view — /calendar monthly grid with event dots, day detail on tap
|
||||
✅ Milk stock management — /milk page, lots with location (room/fridge/freezer), configurable expiry, alerts, notes, mark-as-used; PUMP now in dashboard quick-log
|
||||
✅ Medication profiles — /medications page, preset + custom profiles, dose/unit/interval/crisis-interval, auto-seeded on first use
|
||||
✅ Medication next-dose enforcement — visual warning in event modal + dashboard status card, crisis mode toggle
|
||||
✅ Datetime selectors fixed — split date + time inputs (iOS Safari datetime-local compatibility)
|
||||
✅ Live timer h:m:s — timers ≥ 1h now show h:mm:ss format
|
||||
✅ Edit events from timeline — Modifier button opens pre-filled event modal, saves via PATCH
|
||||
|
||||
## Feature ideas / backlog
|
||||
|
||||
🔲 Offline support / PWA install prompt — service worker for offline event logging
|
||||
🔲 Multi-language UI (FR/EN toggle) — see also pending Bilingual UI
|
||||
🔲 Weekly summary push notification (Sunday digest: feedings, sleep, weight change)
|
||||
🔲 Growth alert: no weight log in 30 days → push notification
|
||||
🔲 Feeding pattern AI insight (detect sleep regression, feeding cluster, etc.)
|
||||
🔲 Contacts / care team directory (pediatrician, midwife, family contact numbers)
|
||||
🔲 Baby activities log (bath, walk, tummy time) — lightweight custom event types
|
||||
🔲 Sleep log chart — visual night timeline (bar per night, wake markers)
|
||||
🔲 Bottle feed from milk stock — direct link: log BOTTLE → decrement milk stock
|
||||
🔲 Shared family notes / pinned message (daily handoff summary visible to all members)
|
||||
🔲 Event templates / quick presets (e.g. "usual nap 13:00–14:30")
|
||||
🔲 Data retention policy — auto-archive events older than N months
|
||||
🔲 Maintenance mode banner in layout (config key exists, layout not wired yet)
|
||||
|
||||
+58
-3
@@ -104,9 +104,11 @@ model Baby {
|
||||
growthLogs GrowthLog[]
|
||||
doctorNotes DoctorNote[]
|
||||
journalNotes JournalNote[]
|
||||
milestones Milestone[]
|
||||
vaccinations Vaccination[]
|
||||
createdAt DateTime @default(now())
|
||||
milestones Milestone[]
|
||||
vaccinations Vaccination[]
|
||||
medicationReminders MedicationReminder[]
|
||||
milkStocks MilkStock[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Event {
|
||||
@@ -127,6 +129,7 @@ model Event {
|
||||
enum EventType {
|
||||
BREASTFEED
|
||||
BOTTLE
|
||||
PUMP
|
||||
DIAPER_WET
|
||||
DIAPER_STOOL
|
||||
NAP
|
||||
@@ -192,3 +195,55 @@ model Vaccination {
|
||||
done Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
userName String
|
||||
action String
|
||||
targetId String?
|
||||
familyId String?
|
||||
detail String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model MedicationProfile {
|
||||
id String @id @default(cuid())
|
||||
familyId String
|
||||
name String
|
||||
molecule String?
|
||||
defaultDose String?
|
||||
unit String @default("mL")
|
||||
intervalHours Float @default(6)
|
||||
minIntervalHours Float?
|
||||
isPreset Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model MilkStock {
|
||||
id String @id @default(cuid())
|
||||
babyId String
|
||||
baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade)
|
||||
volume Float
|
||||
storedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
location String @default("fridge")
|
||||
notes String?
|
||||
used Boolean @default(false)
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model MedicationReminder {
|
||||
id String @id @default(cuid())
|
||||
babyId String
|
||||
baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
dose String?
|
||||
unit String?
|
||||
intervalHours Float
|
||||
startAt DateTime
|
||||
enabled Boolean @default(true)
|
||||
lastSentAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.email) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
if (session.user.email !== process.env.SUPERADMIN_EMAIL) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
const logs = await prisma.auditLog.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return NextResponse.json(logs);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAudit } from "@/lib/audit";
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
@@ -25,6 +26,7 @@ export async function PATCH(
|
||||
include: { user: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
await logAudit(session, { action: "event.update", targetId: id });
|
||||
return NextResponse.json(event);
|
||||
}
|
||||
|
||||
@@ -37,5 +39,6 @@ export async function DELETE(
|
||||
|
||||
const { id } = await params;
|
||||
await prisma.event.delete({ where: { id } });
|
||||
await logAudit(session, { action: "event.delete", targetId: id });
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAudit } from "@/lib/audit";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await auth();
|
||||
@@ -63,5 +64,7 @@ export async function POST(req: Request) {
|
||||
include: { user: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
await logAudit(session, { action: "event.create", targetId: event.id, detail: `${type} pour bébé ${babyId}` });
|
||||
|
||||
return NextResponse.json(event, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendEmail } from "@/lib/email";
|
||||
|
||||
const VALID_ROLES = ["PARENT", "READONLY", "DOCTOR"] as const;
|
||||
type Role = (typeof VALID_ROLES)[number];
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
PARENT: "Parent",
|
||||
READONLY: "Lecture seule",
|
||||
DOCTOR: "Médecin",
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { email, role } = await req.json();
|
||||
if (!email || !role || !VALID_ROLES.includes(role)) {
|
||||
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
include: { family: true },
|
||||
});
|
||||
|
||||
if (!user?.family) {
|
||||
return NextResponse.json({ error: "Famille introuvable" }, { status: 404 });
|
||||
}
|
||||
|
||||
const origin = req.headers.get("origin") ?? process.env.NEXTAUTH_URL ?? "";
|
||||
const inviteUrl = `${origin}/auth/invite/${user.family.inviteCode}?role=${role}`;
|
||||
const assignedRole = role as Role;
|
||||
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: `Invitation Grow — ${user.family.name}`,
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
|
||||
<h2 style="color:#4f46e5">Grow — Invitation</h2>
|
||||
<p><strong>${user.name}</strong> vous invite à rejoindre la famille <strong>${user.family.name}</strong> sur Grow.</p>
|
||||
<p>Votre rôle : <strong>${ROLE_LABELS[assignedRole]}</strong></p>
|
||||
<a href="${inviteUrl}" style="display:inline-block;margin:16px 0;padding:12px 24px;background:#4f46e5;color:#fff;border-radius:8px;text-decoration:none;font-weight:600">
|
||||
Rejoindre la famille
|
||||
</a>
|
||||
<p style="color:#64748b;font-size:13px">Ou copiez ce lien : ${inviteUrl}</p>
|
||||
</div>
|
||||
`,
|
||||
text: `${user.name} vous invite à rejoindre ${user.family.name} sur Grow.\n\nRôle : ${ROLE_LABELS[assignedRole]}\n\nLien : ${inviteUrl}`,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Envoi email impossible — vérifiez la config email." }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
const profile = await prisma.medicationProfile.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.name !== undefined ? { name: body.name } : {}),
|
||||
...(body.molecule !== undefined ? { molecule: body.molecule } : {}),
|
||||
...(body.defaultDose !== undefined ? { defaultDose: body.defaultDose } : {}),
|
||||
...(body.unit !== undefined ? { unit: body.unit } : {}),
|
||||
...(body.intervalHours !== undefined ? { intervalHours: parseFloat(body.intervalHours) } : {}),
|
||||
...(body.minIntervalHours !== undefined ? { minIntervalHours: body.minIntervalHours ? parseFloat(body.minIntervalHours) : null } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(profile);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
await prisma.medicationProfile.delete({ where: { id } });
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Returns the most recent MEDICATION event per medication name for a given baby.
|
||||
// Used to compute next-allowed-dose times in the UI.
|
||||
export async function GET(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const babyId = searchParams.get("babyId");
|
||||
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
|
||||
|
||||
// Fetch last 100 medication events — enough to find latest per med
|
||||
const events = await prisma.event.findMany({
|
||||
where: { babyId, type: "MEDICATION" },
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 100,
|
||||
select: { id: true, startedAt: true, metadata: true },
|
||||
});
|
||||
|
||||
// Deduplicate: keep only the latest per medication name
|
||||
const latest: Record<string, { id: string; startedAt: Date; dose?: string; unit?: string }> = {};
|
||||
for (const ev of events) {
|
||||
const meta = ev.metadata as Record<string, unknown> | null;
|
||||
const name = String(meta?.name ?? "");
|
||||
if (name && !latest[name]) {
|
||||
latest[name] = {
|
||||
id: ev.id,
|
||||
startedAt: ev.startedAt,
|
||||
dose: meta?.dose ? String(meta.dose) : undefined,
|
||||
unit: meta?.unit ? String(meta.unit) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(latest);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { MEDICATION_PRESETS } from "@/lib/medication-presets";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { familyId: true } });
|
||||
if (!user?.familyId) return NextResponse.json([]);
|
||||
|
||||
const profiles = await prisma.medicationProfile.findMany({
|
||||
where: { familyId: user.familyId },
|
||||
orderBy: [{ isPreset: "desc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
// Auto-seed presets for this family if none exist yet
|
||||
if (profiles.length === 0) {
|
||||
await prisma.medicationProfile.createMany({
|
||||
data: MEDICATION_PRESETS.map((p) => ({
|
||||
familyId: user.familyId!,
|
||||
name: p.name,
|
||||
molecule: p.molecule,
|
||||
defaultDose: p.defaultDose ?? null,
|
||||
unit: p.unit,
|
||||
intervalHours: p.intervalHours,
|
||||
minIntervalHours: p.minIntervalHours,
|
||||
isPreset: true,
|
||||
})),
|
||||
});
|
||||
const seeded = await prisma.medicationProfile.findMany({
|
||||
where: { familyId: user.familyId },
|
||||
orderBy: [{ isPreset: "desc" }, { name: "asc" }],
|
||||
});
|
||||
return NextResponse.json(seeded);
|
||||
}
|
||||
|
||||
return NextResponse.json(profiles);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { familyId: true } });
|
||||
if (!user?.familyId) return NextResponse.json({ error: "Famille introuvable" }, { status: 400 });
|
||||
|
||||
const body = await req.json();
|
||||
const { name, molecule, defaultDose, unit, intervalHours, minIntervalHours } = body;
|
||||
if (!name || !intervalHours) return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
|
||||
|
||||
const profile = await prisma.medicationProfile.create({
|
||||
data: {
|
||||
familyId: user.familyId,
|
||||
name,
|
||||
molecule: molecule ?? null,
|
||||
defaultDose: defaultDose ?? null,
|
||||
unit: unit ?? "mL",
|
||||
intervalHours: parseFloat(intervalHours),
|
||||
minIntervalHours: minIntervalHours ? parseFloat(minIntervalHours) : null,
|
||||
isPreset: false,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(profile, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.volume !== undefined) data.volume = parseFloat(body.volume);
|
||||
if (body.notes !== undefined) data.notes = body.notes;
|
||||
if (body.location !== undefined) data.location = body.location;
|
||||
if (body.expiresAt !== undefined) data.expiresAt = new Date(body.expiresAt);
|
||||
if (body.used !== undefined) {
|
||||
data.used = body.used;
|
||||
data.usedAt = body.used ? new Date() : null;
|
||||
}
|
||||
|
||||
const stock = await prisma.milkStock.update({ where: { id }, data });
|
||||
return NextResponse.json(stock);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
await prisma.milkStock.delete({ where: { id } });
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Default expiry hours by location
|
||||
const EXPIRY_HOURS: Record<string, number> = {
|
||||
room: 4,
|
||||
fridge: 96, // 4 days
|
||||
freezer: 4320, // 6 months
|
||||
};
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const babyId = searchParams.get("babyId");
|
||||
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
|
||||
|
||||
const stocks = await prisma.milkStock.findMany({
|
||||
where: { babyId },
|
||||
orderBy: { storedAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(stocks);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const { babyId, volume, storedAt, location, notes, expiryHours } = body;
|
||||
|
||||
if (!babyId || !volume) {
|
||||
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
|
||||
}
|
||||
|
||||
const loc = location ?? "fridge";
|
||||
const storedDate = storedAt ? new Date(storedAt) : new Date();
|
||||
const hours = expiryHours ?? EXPIRY_HOURS[loc] ?? EXPIRY_HOURS.fridge;
|
||||
const expiresAt = new Date(storedDate.getTime() + hours * 60 * 60 * 1000);
|
||||
|
||||
const stock = await prisma.milkStock.create({
|
||||
data: {
|
||||
babyId,
|
||||
volume: parseFloat(volume),
|
||||
storedAt: storedDate,
|
||||
expiresAt,
|
||||
location: loc,
|
||||
notes: notes ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(stock, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
const reminder = await prisma.medicationReminder.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.name !== undefined ? { name: body.name } : {}),
|
||||
...(body.dose !== undefined ? { dose: body.dose } : {}),
|
||||
...(body.unit !== undefined ? { unit: body.unit } : {}),
|
||||
...(body.intervalHours !== undefined ? { intervalHours: parseFloat(body.intervalHours) } : {}),
|
||||
...(body.startAt !== undefined ? { startAt: new Date(body.startAt) } : {}),
|
||||
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(reminder);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
await prisma.medicationReminder.delete({ where: { id } });
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { notifyFamilyFeedAlert } from "@/lib/push-notify";
|
||||
|
||||
export async function POST() {
|
||||
const now = new Date();
|
||||
|
||||
const reminders = await prisma.medicationReminder.findMany({
|
||||
where: { enabled: true },
|
||||
include: { baby: { include: { family: true } } },
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
|
||||
for (const reminder of reminders) {
|
||||
const intervalMs = reminder.intervalHours * 60 * 60 * 1000;
|
||||
const nextDue = new Date(
|
||||
(reminder.lastSentAt ?? reminder.startAt).getTime() + intervalMs
|
||||
);
|
||||
|
||||
if (now >= nextDue) {
|
||||
const dosePart = reminder.dose ? ` — ${reminder.dose}${reminder.unit ? " " + reminder.unit : ""}` : "";
|
||||
const message = `Rappel médicament : ${reminder.name}${dosePart} pour ${reminder.baby.name}`;
|
||||
|
||||
try {
|
||||
await notifyFamilyFeedAlert(reminder.baby.familyId, message);
|
||||
await prisma.medicationReminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { lastSentAt: now },
|
||||
});
|
||||
sent++;
|
||||
} catch {
|
||||
// continue with other reminders even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ checked: reminders.length, sent });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const babyId = searchParams.get("babyId");
|
||||
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
|
||||
|
||||
const reminders = await prisma.medicationReminder.findMany({
|
||||
where: { babyId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(reminders);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const { babyId, name, dose, unit, intervalHours, startAt } = body;
|
||||
|
||||
if (!babyId || !name || !intervalHours || !startAt) {
|
||||
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
|
||||
}
|
||||
|
||||
const reminder = await prisma.medicationReminder.create({
|
||||
data: {
|
||||
babyId,
|
||||
name,
|
||||
dose: dose ?? null,
|
||||
unit: unit ?? null,
|
||||
intervalHours: parseFloat(intervalHours),
|
||||
startAt: new Date(startAt),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(reminder, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
if (!file) return NextResponse.json({ error: "Fichier manquant" }, { status: 400 });
|
||||
if (!ALLOWED_TYPES.includes(file.type)) return NextResponse.json({ error: "Type de fichier non supporté" }, { status: 400 });
|
||||
if (file.size > MAX_SIZE) return NextResponse.json({ error: "Fichier trop volumineux (max 5 MB)" }, { status: 400 });
|
||||
|
||||
const ext = file.type.split("/")[1].replace("jpeg", "jpg");
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const uploadDir = join(process.cwd(), "public", "uploads");
|
||||
await writeFile(join(uploadDir, filename), buffer);
|
||||
|
||||
return NextResponse.json({ url: `/uploads/${filename}` });
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Moon,
|
||||
Pill,
|
||||
Thermometer,
|
||||
Milk,
|
||||
LucideProps,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -19,6 +20,7 @@ const ICONS: Record<string, React.FC<LucideProps>> = {
|
||||
Moon,
|
||||
Pill,
|
||||
Thermometer,
|
||||
Milk,
|
||||
};
|
||||
|
||||
interface EventIconProps extends LucideProps {
|
||||
|
||||
+382
-44
@@ -3,13 +3,23 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { EVENT_CONFIG, EventType } from "@/lib/event-config";
|
||||
import { EventIcon } from "@/components/event-icon";
|
||||
import { X } from "lucide-react";
|
||||
import { X, Camera, Loader2, AlertTriangle, Zap, Clock } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
|
||||
interface Props {
|
||||
type: EventType;
|
||||
babyId: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
initialEvent?: {
|
||||
id: string;
|
||||
startedAt: string;
|
||||
endedAt?: string;
|
||||
notes?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
@@ -19,6 +29,8 @@ interface FormData {
|
||||
breastSide?: "left" | "right" | "both";
|
||||
volume?: string;
|
||||
bottleType?: "maternal" | "formula";
|
||||
pumpSide?: "left" | "right" | "both";
|
||||
pumpVolume?: string;
|
||||
stoolColor?: string;
|
||||
stoolAmount?: "small" | "medium" | "large";
|
||||
medName?: string;
|
||||
@@ -28,6 +40,24 @@ interface FormData {
|
||||
tempUnit?: "C" | "F";
|
||||
}
|
||||
|
||||
interface MedProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
molecule: string | null;
|
||||
defaultDose: string | null;
|
||||
unit: string;
|
||||
intervalHours: number;
|
||||
minIntervalHours: number | null;
|
||||
isPreset: boolean;
|
||||
}
|
||||
|
||||
interface LastIntake {
|
||||
id: string;
|
||||
startedAt: string;
|
||||
dose?: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
const STOOL_COLORS = [
|
||||
{ value: "jaune", label: "Jaune", bg: "#fef3c7", dot: "#f59e0b" },
|
||||
{ value: "vert", label: "Vert", bg: "#d1fae5", dot: "#10b981" },
|
||||
@@ -42,19 +72,41 @@ function localNowString() {
|
||||
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export function EventModal({ type, babyId, onClose, onSaved }: Props) {
|
||||
function msToLabel(ms: number): string {
|
||||
if (ms <= 0) return "maintenant";
|
||||
const h = Math.floor(ms / 3600000);
|
||||
const m = Math.floor((ms % 3600000) / 60000);
|
||||
if (h > 0) return `${h}h${m.toString().padStart(2, "0")}`;
|
||||
return `${m}min`;
|
||||
}
|
||||
|
||||
function toLocal(iso: string) {
|
||||
return new Date(new Date(iso).getTime() - new Date(iso).getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Props) {
|
||||
const config = EVENT_CONFIG[type];
|
||||
const TIMER_KEY = `grow_timer_${type}_${babyId}`;
|
||||
const isEditing = !!initialEvent;
|
||||
|
||||
const meta = initialEvent?.metadata ?? {};
|
||||
|
||||
const [form, setForm] = useState<FormData>({
|
||||
startedAt: localNowString(),
|
||||
endedAt: "",
|
||||
notes: "",
|
||||
breastSide: "left",
|
||||
bottleType: "maternal",
|
||||
stoolColor: "jaune",
|
||||
stoolAmount: "medium",
|
||||
tempUnit: "C",
|
||||
startedAt: initialEvent ? toLocal(initialEvent.startedAt) : localNowString(),
|
||||
endedAt: initialEvent?.endedAt ? toLocal(initialEvent.endedAt) : "",
|
||||
notes: initialEvent?.notes ?? "",
|
||||
breastSide: (meta.breastSide as "left" | "right" | "both") ?? "left",
|
||||
bottleType: (meta.bottleType as "maternal" | "formula") ?? "maternal",
|
||||
pumpSide: (meta.side as "left" | "right" | "both") ?? "both",
|
||||
pumpVolume: meta.volume != null ? String(meta.volume) : undefined,
|
||||
volume: meta.volume != null && type === "BOTTLE" ? String(meta.volume) : undefined,
|
||||
stoolColor: (meta.color as string) ?? "jaune",
|
||||
stoolAmount: (meta.amount as "small" | "medium" | "large") ?? "medium",
|
||||
medName: (meta.name as string) ?? undefined,
|
||||
medDose: (meta.dose as string) ?? undefined,
|
||||
medUnit: (meta.unit as string) ?? undefined,
|
||||
temperature: meta.value != null ? String(meta.value) : undefined,
|
||||
tempUnit: (meta.unit as "C" | "F") ?? "C",
|
||||
});
|
||||
|
||||
const [timerRunning, setTimerRunning] = useState(false);
|
||||
@@ -63,6 +115,39 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
|
||||
const startTimeRef = useRef<Date | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [validationError, setValidationError] = useState("");
|
||||
const [photoUrl, setPhotoUrl] = useState<string | null>(null);
|
||||
const [photoUploading, setPhotoUploading] = useState(false);
|
||||
|
||||
// Medication profile state
|
||||
const [profiles, setProfiles] = useState<MedProfile[]>([]);
|
||||
const [lastIntakes, setLastIntakes] = useState<Record<string, LastIntake>>({});
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||
const [crisisMode, setCrisisMode] = useState(false);
|
||||
|
||||
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (type !== "MEDICATION") return;
|
||||
Promise.all([
|
||||
fetch("/api/medication-profiles").then((r) => r.json()),
|
||||
fetch(`/api/medication-profiles/last-intakes?babyId=${babyId}`).then((r) => r.json()),
|
||||
]).then(([profs, intakes]) => {
|
||||
if (Array.isArray(profs)) setProfiles(profs);
|
||||
if (intakes && typeof intakes === "object") setLastIntakes(intakes);
|
||||
});
|
||||
}, [type, babyId]);
|
||||
|
||||
// Auto-fill dose/unit when profile selected
|
||||
useEffect(() => {
|
||||
if (!selectedProfile) return;
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
medName: selectedProfile.name,
|
||||
medDose: selectedProfile.defaultDose ?? f.medDose ?? "",
|
||||
medUnit: selectedProfile.unit,
|
||||
}));
|
||||
setCrisisMode(false);
|
||||
}, [selectedProfile?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.hasTimer) return;
|
||||
@@ -99,9 +184,47 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
|
||||
}
|
||||
|
||||
function formatTimer(s: number) {
|
||||
return `${Math.floor(s / 60).toString().padStart(2, "0")}:${(s % 60).toString().padStart(2, "0")}`;
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
|
||||
return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setPhotoUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/upload", { method: "POST", body: fd });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPhotoUrl(data.url);
|
||||
}
|
||||
setPhotoUploading(false);
|
||||
}
|
||||
|
||||
// Compute next-dose status for selected profile
|
||||
function getMedStatus(): { tooSoon: boolean; nextAt: Date | null; waitMs: number; intervalUsed: number } | null {
|
||||
if (!selectedProfile) return null;
|
||||
const intake = lastIntakes[selectedProfile.name];
|
||||
if (!intake) return null;
|
||||
|
||||
const intervalUsed = crisisMode && selectedProfile.minIntervalHours
|
||||
? selectedProfile.minIntervalHours
|
||||
: selectedProfile.intervalHours;
|
||||
|
||||
const lastAt = new Date(intake.startedAt).getTime();
|
||||
const nextAt = new Date(lastAt + intervalUsed * 3600000);
|
||||
const waitMs = nextAt.getTime() - Date.now();
|
||||
|
||||
return { tooSoon: waitMs > 0, nextAt, waitMs, intervalUsed };
|
||||
}
|
||||
|
||||
const medStatus = getMedStatus();
|
||||
const hasCrisisOption = !!(selectedProfile?.minIntervalHours && selectedProfile.minIntervalHours < selectedProfile.intervalHours);
|
||||
|
||||
async function handleSave() {
|
||||
setValidationError("");
|
||||
if (type === "TEMPERATURE" && form.temperature) {
|
||||
@@ -117,29 +240,59 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
|
||||
const v = parseFloat(form.volume);
|
||||
if (v < 0 || v > 500) { setValidationError("Volume entre 0 et 500 mL"); return; }
|
||||
}
|
||||
if (type === "PUMP" && form.pumpVolume) {
|
||||
const v = parseFloat(form.pumpVolume);
|
||||
if (v < 0 || v > 1000) { setValidationError("Volume entre 0 et 1000 mL"); return; }
|
||||
}
|
||||
setLoading(true);
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (type === "BREASTFEED") metadata.breastSide = form.breastSide;
|
||||
if (type === "BOTTLE") { metadata.volume = form.volume ? parseFloat(form.volume) : null; metadata.bottleType = form.bottleType; }
|
||||
if (type === "PUMP") { metadata.side = form.pumpSide; metadata.volume = form.pumpVolume ? parseFloat(form.pumpVolume) : null; }
|
||||
if (type === "DIAPER_STOOL") { metadata.color = form.stoolColor; metadata.amount = form.stoolAmount; }
|
||||
if (type === "MEDICATION") { metadata.name = form.medName; metadata.dose = form.medDose; metadata.unit = form.medUnit; }
|
||||
if (type === "MEDICATION") {
|
||||
metadata.name = form.medName;
|
||||
metadata.dose = form.medDose;
|
||||
metadata.unit = form.medUnit;
|
||||
if (selectedProfile) {
|
||||
metadata.profileId = selectedProfile.id;
|
||||
metadata.intervalHours = crisisMode && selectedProfile.minIntervalHours
|
||||
? selectedProfile.minIntervalHours
|
||||
: selectedProfile.intervalHours;
|
||||
if (crisisMode) metadata.crisis = true;
|
||||
}
|
||||
}
|
||||
if (type === "TEMPERATURE") { metadata.value = form.temperature ? parseFloat(form.temperature) : null; metadata.unit = form.tempUnit; }
|
||||
if (photoUrl) metadata.photo = photoUrl;
|
||||
|
||||
const toUTC = (local: string) => local ? new Date(local).toISOString() : null;
|
||||
localStorage.removeItem(TIMER_KEY);
|
||||
|
||||
await fetch("/api/events", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
babyId,
|
||||
type,
|
||||
startedAt: toUTC(form.startedAt),
|
||||
endedAt: form.endedAt ? toUTC(form.endedAt) : null,
|
||||
notes: form.notes || null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
}),
|
||||
});
|
||||
if (isEditing) {
|
||||
await fetch(`/api/events/${initialEvent!.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
startedAt: toUTC(form.startedAt),
|
||||
endedAt: form.endedAt ? toUTC(form.endedAt) : null,
|
||||
notes: form.notes || null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
await fetch("/api/events", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
babyId,
|
||||
type,
|
||||
startedAt: toUTC(form.startedAt),
|
||||
endedAt: form.endedAt ? toUTC(form.endedAt) : null,
|
||||
notes: form.notes || null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
onSaved();
|
||||
onClose();
|
||||
@@ -161,7 +314,9 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
|
||||
<div className={`w-9 h-9 rounded-xl ${config.bgClass} flex items-center justify-center`}>
|
||||
<EventIcon name={config.icon} className={`w-5 h-5 ${config.colorClass}`} />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">{config.label}</h2>
|
||||
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
||||
{isEditing ? `Modifier · ${config.label}` : config.label}
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="w-8 h-8 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 flex items-center justify-center text-slate-400 transition">
|
||||
<X className="w-4 h-4" />
|
||||
@@ -224,6 +379,28 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pump */}
|
||||
{type === "PUMP" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Sein(s)</label>
|
||||
<div className="flex gap-2">
|
||||
{(["left", "right", "both"] as const).map((side) => (
|
||||
<button key={side} type="button" onClick={() => setForm((f) => ({ ...f, pumpSide: side }))}
|
||||
className={`${segmentBase} ${form.pumpSide === side ? segmentActive : segmentInactive}`}>
|
||||
{side === "left" ? "Gauche" : side === "right" ? "Droite" : "Les deux"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Volume tiré (mL)</label>
|
||||
<input type="number" value={form.pumpVolume ?? ""} onChange={(e) => setForm((f) => ({ ...f, pumpVolume: e.target.value }))}
|
||||
className={inputCls} placeholder="80" min="0" max="1000" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diaper stool */}
|
||||
{type === "DIAPER_STOOL" && (
|
||||
<div className="space-y-3">
|
||||
@@ -255,20 +432,141 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Medication */}
|
||||
{/* Medication — profile picker */}
|
||||
{type === "MEDICATION" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Traitement</label>
|
||||
<input type="text" value={form.medName ?? ""} onChange={(e) => setForm((f) => ({ ...f, medName: e.target.value }))}
|
||||
className={inputCls} placeholder="Doliprane" />
|
||||
</div>
|
||||
{/* Profile chips */}
|
||||
{profiles.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">Médicament</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{profiles.map((p) => {
|
||||
const intake = lastIntakes[p.name];
|
||||
const hasIntake = !!intake;
|
||||
const intervalH = p.intervalHours;
|
||||
const nextAt = hasIntake
|
||||
? new Date(new Date(intake.startedAt).getTime() + intervalH * 3600000)
|
||||
: null;
|
||||
const tooSoon = nextAt ? nextAt.getTime() > Date.now() : false;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedProfileId(selectedProfileId === p.id ? null : p.id)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl border text-xs font-medium transition ${
|
||||
selectedProfileId === p.id
|
||||
? "border-green-500 bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300"
|
||||
: tooSoon
|
||||
? "border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400"
|
||||
: "border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-slate-300"
|
||||
}`}
|
||||
>
|
||||
{tooSoon && selectedProfileId !== p.id && <span className="w-1.5 h-1.5 rounded-full bg-red-400 flex-shrink-0" />}
|
||||
{p.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedProfileId(null)}
|
||||
className={`px-2.5 py-1.5 rounded-xl border text-xs font-medium transition ${
|
||||
selectedProfileId === null
|
||||
? "border-indigo-500 bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300"
|
||||
: "border-slate-200 dark:border-slate-600 text-slate-400 hover:border-slate-300"
|
||||
}`}
|
||||
>
|
||||
Autre
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected profile info + timing */}
|
||||
{selectedProfile && (
|
||||
<div className={`rounded-xl p-3 space-y-2 ${
|
||||
medStatus?.tooSoon
|
||||
? "bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800"
|
||||
: "bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800"
|
||||
}`}>
|
||||
{selectedProfile.molecule && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{selectedProfile.molecule}</p>
|
||||
)}
|
||||
|
||||
{medStatus ? (
|
||||
medStatus.tooSoon ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-red-700 dark:text-red-300">
|
||||
Trop tôt — encore {msToLabel(medStatus.waitMs)} à attendre
|
||||
</p>
|
||||
<p className="text-[10px] text-red-500 dark:text-red-400 mt-0.5">
|
||||
Prochaine dose : {format(medStatus.nextAt!, "HH:mm", { locale: fr })}
|
||||
{" "}(intervalle {medStatus.intervalUsed}h)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
|
||||
<p className="text-xs text-green-700 dark:text-green-300">
|
||||
Dernière prise : {msToLabel(Date.now() - new Date(lastIntakes[selectedProfile.name].startedAt).getTime())} — OK
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="text-xs text-green-700 dark:text-green-300">Première prise</p>
|
||||
)}
|
||||
|
||||
{/* Crisis mode toggle */}
|
||||
{hasCrisisOption && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCrisisMode((v) => !v)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition w-full justify-center ${
|
||||
crisisMode
|
||||
? "border-orange-400 bg-orange-50 dark:bg-orange-950 text-orange-700 dark:text-orange-300"
|
||||
: "border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:border-orange-300"
|
||||
}`}
|
||||
>
|
||||
<Zap className={`w-3 h-3 ${crisisMode ? "text-orange-500" : "text-slate-400"}`} />
|
||||
Mode urgence ({selectedProfile.minIntervalHours}h au lieu de {selectedProfile.intervalHours}h)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dose / unit */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Dose</label>
|
||||
<input type="text" value={form.medDose ?? ""} onChange={(e) => setForm((f) => ({ ...f, medDose: e.target.value }))}
|
||||
className={inputCls} placeholder="2.4" />
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||
{selectedProfileId === null ? "Traitement" : "Dose"}
|
||||
</label>
|
||||
{selectedProfileId === null ? (
|
||||
<input type="text" value={form.medName ?? ""} onChange={(e) => setForm((f) => ({ ...f, medName: e.target.value }))}
|
||||
className={inputCls} placeholder="Doliprane" />
|
||||
) : (
|
||||
<input type="text" value={form.medDose ?? ""} onChange={(e) => setForm((f) => ({ ...f, medDose: e.target.value }))}
|
||||
className={inputCls} placeholder={selectedProfile?.defaultDose ?? "—"} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||
{selectedProfileId === null ? "Dose" : "Unité"}
|
||||
</label>
|
||||
{selectedProfileId === null ? (
|
||||
<input type="text" value={form.medDose ?? ""} onChange={(e) => setForm((f) => ({ ...f, medDose: e.target.value }))}
|
||||
className={inputCls} placeholder="2.4" />
|
||||
) : (
|
||||
<input type="text" value={form.medUnit ?? ""} onChange={(e) => setForm((f) => ({ ...f, medUnit: e.target.value }))}
|
||||
className={inputCls} placeholder="mL" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Free-text unit for custom */}
|
||||
{selectedProfileId === null && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Unité</label>
|
||||
<select value={form.medUnit ?? "mL"} onChange={(e) => setForm((f) => ({ ...f, medUnit: e.target.value }))}
|
||||
@@ -276,9 +574,11 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
|
||||
<option value="mL">mL</option>
|
||||
<option value="mg">mg</option>
|
||||
<option value="gouttes">gouttes</option>
|
||||
<option value="sachet">sachet</option>
|
||||
<option value="comprimé">comprimé</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -303,22 +603,32 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time fields */}
|
||||
<div className={`grid ${config.hasDuration ? "grid-cols-2" : "grid-cols-1"} gap-3`}>
|
||||
{/* Time fields — split date + time for iOS Safari compatibility */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||
{config.hasDuration ? "Début" : "Heure"}
|
||||
</label>
|
||||
<input type="datetime-local" value={form.startedAt}
|
||||
onChange={(e) => setForm((f) => ({ ...f, startedAt: e.target.value }))}
|
||||
className={inputCls} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input type="date" value={form.startedAt.slice(0, 10)}
|
||||
onChange={(e) => setForm((f) => ({ ...f, startedAt: `${e.target.value}T${f.startedAt.slice(11, 16) || "00:00"}` }))}
|
||||
className={inputCls} />
|
||||
<input type="time" value={form.startedAt.slice(11, 16)}
|
||||
onChange={(e) => setForm((f) => ({ ...f, startedAt: `${f.startedAt.slice(0, 10)}T${e.target.value}` }))}
|
||||
className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
{config.hasDuration && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Fin</label>
|
||||
<input type="datetime-local" value={form.endedAt}
|
||||
onChange={(e) => setForm((f) => ({ ...f, endedAt: e.target.value }))}
|
||||
className={inputCls} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input type="date" value={form.endedAt.slice(0, 10)}
|
||||
onChange={(e) => setForm((f) => ({ ...f, endedAt: `${e.target.value}T${f.endedAt.slice(11, 16) || "00:00"}` }))}
|
||||
className={inputCls} />
|
||||
<input type="time" value={form.endedAt.slice(11, 16)}
|
||||
onChange={(e) => setForm((f) => ({ ...f, endedAt: `${f.endedAt.slice(0, 10) || new Date().toISOString().slice(0, 10)}T${e.target.value}` }))}
|
||||
className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -332,6 +642,34 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
|
||||
rows={2} className={`${inputCls} resize-none`} placeholder="Observations..." />
|
||||
</div>
|
||||
|
||||
{/* Photo */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||
Photo <span className="text-slate-400 font-normal">(optionnel)</span>
|
||||
</label>
|
||||
{photoUrl ? (
|
||||
<div className="relative w-full h-40 rounded-xl overflow-hidden border border-slate-200 dark:border-slate-600">
|
||||
<Image src={photoUrl} alt="Photo" fill className="object-cover" unoptimized />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPhotoUrl(null)}
|
||||
className="absolute top-2 right-2 w-7 h-7 bg-black/50 rounded-full flex items-center justify-center text-white hover:bg-black/70 transition"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex items-center gap-2 px-3 py-2.5 border border-dashed border-slate-300 dark:border-slate-600 rounded-xl cursor-pointer hover:border-indigo-400 dark:hover:border-indigo-500 transition text-slate-500 dark:text-slate-400 text-sm">
|
||||
{photoUploading ? (
|
||||
<><Loader2 className="w-4 h-4 animate-spin" />Envoi en cours...</>
|
||||
) : (
|
||||
<><Camera className="w-4 h-4" />Ajouter une photo</>
|
||||
)}
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handlePhotoChange} disabled={photoUploading} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<div className="px-3 py-2 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg text-xs text-red-600 dark:text-red-400">
|
||||
{validationError}
|
||||
@@ -346,7 +684,7 @@ export function EventModal({ type, babyId, onClose, onSaved }: Props) {
|
||||
</button>
|
||||
<button type="button" onClick={handleSave} disabled={loading}
|
||||
className="flex-[2] py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white text-sm font-medium transition">
|
||||
{loading ? "Enregistrement..." : "Enregistrer"}
|
||||
{loading ? "..." : isEditing ? "Mettre à jour" : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,17 +23,24 @@ import {
|
||||
Syringe,
|
||||
MoreHorizontal,
|
||||
X,
|
||||
Bell,
|
||||
CalendarDays,
|
||||
Milk,
|
||||
} from "lucide-react";
|
||||
|
||||
const LINKS = [
|
||||
{ href: "/dashboard", label: "Accueil", Icon: LayoutGrid },
|
||||
{ href: "/timeline", label: "Journal", Icon: ScrollText },
|
||||
{ href: "/calendar", label: "Calendrier", Icon: CalendarDays },
|
||||
{ href: "/stats", label: "Statistiques", Icon: BarChart3 },
|
||||
{ href: "/growth", label: "Courbes", Icon: TrendingUp },
|
||||
{ href: "/milk", label: "Stock lait", Icon: Milk },
|
||||
{ href: "/milestones", label: "Étapes", Icon: Star },
|
||||
{ href: "/notes", label: "Notes", Icon: NotebookPen },
|
||||
{ href: "/doctor-notes", label: "Médecin", Icon: Stethoscope },
|
||||
{ href: "/vaccinations", label: "Vaccinations", Icon: Syringe },
|
||||
{ href: "/medications", label: "Médic.", Icon: Stethoscope },
|
||||
{ href: "/reminders", label: "Rappels", Icon: Bell },
|
||||
{ href: "/settings", label: "Réglages", Icon: Settings },
|
||||
];
|
||||
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useBaby } from "@/contexts/baby-context";
|
||||
import { EVENT_CONFIG, EventType } from "@/lib/event-config";
|
||||
import { EventIcon } from "@/components/event-icon";
|
||||
import { EventModal } from "@/components/event-modal";
|
||||
import { Plus, X } from "lucide-react";
|
||||
|
||||
const FAB_HIDDEN_PATHS = ["/settings", "/admin"];
|
||||
|
||||
const QUICK_TYPES: EventType[] = [
|
||||
"BREASTFEED",
|
||||
"BOTTLE",
|
||||
"PUMP",
|
||||
"DIAPER_WET",
|
||||
"DIAPER_STOOL",
|
||||
"NAP",
|
||||
@@ -22,10 +26,11 @@ const QUICK_TYPES: EventType[] = [
|
||||
export function QuickAddFab() {
|
||||
const qc = useQueryClient();
|
||||
const { selectedBaby } = useBaby();
|
||||
const pathname = usePathname();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [activeModal, setActiveModal] = useState<EventType | null>(null);
|
||||
|
||||
if (!selectedBaby) return null;
|
||||
if (!selectedBaby || FAB_HIDDEN_PATHS.includes(pathname)) return null;
|
||||
|
||||
function selectType(type: EventType) {
|
||||
setSheetOpen(false);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { prisma } from "./prisma";
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
interface AuditOptions {
|
||||
action: string;
|
||||
targetId?: string;
|
||||
familyId?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export async function logAudit(session: Session | null, opts: AuditOptions) {
|
||||
if (!session?.user?.id) return;
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
userName: session.user.name ?? "—",
|
||||
action: opts.action,
|
||||
targetId: opts.targetId ?? null,
|
||||
familyId: opts.familyId ?? null,
|
||||
detail: opts.detail ?? null,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// audit failure must never break the main request
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export type EventType =
|
||||
| "BREASTFEED"
|
||||
| "BOTTLE"
|
||||
| "PUMP"
|
||||
| "DIAPER_WET"
|
||||
| "DIAPER_STOOL"
|
||||
| "NAP"
|
||||
@@ -41,6 +42,16 @@ export const EVENT_CONFIG: Record<
|
||||
hasDuration: false,
|
||||
hasTimer: false,
|
||||
},
|
||||
PUMP: {
|
||||
label: "Tirage",
|
||||
icon: "Milk",
|
||||
color: "#0891b2",
|
||||
colorClass: "text-cyan-600",
|
||||
bgClass: "bg-cyan-50",
|
||||
borderClass: "border-cyan-200",
|
||||
hasDuration: true,
|
||||
hasTimer: true,
|
||||
},
|
||||
DIAPER_WET: {
|
||||
label: "Couche mouillée",
|
||||
icon: "Droplets",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const MEDICATION_PRESETS = [
|
||||
{ name: "Doliprane", molecule: "Paracétamol 2.4%", defaultDose: "1", unit: "mL/kg", intervalHours: 6, minIntervalHours: 4 },
|
||||
{ name: "Efferalgan", molecule: "Paracétamol 3%", defaultDose: "1", unit: "mL/kg", intervalHours: 6, minIntervalHours: 4 },
|
||||
{ name: "Advil", molecule: "Ibuprofène 20mg/mL", defaultDose: "0.5", unit: "mL/kg", intervalHours: 6, minIntervalHours: 6 },
|
||||
{ name: "Nurofen", molecule: "Ibuprofène 20mg/mL", defaultDose: "0.5", unit: "mL/kg", intervalHours: 6, minIntervalHours: 6 },
|
||||
{ name: "Motilium", molecule: "Dompéridone", defaultDose: "0.25", unit: "mL/kg", intervalHours: 8, minIntervalHours: 8 },
|
||||
{ name: "Toplexil", molecule: "Oxomémazine", defaultDose: null, unit: "mL", intervalHours: 6, minIntervalHours: 6 },
|
||||
{ name: "Smecta", molecule: "Diosmectite", defaultDose: null, unit: "sachet", intervalHours: 8, minIntervalHours: 8 },
|
||||
{ name: "Zyrtec", molecule: "Cétirizine", defaultDose: null, unit: "mL", intervalHours: 24, minIntervalHours: 24 },
|
||||
{ name: "Solupred", molecule: "Prednisolone", defaultDose: null, unit: "mg/kg", intervalHours: 24, minIntervalHours: 24 },
|
||||
{ name: "Amoxicilline", molecule: "Amoxicilline", defaultDose: null, unit: "mL", intervalHours: 8, minIntervalHours: 8 },
|
||||
] as const;
|
||||
Reference in New Issue
Block a user