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 <noreply@anthropic.com>
This commit is contained in:
+218
-9
@@ -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<Location>("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<string | null>(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<Set<string>>(new Set());
|
||||
const [assembleSaving, setAssembleSaving] = useState(false);
|
||||
|
||||
const { data: stocks = [], isLoading } = useQuery<Stock[]>({
|
||||
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,6 +225,20 @@ export default function MilkPage() {
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Stock de lait</h1>
|
||||
{selectedBaby && <p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{selectedBaby.name}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{available.filter((s) => expiryStatus(s) !== "expired").length >= 2 && (
|
||||
<button
|
||||
onClick={() => { setAssembleMode((v) => !v); setSelectedIds(new Set()); }}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition ${
|
||||
assembleMode
|
||||
? "bg-indigo-100 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 border border-indigo-300 dark:border-indigo-700"
|
||||
: "border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
<Combine className="w-4 h-4" />
|
||||
Assembler
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-cyan-600 text-white rounded-xl text-sm font-medium hover:bg-cyan-700 transition"
|
||||
@@ -141,6 +247,7 @@ export default function MilkPage() {
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
{available.length > 0 && (
|
||||
@@ -174,12 +281,19 @@ export default function MilkPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assemble hint */}
|
||||
{assembleMode && (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 bg-indigo-50 dark:bg-indigo-950 border border-indigo-200 dark:border-indigo-800 rounded-xl mb-4 text-sm text-indigo-700 dark:text-indigo-300">
|
||||
<Combine className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Sélectionnez au moins 2 lots à assembler</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add form */}
|
||||
{showForm && (
|
||||
<form onSubmit={handleCreate} className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4 space-y-3">
|
||||
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Nouveau lot</p>
|
||||
|
||||
{/* Location selector */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Conservation</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div key={s.id} className={`bg-white dark:bg-slate-900 border rounded-xl p-4 transition ${
|
||||
<div key={s.id} className={`bg-white dark:bg-slate-900 border rounded-xl overflow-hidden transition ${
|
||||
isSelected ? "border-indigo-400 dark:border-indigo-600 ring-1 ring-indigo-300 dark:ring-indigo-700" :
|
||||
status === "expired" ? "border-red-200 dark:border-red-800" :
|
||||
status === "soon" ? "border-amber-200 dark:border-amber-800" :
|
||||
"border-slate-200 dark:border-slate-700"
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`flex items-start gap-3 p-4 ${isSelectable ? "cursor-pointer" : ""}`}
|
||||
onClick={isSelectable ? () => toggleSelect(s.id) : undefined}
|
||||
>
|
||||
{assembleMode && (
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 mt-2 transition ${
|
||||
isSelected ? "border-indigo-500 bg-indigo-500" : "border-slate-300 dark:border-slate-600"
|
||||
}`}>
|
||||
{isSelected && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
)}
|
||||
<div className={`w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
||||
status === "expired" ? "bg-red-50 dark:bg-red-950" :
|
||||
status === "soon" ? "bg-amber-50 dark:bg-amber-950" :
|
||||
@@ -321,7 +448,13 @@ export default function MilkPage() {
|
||||
</p>
|
||||
{s.notes && <p className="text-xs text-slate-400 dark:text-slate-500 italic mt-0.5">{s.notes}</p>}
|
||||
</div>
|
||||
{!assembleMode && (
|
||||
<div className="flex flex-col gap-1.5 flex-shrink-0">
|
||||
<button onClick={() => isEditing ? setEditingId(null) : openEdit(s)}
|
||||
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"
|
||||
title="Modifier">
|
||||
{isEditing ? <X className="w-4 h-4" /> : <Pencil className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button onClick={() => markUsed(s.id)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-950 transition"
|
||||
title="Marquer comme utilisé">
|
||||
@@ -333,13 +466,89 @@ export default function MilkPage() {
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline edit form */}
|
||||
{isEditing && (
|
||||
<form onSubmit={handleSaveEdit} className="border-t border-slate-100 dark:border-slate-700 px-4 pb-4 pt-3 space-y-3 bg-slate-50 dark:bg-slate-800/60">
|
||||
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Modifier le lot</p>
|
||||
<div className="flex gap-2">
|
||||
{(["room", "fridge", "freezer"] as Location[]).map((loc) => {
|
||||
const cfg = LOCATION_CONFIG[loc];
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<button key={loc} type="button" onClick={() => setEditForm((f) => ({ ...f, location: loc }))}
|
||||
className={`flex-1 flex flex-col items-center gap-1 py-2 px-1 border rounded-xl text-xs font-medium transition ${
|
||||
editForm.location === loc
|
||||
? "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"
|
||||
}`}>
|
||||
<Icon className={`w-4 h-4 ${editForm.location === loc ? "text-indigo-600 dark:text-indigo-400" : "text-slate-400"}`} />
|
||||
{loc === "room" ? "Ambiant" : loc === "fridge" ? "Frigo" : "Congélo"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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">Volume (mL)</label>
|
||||
<input type="number" value={editForm.volume} onChange={(e) => setEditForm((f) => ({ ...f, volume: e.target.value }))}
|
||||
required className={inputCls} min="1" max="2000" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Péremption</label>
|
||||
<input type="datetime-local" value={editForm.expiresAt} onChange={(e) => setEditForm((f) => ({ ...f, expiresAt: e.target.value }))}
|
||||
required className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Notes</label>
|
||||
<input type="text" value={editForm.notes} onChange={(e) => setEditForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
className={inputCls} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setEditingId(null)}
|
||||
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={editSaving}
|
||||
className="flex-[2] py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{editSaving ? "..." : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assemble action bar */}
|
||||
{assembleMode && selectedIds.size >= 2 && (
|
||||
<div className="fixed bottom-[calc(env(safe-area-inset-bottom)+4.5rem)] md:bottom-8 left-4 right-4 md:left-auto md:right-8 md:w-80 z-40">
|
||||
<div className="bg-indigo-600 text-white rounded-2xl shadow-2xl p-4 flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold">{selectedIds.size} lots · {assembleVolume} mL</p>
|
||||
{assembleExpiry && (
|
||||
<p className="text-xs text-indigo-200 mt-0.5">
|
||||
Expire le {format(new Date(assembleExpiry), "dd/MM à HH:mm")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAssemble}
|
||||
disabled={assembleSaving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-indigo-700 rounded-xl text-sm font-semibold hover:bg-indigo-50 transition disabled:opacity-50"
|
||||
>
|
||||
<Combine className="w-4 h-4" />
|
||||
{assembleSaving ? "..." : "Assembler"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Used / archived */}
|
||||
{used.length > 0 && (
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -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 expiresAt = expiresAtOverride
|
||||
? new Date(expiresAtOverride)
|
||||
: (() => {
|
||||
const hours = expiryHours ?? EXPIRY_HOURS[loc] ?? EXPIRY_HOURS.fridge;
|
||||
const expiresAt = new Date(storedDate.getTime() + hours * 60 * 60 * 1000);
|
||||
return new Date(storedDate.getTime() + hours * 60 * 60 * 1000);
|
||||
})();
|
||||
|
||||
const stock = await prisma.milkStock.create({
|
||||
data: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Footprints,
|
||||
Baby,
|
||||
Activity,
|
||||
FileText,
|
||||
LucideProps,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -29,6 +30,7 @@ const ICONS: Record<string, React.FC<LucideProps>> = {
|
||||
Footprints,
|
||||
Baby,
|
||||
Activity,
|
||||
FileText,
|
||||
};
|
||||
|
||||
interface EventIconProps extends LucideProps {
|
||||
|
||||
Reference in New Issue
Block a user