Add bulk editing to inventory tab with atomic batch API
Build and push image / build (push) Successful in 1m3s
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:
+164
-91
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user