c91fa1a192
Build and push image / build (push) Successful in 48s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import type { Bootstrap, Item } from "../../types.js";
|
|
import { TYPES, helpers, enrichItems } from "../../types.js";
|
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
|
import { api } from "../../api.js";
|
|
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
|
import { ScanField, type ScanResult } from "../ScanField.js";
|
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
|
import { useToast } from "../Toast.js";
|
|
|
|
const AUDIT_MODE_LABELS: Record<string, { title: string; desc: string }> = {
|
|
weigh: {
|
|
title: "Reweigh on a scale",
|
|
desc: "Place the jar (minus tare) and record the new weight.",
|
|
},
|
|
estimate: {
|
|
title: "Visual estimate",
|
|
desc: "Eyeball the remaining amount — quick and approximate.",
|
|
},
|
|
presence: {
|
|
title: "Confirm presence",
|
|
desc: "Verify the item is still where you left it. Count units if applicable.",
|
|
},
|
|
};
|
|
|
|
export function AuditFlow({
|
|
data,
|
|
onClose,
|
|
item: initialItem,
|
|
}: {
|
|
data: Bootstrap;
|
|
onClose: () => void;
|
|
item: Item | null;
|
|
}) {
|
|
const qc = useQueryClient();
|
|
const { toast } = useToast();
|
|
const allItems = enrichItems(data);
|
|
const overdueFirst = [...allItems]
|
|
.filter((i) => i.status === "active")
|
|
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
|
|
|
|
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
|
const [confirmedBy, setConfirmedBy] = useState<"asset" | "visual">("asset");
|
|
|
|
const item = allItems.find((i) => i.id === itemId);
|
|
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined;
|
|
|
|
const initialValueFor = (i: Item | undefined): string => {
|
|
if (!i) return "0";
|
|
if (i.kind === "discrete") {
|
|
return String(i.countLastAudit ?? i.countOriginal);
|
|
}
|
|
return helpers.estimatedRemaining(i, getToday(getStoredTimezone())).toFixed(2);
|
|
};
|
|
const [value, setValue] = useState<string>(initialValueFor(item));
|
|
const isConcentrate = item?.type === "Concentrate";
|
|
const [inputMode, setInputMode] = useState<"direct" | "container">(
|
|
item?.containerWeight != null ? "container" : "direct",
|
|
);
|
|
const [containerTotal, setContainerTotal] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
setValue(initialValueFor(item));
|
|
setInputMode(item?.containerWeight != null ? "container" : "direct");
|
|
setContainerTotal("");
|
|
}, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const tare = item ? helpers.tare(item) : null;
|
|
const derivedRemaining =
|
|
tare != null && containerTotal !== ""
|
|
? parseFloat(containerTotal) - tare
|
|
: null;
|
|
|
|
const effectiveValue =
|
|
inputMode === "container" && derivedRemaining != null
|
|
? derivedRemaining
|
|
: Number(value);
|
|
const effectiveMode =
|
|
inputMode === "container" ? "weigh" : (cfg?.auditMode ?? "weigh");
|
|
|
|
const audit = useMutation({
|
|
mutationFn: () =>
|
|
api.auditInventoryItem(itemId, {
|
|
date,
|
|
mode: effectiveMode,
|
|
value: effectiveValue,
|
|
confirmedBy: cfg?.auditMode === "presence" ? confirmedBy : undefined,
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
|
toast(`Audit saved — next due in ${cfg?.cadenceDays ?? "?"}d`);
|
|
onClose();
|
|
},
|
|
onError: (e: Error) => setError(e.message),
|
|
});
|
|
|
|
const handleScan = (result: ScanResult) => {
|
|
if (result.kind === "item") {
|
|
setItemId(result.item.id);
|
|
}
|
|
};
|
|
|
|
const auditMode = cfg?.auditMode ?? "weigh";
|
|
const ml = inputMode === "container"
|
|
? { title: "Weigh container", desc: "Place the sealed jar on a scale and enter the total weight. Product remaining is calculated from the tare." }
|
|
: AUDIT_MODE_LABELS[auditMode] ?? AUDIT_MODE_LABELS.weigh!;
|
|
|
|
const last = item ? helpers.lastAudit(item) : null;
|
|
const prevValue = item
|
|
? item.kind === "discrete"
|
|
? item.countLastAudit ?? item.countOriginal
|
|
: last
|
|
? last.value
|
|
: item.weight
|
|
: 0;
|
|
|
|
const delta = effectiveValue - prevValue;
|
|
|
|
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={item ? ml.title : "Audit"} eyebrow="" onClose={onClose} />
|
|
|
|
<div style={{ padding: 32 }}>
|
|
<ScanField
|
|
items={overdueFirst}
|
|
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)",
|
|
}}
|
|
>
|
|
<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>
|
|
|
|
{tare != null && !isConcentrate && (
|
|
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
|
|
<Btn
|
|
variant={inputMode === "container" ? "primary" : "ghost"}
|
|
onClick={() => setInputMode("container")}
|
|
>
|
|
Weigh container
|
|
</Btn>
|
|
<Btn
|
|
variant={inputMode === "direct" ? "primary" : "ghost"}
|
|
onClick={() => setInputMode("direct")}
|
|
>
|
|
Direct entry
|
|
</Btn>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: auditMode === "presence" ? "1fr 1fr 1fr" : "1fr 1fr",
|
|
gap: 16,
|
|
marginTop: 24,
|
|
}}
|
|
>
|
|
{inputMode === "container" && tare != null ? (
|
|
<Field label="Container weight now (g)">
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
value={containerTotal}
|
|
onChange={(e) => setContainerTotal(e.target.value)}
|
|
/>
|
|
</Field>
|
|
) : (
|
|
<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="visual">Visual ID</option>
|
|
</Select>
|
|
</Field>
|
|
)}
|
|
</div>
|
|
|
|
{inputMode === "container" && tare != null && (
|
|
<div style={{ marginTop: 8, fontSize: 12, color: "var(--ink-3)" }}>
|
|
Tare (empty jar): {tare.toFixed(2)}g
|
|
{derivedRemaining != null && (
|
|
<span style={{ color: derivedRemaining >= 0 ? "var(--sage)" : "var(--terracotta)" }}>
|
|
{" · "}Product remaining: {derivedRemaining.toFixed(2)}g
|
|
</span>
|
|
)}
|
|
</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 }}>
|
|
{item.kind === "discrete" ? prevValue : prevValue.toFixed(2)} {cfg?.unit}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Now</div>
|
|
<div className="serif" style={{ fontSize: 22, color: "var(--sage)" }}>
|
|
{effectiveValue.toFixed(item.kind === "discrete" ? 0 : 2)} {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 && (
|
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
|
)}
|
|
</div>
|
|
|
|
<ModalFooter>
|
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
|
{item ? `Next audit due in ${cfg?.cadenceDays}d` : ""}
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
<Btn
|
|
variant="primary"
|
|
icon="check"
|
|
disabled={audit.isPending || !item}
|
|
onClick={() => audit.mutate()}
|
|
>
|
|
{audit.isPending ? "Saving…" : "Save audit"}
|
|
</Btn>
|
|
</div>
|
|
</ModalFooter>
|
|
</div>
|
|
</ModalBackdrop>
|
|
);
|
|
}
|