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 });
|
res.json({ id, assetId });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Shared helpers (used by individual routes + batch) ───────────
|
||||||
|
|
||||||
type UpdateBody = Partial<{
|
type UpdateBody = Partial<{
|
||||||
shopId: string | null;
|
shopId: string | null;
|
||||||
binId: string | null;
|
binId: string | null;
|
||||||
@@ -98,36 +100,33 @@ type UpdateBody = Partial<{
|
|||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
inventoryRouter.patch("/inventory/:id", (req, res) => {
|
type ItemRow = {
|
||||||
const { id } = req.params;
|
id: string;
|
||||||
const body = req.body as UpdateBody;
|
shop_id: string | null;
|
||||||
|
bin_id: string | null;
|
||||||
type Row = {
|
product_id: string;
|
||||||
id: string;
|
price: number;
|
||||||
shop_id: string | null;
|
thc: number;
|
||||||
bin_id: string | null;
|
cbd: number;
|
||||||
product_id: string;
|
total_cannabinoids: number;
|
||||||
price: number;
|
weight: number;
|
||||||
thc: number;
|
last_audit_weight: number | null;
|
||||||
cbd: number;
|
count_original: number;
|
||||||
total_cannabinoids: number;
|
count_last_audit: number | null;
|
||||||
weight: number;
|
unit_weight: number;
|
||||||
last_audit_weight: number | null;
|
purchase_date: string;
|
||||||
count_original: number;
|
};
|
||||||
count_last_audit: number | null;
|
|
||||||
unit_weight: number;
|
|
||||||
purchase_date: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
function doUpdate(id: string, body: UpdateBody): void {
|
||||||
const existing = db
|
const existing = db
|
||||||
.prepare<[string], Row>(
|
.prepare<[string], ItemRow>(
|
||||||
`SELECT id, shop_id, bin_id, product_id, price, thc, cbd,
|
`SELECT id, shop_id, bin_id, product_id, price, thc, cbd,
|
||||||
total_cannabinoids, weight, last_audit_weight, count_original,
|
total_cannabinoids, weight, last_audit_weight, count_original,
|
||||||
count_last_audit, unit_weight, purchase_date
|
count_last_audit, unit_weight, purchase_date
|
||||||
FROM inventory_items WHERE id = ?`,
|
FROM inventory_items WHERE id = ?`,
|
||||||
)
|
)
|
||||||
.get(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
|
const product = db
|
||||||
.prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`)
|
.prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`)
|
||||||
@@ -171,8 +170,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
|
|||||||
? (body.unitWeight as number)
|
? (body.unitWeight as number)
|
||||||
: existing.unit_weight;
|
: 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 =
|
const nextLastAuditWeight =
|
||||||
!isDiscrete && auditCount === 0 ? nextWeight : existing.last_audit_weight;
|
!isDiscrete && auditCount === 0 ? nextWeight : existing.last_audit_weight;
|
||||||
const nextCountLastAudit =
|
const nextCountLastAudit =
|
||||||
@@ -208,13 +205,9 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
|
|||||||
unitWeight: nextUnitWeight,
|
unitWeight: nextUnitWeight,
|
||||||
purchaseDate: nextPurchaseDate,
|
purchaseDate: nextPurchaseDate,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ ok: true });
|
function doCheckout(id: string, date: string): void {
|
||||||
});
|
|
||||||
|
|
||||||
inventoryRouter.post("/inventory/:id/checkout", (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { date } = req.body as { date: string };
|
|
||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE inventory_items
|
`UPDATE inventory_items
|
||||||
@@ -222,18 +215,10 @@ inventoryRouter.post("/inventory/:id/checkout", (req, res) => {
|
|||||||
WHERE id = ? AND status = 'active'`,
|
WHERE id = ? AND status = 'active'`,
|
||||||
)
|
)
|
||||||
.run(date, id);
|
.run(date, id);
|
||||||
if (result.changes === 0) return res.status(404).json({ error: "not found or not active" });
|
if (result.changes === 0) throw new 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
function doCheckin(id: string, date: string, binId: string, remainingWeight?: number): void {
|
||||||
const item = db
|
const item = db
|
||||||
.prepare<
|
.prepare<
|
||||||
[string],
|
[string],
|
||||||
@@ -243,43 +228,33 @@ inventoryRouter.post("/inventory/:id/checkin", (req, res) => {
|
|||||||
FROM inventory_items WHERE id = ? AND status = 'checked-out'`,
|
FROM inventory_items WHERE id = ? AND status = 'checked-out'`,
|
||||||
)
|
)
|
||||||
.get(id);
|
.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
|
const product = db
|
||||||
.prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`)
|
.prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`)
|
||||||
.get(item.product_id);
|
.get(item.product_id);
|
||||||
const isBulk = product?.kind === "bulk";
|
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(
|
db.prepare(
|
||||||
`UPDATE inventory_items
|
`INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by)
|
||||||
SET status = 'active', bin_id = ?, checkout_date = NULL
|
VALUES (?, ?, 'weigh', ?, ?, 'checkin')`,
|
||||||
WHERE id = ?`,
|
).run(id, date, remainingWeight, prev);
|
||||||
).run(binId, id);
|
db.prepare(`UPDATE inventory_items SET last_audit_weight = ? WHERE id = ?`).run(
|
||||||
|
remainingWeight,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isBulk && remainingWeight != null && Number.isFinite(remainingWeight)) {
|
function doFinish(id: string, date: string, rating?: number, notes?: string): void {
|
||||||
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;
|
|
||||||
};
|
|
||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE inventory_items
|
`UPDATE inventory_items
|
||||||
@@ -287,37 +262,84 @@ inventoryRouter.post("/inventory/:id/finish", (req, res) => {
|
|||||||
WHERE id = ? AND status IN ('active', 'checked-out')`,
|
WHERE id = ? AND status IN ('active', 'checked-out')`,
|
||||||
)
|
)
|
||||||
.run(date, rating ?? null, notes ?? null, id);
|
.run(date, rating ?? null, notes ?? null, id);
|
||||||
if (result.changes === 0) return res.status(404).json({ error: "not found or not active" });
|
if (result.changes === 0) throw new Error("not found or not active");
|
||||||
res.json({ ok: true });
|
}
|
||||||
|
|
||||||
|
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) => {
|
inventoryRouter.post("/inventory/:id/gone", (req, res) => {
|
||||||
const { id } = req.params;
|
|
||||||
const { date, reason, notes } = req.body as {
|
const { date, reason, notes } = req.body as {
|
||||||
date: string;
|
date: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
notes?: 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 {
|
try {
|
||||||
tx();
|
doGone(req.params.id, date, reason, notes);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
res.status(404).json({ error: "not found or not active" });
|
res.status(404).json({ error: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -376,3 +398,54 @@ inventoryRouter.post("/inventory/:id/audit", (req, res) => {
|
|||||||
tx();
|
tx();
|
||||||
res.json({ ok: true });
|
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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+45
-1
@@ -31,6 +31,11 @@ import {
|
|||||||
EditBrandModal,
|
EditBrandModal,
|
||||||
EditShopModal,
|
EditShopModal,
|
||||||
} from "./components/modals/CatalogModals.js";
|
} from "./components/modals/CatalogModals.js";
|
||||||
|
import { BulkEditModal } from "./components/modals/BulkEditModal.js";
|
||||||
|
import { BulkConsumeModal } from "./components/modals/BulkConsumeModal.js";
|
||||||
|
import { BulkCheckoutModal } from "./components/modals/BulkCheckoutModal.js";
|
||||||
|
import { BulkCheckinModal } from "./components/modals/BulkCheckinModal.js";
|
||||||
|
import { BulkGoneModal } from "./components/modals/BulkGoneModal.js";
|
||||||
|
|
||||||
type ModalKey =
|
type ModalKey =
|
||||||
| "add"
|
| "add"
|
||||||
@@ -46,6 +51,11 @@ type ModalKey =
|
|||||||
| "editBin"
|
| "editBin"
|
||||||
| "editBrand"
|
| "editBrand"
|
||||||
| "editShop"
|
| "editShop"
|
||||||
|
| "bulkEdit"
|
||||||
|
| "bulkConsume"
|
||||||
|
| "bulkCheckout"
|
||||||
|
| "bulkCheckin"
|
||||||
|
| "bulkGone"
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -55,6 +65,7 @@ export function App() {
|
|||||||
const [modalBin, setModalBin] = useState<Bin | null>(null);
|
const [modalBin, setModalBin] = useState<Bin | null>(null);
|
||||||
const [modalBrand, setModalBrand] = useState<Brand | null>(null);
|
const [modalBrand, setModalBrand] = useState<Brand | null>(null);
|
||||||
const [modalShop, setModalShop] = useState<Shop | null>(null);
|
const [modalShop, setModalShop] = useState<Shop | null>(null);
|
||||||
|
const [bulkItems, setBulkItems] = useState<Item[]>([]);
|
||||||
|
|
||||||
const [theme, setTheme] = useState<ThemeKey>(
|
const [theme, setTheme] = useState<ThemeKey>(
|
||||||
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
||||||
@@ -116,6 +127,13 @@ export function App() {
|
|||||||
setSelected(null);
|
setSelected(null);
|
||||||
setModal("edit");
|
setModal("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openBulkEdit = (items: Item[]) => { setBulkItems(items); setModal("bulkEdit"); };
|
||||||
|
const openBulkConsume = (items: Item[]) => { setBulkItems(items); setModal("bulkConsume"); };
|
||||||
|
const openBulkCheckout = (items: Item[]) => { setBulkItems(items); setModal("bulkCheckout"); };
|
||||||
|
const openBulkCheckin = (items: Item[]) => { setBulkItems(items); setModal("bulkCheckin"); };
|
||||||
|
const openBulkGone = (items: Item[]) => { setBulkItems(items); setModal("bulkGone"); };
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -181,7 +199,17 @@ export function App() {
|
|||||||
<Dashboard data={data} stats={stats} onAuditItem={openAudit} onSelectItem={setSelected} />
|
<Dashboard data={data} stats={stats} onAuditItem={openAudit} onSelectItem={setSelected} />
|
||||||
} />
|
} />
|
||||||
<Route path="/inventory" element={
|
<Route path="/inventory" element={
|
||||||
<Inventory data={data} onSelectItem={setSelected} onAddInventory={openAdd} onAuditNew={() => openAudit()} />
|
<Inventory
|
||||||
|
data={data}
|
||||||
|
onSelectItem={setSelected}
|
||||||
|
onAddInventory={openAdd}
|
||||||
|
onAuditNew={() => openAudit()}
|
||||||
|
onBulkEdit={openBulkEdit}
|
||||||
|
onBulkConsume={openBulkConsume}
|
||||||
|
onBulkCheckout={openBulkCheckout}
|
||||||
|
onBulkCheckin={openBulkCheckin}
|
||||||
|
onBulkGone={openBulkGone}
|
||||||
|
/>
|
||||||
} />
|
} />
|
||||||
<Route path="/custody" element={
|
<Route path="/custody" element={
|
||||||
<CustodyView data={data} onSelectItem={setSelected} onCheckin={openCheckin} onConsume={openConsume} onMarkGone={openMarkGone} />
|
<CustodyView data={data} onSelectItem={setSelected} onCheckin={openCheckin} onConsume={openConsume} onMarkGone={openMarkGone} />
|
||||||
@@ -247,6 +275,22 @@ export function App() {
|
|||||||
{modal === "editShop" && modalShop && (
|
{modal === "editShop" && modalShop && (
|
||||||
<EditShopModal shop={modalShop} onClose={() => setModal(null)} />
|
<EditShopModal shop={modalShop} onClose={() => setModal(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{modal === "bulkEdit" && (
|
||||||
|
<BulkEditModal data={data} items={bulkItems} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
{modal === "bulkConsume" && (
|
||||||
|
<BulkConsumeModal data={data} items={bulkItems} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
{modal === "bulkCheckout" && (
|
||||||
|
<BulkCheckoutModal data={data} items={bulkItems} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
{modal === "bulkCheckin" && (
|
||||||
|
<BulkCheckinModal data={data} items={bulkItems} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
{modal === "bulkGone" && (
|
||||||
|
<BulkGoneModal data={data} items={bulkItems} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import type { Bootstrap, AuditMode } from "./types.js";
|
import type { Bootstrap, AuditMode } from "./types.js";
|
||||||
|
|
||||||
|
export type BatchOp =
|
||||||
|
| { action: "update"; id: string; fields: Partial<{ shopId: string | null; binId: string | null; price: number; thc: number; cbd: number; totalCannabinoids: number }> }
|
||||||
|
| { 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 };
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`/api${path}`, {
|
const res = await fetch(`/api${path}`, {
|
||||||
...init,
|
...init,
|
||||||
@@ -146,6 +153,12 @@ export const api = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
batchInventory: (ops: BatchOp[]) =>
|
||||||
|
request<{ ok: true; count: number }>("/inventory/batch", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ops }),
|
||||||
|
}),
|
||||||
|
|
||||||
// Catalog tables (brand/shop/bin) — unchanged
|
// Catalog tables (brand/shop/bin) — unchanged
|
||||||
createBrand: (name: string) =>
|
createBrand: (name: string) =>
|
||||||
request<{ id: string; name: string }>("/brands", {
|
request<{ id: string; name: string }>("/brands", {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Item } from "../types.js";
|
||||||
|
import { Btn, Icon } from "./primitives/index.js";
|
||||||
|
|
||||||
|
export function BulkToolbar({
|
||||||
|
count,
|
||||||
|
selectedItems,
|
||||||
|
onClear,
|
||||||
|
onBulkEdit,
|
||||||
|
onBulkConsume,
|
||||||
|
onBulkCheckout,
|
||||||
|
onBulkCheckin,
|
||||||
|
onBulkGone,
|
||||||
|
}: {
|
||||||
|
count: number;
|
||||||
|
selectedItems: Item[];
|
||||||
|
onClear: () => void;
|
||||||
|
onBulkEdit: () => void;
|
||||||
|
onBulkConsume: () => void;
|
||||||
|
onBulkCheckout: () => void;
|
||||||
|
onBulkCheckin: () => void;
|
||||||
|
onBulkGone: () => void;
|
||||||
|
}) {
|
||||||
|
const canCheckout = selectedItems.filter((i) => i.status === "active").length;
|
||||||
|
const canCheckin = selectedItems.filter((i) => i.status === "checked-out").length;
|
||||||
|
const canConsume = selectedItems.filter((i) => i.status === "active" || i.status === "checked-out").length;
|
||||||
|
const canGone = canConsume;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bulk-toolbar"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: 0,
|
||||||
|
left: 264,
|
||||||
|
right: 0,
|
||||||
|
background: "var(--surface)",
|
||||||
|
borderTop: "1px solid var(--line)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
padding: "12px 24px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 16,
|
||||||
|
zIndex: 40,
|
||||||
|
animation: "toolbar-slide-up 200ms ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)" }}>
|
||||||
|
{count} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
fontSize: 12,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={12} />
|
||||||
|
Deselect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<Btn variant="secondary" icon="edit" onClick={onBulkEdit}>
|
||||||
|
Edit
|
||||||
|
</Btn>
|
||||||
|
{canCheckout > 0 && (
|
||||||
|
<Btn variant="secondary" icon="pocket" onClick={onBulkCheckout}>
|
||||||
|
Checkout ({canCheckout})
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
{canCheckin > 0 && (
|
||||||
|
<Btn variant="secondary" icon="check" onClick={onBulkCheckin}>
|
||||||
|
Check in ({canCheckin})
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
{canConsume > 0 && (
|
||||||
|
<Btn variant="secondary" icon="check" onClick={onBulkConsume}>
|
||||||
|
Consume ({canConsume})
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
{canGone > 0 && (
|
||||||
|
<Btn variant="danger" icon="bin" onClick={onBulkGone}>
|
||||||
|
Mark gone ({canGone})
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
|
import { TODAY_STR } 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 BulkCheckinModal({
|
||||||
|
data,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
items: Item[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const eligible = items.filter((i) => i.status === "checked-out");
|
||||||
|
const excluded = items.length - eligible.length;
|
||||||
|
|
||||||
|
const [date, setDate] = useState(TODAY_STR);
|
||||||
|
const [binId, setBinId] = useState(data.bins[0]?.id ?? "");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const checkin = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const ops: BatchOp[] = eligible.map((i) => ({
|
||||||
|
action: "checkin" as const,
|
||||||
|
id: i.id,
|
||||||
|
date,
|
||||||
|
binId,
|
||||||
|
}));
|
||||||
|
return api.batchInventory(ops);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
const binName = data.bins.find((b) => b.id === binId)?.name ?? "bin";
|
||||||
|
toast(`Checked ${eligible.length} item${eligible.length === 1 ? "" : "s"} into ${binName}`);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(640px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader title="Bulk check in" eyebrow={`${eligible.length} eligible item${eligible.length === 1 ? "" : "s"}`} onClose={onClose} />
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
{excluded > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--amber-soft)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{excluded} item{excluded === 1 ? " is" : "s are"} not checked out and will be skipped.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{eligible.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||||
|
No checked-out items to return.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="Return to bin">
|
||||||
|
<Select value={binId} onChange={(e) => setBinId(e.target.value)}>
|
||||||
|
{data.bins.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>{b.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Date">
|
||||||
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
Items: {eligible.map((i) => i.name).join(", ")}
|
||||||
|
</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={checkin.isPending || eligible.length === 0 || !binId}
|
||||||
|
onClick={() => checkin.mutate()}
|
||||||
|
>
|
||||||
|
{checkin.isPending ? "Saving…" : `Check in ${eligible.length}`}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
|
import { TODAY_STR } from "../../types.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import type { BatchOp } from "../../api.js";
|
||||||
|
import { Btn, Field, Input } from "../primitives/index.js";
|
||||||
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
|
export function BulkCheckoutModal({
|
||||||
|
data,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
items: Item[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const eligible = items.filter((i) => i.status === "active");
|
||||||
|
const excluded = items.length - eligible.length;
|
||||||
|
|
||||||
|
const [date, setDate] = useState(TODAY_STR);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const checkout = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const ops: BatchOp[] = eligible.map((i) => ({
|
||||||
|
action: "checkout" as const,
|
||||||
|
id: i.id,
|
||||||
|
date,
|
||||||
|
}));
|
||||||
|
return api.batchInventory(ops);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
toast(`Checked out ${eligible.length} item${eligible.length === 1 ? "" : "s"}`);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(640px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader title="Bulk checkout" eyebrow={`${eligible.length} eligible item${eligible.length === 1 ? "" : "s"}`} onClose={onClose} />
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
{excluded > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--amber-soft)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{excluded} item{excluded === 1 ? " is" : "s are"} not active and will be skipped.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{eligible.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||||
|
No active items to check out.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ maxWidth: 240 }}>
|
||||||
|
<Field label="Date">
|
||||||
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
Items: {eligible.map((i) => i.name).join(", ")}
|
||||||
|
</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="pocket"
|
||||||
|
disabled={checkout.isPending || eligible.length === 0}
|
||||||
|
onClick={() => checkout.mutate()}
|
||||||
|
>
|
||||||
|
{checkout.isPending ? "Saving…" : `Check out ${eligible.length}`}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
|
import { TODAY_STR } from "../../types.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import type { BatchOp } from "../../api.js";
|
||||||
|
import { Btn, Field, Icon, Input, Textarea } from "../primitives/index.js";
|
||||||
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
|
export function BulkConsumeModal({
|
||||||
|
data,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
items: Item[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const eligible = items.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||||
|
const excluded = items.length - eligible.length;
|
||||||
|
|
||||||
|
const [rating, setRating] = useState(4);
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [date, setDate] = useState(TODAY_STR);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const finish = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const ops: BatchOp[] = eligible.map((i) => ({
|
||||||
|
action: "finish" as const,
|
||||||
|
id: i.id,
|
||||||
|
date,
|
||||||
|
rating,
|
||||||
|
notes: notes || undefined,
|
||||||
|
}));
|
||||||
|
return api.batchInventory(ops);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
toast(`Marked ${eligible.length} item${eligible.length === 1 ? "" : "s"} as consumed`);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader title="Bulk consume" eyebrow={`${eligible.length} eligible item${eligible.length === 1 ? "" : "s"}`} onClose={onClose} />
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
{excluded > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--amber-soft)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{excluded} item{excluded === 1 ? "" : "s"} already consumed or gone — {excluded === 1 ? "it" : "they"} will be skipped.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{eligible.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||||
|
No eligible items to consume.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="Date finished">
|
||||||
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Rating">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 4,
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "10px 12px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => setRating(n)}
|
||||||
|
style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }}
|
||||||
|
>
|
||||||
|
<Icon name="star" size={20} color={n <= rating ? "var(--amber)" : "var(--ink-4)"} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span style={{ marginLeft: "auto", fontSize: 12, color: "var(--ink-3)", fontFamily: "var(--mono)" }}>
|
||||||
|
{rating}/5
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Field label="Notes (optional)">
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Shared notes for all items"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
Items: {eligible.map((i) => i.name).join(", ")}
|
||||||
|
</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={finish.isPending || eligible.length === 0}
|
||||||
|
onClick={() => finish.mutate()}
|
||||||
|
>
|
||||||
|
{finish.isPending ? "Saving…" : `Mark ${eligible.length} consumed`}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
|
import { TODAY_STR } from "../../types.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import type { BatchOp } from "../../api.js";
|
||||||
|
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
|
||||||
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
|
const REASONS: [string, string][] = [
|
||||||
|
["lost", "Lost / misplaced"],
|
||||||
|
["damaged", "Damaged"],
|
||||||
|
["expired", "Expired"],
|
||||||
|
["gifted", "Gifted away"],
|
||||||
|
["other", "Other"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BulkGoneModal({
|
||||||
|
data,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
data: Bootstrap;
|
||||||
|
items: Item[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const eligible = items.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||||
|
const excluded = items.length - eligible.length;
|
||||||
|
|
||||||
|
const [reason, setReason] = useState("lost");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [date, setDate] = useState(TODAY_STR);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mark = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const ops: BatchOp[] = eligible.map((i) => ({
|
||||||
|
action: "gone" as const,
|
||||||
|
id: i.id,
|
||||||
|
date,
|
||||||
|
reason,
|
||||||
|
notes: notes || undefined,
|
||||||
|
}));
|
||||||
|
return api.batchInventory(ops);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
toast(`Marked ${eligible.length} item${eligible.length === 1 ? "" : "s"} as gone`);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(640px, 96vw)",
|
||||||
|
margin: "40px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalHeader
|
||||||
|
title="Bulk mark as gone"
|
||||||
|
eyebrow="Archive · not consumed"
|
||||||
|
eyebrowColor="var(--terracotta)"
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--amber-soft)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
This marks items as lost, damaged, expired, or gifted. Counts as{" "}
|
||||||
|
<strong>spend</strong> but not <strong>consumption</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{excluded > 0 && (
|
||||||
|
<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)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{excluded} item{excluded === 1 ? " is" : "s are"} already consumed or gone and will be skipped.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{eligible.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||||
|
No eligible items to mark gone.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="Reason">
|
||||||
|
<Select value={reason} onChange={(e) => setReason(e.target.value)}>
|
||||||
|
{REASONS.map(([k, l]) => (
|
||||||
|
<option key={k} value={k}>{l}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Date">
|
||||||
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Field label="Notes (optional)">
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="What happened"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
Items: {eligible.map((i) => i.name).join(", ")}
|
||||||
|
</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="danger"
|
||||||
|
icon="bin"
|
||||||
|
disabled={mark.isPending || eligible.length === 0}
|
||||||
|
onClick={() => mark.mutate()}
|
||||||
|
>
|
||||||
|
{mark.isPending ? "Saving…" : `Mark ${eligible.length} gone`}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRef, useEffect } from "react";
|
||||||
import type { CSSProperties, ReactNode, ButtonHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
import type { CSSProperties, ReactNode, ButtonHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||||
|
|
||||||
// ─── Icons ─────────────────────────────────────────────────────────
|
// ─── Icons ─────────────────────────────────────────────────────────
|
||||||
@@ -407,3 +408,41 @@ export function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
|||||||
const { style, ...rest } = props;
|
const { style, ...rest } = props;
|
||||||
return <textarea style={{ ...inputStyle, minHeight: 80, resize: "vertical", ...style }} {...rest} />;
|
return <textarea style={{ ...inputStyle, minHeight: 80, resize: "vertical", ...style }} {...rest} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Checkbox ─────────────────────────────────────────────────────
|
||||||
|
export function Checkbox({
|
||||||
|
checked,
|
||||||
|
indeterminate = false,
|
||||||
|
onChange,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
indeterminate?: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||||
|
}, [indeterminate]);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(e.target.checked);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
accentColor: "var(--sage)",
|
||||||
|
cursor: "pointer",
|
||||||
|
margin: 0,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useSelection(visibleIds: string[]) {
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const lastClicked = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const vis = new Set(visibleIds);
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const id of prev) {
|
||||||
|
if (vis.has(id)) next.add(id);
|
||||||
|
}
|
||||||
|
return next.size === prev.size ? prev : next;
|
||||||
|
});
|
||||||
|
}, [visibleIds]);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
(id: string, shiftKey: boolean) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (shiftKey && lastClicked.current !== null) {
|
||||||
|
const fromIdx = visibleIds.indexOf(lastClicked.current);
|
||||||
|
const toIdx = visibleIds.indexOf(id);
|
||||||
|
if (fromIdx !== -1 && toIdx !== -1) {
|
||||||
|
const lo = Math.min(fromIdx, toIdx);
|
||||||
|
const hi = Math.max(fromIdx, toIdx);
|
||||||
|
const adding = !prev.has(id);
|
||||||
|
for (let i = lo; i <= hi; i++) {
|
||||||
|
if (adding) next.add(visibleIds[i]);
|
||||||
|
else next.delete(visibleIds[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
}
|
||||||
|
lastClicked.current = id;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[visibleIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleAll = useCallback(() => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
if (prev.size === visibleIds.length && visibleIds.every((id) => prev.has(id))) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set(visibleIds);
|
||||||
|
});
|
||||||
|
}, [visibleIds]);
|
||||||
|
|
||||||
|
const toggleGroup = useCallback((groupIds: string[]) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const allIn = groupIds.every((id) => prev.has(id));
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of groupIds) {
|
||||||
|
if (allIn) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setSelected(new Set());
|
||||||
|
lastClicked.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isAllSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
|
||||||
|
const isIndeterminate = !isAllSelected && visibleIds.some((id) => selected.has(id));
|
||||||
|
|
||||||
|
return { selected, toggle, toggleAll, toggleGroup, clear, isAllSelected, isIndeterminate };
|
||||||
|
}
|
||||||
@@ -105,12 +105,16 @@
|
|||||||
from { transform: translateX(100%); }
|
from { transform: translateX(100%); }
|
||||||
to { transform: translateX(0); }
|
to { transform: translateX(0); }
|
||||||
}
|
}
|
||||||
|
@keyframes toolbar-slide-up {
|
||||||
|
from { transform: translateY(100%); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.inv-row > :nth-child(4),
|
.inv-row > :nth-child(5),
|
||||||
.inv-header > :nth-child(4) { display: none; } /* Shop */
|
.inv-header > :nth-child(5) { display: none; } /* Shop (shifted +1 by checkbox col) */
|
||||||
.inv-row > :nth-child(8),
|
.inv-row > :nth-child(9),
|
||||||
.inv-header > :nth-child(8) { display: none; } /* Last checked */
|
.inv-header > :nth-child(9) { display: none; } /* Last checked */
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 880px) {
|
@media (max-width: 880px) {
|
||||||
@@ -156,4 +160,8 @@
|
|||||||
.main {
|
.main {
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
}
|
}
|
||||||
|
.bulk-toolbar {
|
||||||
|
left: 0 !important;
|
||||||
|
bottom: 60px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-21
@@ -3,24 +3,36 @@ import type { Bootstrap, Item } from "../types.js";
|
|||||||
import { TYPES, helpers, TODAY_STR, enrichItems } from "../types.js";
|
import { TYPES, helpers, TODAY_STR, enrichItems } from "../types.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 { Btn, Card, Pill, Icon, Select, inputStyle } from "../components/primitives/index.js";
|
import { Btn, Card, Pill, Icon, Select, Checkbox, inputStyle } from "../components/primitives/index.js";
|
||||||
|
import { useSelection } from "../hooks/useSelection.js";
|
||||||
|
import { BulkToolbar } from "../components/BulkToolbar.js";
|
||||||
|
|
||||||
type FilterKey = "active" | "checked-out" | "consumed" | "gone" | "all";
|
type FilterKey = "active" | "checked-out" | "consumed" | "gone" | "all";
|
||||||
type SortKey = "recent" | "name" | "thc" | "remaining" | "price" | "audit";
|
type SortKey = "recent" | "name" | "thc" | "remaining" | "price" | "audit";
|
||||||
type ViewKey = "flat" | "grouped";
|
type ViewKey = "flat" | "grouped";
|
||||||
|
|
||||||
const GRID_COLS = "32px 2fr 1fr 1fr 0.6fr 0.6fr 0.9fr 0.9fr 0.8fr";
|
const GRID_COLS = "28px 32px 2fr 1fr 1fr 0.6fr 0.6fr 0.9fr 0.9fr 0.8fr";
|
||||||
|
|
||||||
export function Inventory({
|
export function Inventory({
|
||||||
data,
|
data,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
onAddInventory,
|
onAddInventory,
|
||||||
onAuditNew,
|
onAuditNew,
|
||||||
|
onBulkEdit,
|
||||||
|
onBulkConsume,
|
||||||
|
onBulkCheckout,
|
||||||
|
onBulkCheckin,
|
||||||
|
onBulkGone,
|
||||||
}: {
|
}: {
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
onSelectItem: (i: Item) => void;
|
onSelectItem: (i: Item) => void;
|
||||||
onAddInventory: () => void;
|
onAddInventory: () => void;
|
||||||
onAuditNew: () => void;
|
onAuditNew: () => void;
|
||||||
|
onBulkEdit: (items: Item[]) => void;
|
||||||
|
onBulkConsume: (items: Item[]) => void;
|
||||||
|
onBulkCheckout: (items: Item[]) => void;
|
||||||
|
onBulkCheckin: (items: Item[]) => void;
|
||||||
|
onBulkGone: (items: Item[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const items = useMemo(() => enrichItems(data), [data]);
|
const items = useMemo(() => enrichItems(data), [data]);
|
||||||
|
|
||||||
@@ -73,8 +85,6 @@ export function Inventory({
|
|||||||
|
|
||||||
const sorted = useMemo(() => [...filtered].sort(sortFn), [filtered, sortBy]);
|
const sorted = useMemo(() => [...filtered].sort(sortFn), [filtered, sortBy]);
|
||||||
|
|
||||||
// Grouped mode: bucket by productId. Same-product instances collapse under
|
|
||||||
// a header that shows total count + total remaining + last purchase.
|
|
||||||
type Group = {
|
type Group = {
|
||||||
productId: string;
|
productId: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -108,12 +118,41 @@ export function Inventory({
|
|||||||
return out;
|
return out;
|
||||||
}, [filtered, sortBy]);
|
}, [filtered, sortBy]);
|
||||||
|
|
||||||
|
// All visible item IDs in display order (flat or grouped)
|
||||||
|
const visibleIds = useMemo(() => {
|
||||||
|
if (view === "flat") return sorted.map((i) => i.id);
|
||||||
|
return groups.flatMap((g) => g.items.map((i) => i.id));
|
||||||
|
}, [view, sorted, groups]);
|
||||||
|
|
||||||
|
const { selected, toggle, toggleAll, toggleGroup, clear, isAllSelected, isIndeterminate } =
|
||||||
|
useSelection(visibleIds);
|
||||||
|
|
||||||
|
// Clear selection when filters / search / view change
|
||||||
|
useEffect(() => {
|
||||||
|
clear();
|
||||||
|
}, [filter, typeFilter, search, view]);
|
||||||
|
|
||||||
|
// Escape to deselect
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && selected.size > 0) clear();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [selected.size, clear]);
|
||||||
|
|
||||||
|
const selectedItems = useMemo(
|
||||||
|
() => items.filter((i) => selected.has(i.id)),
|
||||||
|
[items, selected],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
||||||
maxWidth: 2400,
|
maxWidth: 2400,
|
||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
|
paddingBottom: selected.size > 0 ? 140 : 80,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
|
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
|
||||||
@@ -229,7 +268,13 @@ export function Inventory({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padded={false}>
|
<Card padded={false}>
|
||||||
<HeaderRow sortBy={sortBy} onSort={setSortBy} />
|
<HeaderRow
|
||||||
|
sortBy={sortBy}
|
||||||
|
onSort={setSortBy}
|
||||||
|
isAllSelected={isAllSelected}
|
||||||
|
isIndeterminate={isIndeterminate}
|
||||||
|
onToggleAll={toggleAll}
|
||||||
|
/>
|
||||||
{sorted.length === 0 && (
|
{sorted.length === 0 && (
|
||||||
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
|
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
|
||||||
No items match these filters.
|
No items match these filters.
|
||||||
@@ -237,18 +282,56 @@ export function Inventory({
|
|||||||
)}
|
)}
|
||||||
{view === "flat" &&
|
{view === "flat" &&
|
||||||
sorted.map((i) => (
|
sorted.map((i) => (
|
||||||
<ItemRow key={i.id} i={i} data={data} onSelect={onSelectItem} />
|
<ItemRow
|
||||||
|
key={i.id}
|
||||||
|
i={i}
|
||||||
|
data={data}
|
||||||
|
onSelect={onSelectItem}
|
||||||
|
isSelected={selected.has(i.id)}
|
||||||
|
onToggle={toggle}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{view === "grouped" &&
|
{view === "grouped" &&
|
||||||
groups.map((g) => (
|
groups.map((g) => {
|
||||||
<div key={g.productId}>
|
const groupIds = g.items.map((i) => i.id);
|
||||||
<GroupHeader group={g} />
|
const allIn = groupIds.length > 0 && groupIds.every((id) => selected.has(id));
|
||||||
{g.items.map((i) => (
|
const someIn = !allIn && groupIds.some((id) => selected.has(id));
|
||||||
<ItemRow key={i.id} i={i} data={data} onSelect={onSelectItem} indented />
|
return (
|
||||||
))}
|
<div key={g.productId}>
|
||||||
</div>
|
<GroupHeader
|
||||||
))}
|
group={g}
|
||||||
|
isGroupSelected={allIn}
|
||||||
|
isGroupIndeterminate={someIn}
|
||||||
|
onToggleGroup={() => toggleGroup(groupIds)}
|
||||||
|
/>
|
||||||
|
{g.items.map((i) => (
|
||||||
|
<ItemRow
|
||||||
|
key={i.id}
|
||||||
|
i={i}
|
||||||
|
data={data}
|
||||||
|
onSelect={onSelectItem}
|
||||||
|
indented
|
||||||
|
isSelected={selected.has(i.id)}
|
||||||
|
onToggle={toggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<BulkToolbar
|
||||||
|
count={selected.size}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onClear={clear}
|
||||||
|
onBulkEdit={() => onBulkEdit(selectedItems)}
|
||||||
|
onBulkConsume={() => onBulkConsume(selectedItems)}
|
||||||
|
onBulkCheckout={() => onBulkCheckout(selectedItems)}
|
||||||
|
onBulkCheckin={() => onBulkCheckin(selectedItems)}
|
||||||
|
onBulkGone={() => onBulkGone(selectedItems)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -295,10 +378,22 @@ function Segmented<T extends string>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const COL_SORT: (SortKey | null)[] = [null, "name", null, null, "thc", "price", "remaining", "audit", null];
|
const COL_SORT: (SortKey | null)[] = [null, null, "name", null, null, "thc", "price", "remaining", "audit", null];
|
||||||
const COL_LABELS = ["", "Item", "Brand", "Shop", "THC", "Price", "Remaining", "Last checked", "Bin"];
|
const COL_LABELS = ["", "", "Item", "Brand", "Shop", "THC", "Price", "Remaining", "Last checked", "Bin"];
|
||||||
|
|
||||||
function HeaderRow({ sortBy, onSort }: { sortBy: SortKey; onSort: (k: SortKey) => void }) {
|
function HeaderRow({
|
||||||
|
sortBy,
|
||||||
|
onSort,
|
||||||
|
isAllSelected,
|
||||||
|
isIndeterminate,
|
||||||
|
onToggleAll,
|
||||||
|
}: {
|
||||||
|
sortBy: SortKey;
|
||||||
|
onSort: (k: SortKey) => void;
|
||||||
|
isAllSelected: boolean;
|
||||||
|
isIndeterminate: boolean;
|
||||||
|
onToggleAll: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="inv-header"
|
className="inv-header"
|
||||||
@@ -313,9 +408,21 @@ function HeaderRow({ sortBy, onSort }: { sortBy: SortKey; onSort: (k: SortKey) =
|
|||||||
color: "var(--ink-3)",
|
color: "var(--ink-3)",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.08em",
|
letterSpacing: "0.08em",
|
||||||
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{COL_LABELS.map((label, i) => {
|
{COL_LABELS.map((label, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isAllSelected}
|
||||||
|
indeterminate={isIndeterminate}
|
||||||
|
onChange={onToggleAll}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
const sk = COL_SORT[i];
|
const sk = COL_SORT[i];
|
||||||
if (!sk) return <div key={i}>{label}</div>;
|
if (!sk) return <div key={i}>{label}</div>;
|
||||||
const active = sortBy === sk;
|
const active = sortBy === sk;
|
||||||
@@ -349,6 +456,9 @@ function HeaderRow({ sortBy, onSort }: { sortBy: SortKey; onSort: (k: SortKey) =
|
|||||||
|
|
||||||
function GroupHeader({
|
function GroupHeader({
|
||||||
group,
|
group,
|
||||||
|
isGroupSelected,
|
||||||
|
isGroupIndeterminate,
|
||||||
|
onToggleGroup,
|
||||||
}: {
|
}: {
|
||||||
group: {
|
group: {
|
||||||
productId: string;
|
productId: string;
|
||||||
@@ -357,9 +467,10 @@ function GroupHeader({
|
|||||||
type: string;
|
type: string;
|
||||||
items: Item[];
|
items: Item[];
|
||||||
};
|
};
|
||||||
|
isGroupSelected: boolean;
|
||||||
|
isGroupIndeterminate: boolean;
|
||||||
|
onToggleGroup: () => void;
|
||||||
}) {
|
}) {
|
||||||
// Aggregate remaining: bulk uses estimatedRemaining; discrete uses raw count.
|
|
||||||
// Counts use status === "active" only — archived rows shouldn't inflate "on hand."
|
|
||||||
const active = group.items.filter((i) => i.status === "active");
|
const active = group.items.filter((i) => i.status === "active");
|
||||||
const totalRemaining = active.reduce((s, i) => {
|
const totalRemaining = active.reduce((s, i) => {
|
||||||
if (i.kind === "bulk") return s + helpers.estimatedRemaining(i, TODAY_STR);
|
if (i.kind === "bulk") return s + helpers.estimatedRemaining(i, TODAY_STR);
|
||||||
@@ -375,7 +486,7 @@ function GroupHeader({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "baseline",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
gap: 16,
|
gap: 16,
|
||||||
padding: "16px 20px 10px",
|
padding: "16px 20px 10px",
|
||||||
@@ -383,7 +494,12 @@ function GroupHeader({
|
|||||||
background: "var(--bg-2)",
|
background: "var(--bg-2)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "baseline", gap: 12, minWidth: 0 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isGroupSelected}
|
||||||
|
indeterminate={isGroupIndeterminate}
|
||||||
|
onChange={onToggleGroup}
|
||||||
|
/>
|
||||||
<div style={{ fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)", width: 18 }}>
|
<div style={{ fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)", width: 18 }}>
|
||||||
{TYPE_GLYPHS[group.type]}
|
{TYPE_GLYPHS[group.type]}
|
||||||
</div>
|
</div>
|
||||||
@@ -425,11 +541,15 @@ function ItemRow({
|
|||||||
data,
|
data,
|
||||||
onSelect,
|
onSelect,
|
||||||
indented = false,
|
indented = false,
|
||||||
|
isSelected,
|
||||||
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
i: Item;
|
i: Item;
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
onSelect: (i: Item) => void;
|
onSelect: (i: Item) => void;
|
||||||
indented?: boolean;
|
indented?: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
onToggle: (id: string, shiftKey: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const bin = data.bins.find((b) => b.id === i.binId);
|
const bin = data.bins.find((b) => b.id === i.binId);
|
||||||
const pctRemaining = helpers.pctRemaining(i, TODAY_STR);
|
const pctRemaining = helpers.pctRemaining(i, TODAY_STR);
|
||||||
@@ -452,8 +572,18 @@ function ItemRow({
|
|||||||
opacity: isInactive ? 0.55 : 1,
|
opacity: isInactive ? 0.55 : 1,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
borderLeft: indented ? "2px solid var(--bg-3)" : "none",
|
borderLeft: indented ? "2px solid var(--bg-3)" : "none",
|
||||||
|
background: isSelected ? "var(--sage-soft)" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle(i.id, e.shiftKey);
|
||||||
|
}}
|
||||||
|
style={{ display: "flex", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<Checkbox checked={isSelected} onChange={() => {}} />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--serif)",
|
fontFamily: "var(--serif)",
|
||||||
|
|||||||
Reference in New Issue
Block a user