Require asset ID scan in audit and consume modals
Build and push image / build (push) Successful in 47s

No default item selection — modals open with a scan prompt.
Form fields appear only after scanning an asset ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 19:34:31 -04:00
parent e50e8ef1fe
commit bc81cc8d18
2 changed files with 201 additions and 186 deletions
+119 -111
View File
@@ -39,7 +39,7 @@ export function AuditFlow({
.filter((i) => i.status === "active") .filter((i) => i.status === "active")
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a)); .sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
const [itemId, setItemId] = useState(initialItem?.id ?? overdueFirst[0]?.id ?? ""); const [itemId, setItemId] = useState(initialItem?.id ?? "");
const [date, setDate] = useState(TODAY_STR); const [date, setDate] = useState(TODAY_STR);
const [confirmedBy, setConfirmedBy] = useState<"asset" | "SKU" | "visual">("asset"); const [confirmedBy, setConfirmedBy] = useState<"asset" | "SKU" | "visual">("asset");
@@ -82,17 +82,17 @@ export function AuditFlow({
} }
}; };
if (!item) return null;
const auditMode = cfg?.auditMode ?? "weigh"; const auditMode = cfg?.auditMode ?? "weigh";
const ml = AUDIT_MODE_LABELS[auditMode] ?? AUDIT_MODE_LABELS.weigh!; const ml = AUDIT_MODE_LABELS[auditMode] ?? AUDIT_MODE_LABELS.weigh!;
const last = helpers.lastAudit(item); const last = item ? helpers.lastAudit(item) : null;
const prevValue = const prevValue = item
item.kind === "discrete" ? item.kind === "discrete"
? item.countLastAudit ?? item.countOriginal ? item.countLastAudit ?? item.countOriginal
: last : last
? last.value ? last.value
: item.weight; : item.weight
: 0;
const delta = Number(value) - prevValue; const delta = Number(value) - prevValue;
@@ -108,7 +108,7 @@ export function AuditFlow({
boxShadow: "var(--shadow-lg)", boxShadow: "var(--shadow-lg)",
}} }}
> >
<ModalHeader title={ml.title} eyebrow="" onClose={onClose} /> <ModalHeader title={item ? ml.title : "Audit"} eyebrow="" onClose={onClose} />
<div style={{ padding: 32 }}> <div style={{ padding: 32 }}>
<ScanField <ScanField
@@ -118,114 +118,122 @@ export function AuditFlow({
onMatch={handleScan} onMatch={handleScan}
/> />
<div {!item ? (
style={{ <div style={{ marginTop: 24, textAlign: "center", color: "var(--ink-3)", fontSize: 13, fontStyle: "italic", padding: "24px 0" }}>
marginTop: 16, Scan an asset ID to continue.
padding: 16,
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<div className="serif" style={{ fontSize: 20, fontWeight: 500 }}>
{item.name}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
<span className="mono">{item.assetId}</span> · {item.type} · {item.kind} · cadence every {cfg?.cadenceDays}d
</div>
</div>
<div style={{ textAlign: "right" }}>
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LAST CHECKED</div>
<div className="serif" style={{ fontSize: 18 }}>
{last ? `${helpers.daysSinceCheck(item)}d ago` : "Never"}
</div>
</div>
</div> </div>
<div style={{ fontSize: 12, color: "var(--ink-2)", marginTop: 10, fontStyle: "italic" }}> ) : (
{ml.desc} <>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: auditMode === "presence" ? "1fr 1fr 1fr" : "1fr 1fr",
gap: 16,
marginTop: 24,
}}
>
<Field
label={
item.kind === "discrete"
? `Count now (${cfg?.unit})`
: auditMode === "weigh"
? `Weight now (${cfg?.unit})`
: `Estimate now (${cfg?.unit})`
}
>
<Input
type="number"
step={item.kind === "discrete" ? "1" : "0.1"}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</Field>
<Field label="Date">
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</Field>
{auditMode === "presence" && (
<Field label="Confirmed by">
<Select
value={confirmedBy}
onChange={(e) => setConfirmedBy(e.target.value as typeof confirmedBy)}
>
<option value="asset">Asset id</option>
<option value="SKU">SKU label</option>
<option value="visual">Visual ID</option>
</Select>
</Field>
)}
</div>
<div
style={{
marginTop: 20,
padding: 14,
background: "var(--surface)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: 16,
}}
>
<div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Was</div>
<div className="serif" style={{ fontSize: 22 }}>
{prevValue} {cfg?.unit}
</div>
</div>
<div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Now</div>
<div className="serif" style={{ fontSize: 22, color: "var(--sage)" }}>
{value} {cfg?.unit}
</div>
</div>
<div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Δ since last</div>
<div <div
className="serif"
style={{ style={{
fontSize: 22, marginTop: 16,
color: delta < 0 ? "var(--terracotta)" : "var(--ink)", padding: 16,
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
}} }}
> >
{delta.toFixed(item.kind === "discrete" ? 0 : 2)} {cfg?.unit} <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<div className="serif" style={{ fontSize: 20, fontWeight: 500 }}>
{item.name}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
<span className="mono">{item.assetId}</span> · {item.type} · {item.kind} · cadence every {cfg?.cadenceDays}d
</div>
</div>
<div style={{ textAlign: "right" }}>
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LAST CHECKED</div>
<div className="serif" style={{ fontSize: 18 }}>
{last ? `${helpers.daysSinceCheck(item)}d ago` : "Never"}
</div>
</div>
</div>
<div style={{ fontSize: 12, color: "var(--ink-2)", marginTop: 10, fontStyle: "italic" }}>
{ml.desc}
</div>
</div> </div>
</div>
</div> <div
style={{
display: "grid",
gridTemplateColumns: auditMode === "presence" ? "1fr 1fr 1fr" : "1fr 1fr",
gap: 16,
marginTop: 24,
}}
>
<Field
label={
item.kind === "discrete"
? `Count now (${cfg?.unit})`
: auditMode === "weigh"
? `Weight now (${cfg?.unit})`
: `Estimate now (${cfg?.unit})`
}
>
<Input
type="number"
step={item.kind === "discrete" ? "1" : "0.1"}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</Field>
<Field label="Date">
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</Field>
{auditMode === "presence" && (
<Field label="Confirmed by">
<Select
value={confirmedBy}
onChange={(e) => setConfirmedBy(e.target.value as typeof confirmedBy)}
>
<option value="asset">Asset id</option>
<option value="SKU">SKU label</option>
<option value="visual">Visual ID</option>
</Select>
</Field>
)}
</div>
<div
style={{
marginTop: 20,
padding: 14,
background: "var(--surface)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: 16,
}}
>
<div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Was</div>
<div className="serif" style={{ fontSize: 22 }}>
{prevValue} {cfg?.unit}
</div>
</div>
<div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Now</div>
<div className="serif" style={{ fontSize: 22, color: "var(--sage)" }}>
{value} {cfg?.unit}
</div>
</div>
<div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Δ since last</div>
<div
className="serif"
style={{
fontSize: 22,
color: delta < 0 ? "var(--terracotta)" : "var(--ink)",
}}
>
{delta.toFixed(item.kind === "discrete" ? 0 : 2)} {cfg?.unit}
</div>
</div>
</div>
</>
)}
{error && ( {error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div> <div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
@@ -234,14 +242,14 @@ export function AuditFlow({
<ModalFooter> <ModalFooter>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}> <div style={{ fontSize: 12, color: "var(--ink-3)" }}>
Next audit due in {cfg?.cadenceDays}d {item ? `Next audit due in ${cfg?.cadenceDays}d` : ""}
</div> </div>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn> <Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn <Btn
variant="primary" variant="primary"
icon="check" icon="check"
disabled={audit.isPending} disabled={audit.isPending || !item}
onClick={() => audit.mutate()} onClick={() => audit.mutate()}
> >
{audit.isPending ? "Saving…" : "Save audit"} {audit.isPending ? "Saving…" : "Save audit"}
+82 -75
View File
@@ -22,7 +22,7 @@ export function ConsumeFlow({
const { toast } = useToast(); const { toast } = useToast();
const allItems = enrichItems(data); const allItems = enrichItems(data);
const active = allItems.filter((i) => i.status === "active"); const active = allItems.filter((i) => i.status === "active");
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? ""); const [itemId, setItemId] = useState(initialItem?.id ?? "");
const [rating, setRating] = useState(4); const [rating, setRating] = useState(4);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [date, setDate] = useState(TODAY_STR); const [date, setDate] = useState(TODAY_STR);
@@ -46,9 +46,8 @@ export function ConsumeFlow({
} }
}; };
if (!item) return null; const bin = item ? data.bins.find((b) => b.id === item.binId) : undefined;
const bin = data.bins.find((b) => b.id === item.binId); const lifespan = item ? Math.round((+new Date(date) - +new Date(item.purchaseDate)) / 86_400_000) : 0;
const lifespan = Math.round((+new Date(date) - +new Date(item.purchaseDate)) / 86_400_000);
return ( return (
<ModalBackdrop onClose={onClose}> <ModalBackdrop onClose={onClose}>
@@ -72,84 +71,92 @@ export function ConsumeFlow({
onMatch={handleScan} onMatch={handleScan}
/> />
<div {!item ? (
style={{ <div style={{ marginTop: 24, textAlign: "center", color: "var(--ink-3)", fontSize: 13, fontStyle: "italic", padding: "24px 0" }}>
marginTop: 16, Scan an asset ID to continue.
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)}
</div>
</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">
<div <div
style={{ style={{
display: "flex", marginTop: 16,
gap: 4, padding: 16,
alignItems: "center", background: "var(--bg-2)",
padding: "10px 12px",
background: "var(--bg)",
border: "1px solid var(--line)", border: "1px solid var(--line)",
borderRadius: "var(--r-md)", borderRadius: "var(--r-md)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}} }}
> >
{[1, 2, 3, 4, 5].map((n) => ( <div>
<button <div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
key={n} {item.name}
onClick={() => setRating(n)} </div>
style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }} <div style={{ fontSize: 12, color: "var(--ink-3)" }}>
> <span className="mono">{item.assetId}</span> · {helpers.brandName(data, item.brandId)} · {bin?.name} · purchased{" "}
<Icon {fmt.dateShort(item.purchaseDate)}
name="star" </div>
size={20} </div>
color={n <= rating ? "var(--amber)" : "var(--ink-4)"} <div style={{ textAlign: "right" }}>
/> <div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LASTED</div>
</button> <div className="serif" style={{ fontSize: 24 }}>{lifespan} days</div>
))} </div>
<span
style={{
marginLeft: "auto",
fontSize: 12,
color: "var(--ink-3)",
fontFamily: "var(--mono)",
}}
>
{rating}/5
</span>
</div> </div>
</Field>
</div> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24 }}>
<div style={{ marginTop: 16 }}> <Field label="Date finished">
<Field label="Final notes" hint="Flavor, effects, would you rebuy"> <Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
<Textarea </Field>
value={notes} <Field label="Rating">
onChange={(e) => setNotes(e.target.value)} <div
placeholder="What stood out?" style={{
/> display: "flex",
</Field> gap: 4,
</div> 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(n)}
style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }}
>
<Icon
name="star"
size={20}
color={n <= rating ? "var(--amber)" : "var(--ink-4)"}
/>
</button>
))}
<span
style={{
marginLeft: "auto",
fontSize: 12,
color: "var(--ink-3)",
fontFamily: "var(--mono)",
}}
>
{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>
</>
)}
{error && ( {error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div> <div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
@@ -163,7 +170,7 @@ export function ConsumeFlow({
<Btn <Btn
variant="primary" variant="primary"
icon="check" icon="check"
disabled={finish.isPending} disabled={finish.isPending || !item}
onClick={() => finish.mutate()} onClick={() => finish.mutate()}
> >
{finish.isPending ? "Saving…" : "Mark consumed"} {finish.isPending ? "Saving…" : "Mark consumed"}