From da60efd7c13d0864efd5fa4b618e3ccd3c949889 Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Thu, 18 Jun 2026 22:10:39 +0200 Subject: [PATCH] feat(milk): add stock editing and lot assembly - Inline edit form per stock (volume, location, expiry, notes) - Assembly mode: select 2+ lots, merge into one with combined volume and earliest expiry date, deletes originals - POST /api/milk now accepts expiresAt override directly - Fix OTHER event type icon (add FileText to EventIcon map) Co-Authored-By: Claude Sonnet 4.6 --- src/app/(app)/milk/page.tsx | 265 ++++++++++++++++++++++++++++++---- src/app/api/milk/route.ts | 10 +- src/components/event-icon.tsx | 2 + 3 files changed, 246 insertions(+), 31 deletions(-) diff --git a/src/app/(app)/milk/page.tsx b/src/app/(app)/milk/page.tsx index fd8909b..640f90d 100644 --- a/src/app/(app)/milk/page.tsx +++ b/src/app/(app)/milk/page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useBaby } from "@/contexts/baby-context"; -import { Plus, Trash2, Check, AlertTriangle, Clock, Thermometer, Snowflake, Wind } from "lucide-react"; +import { Plus, Trash2, Check, AlertTriangle, Clock, Thermometer, Snowflake, Wind, Pencil, Combine, X } from "lucide-react"; import { format, formatDistanceToNow } from "date-fns"; import { fr } from "date-fns/locale"; @@ -33,11 +33,15 @@ function localNowString() { return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16); } +function toLocalDatetime(iso: string) { + const d = new Date(iso); + return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16); +} + function expiryStatus(stock: Stock): "expired" | "soon" | "ok" { const now = Date.now(); const exp = new Date(stock.expiresAt).getTime(); if (exp <= now) return "expired"; - // "soon" threshold scales with location: room <2h, fridge <24h, freezer <7d const remaining = exp - now; const soonMs = stock.location === "freezer" ? 7 * 24 * 3600_000 : @@ -50,6 +54,8 @@ function expiryStatus(stock: Stock): "expired" | "soon" | "ok" { export default function MilkPage() { const qc = useQueryClient(); const { selectedBaby } = useBaby(); + + // Add form const [showForm, setShowForm] = useState(false); const [showUsed, setShowUsed] = useState(false); const [location, setLocation] = useState("fridge"); @@ -57,10 +63,25 @@ export default function MilkPage() { volume: "", storedAt: localNowString(), notes: "", - expiryCustom: "", // in hours (room), days (fridge), months (freezer) + expiryCustom: "", }); const [saving, setSaving] = useState(false); + // Edit + const [editingId, setEditingId] = useState(null); + const [editForm, setEditForm] = useState({ + volume: "", + location: "fridge" as Location, + expiresAt: "", + notes: "", + }); + const [editSaving, setEditSaving] = useState(false); + + // Assemble + const [assembleMode, setAssembleMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [assembleSaving, setAssembleSaving] = useState(false); + const { data: stocks = [], isLoading } = useQuery({ queryKey: ["milk", selectedBaby?.id], queryFn: () => fetch(`/api/milk?babyId=${selectedBaby!.id}`).then((r) => r.json()), @@ -108,6 +129,35 @@ export default function MilkPage() { setSaving(false); } + function openEdit(s: Stock) { + setEditingId(s.id); + setEditForm({ + volume: String(s.volume), + location: (s.location as Location) ?? "fridge", + expiresAt: toLocalDatetime(s.expiresAt), + notes: s.notes ?? "", + }); + } + + async function handleSaveEdit(e: React.FormEvent) { + e.preventDefault(); + if (!editingId) return; + setEditSaving(true); + await fetch(`/api/milk/${editingId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + volume: parseFloat(editForm.volume), + location: editForm.location, + expiresAt: new Date(editForm.expiresAt).toISOString(), + notes: editForm.notes || null, + }), + }); + qc.invalidateQueries({ queryKey: ["milk"] }); + setEditingId(null); + setEditSaving(false); + } + async function markUsed(id: string) { await fetch(`/api/milk/${id}`, { method: "PATCH", @@ -123,6 +173,48 @@ export default function MilkPage() { qc.invalidateQueries({ queryKey: ["milk"] }); } + function toggleSelect(id: string) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + const selectedStocks = available.filter((s) => selectedIds.has(s.id)); + const assembleVolume = selectedStocks.reduce((sum, s) => sum + s.volume, 0); + const assembleExpiry = selectedStocks.length > 0 + ? selectedStocks.reduce((min, s) => { + const t = new Date(s.expiresAt).getTime(); + return t < min ? t : min; + }, Infinity) + : null; + + async function handleAssemble() { + if (!selectedBaby || selectedStocks.length < 2) return; + setAssembleSaving(true); + await fetch("/api/milk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + babyId: selectedBaby.id, + volume: assembleVolume, + storedAt: new Date().toISOString(), + location: selectedStocks[0].location, + notes: `Assemblage de ${selectedStocks.length} lots`, + expiresAt: assembleExpiry ? new Date(assembleExpiry).toISOString() : undefined, + }), + }); + await Promise.all(selectedStocks.map((s) => + fetch(`/api/milk/${s.id}`, { method: "DELETE" }) + )); + qc.invalidateQueries({ queryKey: ["milk"] }); + setSelectedIds(new Set()); + setAssembleMode(false); + setAssembleSaving(false); + } + const defaultExpiryHours = LOCATION_CONFIG[location].defaultHours; return ( @@ -133,13 +225,28 @@ export default function MilkPage() {

Stock de lait

{selectedBaby &&

{selectedBaby.name}

} - +
+ {available.filter((s) => expiryStatus(s) !== "expired").length >= 2 && ( + + )} + +
{/* Summary cards */} @@ -174,12 +281,19 @@ export default function MilkPage() { )} + {/* Assemble hint */} + {assembleMode && ( +
+ + Sélectionnez au moins 2 lots à assembler +
+ )} + {/* Add form */} {showForm && (

Nouveau lot

- {/* Location selector */}
@@ -227,9 +341,7 @@ export default function MilkPage() { value={form.expiryCustom} onChange={(e) => setForm((f) => ({ ...f, expiryCustom: e.target.value }))} className={inputCls} - placeholder={ - location === "freezer" ? "6" : location === "fridge" ? "4" : "4" - } + placeholder={location === "freezer" ? "6" : location === "fridge" ? "4" : "4"} min="1" />
@@ -280,13 +392,28 @@ export default function MilkPage() { .map((s) => { const status = expiryStatus(s); const LocIcon = LOCATION_CONFIG[s.location as Location]?.icon ?? Thermometer; + const isEditing = editingId === s.id; + const isSelectable = assembleMode && status !== "expired"; + const isSelected = selectedIds.has(s.id); + return ( -
-
+
toggleSelect(s.id) : undefined} + > + {assembleMode && ( +
+ {isSelected && } +
+ )}
{s.notes &&

{s.notes}

}
-
- - -
+ {!assembleMode && ( +
+ + + +
+ )}
+ + {/* Inline edit form */} + {isEditing && ( + +

Modifier le lot

+
+ {(["room", "fridge", "freezer"] as Location[]).map((loc) => { + const cfg = LOCATION_CONFIG[loc]; + const Icon = cfg.icon; + return ( + + ); + })} +
+
+
+ + setEditForm((f) => ({ ...f, volume: e.target.value }))} + required className={inputCls} min="1" max="2000" /> +
+
+ + setEditForm((f) => ({ ...f, expiresAt: e.target.value }))} + required className={inputCls} /> +
+
+
+ + setEditForm((f) => ({ ...f, notes: e.target.value }))} + className={inputCls} /> +
+
+ + +
+ + )}
); })}
)} + {/* Assemble action bar */} + {assembleMode && selectedIds.size >= 2 && ( +
+
+
+

{selectedIds.size} lots · {assembleVolume} mL

+ {assembleExpiry && ( +

+ Expire le {format(new Date(assembleExpiry), "dd/MM à HH:mm")} +

+ )} +
+ +
+
+ )} + {/* Used / archived */} {used.length > 0 && (
diff --git a/src/app/api/milk/route.ts b/src/app/api/milk/route.ts index 4ed9513..305e9e7 100644 --- a/src/app/api/milk/route.ts +++ b/src/app/api/milk/route.ts @@ -35,7 +35,7 @@ export async function POST(req: Request) { if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); const body = await req.json(); - const { babyId, volume, storedAt, location, notes, expiryHours } = body; + const { babyId, volume, storedAt, location, notes, expiryHours, expiresAt: expiresAtOverride } = body; if (!babyId || !volume) { return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); @@ -52,8 +52,12 @@ export async function POST(req: Request) { const loc = location ?? "fridge"; const storedDate = storedAt ? new Date(storedAt) : new Date(); - const hours = expiryHours ?? EXPIRY_HOURS[loc] ?? EXPIRY_HOURS.fridge; - const expiresAt = new Date(storedDate.getTime() + hours * 60 * 60 * 1000); + const expiresAt = expiresAtOverride + ? new Date(expiresAtOverride) + : (() => { + const hours = expiryHours ?? EXPIRY_HOURS[loc] ?? EXPIRY_HOURS.fridge; + return new Date(storedDate.getTime() + hours * 60 * 60 * 1000); + })(); const stock = await prisma.milkStock.create({ data: { diff --git a/src/components/event-icon.tsx b/src/components/event-icon.tsx index 5c467cf..99ee072 100644 --- a/src/components/event-icon.tsx +++ b/src/components/event-icon.tsx @@ -12,6 +12,7 @@ import { Footprints, Baby, Activity, + FileText, LucideProps, } from "lucide-react"; @@ -29,6 +30,7 @@ const ICONS: Record> = { Footprints, Baby, Activity, + FileText, }; interface EventIconProps extends LucideProps {