Split audits into Weigh Ins (bulk) and Bin Checks (discrete)
Build and push image / build (push) Successful in 57s

Replaces the unified audit system with two purpose-built flows:
- Weigh Ins: rebranded audit flow for bulk products (Flower, Concentrate,
  Tincture) with scale weigh, container weigh, and estimate modes
- Bin Checks: new bin-level presence check — select a bin, scan every item,
  resolve discrepancies (wrong bin, unknown, missing), auto-records presence
  audits on verified items

Adds cadence_days and last_checked to bins table, with per-bin overdue
tracking on the dashboard and bins view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 18:28:55 -04:00
parent d7da7afe5e
commit 5fa1e34914
19 changed files with 769 additions and 134 deletions
+10
View File
@@ -16,6 +16,7 @@ archiveV1IfPresent();
migrateAddCheckoutDate();
migrateAddContainerWeight();
migrateAddPrevBinId();
migrateAddBinCheckFields();
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
db.exec(schema);
@@ -44,6 +45,15 @@ function migrateAddPrevBinId(): void {
db.exec(`ALTER TABLE inventory_items ADD COLUMN prev_bin_id TEXT REFERENCES bins(id)`);
}
function migrateAddBinCheckFields(): void {
const cols = db
.prepare(`PRAGMA table_info(bins)`)
.all() as { name: string }[];
if (cols.length === 0 || cols.some((c) => c.name === "cadence_days")) return;
db.exec(`ALTER TABLE bins ADD COLUMN cadence_days INTEGER NOT NULL DEFAULT 30`);
db.exec(`ALTER TABLE bins ADD COLUMN last_checked TEXT`);
}
// One-shot migration: the original schema put per-instance fields (weight,
// bin_id, etc.) directly on `products`. The split schema separates products
// (catalog) from inventory_items (instance). When we detect the old shape,
+8 -1
View File
@@ -70,7 +70,14 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
.all();
const shops = db.prepare("SELECT * FROM shops ORDER BY id").all();
const brands = db.prepare("SELECT * FROM brands ORDER BY id").all();
const bins = db.prepare("SELECT id, name, capacity FROM bins ORDER BY id").all();
const binsRaw = db.prepare("SELECT id, name, capacity, cadence_days, last_checked FROM bins ORDER BY id").all() as { id: string; name: string; capacity: number; cadence_days: number; last_checked: string | null }[];
const bins = binsRaw.map((b) => ({
id: b.id,
name: b.name,
capacity: b.capacity,
cadenceDays: b.cadence_days,
lastChecked: b.last_checked,
}));
const strains = db
.prepare<[], StrainRow>("SELECT * FROM strains ORDER BY name COLLATE NOCASE")
.all();
+13 -8
View File
@@ -103,20 +103,21 @@ catalogRouter.delete("/shops/:id", (req, res) => {
});
catalogRouter.post("/bins", (req, res) => {
const { name, capacity } = req.body as { name: string; capacity?: number };
const { name, capacity, cadenceDays } = req.body as { name: string; capacity?: number; cadenceDays?: number };
if (!name?.trim()) return res.status(400).json({ error: "name required" });
const id = nextId("bin", "bins");
const cap = Number.isFinite(capacity) && (capacity as number) > 0 ? Math.floor(capacity as number) : 10;
db.prepare("INSERT INTO bins (id, name, capacity) VALUES (?, ?, ?)").run(id, name.trim(), cap);
res.json({ id, name: name.trim(), capacity: cap });
const cad = Number.isFinite(cadenceDays) && (cadenceDays as number) > 0 ? Math.floor(cadenceDays as number) : 30;
db.prepare("INSERT INTO bins (id, name, capacity, cadence_days) VALUES (?, ?, ?, ?)").run(id, name.trim(), cap, cad);
res.json({ id, name: name.trim(), capacity: cap, cadenceDays: cad, lastChecked: null });
});
catalogRouter.patch("/bins/:id", (req, res) => {
const { id } = req.params;
const { name, capacity } = req.body as { name?: string; capacity?: number };
const { name, capacity, cadenceDays } = req.body as { name?: string; capacity?: number; cadenceDays?: number };
const existing = db
.prepare<[string], { id: string; name: string; capacity: number }>(
"SELECT id, name, capacity FROM bins WHERE id = ?",
.prepare<[string], { id: string; name: string; capacity: number; cadence_days: number; last_checked: string | null }>(
"SELECT id, name, capacity, cadence_days, last_checked FROM bins WHERE id = ?",
)
.get(id);
if (!existing) return res.status(404).json({ error: "bin not found" });
@@ -126,9 +127,13 @@ catalogRouter.patch("/bins/:id", (req, res) => {
Number.isFinite(capacity) && (capacity as number) > 0
? Math.floor(capacity as number)
: existing.capacity;
const nextCadence =
Number.isFinite(cadenceDays) && (cadenceDays as number) > 0
? Math.floor(cadenceDays as number)
: existing.cadence_days;
db.prepare("UPDATE bins SET name = ?, capacity = ? WHERE id = ?").run(nextName, nextCapacity, id);
res.json({ id, name: nextName, capacity: nextCapacity });
db.prepare("UPDATE bins SET name = ?, capacity = ?, cadence_days = ? WHERE id = ?").run(nextName, nextCapacity, nextCadence, id);
res.json({ id, name: nextName, capacity: nextCapacity, cadenceDays: nextCadence, lastChecked: existing.last_checked });
});
// Deleting a bin unassigns any inventory items that reference it (bin_id → NULL),
+56
View File
@@ -435,6 +435,62 @@ inventoryRouter.post("/inventory/:id/audit", (req, res) => {
res.json({ ok: true });
});
// ─── Bin check endpoint ──────────────────────────────────────────
inventoryRouter.post("/bins/:id/check", (req, res) => {
const { id } = req.params;
const { date, verifiedItemIds, goneItemIds } = req.body as {
date: string;
verifiedItemIds: string[];
goneItemIds: string[];
};
const bin = db
.prepare<[string], { id: string }>("SELECT id FROM bins WHERE id = ?")
.get(id);
if (!bin) return res.status(404).json({ error: "bin not found" });
const tx = db.transaction(() => {
for (const itemId of verifiedItemIds) {
const item = db
.prepare<
[string],
{ product_id: string; count_original: number; count_last_audit: number | null }
>(
`SELECT product_id, count_original, count_last_audit FROM inventory_items WHERE id = ?`,
)
.get(itemId);
if (!item) continue;
const prev = item.count_last_audit ?? item.count_original;
db.prepare(
`INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by)
VALUES (?, ?, 'presence', ?, ?, 'bin-check')`,
).run(itemId, date, prev, prev);
db.prepare(
`UPDATE inventory_items SET count_last_audit = ? WHERE id = ?`,
).run(prev, itemId);
}
for (const itemId of goneItemIds) {
try {
doGone(itemId, date, "missing from bin check");
} catch {
// item may already be gone
}
}
db.prepare("UPDATE bins SET last_checked = ? WHERE id = ?").run(date, id);
});
try {
tx();
res.json({ ok: true, verified: verifiedItemIds.length, gone: goneItemIds.length });
} catch (e: any) {
res.status(400).json({ error: e.message });
}
});
// ─── Batch endpoint ───────────────────────────────────────────────
type BatchOp =
+3 -1
View File
@@ -15,7 +15,9 @@ CREATE TABLE IF NOT EXISTS bins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT,
capacity INTEGER NOT NULL DEFAULT 10
capacity INTEGER NOT NULL DEFAULT 10,
cadence_days INTEGER NOT NULL DEFAULT 30,
last_checked TEXT
);
-- Strains: one row per cannabis strain (catalog-level). UNIQUE on name only,
+30 -18
View File
@@ -37,7 +37,8 @@ import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js";
import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js";
import { ConsumeFlow } from "./components/modals/ConsumeFlow.js";
import { MarkGoneFlow } from "./components/modals/MarkGoneFlow.js";
import { AuditFlow } from "./components/modals/AuditFlow.js";
import { WeighInFlow } from "./components/modals/WeighInFlow.js";
import { BinCheckFlow } from "./components/modals/BinCheckFlow.js";
import { CheckoutFlow } from "./components/modals/CheckoutFlow.js";
import { CheckinFlow } from "./components/modals/CheckinFlow.js";
import { CustodyView } from "./views/CustodyView.js";
@@ -61,7 +62,8 @@ type ModalKey =
| "edit"
| "consume"
| "gone"
| "audit"
| "weighIn"
| "binCheck"
| "checkout"
| "checkin"
| "addBrand"
@@ -170,17 +172,22 @@ export function App() {
setSelected(null);
setModal("gone");
};
const [auditQueue, setAuditQueue] = useState<Item[]>([]);
const openAudit = (i?: Item) => {
const [weighInQueue, setWeighInQueue] = useState<Item[]>([]);
const openWeighIn = (i?: Item) => {
setModalItem(i ?? null);
setAuditQueue([]);
setModal("audit");
setWeighInQueue([]);
setModal("weighIn");
};
const openAuditQueue = (queue: Item[]) => {
const openWeighInQueue = (queue: Item[]) => {
if (queue.length === 0) return;
setModalItem(queue[0]!);
setAuditQueue(queue);
setModal("audit");
setWeighInQueue(queue);
setModal("weighIn");
};
const [binCheckBin, setBinCheckBin] = useState<Bin | null>(null);
const openBinCheck = (bin?: Bin) => {
setBinCheckBin(bin ?? null);
setModal("binCheck");
};
const openCheckout = (i?: Item) => {
setModalItem(i ?? null);
@@ -273,7 +280,8 @@ export function App() {
<Sidebar
onAddProduct={openAdd}
onMarkFinished={() => openConsume()}
onAudit={() => openAudit()}
onWeighIn={() => openWeighIn()}
onBinCheck={() => openBinCheck()}
onCheckout={() => openCheckout()}
/>
)}
@@ -294,14 +302,14 @@ export function App() {
)}
<Routes>
<Route path="/" element={
<Dashboard data={data} stats={stats} onAuditItem={openAudit} onAuditQueue={openAuditQueue} onSelectItem={setSelected} />
<Dashboard data={data} stats={stats} onWeighInItem={openWeighIn} onWeighInQueue={openWeighInQueue} onBinCheck={openBinCheck} onSelectItem={setSelected} />
} />
<Route path="/inventory" element={
<Inventory
data={data}
onSelectItem={setSelected}
onAddInventory={openAdd}
onAuditNew={() => openAudit()}
onWeighInNew={() => openWeighIn()}
onBulkEdit={openBulkEdit}
onBulkConsume={openBulkConsume}
onBulkCheckout={openBulkCheckout}
@@ -320,7 +328,7 @@ export function App() {
<CustodyView data={data} onSelectItem={setSelected} onCheckin={openCheckin} onConsume={openConsume} onMarkGone={openMarkGone} />
} />
<Route path="/bins" element={
<BinsView data={data} onSelectItem={setSelected} onAddBin={() => setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} />
<BinsView data={data} onSelectItem={setSelected} onAddBin={() => setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} onBinCheck={openBinCheck} />
} />
<Route path="/shops" element={
<ShopsView data={data} onSelectShop={setSelectedShop} onAddShop={() => setModal("addShop")} />
@@ -342,7 +350,7 @@ export function App() {
onClose={() => { setSelected(null); setDrawerBack(null); }}
onConsume={openConsume}
onMarkGone={openMarkGone}
onAudit={openAudit}
onWeighIn={openWeighIn}
onEdit={openEdit}
onCheckout={openCheckout}
onCheckin={openCheckin}
@@ -462,8 +470,11 @@ export function App() {
{modal === "gone" && (
<MarkGoneFlow data={data} onClose={() => setModal(null)} item={modalItem} />
)}
{modal === "audit" && (
<AuditFlow data={data} onClose={() => setModal(null)} item={modalItem} queue={auditQueue.length > 0 ? auditQueue : undefined} />
{modal === "weighIn" && (
<WeighInFlow data={data} onClose={() => setModal(null)} item={modalItem} queue={weighInQueue.length > 0 ? weighInQueue : undefined} />
)}
{modal === "binCheck" && (
<BinCheckFlow data={data} onClose={() => setModal(null)} bin={binCheckBin} />
)}
{modal === "checkout" && (
<CheckoutFlow data={data} onClose={() => setModal(null)} item={modalItem} />
@@ -510,7 +521,8 @@ export function App() {
onScan={() => setScannerOpen(true)}
onAddProduct={openAdd}
onMarkFinished={() => openConsume()}
onAudit={() => openAudit()}
onWeighIn={() => openWeighIn()}
onBinCheck={() => openBinCheck()}
onCheckout={() => openCheckout()}
/>
)}
@@ -529,7 +541,7 @@ export function App() {
noMatchText={scanNoMatch}
onClose={() => { setScanResult(null); setScanNoMatch(null); }}
onViewItem={(i) => setSelected(i)}
onAudit={(i) => openAudit(i)}
onWeighIn={(i) => openWeighIn(i)}
onCheckout={(i) => openCheckout(i)}
onCheckin={(i) => openCheckin(i)}
onConsume={(i) => openConsume(i)}
+13 -4
View File
@@ -202,18 +202,27 @@ export const api = {
deleteShop: (id: string) =>
request<{ ok: true }>(`/shops/${id}`, { method: "DELETE" }),
createBin: (body: { name: string; capacity?: number }) =>
request<{ id: string; name: string; capacity: number }>("/bins", {
createBin: (body: { name: string; capacity?: number; cadenceDays?: number }) =>
request<{ id: string; name: string; capacity: number; cadenceDays: number; lastChecked: string | null }>("/bins", {
method: "POST",
body: JSON.stringify(body),
}),
updateBin: (id: string, body: { name?: string; capacity?: number }) =>
request<{ id: string; name: string; capacity: number }>(`/bins/${id}`, {
updateBin: (id: string, body: { name?: string; capacity?: number; cadenceDays?: number }) =>
request<{ id: string; name: string; capacity: number; cadenceDays: number; lastChecked: string | null }>(`/bins/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
deleteBin: (id: string) =>
request<{ ok: true }>(`/bins/${id}`, { method: "DELETE" }),
completeBinCheck: (
binId: string,
body: { date: string; verifiedItemIds: string[]; goneItemIds: string[] },
) =>
request<{ ok: true; verified: number; gone: number }>(`/bins/${binId}/check`, {
method: "POST",
body: JSON.stringify(body),
}),
};
+6 -3
View File
@@ -18,13 +18,15 @@ export function MobileBottomNav({
onScan,
onAddProduct,
onMarkFinished,
onAudit,
onWeighIn,
onBinCheck,
onCheckout,
}: {
onScan: () => void;
onAddProduct: () => void;
onMarkFinished: () => void;
onAudit: () => void;
onWeighIn: () => void;
onBinCheck: () => void;
onCheckout: () => void;
}) {
const [moreOpen, setMoreOpen] = useState(false);
@@ -129,7 +131,8 @@ export function MobileBottomNav({
</div>
{[
{ icon: "plus", label: "Add inventory", action: onAddProduct },
{ icon: "search", label: "Audit", action: onAudit },
{ icon: "search", label: "Weigh In", action: onWeighIn },
{ icon: "bin", label: "Bin Check", action: onBinCheck },
{ icon: "pocket", label: "Check out", action: onCheckout },
{ icon: "check", label: "Mark consumed", action: onMarkFinished },
].map((a) => (
+14 -12
View File
@@ -18,7 +18,7 @@ export function ProductDetail({
onClose,
onConsume,
onMarkGone,
onAudit,
onWeighIn,
onEdit,
onCheckout,
onCheckin,
@@ -30,7 +30,7 @@ export function ProductDetail({
onClose: () => void;
onConsume: (i: Item) => void;
onMarkGone: (i: Item) => void;
onAudit: (i: Item) => void;
onWeighIn: (i: Item) => void;
onEdit: (i: Item) => void;
onCheckout: (i: Item) => void;
onCheckin: (i: Item) => void;
@@ -182,9 +182,9 @@ export function ProductDetail({
</div>
) : (
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
{isActive && (
<Btn variant={overdue ? "sage" : "ghost"} icon="search" onClick={() => onAudit(item)}>
Audit
{isActive && item.kind === "bulk" && (
<Btn variant={overdue ? "sage" : "ghost"} icon="search" onClick={() => onWeighIn(item)}>
Weigh In
</Btn>
)}
{isActive && (
@@ -231,7 +231,7 @@ export function ProductDetail({
{isCheckedOut && (
<Pill tone="outline">Checked out · {fmt.daysAgo(item.checkoutDate, getStoredTimezone())}</Pill>
)}
{isActive && overdue && <Pill tone="amber">Audit overdue · {sinceCheck}d</Pill>}
{isActive && overdue && <Pill tone="amber">Weigh-in overdue · {sinceCheck}d</Pill>}
</div>
<h1
className="serif"
@@ -365,10 +365,12 @@ export function ProductDetail({
marginBottom: 12,
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Audit history</div>
{isActive && (
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
{item.kind === "bulk" ? "Weigh-in history" : "Check history"}
</div>
{isActive && item.kind === "bulk" && (
<button
onClick={() => onAudit(item)}
onClick={() => onWeighIn(item)}
style={{
background: "none",
border: "none",
@@ -378,7 +380,7 @@ export function ProductDetail({
textDecoration: "underline",
}}
>
+ New audit
+ New weigh-in
</button>
)}
</div>
@@ -513,8 +515,8 @@ export function ProductDetail({
{isMobile && (
<BottomSheet open={actionsOpen} onClose={() => setActionsOpen(false)}>
<div style={{ padding: "8px 16px 20px" }}>
{isActive && (
<MobileAction icon="search" label="Audit" onClick={() => { setActionsOpen(false); onAudit(item); }} />
{isActive && item.kind === "bulk" && (
<MobileAction icon="search" label="Weigh In" onClick={() => { setActionsOpen(false); onWeighIn(item); }} />
)}
{isActive && (
<MobileAction icon="pocket" label="Check out" onClick={() => { setActionsOpen(false); onCheckout(item); }} />
+6 -4
View File
@@ -12,7 +12,7 @@ export function ScanAction({
noMatchText,
onClose,
onViewItem,
onAudit,
onWeighIn,
onCheckout,
onCheckin,
onConsume,
@@ -26,7 +26,7 @@ export function ScanAction({
noMatchText: string | null;
onClose: () => void;
onViewItem: (item: Item) => void;
onAudit: (item: Item) => void;
onWeighIn: (item: Item) => void;
onCheckout: (item: Item) => void;
onCheckin: (item: Item) => void;
onConsume: (item: Item) => void;
@@ -89,14 +89,16 @@ export function ScanAction({
<span className="mono">{item.assetId}</span>
<span style={{ color: "var(--ink-4)" }}>·</span>
<span className="mono">{remainingShort(item)}</span>
{overdue && <Pill tone="amber" style={{ fontSize: 10 }}>Audit due</Pill>}
{overdue && <Pill tone="amber" style={{ fontSize: 10 }}>Weigh-in due</Pill>}
</div>
</div>
</div>
{/* Actions */}
<ActionButton icon="arrow" label="View details" onClick={() => { onClose(); onViewItem(item); }} />
<ActionButton icon="search" label="Audit" onClick={() => { onClose(); onAudit(item); }} />
{item.kind === "bulk" && (
<ActionButton icon="search" label="Weigh In" onClick={() => { onClose(); onWeighIn(item); }} />
)}
{item.status === "active" && (
<ActionButton icon="pocket" label="Check out" onClick={() => { onClose(); onCheckout(item); }} />
)}
+9 -4
View File
@@ -45,12 +45,14 @@ const TAGLINE = TAGLINES[Math.floor(Math.random() * TAGLINES.length)]!;
export function Sidebar({
onAddProduct,
onMarkFinished,
onAudit,
onWeighIn,
onBinCheck,
onCheckout,
}: {
onAddProduct: () => void;
onMarkFinished: () => void;
onAudit: () => void;
onWeighIn: () => void;
onBinCheck: () => void;
onCheckout: () => void;
}) {
return (
@@ -91,8 +93,11 @@ export function Sidebar({
<button className="nav-link nav-action" onClick={onAddProduct} title="Add inventory">
<Icon name="plus" size={16} /> <span className="nav-label">Add inventory</span>
</button>
<button className="nav-link nav-action" onClick={onAudit} title="Audit">
<Icon name="search" size={16} /> <span className="nav-label">Audit</span>
<button className="nav-link nav-action" onClick={onWeighIn} title="Weigh in">
<Icon name="search" size={16} /> <span className="nav-label">Weigh In</span>
</button>
<button className="nav-link nav-action" onClick={onBinCheck} title="Bin check">
<Icon name="bin" size={16} /> <span className="nav-label">Bin Check</span>
</button>
<button className="nav-link nav-action" onClick={onCheckout} title="Check out">
<Icon name="pocket" size={16} /> <span className="nav-label">Check out</span>
+442
View File
@@ -0,0 +1,442 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bin, Bootstrap, Item } from "../../types.js";
import { helpers, enrichItems } from "../../types.js";
import { getToday, getStoredTimezone } from "../../tz.js";
import { api } from "../../api.js";
import { Btn, Icon } from "../primitives/index.js";
import { ScanField, type ScanResult } from "../ScanField.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
import { useToast } from "../Toast.js";
type Phase = "select" | "scan" | "review";
export function BinCheckFlow({
data,
onClose,
bin: initialBin,
}: {
data: Bootstrap;
onClose: () => void;
bin: Bin | null;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const allItems = enrichItems(data);
const todayStr = getToday(getStoredTimezone());
const [phase, setPhase] = useState<Phase>(initialBin ? "scan" : "select");
const [selectedBin, setSelectedBin] = useState<Bin | null>(initialBin);
const [verified, setVerified] = useState<Set<string>>(new Set());
const [gone, setGone] = useState<Set<string>>(new Set());
const [scanMessage, setScanMessage] = useState<{ text: string; color: string } | null>(null);
const expectedItems = selectedBin
? allItems.filter((i) => i.binId === selectedBin.id && i.status === "active")
: [];
const missingItems = expectedItems.filter(
(i) => !verified.has(i.id) && !gone.has(i.id),
);
const sortedBins = [...data.bins].sort((a, b) => {
const aOver = helpers.binCheckOverdue(a, todayStr) ? 0 : 1;
const bOver = helpers.binCheckOverdue(b, todayStr) ? 0 : 1;
if (aOver !== bOver) return aOver - bOver;
return helpers.daysSinceBinCheck(b, todayStr) - helpers.daysSinceBinCheck(a, todayStr);
});
const handleSelectBin = (bin: Bin) => {
setSelectedBin(bin);
setVerified(new Set());
setGone(new Set());
setScanMessage(null);
setPhase("scan");
};
const handleScan = (result: ScanResult) => {
if (result.kind !== "item") return;
const item = result.item;
if (verified.has(item.id)) {
setScanMessage({ text: `Already scanned — ${item.name}`, color: "var(--ink-3)" });
return;
}
if (item.binId === selectedBin?.id && item.status === "active") {
setVerified((prev) => new Set(prev).add(item.id));
setScanMessage({ text: `Verified — ${item.name}`, color: "var(--sage)" });
} else if (item.binId && item.binId !== selectedBin?.id) {
const correctBin = data.bins.find((b) => b.id === item.binId);
setScanMessage({
text: `Wrong bin — move ${item.name} to ${correctBin?.name ?? item.binId}`,
color: "var(--amber)",
});
} else {
setScanMessage({
text: `${item.name} is not assigned to this bin`,
color: "var(--amber)",
});
}
};
const handleScanNoMatch = (raw: string) => {
setScanMessage({
text: `"${raw}" not in system — ingest this item first`,
color: "var(--terracotta)",
});
};
const handleMarkGone = (itemId: string) => {
setGone((prev) => new Set(prev).add(itemId));
};
const complete = useMutation({
mutationFn: () =>
api.completeBinCheck(selectedBin!.id, {
date: todayStr,
verifiedItemIds: [...verified],
goneItemIds: [...gone],
}),
onSuccess: (result) => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
const parts: string[] = [];
if (result.verified > 0) parts.push(`${result.verified} verified`);
if (result.gone > 0) parts.push(`${result.gone} marked gone`);
toast(`Bin check complete — ${parts.join(", ")}`);
onClose();
},
});
const binItemCount = (bin: Bin) =>
allItems.filter((i) => i.binId === bin.id && i.status === "active").length;
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)",
}}
>
{/* ── Phase: Select bin ─────────────────────────── */}
{phase === "select" && (
<>
<ModalHeader title="Bin Check" eyebrow="Select a bin to check" onClose={onClose} />
<div style={{ padding: 32, maxHeight: "60vh", overflowY: "auto" }}>
{sortedBins.length === 0 ? (
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
No bins created yet.
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{sortedBins.map((bin) => {
const overdue = helpers.binCheckOverdue(bin, todayStr);
const days = helpers.daysSinceBinCheck(bin, todayStr);
const count = binItemCount(bin);
return (
<button
key={bin.id}
onClick={() => handleSelectBin(bin)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "14px 18px",
background: overdue ? "var(--amber-soft)" : "var(--bg-2)",
border: `1px solid ${overdue ? "var(--amber)" : "var(--line)"}`,
borderRadius: "var(--r-md)",
cursor: "pointer",
textAlign: "left",
width: "100%",
transition: "background 120ms",
}}
>
<div>
<div className="serif" style={{ fontSize: 18, fontWeight: 500 }}>
{bin.name}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 2 }}>
{count} item{count !== 1 ? "s" : ""} · cadence {bin.cadenceDays}d
</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 13, color: overdue ? "var(--terracotta)" : "var(--ink-2)" }}>
{days === Infinity ? "Never checked" : `${days}d ago`}
</div>
{overdue && (
<div style={{ fontSize: 11, color: "var(--terracotta)", fontWeight: 600, marginTop: 2 }}>
OVERDUE
</div>
)}
</div>
</button>
);
})}
</div>
)}
</div>
<ModalFooter>
<div />
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
</ModalFooter>
</>
)}
{/* ── Phase: Scan items ─────────────────────────── */}
{phase === "scan" && selectedBin && (
<>
<ModalHeader
title={selectedBin.name}
eyebrow={`${verified.size} of ${expectedItems.length} verified`}
onClose={onClose}
/>
{expectedItems.length > 0 && (
<div style={{ height: 3, background: "var(--bg-3)" }}>
<div
style={{
height: "100%",
width: `${(verified.size / expectedItems.length) * 100}%`,
background: "var(--sage)",
borderRadius: "0 2px 2px 0",
transition: "width 300ms ease",
}}
/>
</div>
)}
<div style={{ padding: 32 }}>
<ScanField
items={allItems}
products={[]}
matchedLabel={null}
onMatch={handleScan}
onScanNoMatch={handleScanNoMatch}
mode="assetId"
/>
{scanMessage && (
<div
style={{
marginTop: 12,
padding: "10px 14px",
borderRadius: "var(--r-md)",
background: "var(--bg-2)",
border: "1px solid var(--line)",
fontSize: 13,
color: scanMessage.color,
fontWeight: 500,
}}
>
{scanMessage.text}
</div>
)}
<div style={{ marginTop: 20, maxHeight: 320, overflowY: "auto" }}>
{expectedItems.length === 0 ? (
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
This bin has no active items.
</div>
) : (
expectedItems.map((item) => {
const isVerified = verified.has(item.id);
return (
<div
key={item.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 0",
borderBottom: "1px solid var(--line)",
}}
>
<div
style={{
width: 22,
height: 22,
borderRadius: "50%",
border: `2px solid ${isVerified ? "var(--sage)" : "var(--line)"}`,
background: isVerified ? "var(--sage)" : "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{isVerified && <Icon name="check" size={14} color="white" />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: isVerified ? "var(--sage)" : "var(--ink)" }}>
{item.name}
</div>
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
<span className="mono">{item.assetId}</span> · {item.type}
</div>
</div>
</div>
);
})
)}
</div>
</div>
<ModalFooter>
<Btn variant="ghost" onClick={() => { setPhase("select"); setSelectedBin(null); }}>
Back
</Btn>
<Btn
variant="primary"
onClick={() => setPhase("review")}
>
Done scanning
</Btn>
</ModalFooter>
</>
)}
{/* ── Phase: Review & complete ──────────────────── */}
{phase === "review" && selectedBin && (
<>
<ModalHeader
title="Review"
eyebrow={selectedBin.name}
onClose={onClose}
/>
<div style={{ padding: 32 }}>
{/* Summary */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: 16,
marginBottom: 24,
}}
>
<div style={{ padding: 14, background: "var(--bg-2)", borderRadius: "var(--r-md)", textAlign: "center" }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Verified</div>
<div className="serif" style={{ fontSize: 24, color: "var(--sage)" }}>{verified.size}</div>
</div>
<div style={{ padding: 14, background: "var(--bg-2)", borderRadius: "var(--r-md)", textAlign: "center" }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Missing</div>
<div className="serif" style={{ fontSize: 24, color: missingItems.length > 0 ? "var(--terracotta)" : "var(--ink)" }}>
{missingItems.length}
</div>
</div>
<div style={{ padding: 14, background: "var(--bg-2)", borderRadius: "var(--r-md)", textAlign: "center" }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Gone</div>
<div className="serif" style={{ fontSize: 24, color: gone.size > 0 ? "var(--terracotta)" : "var(--ink)" }}>
{gone.size}
</div>
</div>
</div>
{/* Missing items list */}
{missingItems.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div className="smallcaps" style={{ color: "var(--terracotta)", marginBottom: 8 }}>
Missing items
</div>
{missingItems.map((item) => (
<div
key={item.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 0",
borderBottom: "1px solid var(--line)",
}}
>
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{item.name}</div>
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
<span className="mono">{item.assetId}</span> · {item.type}
</div>
</div>
<div style={{ display: "flex", gap: 6 }}>
<Btn variant="ghost" onClick={() => handleMarkGone(item.id)}>
Mark gone
</Btn>
</div>
</div>
))}
<div style={{ marginTop: 12 }}>
<Btn variant="ghost" onClick={() => setPhase("scan")}>
Re-scan
</Btn>
</div>
</div>
)}
{/* Gone items */}
{gone.size > 0 && (
<div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 8 }}>
Marked as gone
</div>
{[...gone].map((id) => {
const item = allItems.find((i) => i.id === id);
if (!item) return null;
return (
<div
key={id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 0",
borderBottom: "1px solid var(--line)",
color: "var(--ink-3)",
}}
>
<div>
<div style={{ fontSize: 14, textDecoration: "line-through" }}>{item.name}</div>
<div style={{ fontSize: 11 }}>
<span className="mono">{item.assetId}</span>
</div>
</div>
<Btn
variant="ghost"
onClick={() => setGone((prev) => { const next = new Set(prev); next.delete(id); return next; })}
>
Undo
</Btn>
</div>
);
})}
</div>
)}
{missingItems.length === 0 && gone.size === 0 && (
<div style={{ textAlign: "center", padding: "16px 0", color: "var(--sage)", fontSize: 15, fontWeight: 500 }}>
All items verified ready to complete.
</div>
)}
</div>
<ModalFooter>
<Btn variant="ghost" onClick={() => setPhase("scan")}>
Back
</Btn>
<Btn
variant="primary"
icon="check"
disabled={complete.isPending}
onClick={() => complete.mutate()}
>
{complete.isPending ? "Saving…" : "Complete bin check"}
</Btn>
</ModalFooter>
</>
)}
</div>
</ModalBackdrop>
);
}
+25 -5
View File
@@ -125,14 +125,15 @@ export function EditBinModal({
bin,
onClose,
}: {
bin: { id: string; name: string; capacity: number };
bin: { id: string; name: string; capacity: number; cadenceDays: number };
onClose: () => void;
}) {
const qc = useQueryClient();
const [name, setName] = useState(bin.name);
const [capacity, setCapacity] = useState(bin.capacity);
const [cadenceDays, setCadenceDays] = useState(bin.cadenceDays);
const update = useMutation({
mutationFn: () => api.updateBin(bin.id, { name: name.trim(), capacity }),
mutationFn: () => api.updateBin(bin.id, { name: name.trim(), capacity, cadenceDays }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose();
@@ -152,7 +153,7 @@ export function EditBinModal({
}}
>
<ModalHeader title="Edit bin" eyebrow="Storage" onClose={onClose} />
<div style={{ padding: 32, display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
<div style={{ padding: 32, display: "grid", gridTemplateColumns: "2fr 1fr 1fr", gap: 16 }}>
<Field label="Bin name">
<Input
autoFocus
@@ -170,6 +171,15 @@ export function EditBinModal({
onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))}
/>
</Field>
<Field label="Check cadence">
<Input
type="number"
min={1}
step={1}
value={cadenceDays}
onChange={(e) => setCadenceDays(Math.max(1, Math.floor(+e.target.value || 30)))}
/>
</Field>
</div>
<ModalFooter>
<div />
@@ -194,8 +204,9 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
const qc = useQueryClient();
const [name, setName] = useState("");
const [capacity, setCapacity] = useState(10);
const [cadenceDays, setCadenceDays] = useState(30);
const create = useMutation({
mutationFn: () => api.createBin({ name: name.trim(), capacity }),
mutationFn: () => api.createBin({ name: name.trim(), capacity, cadenceDays }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose();
@@ -215,7 +226,7 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
}}
>
<ModalHeader title="Add a bin" eyebrow="Storage" onClose={onClose} />
<div style={{ padding: 32, display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
<div style={{ padding: 32, display: "grid", gridTemplateColumns: "2fr 1fr 1fr", gap: 16 }}>
<Field label="Bin name">
<Input
autoFocus
@@ -233,6 +244,15 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))}
/>
</Field>
<Field label="Check cadence">
<Input
type="number"
min={1}
step={1}
value={cadenceDays}
onChange={(e) => setCadenceDays(Math.max(1, Math.floor(+e.target.value || 30)))}
/>
</Field>
</div>
<ModalFooter>
<div />
@@ -4,12 +4,12 @@ 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 { Btn, Field, Input } 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 }> = {
const 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.",
@@ -18,13 +18,9 @@ const AUDIT_MODE_LABELS: Record<string, { title: string; desc: string }> = {
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({
export function WeighInFlow({
data,
onClose,
item: initialItem,
@@ -38,28 +34,23 @@ export function AuditFlow({
const qc = useQueryClient();
const { toast } = useToast();
const allItems = enrichItems(data);
const overdueFirst = [...allItems]
.filter((i) => i.status === "active")
const bulkItems = [...allItems]
.filter((i) => i.status === "active" && i.kind === "bulk")
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
const [queueIdx, setQueueIdx] = useState(0);
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);
}
const last = helpers.lastAudit(i);
return (last ? last.value : i.weight).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",
);
@@ -86,17 +77,16 @@ export function AuditFlow({
const effectiveMode =
inputMode === "container" ? "weigh" : (cfg?.auditMode ?? "weigh");
const audit = useMutation({
const weighIn = 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`);
toast(`Weigh-in saved — next due in ${cfg?.cadenceDays ?? "?"}d`);
if (queue && queueIdx + 1 < queue.length) {
const nextItem = queue[queueIdx + 1]!;
setQueueIdx((i) => i + 1);
@@ -118,13 +108,11 @@ export function AuditFlow({
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!;
: MODE_LABELS[auditMode] ?? MODE_LABELS.weigh!;
const last = item ? helpers.lastAudit(item) : null;
const prevValue = item
? item.kind === "discrete"
? item.countLastAudit ?? item.countOriginal
: last
? last
? last.value
: item.weight
: 0;
@@ -144,8 +132,8 @@ export function AuditFlow({
}}
>
<ModalHeader
title={item ? ml.title : "Audit"}
eyebrow={queue && queue.length > 1 ? `${queueIdx + 1} of ${queue.length} overdue` : ""}
title={item ? ml.title : "Weigh In"}
eyebrow={queue && queue.length > 1 ? `${queueIdx + 1} of ${queue.length} overdue weigh-ins` : ""}
onClose={onClose}
/>
@@ -165,7 +153,7 @@ export function AuditFlow({
<div style={{ padding: 32 }}>
<ScanField
items={overdueFirst}
items={bulkItems}
products={[]}
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
onMatch={handleScan}
@@ -208,7 +196,7 @@ export function AuditFlow({
</div>
</div>
{tare != null && !isConcentrate && (
{tare != null && (
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<Btn
variant={inputMode === "container" ? "primary" : "ghost"}
@@ -228,7 +216,7 @@ export function AuditFlow({
<div
style={{
display: "grid",
gridTemplateColumns: auditMode === "presence" ? "1fr 1fr 1fr" : "1fr 1fr",
gridTemplateColumns: "1fr 1fr",
gap: 16,
marginTop: 24,
}}
@@ -245,16 +233,14 @@ export function AuditFlow({
) : (
<Field
label={
item.kind === "discrete"
? `Count now (${cfg?.unit})`
: auditMode === "weigh"
auditMode === "weigh"
? `Weight now (${cfg?.unit})`
: `Estimate now (${cfg?.unit})`
}
>
<Input
type="number"
step={item.kind === "discrete" ? "1" : "0.1"}
step="0.1"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
@@ -263,17 +249,6 @@ export function AuditFlow({
<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 && (
@@ -302,13 +277,13 @@ export function AuditFlow({
<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}
{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}
{effectiveValue.toFixed(2)} {cfg?.unit}
</div>
</div>
<div>
@@ -320,7 +295,7 @@ export function AuditFlow({
color: delta < 0 ? "var(--terracotta)" : "var(--ink)",
}}
>
{delta.toFixed(item.kind === "discrete" ? 0 : 2)} {cfg?.unit}
{delta.toFixed(2)} {cfg?.unit}
</div>
</div>
</div>
@@ -334,23 +309,23 @@ export function AuditFlow({
<ModalFooter>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{item ? `Next audit due in ${cfg?.cadenceDays}d` : ""}
{item ? `Next weigh-in 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()}
disabled={weighIn.isPending || !item}
onClick={() => weighIn.mutate()}
>
{audit.isPending
{weighIn.isPending
? "Saving…"
: error
? "Try again"
: queue && queueIdx + 1 < queue.length
? "Save & next"
: "Save audit"}
: "Save weigh-in"}
</Btn>
</div>
</ModalFooter>
+7 -4
View File
@@ -3,7 +3,7 @@
// consumed. Gone items contribute spend but NOT grams (so daily averages
// stay clean). Operates on the enriched Item[] view, not raw products.
import type { Bootstrap, Item } from "./types.js";
import type { Bin, Bootstrap, Item } from "./types.js";
import { TYPES, helpers, enrichItems } from "./types.js";
import { getToday, getStoredTimezone } from "./tz.js";
@@ -37,7 +37,8 @@ export interface Stats {
goneCount: number;
archivedCount: number;
purchaseCount: number;
overdueAudits: Item[];
overdueWeighIns: Item[];
overdueBinChecks: Bin[];
lowStockBulk: Item[];
lowStockDiscreteGroups: {
key: string;
@@ -220,7 +221,8 @@ export function computeStats(data: Bootstrap): Stats {
}
const avgGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;
const overdueAudits = active.filter((p) => helpers.auditOverdue(p, todayStr));
const overdueWeighIns = active.filter((p) => helpers.auditOverdue(p, todayStr));
const overdueBinChecks = data.bins.filter((b) => helpers.binCheckOverdue(b, todayStr));
const lowStockBulk = active.filter(
(p) => p.kind === "bulk" && helpers.pctRemaining(p) < 0.25,
@@ -283,7 +285,8 @@ export function computeStats(data: Bootstrap): Stats {
goneCount: gone.length,
archivedCount: consumed.length + gone.length,
purchaseCount: items.length,
overdueAudits,
overdueWeighIns,
overdueBinChecks,
lowStockBulk,
lowStockDiscreteGroups,
};
+9 -1
View File
@@ -91,6 +91,8 @@ export interface Bin {
id: string;
name: string;
capacity: number;
cadenceDays: number;
lastChecked: string | null;
}
export interface TypeConfig {
@@ -203,9 +205,15 @@ export const helpers = {
auditOverdue(p: Item, today = TODAY_STR): boolean {
if (p.status !== "active" && p.status !== "checked-out") return false;
const cfg = TYPES.find((t) => t.id === p.type);
if (!cfg) return false;
if (!cfg || cfg.kind === "discrete") return false;
return this.daysSinceCheck(p, today) >= cfg.cadenceDays;
},
binCheckOverdue(bin: Bin, today = TODAY_STR): boolean {
return this.daysSinceBinCheck(bin, today) >= bin.cadenceDays;
},
daysSinceBinCheck(bin: Bin, today = TODAY_STR): number {
return this.daysSince(bin.lastChecked, today);
},
remaining(p: Item): number {
if (p.status !== "active" && p.status !== "checked-out") return 0;
if (p.kind === "discrete") {
+40
View File
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Bin, Item } from "../types.js";
import { helpers, enrichItems } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js";
import { remainingShort } from "../stats.js";
import { fmt, TYPE_GLYPHS } from "../format.js";
import { api } from "../api.js";
@@ -45,11 +46,13 @@ export function BinsView({
onSelectItem,
onAddBin,
onEditBin,
onBinCheck,
}: {
data: Bootstrap;
onSelectItem: (i: Item) => void;
onAddBin: () => void;
onEditBin: (bin: Bin) => void;
onBinCheck: (bin: Bin) => void;
}) {
const isMobile = useIsMobile();
const qc = useQueryClient();
@@ -219,6 +222,43 @@ export function BinsView({
}}
/>
</div>
{(() => {
const todayStr = getToday(getStoredTimezone());
const overdue = helpers.binCheckOverdue(bin, todayStr);
const days = helpers.daysSinceBinCheck(bin, todayStr);
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 10,
}}
>
<div style={{ fontSize: 11, color: overdue ? "var(--terracotta)" : "var(--ink-3)" }}>
{days === Infinity ? "Never checked" : `Checked ${days}d ago`}
{overdue && " · overdue"}
</div>
<button
onClick={(e) => { e.stopPropagation(); onBinCheck(bin); }}
title="Check bin"
style={{
background: "transparent",
border: "none",
padding: "2px 6px",
borderRadius: "var(--r-sm)",
cursor: "pointer",
color: overdue ? "var(--terracotta)" : "var(--ink-3)",
fontSize: 11,
fontWeight: 600,
textDecoration: "underline",
}}
>
Check
</button>
</div>
);
})()}
</div>
<div style={{ padding: 8, flex: 1 }}>
{binItems.length === 0 && (
+45 -11
View File
@@ -1,4 +1,4 @@
import type { Bootstrap, Item } from "../types.js";
import type { Bin, Bootstrap, Item } from "../types.js";
import { helpers } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js";
import type { Stats } from "../stats.js";
@@ -20,14 +20,16 @@ const TYPE_COLORS: Record<string, string> = {
export function Dashboard({
data,
stats,
onAuditItem,
onAuditQueue,
onWeighInItem,
onWeighInQueue,
onBinCheck,
onSelectItem,
}: {
data: Bootstrap;
stats: Stats;
onAuditItem: (i: Item) => void;
onAuditQueue: (items: Item[]) => void;
onWeighInItem: (i: Item) => void;
onWeighInQueue: (items: Item[]) => void;
onBinCheck: (bin?: Bin) => void;
onSelectItem: (i: Item) => void;
}) {
const isMobile = useIsMobile();
@@ -41,7 +43,8 @@ export function Dashboard({
color: TYPE_COLORS[k] ?? "var(--ink-3)",
}));
const overdue = stats.overdueAudits;
const overdue = stats.overdueWeighIns;
const overdueBins = stats.overdueBinChecks;
const lowBulk = stats.lowStockBulk;
const lowDiscrete = stats.lowStockDiscreteGroups;
@@ -86,7 +89,12 @@ export function Dashboard({
{stats.goneCount} gone.
{overdue.length > 0 && (
<span style={{ color: "var(--terracotta)" }}>
{" "}· {overdue.length} audit{overdue.length === 1 ? "" : "s"} overdue.
{" "}· {overdue.length} weigh-in{overdue.length === 1 ? "" : "s"} overdue.
</span>
)}
{overdueBins.length > 0 && (
<span style={{ color: "var(--terracotta)" }}>
{" "}· {overdueBins.length} bin check{overdueBins.length === 1 ? "" : "s"} overdue.
</span>
)}
</div>
@@ -182,17 +190,43 @@ export function Dashboard({
>
<div style={{ display: "flex", alignItems: "center", gap: 18, flexWrap: "wrap" }}>
<div style={{ flex: 1, minWidth: 240 }}>
<div className="smallcaps" style={{ color: "oklch(48% 0.10 75)" }}>Audit overdue</div>
<div className="smallcaps" style={{ color: "oklch(48% 0.10 75)" }}>Weigh-ins overdue</div>
<div className="serif" style={{ fontSize: 20, marginTop: 4, color: "var(--ink)" }}>
{overdue.length} item{overdue.length === 1 ? "" : "s"} haven't been checked in a while
{overdue.length} item{overdue.length === 1 ? "" : "s"} need weighing
</div>
<div style={{ fontSize: 12, color: "var(--ink-2)", marginTop: 4 }}>
{overdue.slice(0, 3).map((p) => p.name).join(" · ")}
{overdue.length > 3 && ` · +${overdue.length - 3} more`}
</div>
</div>
<Btn variant="secondary" icon="search" onClick={() => onAuditQueue(overdue)}>
Audit {overdue.length > 1 ? `all ${overdue.length}` : ""}
<Btn variant="secondary" icon="search" onClick={() => onWeighInQueue(overdue)}>
Weigh in {overdue.length > 1 ? `all ${overdue.length}` : ""}
</Btn>
</div>
</Card>
)}
{overdueBins.length > 0 && (
<Card
style={{
marginBottom: 18,
borderColor: "var(--amber)",
background: "var(--amber-soft)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 18, flexWrap: "wrap" }}>
<div style={{ flex: 1, minWidth: 240 }}>
<div className="smallcaps" style={{ color: "oklch(48% 0.10 75)" }}>Bin checks overdue</div>
<div className="serif" style={{ fontSize: 20, marginTop: 4, color: "var(--ink)" }}>
{overdueBins.length} bin{overdueBins.length === 1 ? "" : "s"} need checking
</div>
<div style={{ fontSize: 12, color: "var(--ink-2)", marginTop: 4 }}>
{overdueBins.slice(0, 3).map((b) => b.name).join(" · ")}
{overdueBins.length > 3 && ` · +${overdueBins.length - 3} more`}
</div>
</div>
<Btn variant="secondary" icon="bin" onClick={() => onBinCheck()}>
Start bin check
</Btn>
</div>
</Card>
+5 -5
View File
@@ -20,7 +20,7 @@ export function Inventory({
data,
onSelectItem,
onAddInventory,
onAuditNew,
onWeighInNew,
onBulkEdit,
onBulkConsume,
onBulkCheckout,
@@ -30,7 +30,7 @@ export function Inventory({
data: Bootstrap;
onSelectItem: (i: Item) => void;
onAddInventory: () => void;
onAuditNew: () => void;
onWeighInNew: () => void;
onBulkEdit: (items: Item[]) => void;
onBulkConsume: (items: Item[]) => void;
onBulkCheckout: (items: Item[]) => void;
@@ -184,7 +184,7 @@ export function Inventory({
</div>
{!isMobile && (
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="secondary" icon="check" onClick={onAuditNew}>Audit</Btn>
<Btn variant="secondary" icon="check" onClick={onWeighInNew}>Weigh In</Btn>
<Btn variant="primary" icon="plus" onClick={onAddInventory}>Add inventory</Btn>
</div>
)}
@@ -380,7 +380,7 @@ export function Inventory({
<option value="thc">THC %</option>
<option value="remaining">Remaining</option>
<option value="price">Price</option>
<option value="audit">Audit overdue</option>
<option value="audit">Weigh-in overdue</option>
</Select>
</div>
</Card>
@@ -586,7 +586,7 @@ export function Inventory({
["thc", "THC %"],
["remaining", "Remaining"],
["price", "Price"],
["audit", "Audit overdue"],
["audit", "Weigh-in overdue"],
] as [SortKey, string][]
).map(([k, l]) => (
<button