020539e2e2
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>
233 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|