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

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

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