6dc09fb696
- Photo client-side limit: 5MB → 30MB (matches server) - OTHER event shows notes preview in dashboard + timeline summaries - Stats: add "Activités par jour" chart (Bain/Balade/Plat ventre/Autre) - Combined diaper (wet→stool): show color/amount pickers, pass metadata - Quick-add FAB: 4-col → 5-col grid (14 types in 3 rows instead of 4) - Stool color buttons: force dark text, readable on light bg in dark mode - Reminders: mark taken (✓) sets lastSentAt, ↺ clears it; shows elapsed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
"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, CheckCircle2, RotateCcw } from "lucide-react";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import { fr } from "date-fns/locale";
|
|
|
|
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 markTaken(id: string) {
|
|
await fetch(`/api/reminders/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ lastSentAt: new Date().toISOString() }),
|
|
});
|
|
qc.invalidateQueries({ queryKey: ["reminders"] });
|
|
}
|
|
|
|
async function unmarkTaken(id: string) {
|
|
await fetch(`/api/reminders/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ lastSentAt: null }),
|
|
});
|
|
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>
|
|
{r.lastSentAt && (
|
|
<p className="text-xs text-green-600 dark:text-green-400 mt-0.5 flex items-center gap-1">
|
|
<CheckCircle2 className="w-3 h-3" />
|
|
Pris {formatDistanceToNow(new Date(r.lastSentAt), { locale: fr, addSuffix: true })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
{r.lastSentAt ? (
|
|
<button
|
|
onClick={() => unmarkTaken(r.id)}
|
|
title="Annuler la prise"
|
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-green-500 hover:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => markTaken(r.id)}
|
|
title="Marquer comme pris"
|
|
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"
|
|
>
|
|
<CheckCircle2 className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
<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>
|
|
);
|
|
}
|