feat(pump): link pump events to milk stock, fix expiry units

- PUMP modal: "Ajouter au stock" toggle with location selector
- Merge option: pick existing today's lot to add volume to (expiry kept)
- On save: POST new MilkStock or PATCH existing volume
- Milk page: expiry input now in hours/days/months per location
- Milk page: "soon" alert threshold scales per location (freezer=7d, fridge=24h, room=2h)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 15:34:35 +02:00
parent 142d27e85c
commit 9fb1a726dd
2 changed files with 130 additions and 10 deletions
+37 -10
View File
@@ -37,7 +37,13 @@ function expiryStatus(stock: Stock): "expired" | "soon" | "ok" {
const now = Date.now(); const now = Date.now();
const exp = new Date(stock.expiresAt).getTime(); const exp = new Date(stock.expiresAt).getTime();
if (exp <= now) return "expired"; if (exp <= now) return "expired";
if (exp - now < 6 * 60 * 60 * 1000) return "soon"; // < 6h // "soon" threshold scales with location: room <2h, fridge <24h, freezer <7d
const remaining = exp - now;
const soonMs =
stock.location === "freezer" ? 7 * 24 * 3600_000 :
stock.location === "fridge" ? 24 * 3600_000 :
2 * 3600_000;
if (remaining < soonMs) return "soon";
return "ok"; return "ok";
} }
@@ -51,7 +57,7 @@ export default function MilkPage() {
volume: "", volume: "",
storedAt: localNowString(), storedAt: localNowString(),
notes: "", notes: "",
expiryHours: "", expiryCustom: "", // in hours (room), days (fridge), months (freezer)
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -77,7 +83,13 @@ export default function MilkPage() {
e.preventDefault(); e.preventDefault();
if (!selectedBaby) return; if (!selectedBaby) return;
setSaving(true); setSaving(true);
const expiryHours = form.expiryHours ? parseFloat(form.expiryHours) : undefined; let expiryCustom: number | undefined;
if (form.expiryCustom) {
const v = parseFloat(form.expiryCustom);
if (location === "fridge") expiryCustom = v * 24;
else if (location === "freezer") expiryCustom = v * 24 * 30;
else expiryCustom = v;
}
await fetch("/api/milk", { await fetch("/api/milk", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -87,11 +99,11 @@ export default function MilkPage() {
storedAt: form.storedAt, storedAt: form.storedAt,
location, location,
notes: form.notes || null, notes: form.notes || null,
expiryHours, expiryCustom,
}), }),
}); });
qc.invalidateQueries({ queryKey: ["milk"] }); qc.invalidateQueries({ queryKey: ["milk"] });
setForm({ volume: "", storedAt: localNowString(), notes: "", expiryHours: "" }); setForm({ volume: "", storedAt: localNowString(), notes: "", expiryCustom: "" });
setShowForm(false); setShowForm(false);
setSaving(false); setSaving(false);
} }
@@ -177,7 +189,7 @@ export default function MilkPage() {
return ( return (
<button key={loc} type="button" onClick={() => { <button key={loc} type="button" onClick={() => {
setLocation(loc); setLocation(loc);
setForm((f) => ({ ...f, expiryHours: "" })); setForm((f) => ({ ...f, expiryCustom: "" }));
}} }}
className={`flex-1 flex flex-col items-center gap-1 py-2 px-1 border rounded-xl text-xs font-medium transition ${ className={`flex-1 flex flex-col items-center gap-1 py-2 px-1 border rounded-xl text-xs font-medium transition ${
location === loc location === loc
@@ -191,7 +203,12 @@ export default function MilkPage() {
})} })}
</div> </div>
<p className="text-[10px] text-slate-400 dark:text-slate-500 mt-1"> <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`} Durée par défaut :{" "}
{location === "freezer"
? `${defaultExpiryHours / 24 / 30} mois`
: location === "fridge"
? `${defaultExpiryHours / 24} jours`
: `${defaultExpiryHours}h`}
</p> </p>
</div> </div>
@@ -202,9 +219,19 @@ export default function MilkPage() {
required className={inputCls} placeholder="80" min="1" max="2000" /> required className={inputCls} placeholder="80" min="1" max="2000" />
</div> </div>
<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> <label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<input type="number" value={form.expiryHours} onChange={(e) => setForm((f) => ({ ...f, expiryHours: e.target.value }))} Péremption ({location === "freezer" ? "mois" : location === "fridge" ? "jours" : "heures"})
className={inputCls} placeholder={String(defaultExpiryHours)} min="1" /> </label>
<input
type="number"
value={form.expiryCustom}
onChange={(e) => setForm((f) => ({ ...f, expiryCustom: e.target.value }))}
className={inputCls}
placeholder={
location === "freezer" ? "6" : location === "fridge" ? "4" : "4"
}
min="1"
/>
</div> </div>
</div> </div>
+93
View File
@@ -137,6 +137,12 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
const [milkStocks, setMilkStocks] = useState<MilkStock[]>([]); const [milkStocks, setMilkStocks] = useState<MilkStock[]>([]);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null); const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// Pump → stock state
const [addToStock, setAddToStock] = useState(false);
const [pumpStockLocation, setPumpStockLocation] = useState<"room" | "fridge" | "freezer">("fridge");
const [todayLots, setTodayLots] = useState<MilkStock[]>([]);
const [mergeLotId, setMergeLotId] = useState<string | null>(null);
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) ?? null; const selectedProfile = profiles.find((p) => p.id === selectedProfileId) ?? null;
useEffect(() => { useEffect(() => {
@@ -150,6 +156,22 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
}); });
}, [type, babyId]); }, [type, babyId]);
useEffect(() => {
if (type !== "PUMP") return;
fetch(`/api/milk?babyId=${babyId}`)
.then((r) => r.json())
.then((stocks) => {
if (!Array.isArray(stocks)) return;
const today = new Date().toDateString();
const lots = stocks.filter(
(s: MilkStock & { used?: boolean }) =>
!s.used && new Date(s.storedAt).toDateString() === today
);
lots.sort((a: MilkStock, b: MilkStock) => new Date(b.storedAt).getTime() - new Date(a.storedAt).getTime());
setTodayLots(lots);
});
}, [type, babyId]);
useEffect(() => { useEffect(() => {
if (type !== "BOTTLE") return; if (type !== "BOTTLE") return;
fetch(`/api/milk?babyId=${babyId}`) fetch(`/api/milk?babyId=${babyId}`)
@@ -327,6 +349,25 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
body: JSON.stringify({ used: true }), body: JSON.stringify({ used: true }),
}); });
} }
if (type === "PUMP" && addToStock && form.pumpVolume && !isEditing) {
const vol = parseFloat(form.pumpVolume);
if (vol > 0) {
if (mergeLotId) {
const existing = todayLots.find((l) => l.id === mergeLotId);
await fetch(`/api/milk/${mergeLotId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ volume: (existing?.volume ?? 0) + vol }),
});
} else {
await fetch("/api/milk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ babyId, volume: vol, location: pumpStockLocation }),
});
}
}
}
setLoading(false); setLoading(false);
onSaved(); onSaved();
onClose(); onClose();
@@ -470,6 +511,58 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
<input type="number" value={form.pumpVolume ?? ""} onChange={(e) => setForm((f) => ({ ...f, pumpVolume: e.target.value }))} <input type="number" value={form.pumpVolume ?? ""} onChange={(e) => setForm((f) => ({ ...f, pumpVolume: e.target.value }))}
className={inputCls} placeholder="80" min="0" max="1000" /> className={inputCls} placeholder="80" min="0" max="1000" />
</div> </div>
{!isEditing && (
<div className="border border-slate-200 dark:border-slate-600 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => setAddToStock((v) => !v)}
className="w-full flex items-center justify-between px-3 py-2.5 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
>
<span>Ajouter au stock lait</span>
<div className={`w-9 h-5 rounded-full transition-colors ${addToStock ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-600"} relative flex-shrink-0`}>
<div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${addToStock ? "translate-x-4" : "translate-x-0.5"}`} />
</div>
</button>
{addToStock && (
<div className="px-3 pb-3 pt-2 space-y-3 bg-slate-50 dark:bg-slate-800/60 border-t border-slate-100 dark:border-slate-700">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Conservation</label>
<div className="flex gap-2">
{(["room", "fridge", "freezer"] as const).map((loc) => (
<button key={loc} type="button" onClick={() => setPumpStockLocation(loc)}
className={`${segmentBase} ${pumpStockLocation === loc ? segmentActive : segmentInactive}`}>
{loc === "room" ? "Ambiant" : loc === "fridge" ? "Frigo" : "Congélo"}
</button>
))}
</div>
</div>
{todayLots.length > 0 && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Fusionner avec lot du jour</label>
<div className="flex flex-col gap-1.5">
<button type="button" onClick={() => setMergeLotId(null)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-xs transition ${mergeLotId === 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-500 dark:text-slate-400"}`}>
Nouveau lot
</button>
{todayLots.map((lot) => (
<button key={lot.id} type="button" onClick={() => setMergeLotId(lot.id)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-xs transition ${mergeLotId === lot.id ? "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"}`}>
{lot.volume} mL · {lot.location === "fridge" ? "frigo" : lot.location === "freezer" ? "congélo" : "ambiant"}
{" · "}tirage {format(new Date(lot.storedAt), "HH:mm", { locale: fr })}
</button>
))}
</div>
{mergeLotId && (
<p className="text-[11px] text-indigo-500 mt-1.5">
Volume sera ajouté à ce lot. Péremption inchangée.
</p>
)}
</div>
)}
</div>
)}
</div>
)}
</div> </div>
)} )}