Split audits into Weigh Ins (bulk) and Bin Checks (discrete)
Build and push image / build (push) Successful in 57s
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:
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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); }} />
|
||||||
|
|||||||
@@ -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); }} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
+24
-49
@@ -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,13 +108,11 @@ 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
|
|
||||||
? last.value
|
? last.value
|
||||||
: item.weight
|
: item.weight
|
||||||
: 0;
|
: 0;
|
||||||
@@ -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})`
|
|
||||||
: auditMode === "weigh"
|
|
||||||
? `Weight now (${cfg?.unit})`
|
? `Weight now (${cfg?.unit})`
|
||||||
: `Estimate 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
@@ -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
@@ -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") {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user