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