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(); migrateAddCheckoutDate();
migrateAddContainerWeight(); migrateAddContainerWeight();
migrateAddPrevBinId(); migrateAddPrevBinId();
migrateAddBinCheckFields();
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8"); const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
db.exec(schema); 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)`); 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, // One-shot migration: the original schema put per-instance fields (weight,
// bin_id, etc.) directly on `products`. The split schema separates products // bin_id, etc.) directly on `products`. The split schema separates products
// (catalog) from inventory_items (instance). When we detect the old shape, // (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(); .all();
const shops = db.prepare("SELECT * FROM shops ORDER BY id").all(); const shops = db.prepare("SELECT * FROM shops ORDER BY id").all();
const brands = db.prepare("SELECT * FROM brands 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 const strains = db
.prepare<[], StrainRow>("SELECT * FROM strains ORDER BY name COLLATE NOCASE") .prepare<[], StrainRow>("SELECT * FROM strains ORDER BY name COLLATE NOCASE")
.all(); .all();
+13 -8
View File
@@ -103,20 +103,21 @@ catalogRouter.delete("/shops/:id", (req, res) => {
}); });
catalogRouter.post("/bins", (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" }); if (!name?.trim()) return res.status(400).json({ error: "name required" });
const id = nextId("bin", "bins"); const id = nextId("bin", "bins");
const cap = Number.isFinite(capacity) && (capacity as number) > 0 ? Math.floor(capacity as number) : 10; 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); const cad = Number.isFinite(cadenceDays) && (cadenceDays as number) > 0 ? Math.floor(cadenceDays as number) : 30;
res.json({ id, name: name.trim(), capacity: cap }); 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) => { catalogRouter.patch("/bins/:id", (req, res) => {
const { id } = req.params; 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 const existing = db
.prepare<[string], { id: string; name: string; capacity: number }>( .prepare<[string], { id: string; name: string; capacity: number; cadence_days: number; last_checked: string | null }>(
"SELECT id, name, capacity FROM bins WHERE id = ?", "SELECT id, name, capacity, cadence_days, last_checked FROM bins WHERE id = ?",
) )
.get(id); .get(id);
if (!existing) return res.status(404).json({ error: "bin not found" }); 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 Number.isFinite(capacity) && (capacity as number) > 0
? Math.floor(capacity as number) ? Math.floor(capacity as number)
: existing.capacity; : 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); db.prepare("UPDATE bins SET name = ?, capacity = ?, cadence_days = ? WHERE id = ?").run(nextName, nextCapacity, nextCadence, id);
res.json({ id, name: nextName, capacity: nextCapacity }); 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), // 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 }); 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 ─────────────────────────────────────────────── // ─── Batch endpoint ───────────────────────────────────────────────
type BatchOp = type BatchOp =
+3 -1
View File
@@ -15,7 +15,9 @@ CREATE TABLE IF NOT EXISTS bins (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
location TEXT, 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, -- 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 { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js";
import { ConsumeFlow } from "./components/modals/ConsumeFlow.js"; import { ConsumeFlow } from "./components/modals/ConsumeFlow.js";
import { MarkGoneFlow } from "./components/modals/MarkGoneFlow.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 { CheckoutFlow } from "./components/modals/CheckoutFlow.js";
import { CheckinFlow } from "./components/modals/CheckinFlow.js"; import { CheckinFlow } from "./components/modals/CheckinFlow.js";
import { CustodyView } from "./views/CustodyView.js"; import { CustodyView } from "./views/CustodyView.js";
@@ -61,7 +62,8 @@ type ModalKey =
| "edit" | "edit"
| "consume" | "consume"
| "gone" | "gone"
| "audit" | "weighIn"
| "binCheck"
| "checkout" | "checkout"
| "checkin" | "checkin"
| "addBrand" | "addBrand"
@@ -170,17 +172,22 @@ export function App() {
setSelected(null); setSelected(null);
setModal("gone"); setModal("gone");
}; };
const [auditQueue, setAuditQueue] = useState<Item[]>([]); const [weighInQueue, setWeighInQueue] = useState<Item[]>([]);
const openAudit = (i?: Item) => { const openWeighIn = (i?: Item) => {
setModalItem(i ?? null); setModalItem(i ?? null);
setAuditQueue([]); setWeighInQueue([]);
setModal("audit"); setModal("weighIn");
}; };
const openAuditQueue = (queue: Item[]) => { const openWeighInQueue = (queue: Item[]) => {
if (queue.length === 0) return; if (queue.length === 0) return;
setModalItem(queue[0]!); setModalItem(queue[0]!);
setAuditQueue(queue); setWeighInQueue(queue);
setModal("audit"); setModal("weighIn");
};
const [binCheckBin, setBinCheckBin] = useState<Bin | null>(null);
const openBinCheck = (bin?: Bin) => {
setBinCheckBin(bin ?? null);
setModal("binCheck");
}; };
const openCheckout = (i?: Item) => { const openCheckout = (i?: Item) => {
setModalItem(i ?? null); setModalItem(i ?? null);
@@ -273,7 +280,8 @@ export function App() {
<Sidebar <Sidebar
onAddProduct={openAdd} onAddProduct={openAdd}
onMarkFinished={() => openConsume()} onMarkFinished={() => openConsume()}
onAudit={() => openAudit()} onWeighIn={() => openWeighIn()}
onBinCheck={() => openBinCheck()}
onCheckout={() => openCheckout()} onCheckout={() => openCheckout()}
/> />
)} )}
@@ -294,14 +302,14 @@ export function App() {
)} )}
<Routes> <Routes>
<Route path="/" element={ <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={ <Route path="/inventory" element={
<Inventory <Inventory
data={data} data={data}
onSelectItem={setSelected} onSelectItem={setSelected}
onAddInventory={openAdd} onAddInventory={openAdd}
onAuditNew={() => openAudit()} onWeighInNew={() => openWeighIn()}
onBulkEdit={openBulkEdit} onBulkEdit={openBulkEdit}
onBulkConsume={openBulkConsume} onBulkConsume={openBulkConsume}
onBulkCheckout={openBulkCheckout} onBulkCheckout={openBulkCheckout}
@@ -320,7 +328,7 @@ export function App() {
<CustodyView data={data} onSelectItem={setSelected} onCheckin={openCheckin} onConsume={openConsume} onMarkGone={openMarkGone} /> <CustodyView data={data} onSelectItem={setSelected} onCheckin={openCheckin} onConsume={openConsume} onMarkGone={openMarkGone} />
} /> } />
<Route path="/bins" element={ <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={ <Route path="/shops" element={
<ShopsView data={data} onSelectShop={setSelectedShop} onAddShop={() => setModal("addShop")} /> <ShopsView data={data} onSelectShop={setSelectedShop} onAddShop={() => setModal("addShop")} />
@@ -342,7 +350,7 @@ export function App() {
onClose={() => { setSelected(null); setDrawerBack(null); }} onClose={() => { setSelected(null); setDrawerBack(null); }}
onConsume={openConsume} onConsume={openConsume}
onMarkGone={openMarkGone} onMarkGone={openMarkGone}
onAudit={openAudit} onWeighIn={openWeighIn}
onEdit={openEdit} onEdit={openEdit}
onCheckout={openCheckout} onCheckout={openCheckout}
onCheckin={openCheckin} onCheckin={openCheckin}
@@ -462,8 +470,11 @@ export function App() {
{modal === "gone" && ( {modal === "gone" && (
<MarkGoneFlow data={data} onClose={() => setModal(null)} item={modalItem} /> <MarkGoneFlow data={data} onClose={() => setModal(null)} item={modalItem} />
)} )}
{modal === "audit" && ( {modal === "weighIn" && (
<AuditFlow data={data} onClose={() => setModal(null)} item={modalItem} queue={auditQueue.length > 0 ? auditQueue : undefined} /> <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" && ( {modal === "checkout" && (
<CheckoutFlow data={data} onClose={() => setModal(null)} item={modalItem} /> <CheckoutFlow data={data} onClose={() => setModal(null)} item={modalItem} />
@@ -510,7 +521,8 @@ export function App() {
onScan={() => setScannerOpen(true)} onScan={() => setScannerOpen(true)}
onAddProduct={openAdd} onAddProduct={openAdd}
onMarkFinished={() => openConsume()} onMarkFinished={() => openConsume()}
onAudit={() => openAudit()} onWeighIn={() => openWeighIn()}
onBinCheck={() => openBinCheck()}
onCheckout={() => openCheckout()} onCheckout={() => openCheckout()}
/> />
)} )}
@@ -529,7 +541,7 @@ export function App() {
noMatchText={scanNoMatch} noMatchText={scanNoMatch}
onClose={() => { setScanResult(null); setScanNoMatch(null); }} onClose={() => { setScanResult(null); setScanNoMatch(null); }}
onViewItem={(i) => setSelected(i)} onViewItem={(i) => setSelected(i)}
onAudit={(i) => openAudit(i)} onWeighIn={(i) => openWeighIn(i)}
onCheckout={(i) => openCheckout(i)} onCheckout={(i) => openCheckout(i)}
onCheckin={(i) => openCheckin(i)} onCheckin={(i) => openCheckin(i)}
onConsume={(i) => openConsume(i)} onConsume={(i) => openConsume(i)}
+13 -4
View File
@@ -202,18 +202,27 @@ export const api = {
deleteShop: (id: string) => deleteShop: (id: string) =>
request<{ ok: true }>(`/shops/${id}`, { method: "DELETE" }), request<{ ok: true }>(`/shops/${id}`, { method: "DELETE" }),
createBin: (body: { name: string; capacity?: number }) => createBin: (body: { name: string; capacity?: number; cadenceDays?: number }) =>
request<{ id: string; name: string; capacity: number }>("/bins", { request<{ id: string; name: string; capacity: number; cadenceDays: number; lastChecked: string | null }>("/bins", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
updateBin: (id: string, body: { name?: string; capacity?: number }) => updateBin: (id: string, body: { name?: string; capacity?: number; cadenceDays?: number }) =>
request<{ id: string; name: string; capacity: number }>(`/bins/${id}`, { request<{ id: string; name: string; capacity: number; cadenceDays: number; lastChecked: string | null }>(`/bins/${id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
deleteBin: (id: string) => deleteBin: (id: string) =>
request<{ ok: true }>(`/bins/${id}`, { method: "DELETE" }), 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, onScan,
onAddProduct, onAddProduct,
onMarkFinished, onMarkFinished,
onAudit, onWeighIn,
onBinCheck,
onCheckout, onCheckout,
}: { }: {
onScan: () => void; onScan: () => void;
onAddProduct: () => void; onAddProduct: () => void;
onMarkFinished: () => void; onMarkFinished: () => void;
onAudit: () => void; onWeighIn: () => void;
onBinCheck: () => void;
onCheckout: () => void; onCheckout: () => void;
}) { }) {
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
@@ -129,7 +131,8 @@ export function MobileBottomNav({
</div> </div>
{[ {[
{ icon: "plus", label: "Add inventory", action: onAddProduct }, { 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: "pocket", label: "Check out", action: onCheckout },
{ icon: "check", label: "Mark consumed", action: onMarkFinished }, { icon: "check", label: "Mark consumed", action: onMarkFinished },
].map((a) => ( ].map((a) => (
+14 -12
View File
@@ -18,7 +18,7 @@ export function ProductDetail({
onClose, onClose,
onConsume, onConsume,
onMarkGone, onMarkGone,
onAudit, onWeighIn,
onEdit, onEdit,
onCheckout, onCheckout,
onCheckin, onCheckin,
@@ -30,7 +30,7 @@ export function ProductDetail({
onClose: () => void; onClose: () => void;
onConsume: (i: Item) => void; onConsume: (i: Item) => void;
onMarkGone: (i: Item) => void; onMarkGone: (i: Item) => void;
onAudit: (i: Item) => void; onWeighIn: (i: Item) => void;
onEdit: (i: Item) => void; onEdit: (i: Item) => void;
onCheckout: (i: Item) => void; onCheckout: (i: Item) => void;
onCheckin: (i: Item) => void; onCheckin: (i: Item) => void;
@@ -182,9 +182,9 @@ export function ProductDetail({
</div> </div>
) : ( ) : (
<div style={{ display: "flex", gap: 6, alignItems: "center" }}> <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
{isActive && ( {isActive && item.kind === "bulk" && (
<Btn variant={overdue ? "sage" : "ghost"} icon="search" onClick={() => onAudit(item)}> <Btn variant={overdue ? "sage" : "ghost"} icon="search" onClick={() => onWeighIn(item)}>
Audit Weigh In
</Btn> </Btn>
)} )}
{isActive && ( {isActive && (
@@ -231,7 +231,7 @@ export function ProductDetail({
{isCheckedOut && ( {isCheckedOut && (
<Pill tone="outline">Checked out · {fmt.daysAgo(item.checkoutDate, getStoredTimezone())}</Pill> <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> </div>
<h1 <h1
className="serif" className="serif"
@@ -365,10 +365,12 @@ export function ProductDetail({
marginBottom: 12, marginBottom: 12,
}} }}
> >
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Audit history</div> <div className="smallcaps" style={{ color: "var(--ink-3)" }}>
{isActive && ( {item.kind === "bulk" ? "Weigh-in history" : "Check history"}
</div>
{isActive && item.kind === "bulk" && (
<button <button
onClick={() => onAudit(item)} onClick={() => onWeighIn(item)}
style={{ style={{
background: "none", background: "none",
border: "none", border: "none",
@@ -378,7 +380,7 @@ export function ProductDetail({
textDecoration: "underline", textDecoration: "underline",
}} }}
> >
+ New audit + New weigh-in
</button> </button>
)} )}
</div> </div>
@@ -513,8 +515,8 @@ export function ProductDetail({
{isMobile && ( {isMobile && (
<BottomSheet open={actionsOpen} onClose={() => setActionsOpen(false)}> <BottomSheet open={actionsOpen} onClose={() => setActionsOpen(false)}>
<div style={{ padding: "8px 16px 20px" }}> <div style={{ padding: "8px 16px 20px" }}>
{isActive && ( {isActive && item.kind === "bulk" && (
<MobileAction icon="search" label="Audit" onClick={() => { setActionsOpen(false); onAudit(item); }} /> <MobileAction icon="search" label="Weigh In" onClick={() => { setActionsOpen(false); onWeighIn(item); }} />
)} )}
{isActive && ( {isActive && (
<MobileAction icon="pocket" label="Check out" onClick={() => { setActionsOpen(false); onCheckout(item); }} /> <MobileAction icon="pocket" label="Check out" onClick={() => { setActionsOpen(false); onCheckout(item); }} />
+6 -4
View File
@@ -12,7 +12,7 @@ export function ScanAction({
noMatchText, noMatchText,
onClose, onClose,
onViewItem, onViewItem,
onAudit, onWeighIn,
onCheckout, onCheckout,
onCheckin, onCheckin,
onConsume, onConsume,
@@ -26,7 +26,7 @@ export function ScanAction({
noMatchText: string | null; noMatchText: string | null;
onClose: () => void; onClose: () => void;
onViewItem: (item: Item) => void; onViewItem: (item: Item) => void;
onAudit: (item: Item) => void; onWeighIn: (item: Item) => void;
onCheckout: (item: Item) => void; onCheckout: (item: Item) => void;
onCheckin: (item: Item) => void; onCheckin: (item: Item) => void;
onConsume: (item: Item) => void; onConsume: (item: Item) => void;
@@ -89,14 +89,16 @@ export function ScanAction({
<span className="mono">{item.assetId}</span> <span className="mono">{item.assetId}</span>
<span style={{ color: "var(--ink-4)" }}>·</span> <span style={{ color: "var(--ink-4)" }}>·</span>
<span className="mono">{remainingShort(item)}</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> </div>
</div> </div>
{/* Actions */} {/* Actions */}
<ActionButton icon="arrow" label="View details" onClick={() => { onClose(); onViewItem(item); }} /> <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" && ( {item.status === "active" && (
<ActionButton icon="pocket" label="Check out" onClick={() => { onClose(); onCheckout(item); }} /> <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({ export function Sidebar({
onAddProduct, onAddProduct,
onMarkFinished, onMarkFinished,
onAudit, onWeighIn,
onBinCheck,
onCheckout, onCheckout,
}: { }: {
onAddProduct: () => void; onAddProduct: () => void;
onMarkFinished: () => void; onMarkFinished: () => void;
onAudit: () => void; onWeighIn: () => void;
onBinCheck: () => void;
onCheckout: () => void; onCheckout: () => void;
}) { }) {
return ( return (
@@ -91,8 +93,11 @@ export function Sidebar({
<button className="nav-link nav-action" onClick={onAddProduct} title="Add inventory"> <button className="nav-link nav-action" onClick={onAddProduct} title="Add inventory">
<Icon name="plus" size={16} /> <span className="nav-label">Add inventory</span> <Icon name="plus" size={16} /> <span className="nav-label">Add inventory</span>
</button> </button>
<button className="nav-link nav-action" onClick={onAudit} title="Audit"> <button className="nav-link nav-action" onClick={onWeighIn} title="Weigh in">
<Icon name="search" size={16} /> <span className="nav-label">Audit</span> <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>
<button className="nav-link nav-action" onClick={onCheckout} title="Check out"> <button className="nav-link nav-action" onClick={onCheckout} title="Check out">
<Icon name="pocket" size={16} /> <span className="nav-label">Check out</span> <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, bin,
onClose, onClose,
}: { }: {
bin: { id: string; name: string; capacity: number }; bin: { id: string; name: string; capacity: number; cadenceDays: number };
onClose: () => void; onClose: () => void;
}) { }) {
const qc = useQueryClient(); const qc = useQueryClient();
const [name, setName] = useState(bin.name); const [name, setName] = useState(bin.name);
const [capacity, setCapacity] = useState(bin.capacity); const [capacity, setCapacity] = useState(bin.capacity);
const [cadenceDays, setCadenceDays] = useState(bin.cadenceDays);
const update = useMutation({ const update = useMutation({
mutationFn: () => api.updateBin(bin.id, { name: name.trim(), capacity }), mutationFn: () => api.updateBin(bin.id, { name: name.trim(), capacity, cadenceDays }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] }); qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose(); onClose();
@@ -152,7 +153,7 @@ export function EditBinModal({
}} }}
> >
<ModalHeader title="Edit bin" eyebrow="Storage" onClose={onClose} /> <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"> <Field label="Bin name">
<Input <Input
autoFocus autoFocus
@@ -170,6 +171,15 @@ export function EditBinModal({
onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))} onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))}
/> />
</Field> </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> </div>
<ModalFooter> <ModalFooter>
<div /> <div />
@@ -194,8 +204,9 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
const qc = useQueryClient(); const qc = useQueryClient();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [capacity, setCapacity] = useState(10); const [capacity, setCapacity] = useState(10);
const [cadenceDays, setCadenceDays] = useState(30);
const create = useMutation({ const create = useMutation({
mutationFn: () => api.createBin({ name: name.trim(), capacity }), mutationFn: () => api.createBin({ name: name.trim(), capacity, cadenceDays }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] }); qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose(); onClose();
@@ -215,7 +226,7 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
}} }}
> >
<ModalHeader title="Add a bin" eyebrow="Storage" onClose={onClose} /> <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"> <Field label="Bin name">
<Input <Input
autoFocus autoFocus
@@ -233,6 +244,15 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))} onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))}
/> />
</Field> </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> </div>
<ModalFooter> <ModalFooter>
<div /> <div />
@@ -4,12 +4,12 @@ import type { Bootstrap, Item } from "../../types.js";
import { TYPES, helpers, enrichItems } from "../../types.js"; import { TYPES, helpers, enrichItems } from "../../types.js";
import { getToday, getStoredTimezone } from "../../tz.js"; import { getToday, getStoredTimezone } from "../../tz.js";
import { api } from "../../api.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 { ScanField, type ScanResult } from "../ScanField.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
import { useToast } from "../Toast.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: { weigh: {
title: "Reweigh on a scale", title: "Reweigh on a scale",
desc: "Place the jar (minus tare) and record the new weight.", 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", title: "Visual estimate",
desc: "Eyeball the remaining amount — quick and approximate.", 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, data,
onClose, onClose,
item: initialItem, item: initialItem,
@@ -38,28 +34,23 @@ export function AuditFlow({
const qc = useQueryClient(); const qc = useQueryClient();
const { toast } = useToast(); const { toast } = useToast();
const allItems = enrichItems(data); const allItems = enrichItems(data);
const overdueFirst = [...allItems] const bulkItems = [...allItems]
.filter((i) => i.status === "active") .filter((i) => i.status === "active" && i.kind === "bulk")
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a)); .sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
const [queueIdx, setQueueIdx] = useState(0); const [queueIdx, setQueueIdx] = useState(0);
const [itemId, setItemId] = useState(initialItem?.id ?? ""); const [itemId, setItemId] = useState(initialItem?.id ?? "");
const [date, setDate] = useState(getToday(getStoredTimezone())); const [date, setDate] = useState(getToday(getStoredTimezone()));
const [confirmedBy, setConfirmedBy] = useState<"asset" | "visual">("asset");
const item = allItems.find((i) => i.id === itemId); const item = allItems.find((i) => i.id === itemId);
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined; const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined;
const initialValueFor = (i: Item | undefined): string => { const initialValueFor = (i: Item | undefined): string => {
if (!i) return "0"; if (!i) return "0";
if (i.kind === "discrete") {
return String(i.countLastAudit ?? i.countOriginal);
}
const last = helpers.lastAudit(i); const last = helpers.lastAudit(i);
return (last ? last.value : i.weight).toFixed(2); return (last ? last.value : i.weight).toFixed(2);
}; };
const [value, setValue] = useState<string>(initialValueFor(item)); const [value, setValue] = useState<string>(initialValueFor(item));
const isConcentrate = item?.type === "Concentrate";
const [inputMode, setInputMode] = useState<"direct" | "container">( const [inputMode, setInputMode] = useState<"direct" | "container">(
item?.containerWeight != null ? "container" : "direct", item?.containerWeight != null ? "container" : "direct",
); );
@@ -86,17 +77,16 @@ export function AuditFlow({
const effectiveMode = const effectiveMode =
inputMode === "container" ? "weigh" : (cfg?.auditMode ?? "weigh"); inputMode === "container" ? "weigh" : (cfg?.auditMode ?? "weigh");
const audit = useMutation({ const weighIn = useMutation({
mutationFn: () => mutationFn: () =>
api.auditInventoryItem(itemId, { api.auditInventoryItem(itemId, {
date, date,
mode: effectiveMode, mode: effectiveMode,
value: effectiveValue, value: effectiveValue,
confirmedBy: cfg?.auditMode === "presence" ? confirmedBy : undefined,
}), }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] }); 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) { if (queue && queueIdx + 1 < queue.length) {
const nextItem = queue[queueIdx + 1]!; const nextItem = queue[queueIdx + 1]!;
setQueueIdx((i) => i + 1); setQueueIdx((i) => i + 1);
@@ -118,15 +108,13 @@ export function AuditFlow({
const auditMode = cfg?.auditMode ?? "weigh"; const auditMode = cfg?.auditMode ?? "weigh";
const ml = inputMode === "container" 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." } ? { 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 last = item ? helpers.lastAudit(item) : null;
const prevValue = item const prevValue = item
? item.kind === "discrete" ? last
? item.countLastAudit ?? item.countOriginal ? last.value
: last : item.weight
? last.value
: item.weight
: 0; : 0;
const delta = effectiveValue - prevValue; const delta = effectiveValue - prevValue;
@@ -144,8 +132,8 @@ export function AuditFlow({
}} }}
> >
<ModalHeader <ModalHeader
title={item ? ml.title : "Audit"} title={item ? ml.title : "Weigh In"}
eyebrow={queue && queue.length > 1 ? `${queueIdx + 1} of ${queue.length} overdue` : ""} eyebrow={queue && queue.length > 1 ? `${queueIdx + 1} of ${queue.length} overdue weigh-ins` : ""}
onClose={onClose} onClose={onClose}
/> />
@@ -165,7 +153,7 @@ export function AuditFlow({
<div style={{ padding: 32 }}> <div style={{ padding: 32 }}>
<ScanField <ScanField
items={overdueFirst} items={bulkItems}
products={[]} products={[]}
matchedLabel={item ? `${item.assetId} · ${item.name}` : null} matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
onMatch={handleScan} onMatch={handleScan}
@@ -208,7 +196,7 @@ export function AuditFlow({
</div> </div>
</div> </div>
{tare != null && !isConcentrate && ( {tare != null && (
<div style={{ display: "flex", gap: 8, marginTop: 16 }}> <div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<Btn <Btn
variant={inputMode === "container" ? "primary" : "ghost"} variant={inputMode === "container" ? "primary" : "ghost"}
@@ -228,7 +216,7 @@ export function AuditFlow({
<div <div
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: auditMode === "presence" ? "1fr 1fr 1fr" : "1fr 1fr", gridTemplateColumns: "1fr 1fr",
gap: 16, gap: 16,
marginTop: 24, marginTop: 24,
}} }}
@@ -245,16 +233,14 @@ export function AuditFlow({
) : ( ) : (
<Field <Field
label={ label={
item.kind === "discrete" auditMode === "weigh"
? `Count now (${cfg?.unit})` ? `Weight now (${cfg?.unit})`
: auditMode === "weigh" : `Estimate now (${cfg?.unit})`
? `Weight now (${cfg?.unit})`
: `Estimate now (${cfg?.unit})`
} }
> >
<Input <Input
type="number" type="number"
step={item.kind === "discrete" ? "1" : "0.1"} step="0.1"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
/> />
@@ -263,17 +249,6 @@ export function AuditFlow({
<Field label="Date"> <Field label="Date">
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} /> <Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</Field> </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> </div>
{inputMode === "container" && tare != null && ( {inputMode === "container" && tare != null && (
@@ -302,13 +277,13 @@ export function AuditFlow({
<div> <div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Was</div> <div className="smallcaps" style={{ color: "var(--ink-3)" }}>Was</div>
<div className="serif" style={{ fontSize: 22 }}> <div className="serif" style={{ fontSize: 22 }}>
{item.kind === "discrete" ? prevValue : prevValue.toFixed(2)} {cfg?.unit} {prevValue.toFixed(2)} {cfg?.unit}
</div> </div>
</div> </div>
<div> <div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Now</div> <div className="smallcaps" style={{ color: "var(--ink-3)" }}>Now</div>
<div className="serif" style={{ fontSize: 22, color: "var(--sage)" }}> <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> </div>
<div> <div>
@@ -320,7 +295,7 @@ export function AuditFlow({
color: delta < 0 ? "var(--terracotta)" : "var(--ink)", color: delta < 0 ? "var(--terracotta)" : "var(--ink)",
}} }}
> >
{delta.toFixed(item.kind === "discrete" ? 0 : 2)} {cfg?.unit} {delta.toFixed(2)} {cfg?.unit}
</div> </div>
</div> </div>
</div> </div>
@@ -334,23 +309,23 @@ export function AuditFlow({
<ModalFooter> <ModalFooter>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}> <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>
<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 || !item} disabled={weighIn.isPending || !item}
onClick={() => audit.mutate()} onClick={() => weighIn.mutate()}
> >
{audit.isPending {weighIn.isPending
? "Saving…" ? "Saving…"
: error : error
? "Try again" ? "Try again"
: queue && queueIdx + 1 < queue.length : queue && queueIdx + 1 < queue.length
? "Save & next" ? "Save & next"
: "Save audit"} : "Save weigh-in"}
</Btn> </Btn>
</div> </div>
</ModalFooter> </ModalFooter>
+7 -4
View File
@@ -3,7 +3,7 @@
// consumed. Gone items contribute spend but NOT grams (so daily averages // consumed. Gone items contribute spend but NOT grams (so daily averages
// stay clean). Operates on the enriched Item[] view, not raw products. // 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 { TYPES, helpers, enrichItems } from "./types.js";
import { getToday, getStoredTimezone } from "./tz.js"; import { getToday, getStoredTimezone } from "./tz.js";
@@ -37,7 +37,8 @@ export interface Stats {
goneCount: number; goneCount: number;
archivedCount: number; archivedCount: number;
purchaseCount: number; purchaseCount: number;
overdueAudits: Item[]; overdueWeighIns: Item[];
overdueBinChecks: Bin[];
lowStockBulk: Item[]; lowStockBulk: Item[];
lowStockDiscreteGroups: { lowStockDiscreteGroups: {
key: string; 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 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( const lowStockBulk = active.filter(
(p) => p.kind === "bulk" && helpers.pctRemaining(p) < 0.25, (p) => p.kind === "bulk" && helpers.pctRemaining(p) < 0.25,
@@ -283,7 +285,8 @@ export function computeStats(data: Bootstrap): Stats {
goneCount: gone.length, goneCount: gone.length,
archivedCount: consumed.length + gone.length, archivedCount: consumed.length + gone.length,
purchaseCount: items.length, purchaseCount: items.length,
overdueAudits, overdueWeighIns,
overdueBinChecks,
lowStockBulk, lowStockBulk,
lowStockDiscreteGroups, lowStockDiscreteGroups,
}; };
+9 -1
View File
@@ -91,6 +91,8 @@ export interface Bin {
id: string; id: string;
name: string; name: string;
capacity: number; capacity: number;
cadenceDays: number;
lastChecked: string | null;
} }
export interface TypeConfig { export interface TypeConfig {
@@ -203,9 +205,15 @@ export const helpers = {
auditOverdue(p: Item, today = TODAY_STR): boolean { auditOverdue(p: Item, today = TODAY_STR): boolean {
if (p.status !== "active" && p.status !== "checked-out") return false; if (p.status !== "active" && p.status !== "checked-out") return false;
const cfg = TYPES.find((t) => t.id === p.type); 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; 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 { remaining(p: Item): number {
if (p.status !== "active" && p.status !== "checked-out") return 0; if (p.status !== "active" && p.status !== "checked-out") return 0;
if (p.kind === "discrete") { if (p.kind === "discrete") {
+40
View File
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Bin, Item } from "../types.js"; import type { Bootstrap, Bin, Item } from "../types.js";
import { helpers, enrichItems } from "../types.js"; import { helpers, enrichItems } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js";
import { remainingShort } from "../stats.js"; import { remainingShort } from "../stats.js";
import { fmt, TYPE_GLYPHS } from "../format.js"; import { fmt, TYPE_GLYPHS } from "../format.js";
import { api } from "../api.js"; import { api } from "../api.js";
@@ -45,11 +46,13 @@ export function BinsView({
onSelectItem, onSelectItem,
onAddBin, onAddBin,
onEditBin, onEditBin,
onBinCheck,
}: { }: {
data: Bootstrap; data: Bootstrap;
onSelectItem: (i: Item) => void; onSelectItem: (i: Item) => void;
onAddBin: () => void; onAddBin: () => void;
onEditBin: (bin: Bin) => void; onEditBin: (bin: Bin) => void;
onBinCheck: (bin: Bin) => void;
}) { }) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -219,6 +222,43 @@ export function BinsView({
}} }}
/> />
</div> </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>
<div style={{ padding: 8, flex: 1 }}> <div style={{ padding: 8, flex: 1 }}>
{binItems.length === 0 && ( {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 { helpers } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js"; import { getToday, getStoredTimezone } from "../tz.js";
import type { Stats } from "../stats.js"; import type { Stats } from "../stats.js";
@@ -20,14 +20,16 @@ const TYPE_COLORS: Record<string, string> = {
export function Dashboard({ export function Dashboard({
data, data,
stats, stats,
onAuditItem, onWeighInItem,
onAuditQueue, onWeighInQueue,
onBinCheck,
onSelectItem, onSelectItem,
}: { }: {
data: Bootstrap; data: Bootstrap;
stats: Stats; stats: Stats;
onAuditItem: (i: Item) => void; onWeighInItem: (i: Item) => void;
onAuditQueue: (items: Item[]) => void; onWeighInQueue: (items: Item[]) => void;
onBinCheck: (bin?: Bin) => void;
onSelectItem: (i: Item) => void; onSelectItem: (i: Item) => void;
}) { }) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -41,7 +43,8 @@ export function Dashboard({
color: TYPE_COLORS[k] ?? "var(--ink-3)", color: TYPE_COLORS[k] ?? "var(--ink-3)",
})); }));
const overdue = stats.overdueAudits; const overdue = stats.overdueWeighIns;
const overdueBins = stats.overdueBinChecks;
const lowBulk = stats.lowStockBulk; const lowBulk = stats.lowStockBulk;
const lowDiscrete = stats.lowStockDiscreteGroups; const lowDiscrete = stats.lowStockDiscreteGroups;
@@ -86,7 +89,12 @@ export function Dashboard({
{stats.goneCount} gone. {stats.goneCount} gone.
{overdue.length > 0 && ( {overdue.length > 0 && (
<span style={{ color: "var(--terracotta)" }}> <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> </span>
)} )}
</div> </div>
@@ -182,17 +190,43 @@ export function Dashboard({
> >
<div style={{ display: "flex", alignItems: "center", gap: 18, flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "center", gap: 18, flexWrap: "wrap" }}>
<div style={{ flex: 1, minWidth: 240 }}> <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)" }}> <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>
<div style={{ fontSize: 12, color: "var(--ink-2)", marginTop: 4 }}> <div style={{ fontSize: 12, color: "var(--ink-2)", marginTop: 4 }}>
{overdue.slice(0, 3).map((p) => p.name).join(" · ")} {overdue.slice(0, 3).map((p) => p.name).join(" · ")}
{overdue.length > 3 && ` · +${overdue.length - 3} more`} {overdue.length > 3 && ` · +${overdue.length - 3} more`}
</div> </div>
</div> </div>
<Btn variant="secondary" icon="search" onClick={() => onAuditQueue(overdue)}> <Btn variant="secondary" icon="search" onClick={() => onWeighInQueue(overdue)}>
Audit {overdue.length > 1 ? `all ${overdue.length}` : ""} 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> </Btn>
</div> </div>
</Card> </Card>
+5 -5
View File
@@ -20,7 +20,7 @@ export function Inventory({
data, data,
onSelectItem, onSelectItem,
onAddInventory, onAddInventory,
onAuditNew, onWeighInNew,
onBulkEdit, onBulkEdit,
onBulkConsume, onBulkConsume,
onBulkCheckout, onBulkCheckout,
@@ -30,7 +30,7 @@ export function Inventory({
data: Bootstrap; data: Bootstrap;
onSelectItem: (i: Item) => void; onSelectItem: (i: Item) => void;
onAddInventory: () => void; onAddInventory: () => void;
onAuditNew: () => void; onWeighInNew: () => void;
onBulkEdit: (items: Item[]) => void; onBulkEdit: (items: Item[]) => void;
onBulkConsume: (items: Item[]) => void; onBulkConsume: (items: Item[]) => void;
onBulkCheckout: (items: Item[]) => void; onBulkCheckout: (items: Item[]) => void;
@@ -184,7 +184,7 @@ export function Inventory({
</div> </div>
{!isMobile && ( {!isMobile && (
<div style={{ display: "flex", gap: 8 }}> <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> <Btn variant="primary" icon="plus" onClick={onAddInventory}>Add inventory</Btn>
</div> </div>
)} )}
@@ -380,7 +380,7 @@ export function Inventory({
<option value="thc">THC %</option> <option value="thc">THC %</option>
<option value="remaining">Remaining</option> <option value="remaining">Remaining</option>
<option value="price">Price</option> <option value="price">Price</option>
<option value="audit">Audit overdue</option> <option value="audit">Weigh-in overdue</option>
</Select> </Select>
</div> </div>
</Card> </Card>
@@ -586,7 +586,7 @@ export function Inventory({
["thc", "THC %"], ["thc", "THC %"],
["remaining", "Remaining"], ["remaining", "Remaining"],
["price", "Price"], ["price", "Price"],
["audit", "Audit overdue"], ["audit", "Weigh-in overdue"],
] as [SortKey, string][] ] as [SortKey, string][]
).map(([k, l]) => ( ).map(([k, l]) => (
<button <button