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" });
|
||||
}
|
||||
});
|
||||
|
||||
+45
-1
@@ -31,6 +31,11 @@ import {
|
||||
EditBrandModal,
|
||||
EditShopModal,
|
||||
} 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 =
|
||||
| "add"
|
||||
@@ -46,6 +51,11 @@ type ModalKey =
|
||||
| "editBin"
|
||||
| "editBrand"
|
||||
| "editShop"
|
||||
| "bulkEdit"
|
||||
| "bulkConsume"
|
||||
| "bulkCheckout"
|
||||
| "bulkCheckin"
|
||||
| "bulkGone"
|
||||
| null;
|
||||
|
||||
export function App() {
|
||||
@@ -55,6 +65,7 @@ export function App() {
|
||||
const [modalBin, setModalBin] = useState<Bin | null>(null);
|
||||
const [modalBrand, setModalBrand] = useState<Brand | null>(null);
|
||||
const [modalShop, setModalShop] = useState<Shop | null>(null);
|
||||
const [bulkItems, setBulkItems] = useState<Item[]>([]);
|
||||
|
||||
const [theme, setTheme] = useState<ThemeKey>(
|
||||
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
||||
@@ -116,6 +127,13 @@ export function App() {
|
||||
setSelected(null);
|
||||
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) {
|
||||
return (
|
||||
<div
|
||||
@@ -181,7 +199,17 @@ export function App() {
|
||||
<Dashboard data={data} stats={stats} onAuditItem={openAudit} onSelectItem={setSelected} />
|
||||
} />
|
||||
<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={
|
||||
<CustodyView data={data} onSelectItem={setSelected} onCheckin={openCheckin} onConsume={openConsume} onMarkGone={openMarkGone} />
|
||||
@@ -247,6 +275,22 @@ export function App() {
|
||||
{modal === "editShop" && modalShop && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
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> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
...init,
|
||||
@@ -146,6 +153,12 @@ export const api = {
|
||||
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
|
||||
createBrand: (name: string) =>
|
||||
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";
|
||||
|
||||
// ─── Icons ─────────────────────────────────────────────────────────
|
||||
@@ -407,3 +408,41 @@ export function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
const { style, ...rest } = props;
|
||||
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%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
@keyframes toolbar-slide-up {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.inv-row > :nth-child(4),
|
||||
.inv-header > :nth-child(4) { display: none; } /* Shop */
|
||||
.inv-row > :nth-child(8),
|
||||
.inv-header > :nth-child(8) { display: none; } /* Last checked */
|
||||
.inv-row > :nth-child(5),
|
||||
.inv-header > :nth-child(5) { display: none; } /* Shop (shifted +1 by checkbox col) */
|
||||
.inv-row > :nth-child(9),
|
||||
.inv-header > :nth-child(9) { display: none; } /* Last checked */
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
@@ -156,4 +160,8 @@
|
||||
.main {
|
||||
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 { remainingShort } from "../stats.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 SortKey = "recent" | "name" | "thc" | "remaining" | "price" | "audit";
|
||||
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({
|
||||
data,
|
||||
onSelectItem,
|
||||
onAddInventory,
|
||||
onAuditNew,
|
||||
onBulkEdit,
|
||||
onBulkConsume,
|
||||
onBulkCheckout,
|
||||
onBulkCheckin,
|
||||
onBulkGone,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onSelectItem: (i: Item) => void;
|
||||
onAddInventory: () => 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]);
|
||||
|
||||
@@ -73,8 +85,6 @@ export function Inventory({
|
||||
|
||||
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 = {
|
||||
productId: string;
|
||||
label: string;
|
||||
@@ -108,12 +118,41 @@ export function Inventory({
|
||||
return out;
|
||||
}, [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 (
|
||||
<div
|
||||
style={{
|
||||
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
||||
maxWidth: 2400,
|
||||
margin: "0 auto",
|
||||
paddingBottom: selected.size > 0 ? 140 : 80,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
|
||||
@@ -229,7 +268,13 @@ export function Inventory({
|
||||
</Card>
|
||||
|
||||
<Card padded={false}>
|
||||
<HeaderRow sortBy={sortBy} onSort={setSortBy} />
|
||||
<HeaderRow
|
||||
sortBy={sortBy}
|
||||
onSort={setSortBy}
|
||||
isAllSelected={isAllSelected}
|
||||
isIndeterminate={isIndeterminate}
|
||||
onToggleAll={toggleAll}
|
||||
/>
|
||||
{sorted.length === 0 && (
|
||||
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
|
||||
No items match these filters.
|
||||
@@ -237,18 +282,56 @@ export function Inventory({
|
||||
)}
|
||||
{view === "flat" &&
|
||||
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" &&
|
||||
groups.map((g) => (
|
||||
<div key={g.productId}>
|
||||
<GroupHeader group={g} />
|
||||
{g.items.map((i) => (
|
||||
<ItemRow key={i.id} i={i} data={data} onSelect={onSelectItem} indented />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
groups.map((g) => {
|
||||
const groupIds = g.items.map((i) => i.id);
|
||||
const allIn = groupIds.length > 0 && groupIds.every((id) => selected.has(id));
|
||||
const someIn = !allIn && groupIds.some((id) => selected.has(id));
|
||||
return (
|
||||
<div key={g.productId}>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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_LABELS = ["", "Item", "Brand", "Shop", "THC", "Price", "Remaining", "Last checked", "Bin"];
|
||||
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"];
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="inv-header"
|
||||
@@ -313,9 +408,21 @@ function HeaderRow({ sortBy, onSort }: { sortBy: SortKey; onSort: (k: SortKey) =
|
||||
color: "var(--ink-3)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{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];
|
||||
if (!sk) return <div key={i}>{label}</div>;
|
||||
const active = sortBy === sk;
|
||||
@@ -349,6 +456,9 @@ function HeaderRow({ sortBy, onSort }: { sortBy: SortKey; onSort: (k: SortKey) =
|
||||
|
||||
function GroupHeader({
|
||||
group,
|
||||
isGroupSelected,
|
||||
isGroupIndeterminate,
|
||||
onToggleGroup,
|
||||
}: {
|
||||
group: {
|
||||
productId: string;
|
||||
@@ -357,9 +467,10 @@ function GroupHeader({
|
||||
type: string;
|
||||
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 totalRemaining = active.reduce((s, i) => {
|
||||
if (i.kind === "bulk") return s + helpers.estimatedRemaining(i, TODAY_STR);
|
||||
@@ -375,7 +486,7 @@ function GroupHeader({
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 16,
|
||||
padding: "16px 20px 10px",
|
||||
@@ -383,7 +494,12 @@ function GroupHeader({
|
||||
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 }}>
|
||||
{TYPE_GLYPHS[group.type]}
|
||||
</div>
|
||||
@@ -425,11 +541,15 @@ function ItemRow({
|
||||
data,
|
||||
onSelect,
|
||||
indented = false,
|
||||
isSelected,
|
||||
onToggle,
|
||||
}: {
|
||||
i: Item;
|
||||
data: Bootstrap;
|
||||
onSelect: (i: Item) => void;
|
||||
indented?: boolean;
|
||||
isSelected: boolean;
|
||||
onToggle: (id: string, shiftKey: boolean) => void;
|
||||
}) {
|
||||
const bin = data.bins.find((b) => b.id === i.binId);
|
||||
const pctRemaining = helpers.pctRemaining(i, TODAY_STR);
|
||||
@@ -452,8 +572,18 @@ function ItemRow({
|
||||
opacity: isInactive ? 0.55 : 1,
|
||||
fontSize: 13,
|
||||
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
|
||||
style={{
|
||||
fontFamily: "var(--serif)",
|
||||
|
||||
Reference in New Issue
Block a user