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