Files
Grow/src/app/(app)/medications/page.tsx
T
arnaudne 020539e2e2 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>
2026-06-13 15:01:38 +02:00

233 lines
10 KiB
TypeScript

"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>
);
}