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
+179
View File
@@ -0,0 +1,179 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js";
import { api } from "../../api.js";
import type { BatchOp } from "../../api.js";
import { Btn, Field, Input, Select } from "../primitives/index.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
import { useToast } from "../Toast.js";
export function BulkEditModal({
data,
items,
onClose,
}: {
data: Bootstrap;
items: Item[];
onClose: () => void;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const [shopId, setShopId] = useState("");
const [binId, setBinId] = useState("");
const [price, setPrice] = useState("");
const [thc, setThc] = useState("");
const [cbd, setCbd] = useState("");
const [totalCannabinoids, setTotalCannabinoids] = useState("");
const [error, setError] = useState<string | null>(null);
const save = useMutation({
mutationFn: () => {
const fields: Record<string, string | number | null> = {};
if (shopId) fields.shopId = shopId;
if (binId) fields.binId = binId;
if (price !== "") fields.price = parseFloat(price);
if (thc !== "") fields.thc = parseFloat(thc);
if (cbd !== "") fields.cbd = parseFloat(cbd);
if (totalCannabinoids !== "") fields.totalCannabinoids = parseFloat(totalCannabinoids);
if (Object.keys(fields).length === 0) {
return Promise.reject(new Error("No fields to update — fill in at least one field."));
}
const ops: BatchOp[] = items.map((i) => ({
action: "update" as const,
id: i.id,
fields,
}));
return api.batchInventory(ops);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
toast(`Updated ${items.length} item${items.length === 1 ? "" : "s"}`);
onClose();
},
onError: (e: Error) => setError(e.message),
});
return (
<ModalBackdrop onClose={onClose}>
<div
style={{
width: "min(840px, 96vw)",
margin: "40px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader
title="Bulk edit"
eyebrow={`${items.length} item${items.length === 1 ? "" : "s"} selected`}
onClose={onClose}
/>
<div style={{ padding: 32 }}>
<div
style={{
fontSize: 13,
color: "var(--ink-2)",
marginBottom: 20,
padding: 14,
background: "var(--bg-2)",
borderRadius: "var(--r-md)",
border: "1px solid var(--line)",
}}
>
Only fields you fill in will be updated. Leave blank to keep current values.
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
Source
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16, marginBottom: 28 }}>
<Field label="Shop">
<Select value={shopId} onChange={(e) => setShopId(e.target.value)}>
<option value="">No change</option>
{data.shops.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</Select>
</Field>
<Field label="Bin">
<Select value={binId} onChange={(e) => setBinId(e.target.value)}>
<option value="">No change</option>
{data.bins.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</Select>
</Field>
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
Values
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16 }}>
<Field label="Price ($)">
<Input
type="number"
step="0.01"
min="0"
placeholder="—"
value={price}
onChange={(e) => setPrice(e.target.value)}
/>
</Field>
<Field label="THC %">
<Input
type="number"
step="0.1"
placeholder="—"
value={thc}
onChange={(e) => setThc(e.target.value)}
/>
</Field>
<Field label="CBD %">
<Input
type="number"
step="0.1"
placeholder="—"
value={cbd}
onChange={(e) => setCbd(e.target.value)}
/>
</Field>
<Field label="Total cannabinoids %">
<Input
type="number"
step="0.1"
placeholder="—"
value={totalCannabinoids}
onChange={(e) => setTotalCannabinoids(e.target.value)}
/>
</Field>
</div>
{error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
</div>
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn
variant="primary"
icon="check"
disabled={save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? "Saving…" : `Update ${items.length} item${items.length === 1 ? "" : "s"}`}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}