Add bulk editing to inventory tab with atomic batch API
Build and push image / build (push) Successful in 1m3s

Multi-select inventory items via checkboxes (select-all, shift-click
range, group header select) and apply bulk actions through a floating
toolbar: edit fields (shop, bin, price, THC/CBD), consume, checkout,
check in, and mark gone. Backend processes all operations in a single
SQLite transaction for atomicity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 22:14:01 -04:00
parent d44c23ef6d
commit 946e96c3ea
13 changed files with 1328 additions and 117 deletions
+164 -91
View File
@@ -85,6 +85,8 @@ inventoryRouter.post("/inventory", (req, res) => {
res.json({ id, assetId });
});
// ─── Shared helpers (used by individual routes + batch) ───────────
type UpdateBody = Partial<{
shopId: string | null;
binId: string | null;
@@ -98,36 +100,33 @@ type UpdateBody = Partial<{
purchaseDate: string;
}>;
inventoryRouter.patch("/inventory/:id", (req, res) => {
const { id } = req.params;
const body = req.body as UpdateBody;
type Row = {
id: string;
shop_id: string | null;
bin_id: string | null;
product_id: string;
price: number;
thc: number;
cbd: number;
total_cannabinoids: number;
weight: number;
last_audit_weight: number | null;
count_original: number;
count_last_audit: number | null;
unit_weight: number;
purchase_date: string;
};
type ItemRow = {
id: string;
shop_id: string | null;
bin_id: string | null;
product_id: string;
price: number;
thc: number;
cbd: number;
total_cannabinoids: number;
weight: number;
last_audit_weight: number | null;
count_original: number;
count_last_audit: number | null;
unit_weight: number;
purchase_date: string;
};
function doUpdate(id: string, body: UpdateBody): void {
const existing = db
.prepare<[string], Row>(
.prepare<[string], ItemRow>(
`SELECT id, shop_id, bin_id, product_id, price, thc, cbd,
total_cannabinoids, weight, last_audit_weight, count_original,
count_last_audit, unit_weight, purchase_date
FROM inventory_items WHERE id = ?`,
)
.get(id);
if (!existing) return res.status(404).json({ error: "inventory item not found" });
if (!existing) throw new Error("inventory item not found");
const product = db
.prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`)
@@ -171,8 +170,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
? (body.unitWeight as number)
: existing.unit_weight;
// Mirror the original size into the "last audit" field while no audits
// exist — keeps the next audit's prev_value accurate after an edit.
const nextLastAuditWeight =
!isDiscrete && auditCount === 0 ? nextWeight : existing.last_audit_weight;
const nextCountLastAudit =
@@ -208,13 +205,9 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
unitWeight: nextUnitWeight,
purchaseDate: nextPurchaseDate,
});
}
res.json({ ok: true });
});
inventoryRouter.post("/inventory/:id/checkout", (req, res) => {
const { id } = req.params;
const { date } = req.body as { date: string };
function doCheckout(id: string, date: string): void {
const result = db
.prepare(
`UPDATE inventory_items
@@ -222,18 +215,10 @@ inventoryRouter.post("/inventory/:id/checkout", (req, res) => {
WHERE id = ? AND status = 'active'`,
)
.run(date, id);
if (result.changes === 0) return res.status(404).json({ error: "not found or not active" });
res.json({ ok: true });
});
inventoryRouter.post("/inventory/:id/checkin", (req, res) => {
const { id } = req.params;
const { date, binId, remainingWeight } = req.body as {
date: string;
binId: string;
remainingWeight?: number;
};
if (result.changes === 0) throw new Error("not found or not active");
}
function doCheckin(id: string, date: string, binId: string, remainingWeight?: number): void {
const item = db
.prepare<
[string],
@@ -243,43 +228,33 @@ inventoryRouter.post("/inventory/:id/checkin", (req, res) => {
FROM inventory_items WHERE id = ? AND status = 'checked-out'`,
)
.get(id);
if (!item) return res.status(404).json({ error: "not found or not checked-out" });
if (!item) throw new Error("not found or not checked-out");
const product = db
.prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`)
.get(item.product_id);
const isBulk = product?.kind === "bulk";
const tx = db.transaction(() => {
db.prepare(
`UPDATE inventory_items
SET status = 'active', bin_id = ?, checkout_date = NULL
WHERE id = ?`,
).run(binId, id);
if (isBulk && remainingWeight != null && Number.isFinite(remainingWeight)) {
const prev = item.last_audit_weight ?? item.weight;
db.prepare(
`UPDATE inventory_items
SET status = 'active', bin_id = ?, checkout_date = NULL
WHERE id = ?`,
).run(binId, id);
`INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by)
VALUES (?, ?, 'weigh', ?, ?, 'checkin')`,
).run(id, date, remainingWeight, prev);
db.prepare(`UPDATE inventory_items SET last_audit_weight = ? WHERE id = ?`).run(
remainingWeight,
id,
);
}
}
if (isBulk && remainingWeight != null && Number.isFinite(remainingWeight)) {
const prev = item.last_audit_weight ?? item.weight;
db.prepare(
`INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by)
VALUES (?, ?, 'weigh', ?, ?, 'checkin')`,
).run(id, date, remainingWeight, prev);
db.prepare(`UPDATE inventory_items SET last_audit_weight = ? WHERE id = ?`).run(
remainingWeight,
id,
);
}
});
tx();
res.json({ ok: true });
});
inventoryRouter.post("/inventory/:id/finish", (req, res) => {
const { id } = req.params;
const { date, rating, notes } = req.body as {
date: string;
rating?: number;
notes?: string;
};
function doFinish(id: string, date: string, rating?: number, notes?: string): void {
const result = db
.prepare(
`UPDATE inventory_items
@@ -287,37 +262,84 @@ inventoryRouter.post("/inventory/:id/finish", (req, res) => {
WHERE id = ? AND status IN ('active', 'checked-out')`,
)
.run(date, rating ?? null, notes ?? null, id);
if (result.changes === 0) return res.status(404).json({ error: "not found or not active" });
res.json({ ok: true });
if (result.changes === 0) throw new Error("not found or not active");
}
function doGone(id: string, date: string, reason: string, notes?: string): void {
const combinedNotes = notes ? `${reason}: ${notes}` : reason;
const result = db
.prepare(
`UPDATE inventory_items
SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL, checkout_date = NULL
WHERE id = ? AND status IN ('active', 'checked-out')`,
)
.run(date, combinedNotes, id);
if (result.changes === 0) throw new Error("not found or not active");
db.prepare(
`INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by)
VALUES (?, ?, 'presence', 0, NULL, 'lost')`,
).run(id, date);
}
// ─── Individual routes (thin wrappers around helpers) ─────────────
inventoryRouter.patch("/inventory/:id", (req, res) => {
try {
doUpdate(req.params.id, req.body as UpdateBody);
res.json({ ok: true });
} catch (e: any) {
res.status(404).json({ error: e.message });
}
});
inventoryRouter.post("/inventory/:id/checkout", (req, res) => {
try {
doCheckout(req.params.id, (req.body as { date: string }).date);
res.json({ ok: true });
} catch (e: any) {
res.status(404).json({ error: e.message });
}
});
inventoryRouter.post("/inventory/:id/checkin", (req, res) => {
const { date, binId, remainingWeight } = req.body as {
date: string;
binId: string;
remainingWeight?: number;
};
try {
doCheckin(req.params.id, date, binId, remainingWeight);
res.json({ ok: true });
} catch (e: any) {
res.status(404).json({ error: e.message });
}
});
inventoryRouter.post("/inventory/:id/finish", (req, res) => {
const { date, rating, notes } = req.body as {
date: string;
rating?: number;
notes?: string;
};
try {
doFinish(req.params.id, date, rating, notes);
res.json({ ok: true });
} catch (e: any) {
res.status(404).json({ error: e.message });
}
});
inventoryRouter.post("/inventory/:id/gone", (req, res) => {
const { id } = req.params;
const { date, reason, notes } = req.body as {
date: string;
reason: string;
notes?: string;
};
const combinedNotes = notes ? `${reason}: ${notes}` : reason;
const tx = db.transaction(() => {
const result = db
.prepare(
`UPDATE inventory_items
SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL, checkout_date = NULL
WHERE id = ? AND status IN ('active', 'checked-out')`,
)
.run(date, combinedNotes, id);
if (result.changes === 0) throw new Error("not found");
db.prepare(
`INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by)
VALUES (?, ?, 'presence', 0, NULL, 'lost')`,
).run(id, date);
});
try {
tx();
doGone(req.params.id, date, reason, notes);
res.json({ ok: true });
} catch {
res.status(404).json({ error: "not found or not active" });
} catch (e: any) {
res.status(404).json({ error: e.message });
}
});
@@ -376,3 +398,54 @@ inventoryRouter.post("/inventory/:id/audit", (req, res) => {
tx();
res.json({ ok: true });
});
// ─── Batch endpoint ───────────────────────────────────────────────
type BatchOp =
| { action: "update"; id: string; fields: UpdateBody }
| { action: "checkout"; id: string; date: string }
| { action: "checkin"; id: string; date: string; binId: string }
| { action: "finish"; id: string; date: string; rating?: number; notes?: string }
| { action: "gone"; id: string; date: string; reason: string; notes?: string };
inventoryRouter.post("/inventory/batch", (req, res) => {
const { ops } = req.body as { ops: BatchOp[] };
if (!Array.isArray(ops) || ops.length === 0) {
return res.status(400).json({ error: "ops array required" });
}
if (ops.length > 200) {
return res.status(400).json({ error: "too many operations (max 200)" });
}
const tx = db.transaction(() => {
for (const op of ops) {
switch (op.action) {
case "update":
doUpdate(op.id, op.fields);
break;
case "checkout":
doCheckout(op.id, op.date);
break;
case "checkin":
doCheckin(op.id, op.date, op.binId);
break;
case "finish":
doFinish(op.id, op.date, op.rating, op.notes);
break;
case "gone":
doGone(op.id, op.date, op.reason, op.notes);
break;
default:
throw new Error(`unknown action: ${(op as any).action}`);
}
}
});
try {
tx();
res.json({ ok: true, count: ops.length });
} catch (e: any) {
res.status(400).json({ error: e.message ?? "batch failed" });
}
});