538e5079ab
Build and push image / build (push) Successful in 54s
Comprehensive UX audit covering modals, drawers, dashboard, and inventory. Key changes: confirmation steps before destructive actions, undo via toast for consume/gone/checkout, back-navigation across entity drawers, optional ratings, discrete item count field, audit progress bar, and sortable column affordance. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
230 lines
8.4 KiB
TypeScript
230 lines
8.4 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import type { Bootstrap, Item } from "../../types.js";
|
|
import { helpers, enrichItems } from "../../types.js";
|
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
|
import { fmt } from "../../format.js";
|
|
import { api } from "../../api.js";
|
|
import { Btn, Field, Icon, Input, Textarea } from "../primitives/index.js";
|
|
import { ScanField, type ScanResult } from "../ScanField.js";
|
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
|
import { useToast } from "../Toast.js";
|
|
|
|
export function ConsumeFlow({
|
|
data,
|
|
onClose,
|
|
item: initialItem,
|
|
}: {
|
|
data: Bootstrap;
|
|
onClose: () => void;
|
|
item: Item | null;
|
|
}) {
|
|
const qc = useQueryClient();
|
|
const { toast } = useToast();
|
|
const allItems = enrichItems(data);
|
|
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
|
|
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
|
const [rating, setRating] = useState<number | null>(null);
|
|
const [notes, setNotes] = useState("");
|
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [confirming, setConfirming] = useState(false);
|
|
|
|
const item = allItems.find((i) => i.id === itemId);
|
|
|
|
useEffect(() => { setError(null); setConfirming(false); }, [itemId]);
|
|
|
|
const finish = useMutation({
|
|
mutationFn: () => api.finishInventoryItem(itemId, { date, rating: rating ?? undefined, notes }),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
|
const ratingStr = rating != null ? ` — ${rating}/5 stars` : "";
|
|
const undoItemId = itemId;
|
|
const undoBinId = item?.binId ?? data.bins[0]?.id ?? "";
|
|
toast(`Marked ${item?.name ?? "item"} as consumed${ratingStr}`, "success", {
|
|
label: "Undo",
|
|
onClick: () => {
|
|
api.reactivateInventoryItem(undoItemId, { binId: undoBinId }).then(() => {
|
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
|
});
|
|
},
|
|
});
|
|
onClose();
|
|
},
|
|
onError: (e: Error) => setError(e.message),
|
|
});
|
|
|
|
const handleScan = (result: ScanResult) => {
|
|
if (result.kind === "item") {
|
|
setItemId(result.item.id);
|
|
}
|
|
};
|
|
|
|
const bin = item ? data.bins.find((b) => b.id === item.binId) : undefined;
|
|
const lifespan = item ? Math.round((+new Date(date) - +new Date(item.purchaseDate)) / 86_400_000) : 0;
|
|
|
|
return (
|
|
<ModalBackdrop onClose={onClose}>
|
|
<div
|
|
style={{
|
|
width: "min(720px, 96vw)",
|
|
margin: "40px 20px",
|
|
background: "var(--bg)",
|
|
border: "1px solid var(--line)",
|
|
borderRadius: "var(--r-lg)",
|
|
boxShadow: "var(--shadow-lg)",
|
|
}}
|
|
>
|
|
<ModalHeader title="Mark as consumed" eyebrow="" onClose={onClose} />
|
|
|
|
<div style={{ padding: 32 }}>
|
|
<ScanField
|
|
items={active}
|
|
products={[]}
|
|
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
|
onMatch={handleScan}
|
|
mode="assetId"
|
|
/>
|
|
|
|
{!item ? (
|
|
<div style={{ marginTop: 24, textAlign: "center", color: "var(--ink-3)", fontSize: 13, fontStyle: "italic", padding: "24px 0" }}>
|
|
Scan an asset ID to continue.
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div
|
|
style={{
|
|
marginTop: 16,
|
|
padding: 16,
|
|
background: "var(--bg-2)",
|
|
border: "1px solid var(--line)",
|
|
borderRadius: "var(--r-md)",
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<div>
|
|
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
|
{item.name}
|
|
</div>
|
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
|
<span className="mono">{item.assetId}</span> · {helpers.brandName(data, item.brandId)} · {bin?.name} · purchased{" "}
|
|
{fmt.dateShort(item.purchaseDate, getStoredTimezone())}
|
|
</div>
|
|
</div>
|
|
<div style={{ textAlign: "right" }}>
|
|
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LASTED</div>
|
|
<div className="serif" style={{ fontSize: 24 }}>{lifespan} days</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24 }}>
|
|
<Field label="Date finished">
|
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
|
</Field>
|
|
<Field label="Rating" hint="Optional — click to rate, click again to clear">
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 4,
|
|
alignItems: "center",
|
|
padding: "10px 12px",
|
|
background: "var(--bg)",
|
|
border: "1px solid var(--line)",
|
|
borderRadius: "var(--r-md)",
|
|
}}
|
|
>
|
|
{[1, 2, 3, 4, 5].map((n) => (
|
|
<button
|
|
key={n}
|
|
onClick={() => setRating(rating === n ? null : n)}
|
|
style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }}
|
|
>
|
|
<Icon
|
|
name="star"
|
|
size={20}
|
|
color={rating != null && n <= rating ? "var(--amber)" : "var(--ink-4)"}
|
|
/>
|
|
</button>
|
|
))}
|
|
<span
|
|
style={{
|
|
marginLeft: "auto",
|
|
fontSize: 12,
|
|
color: "var(--ink-3)",
|
|
fontFamily: "var(--mono)",
|
|
}}
|
|
>
|
|
{rating != null ? `${rating}/5` : "—"}
|
|
</span>
|
|
</div>
|
|
</Field>
|
|
</div>
|
|
<div style={{ marginTop: 16 }}>
|
|
<Field label="Final notes" hint="Flavor, effects, would you rebuy">
|
|
<Textarea
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
placeholder="What stood out?"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{confirming && item && (
|
|
<div
|
|
style={{
|
|
marginTop: 20,
|
|
padding: 14,
|
|
background: "var(--amber-soft)",
|
|
border: "1px solid var(--amber)",
|
|
borderRadius: "var(--r-md)",
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
Mark <strong>{item.name}</strong> (<span className="mono">{item.assetId}</span>) as consumed? This cannot be undone.
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
|
)}
|
|
</div>
|
|
|
|
<ModalFooter>
|
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
|
{item ? `Lasted ${lifespan} day${lifespan === 1 ? "" : "s"} from purchase.` : ""}
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
{confirming ? (
|
|
<>
|
|
<Btn variant="ghost" onClick={() => setConfirming(false)}>Back</Btn>
|
|
<Btn
|
|
variant="danger"
|
|
icon="check"
|
|
disabled={finish.isPending}
|
|
onClick={() => finish.mutate()}
|
|
>
|
|
{finish.isPending ? "Saving…" : "Confirm"}
|
|
</Btn>
|
|
</>
|
|
) : (
|
|
<Btn
|
|
variant="primary"
|
|
icon="check"
|
|
disabled={!item}
|
|
onClick={() => setConfirming(true)}
|
|
>
|
|
{error ? "Try again" : "Mark consumed"}
|
|
</Btn>
|
|
)}
|
|
</div>
|
|
</ModalFooter>
|
|
</div>
|
|
</ModalBackdrop>
|
|
);
|
|
}
|