Fix 18 UX issues: confirmations, undo, drawer nav, empty states, and polish
Build and push image / build (push) Successful in 54s

Comprehensive UX audit covering modals, drawers, dashboard, and inventory.
Key changes: confirmation steps before destructive actions, undo via toast
for consume/gone/checkout, back-navigation across entity drawers, optional
ratings, discrete item count field, audit progress bar, and sortable column
affordance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 16:25:41 -04:00
parent 9e31a6ad00
commit 538e5079ab
24 changed files with 519 additions and 116 deletions
+1
View File
@@ -18,3 +18,4 @@ web/dist
# Claude Code local settings # Claude Code local settings
.claude/settings.local.json .claude/settings.local.json
.gstack/
+9
View File
@@ -15,6 +15,7 @@ archiveLegacyIfPresent();
archiveV1IfPresent(); archiveV1IfPresent();
migrateAddCheckoutDate(); migrateAddCheckoutDate();
migrateAddContainerWeight(); migrateAddContainerWeight();
migrateAddPrevBinId();
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8"); const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
db.exec(schema); db.exec(schema);
@@ -35,6 +36,14 @@ function migrateAddContainerWeight(): void {
db.exec(`ALTER TABLE inventory_items ADD COLUMN container_weight REAL`); db.exec(`ALTER TABLE inventory_items ADD COLUMN container_weight REAL`);
} }
function migrateAddPrevBinId(): void {
const cols = db
.prepare(`PRAGMA table_info(inventory_items)`)
.all() as { name: string }[];
if (cols.length === 0 || cols.some((c) => c.name === "prev_bin_id")) return;
db.exec(`ALTER TABLE inventory_items ADD COLUMN prev_bin_id TEXT REFERENCES bins(id)`);
}
// One-shot migration: the original schema put per-instance fields (weight, // One-shot migration: the original schema put per-instance fields (weight,
// bin_id, etc.) directly on `products`. The split schema separates products // bin_id, etc.) directly on `products`. The split schema separates products
// (catalog) from inventory_items (instance). When we detect the old shape, // (catalog) from inventory_items (instance). When we detect the old shape,
+2
View File
@@ -34,6 +34,7 @@ type InventoryRow = {
consumed_date: string | null; consumed_date: string | null;
gone_date: string | null; gone_date: string | null;
checkout_date: string | null; checkout_date: string | null;
prev_bin_id: string | null;
rating: number | null; rating: number | null;
notes: string | null; notes: string | null;
}; };
@@ -112,6 +113,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
consumedDate: i.consumed_date, consumedDate: i.consumed_date,
goneDate: i.gone_date, goneDate: i.gone_date,
checkoutDate: i.checkout_date, checkoutDate: i.checkout_date,
prevBinId: i.prev_bin_id,
rating: i.rating, rating: i.rating,
notes: i.notes, notes: i.notes,
audits: (auditsByInventory.get(i.id) ?? []).map((a) => ({ audits: (auditsByInventory.get(i.id) ?? []).map((a) => ({
+26 -2
View File
@@ -223,7 +223,7 @@ function doCheckout(id: string, date: string): void {
const result = db const result = db
.prepare( .prepare(
`UPDATE inventory_items `UPDATE inventory_items
SET status = 'checked-out', checkout_date = ?, bin_id = NULL SET status = 'checked-out', checkout_date = ?, prev_bin_id = bin_id, bin_id = NULL
WHERE id = ? AND status = 'active'`, WHERE id = ? AND status = 'active'`,
) )
.run(date, id); .run(date, id);
@@ -249,7 +249,7 @@ function doCheckin(id: string, date: string, binId: string, remainingWeight?: nu
db.prepare( db.prepare(
`UPDATE inventory_items `UPDATE inventory_items
SET status = 'active', bin_id = ?, checkout_date = NULL SET status = 'active', bin_id = ?, checkout_date = NULL, prev_bin_id = NULL
WHERE id = ?`, WHERE id = ?`,
).run(binId, id); ).run(binId, id);
@@ -355,6 +355,30 @@ inventoryRouter.post("/inventory/:id/gone", (req, res) => {
} }
}); });
inventoryRouter.post("/inventory/:id/reactivate", (req, res) => {
const { binId } = req.body as { binId: string };
try {
const result = db
.prepare(
`UPDATE inventory_items
SET status = 'active',
bin_id = ?,
consumed_date = NULL,
gone_date = NULL,
checkout_date = NULL,
prev_bin_id = NULL
WHERE id = ? AND status IN ('consumed', 'gone', 'checked-out')`,
)
.run(binId, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: "not found or already active" });
}
res.json({ ok: true });
} catch (e: any) {
res.status(400).json({ error: e.message });
}
});
inventoryRouter.post("/inventory/:id/audit", (req, res) => { inventoryRouter.post("/inventory/:id/audit", (req, res) => {
const { id } = req.params; const { id } = req.params;
const { date, mode, value, confirmedBy } = req.body as { const { date, mode, value, confirmedBy } = req.body as {
+1
View File
@@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS inventory_items (
consumed_date TEXT, consumed_date TEXT,
gone_date TEXT, gone_date TEXT,
checkout_date TEXT, checkout_date TEXT,
prev_bin_id TEXT REFERENCES bins(id),
rating INTEGER, rating INTEGER,
notes TEXT notes TEXT
); );
+40 -4
View File
@@ -4,6 +4,12 @@ import { Routes, Route } from "react-router-dom";
import { api } from "./api.js"; import { api } from "./api.js";
import type { Bin, Bootstrap, Brand, Item, Product, Shop } from "./types.js"; import type { Bin, Bootstrap, Brand, Item, Product, Shop } from "./types.js";
import { enrichItems } from "./types.js"; import { enrichItems } from "./types.js";
type DrawerBack =
| { kind: "brand"; brand: Brand }
| { kind: "shop"; shop: Shop }
| { kind: "sku"; product: Product }
| null;
import { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js"; import { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js";
import { computeStats } from "./stats.js"; import { computeStats } from "./stats.js";
import { Sidebar } from "./components/Sidebar.js"; import { Sidebar } from "./components/Sidebar.js";
@@ -79,6 +85,7 @@ export function App() {
const [selectedBrand, setSelectedBrand] = useState<Brand | null>(null); const [selectedBrand, setSelectedBrand] = useState<Brand | null>(null);
const [selectedShop, setSelectedShop] = useState<Shop | null>(null); const [selectedShop, setSelectedShop] = useState<Shop | null>(null);
const [modalProduct, setModalProduct] = useState<Product | null>(null); const [modalProduct, setModalProduct] = useState<Product | null>(null);
const [drawerBack, setDrawerBack] = useState<DrawerBack>(null);
const [theme, setTheme] = useState<ThemeKey>( const [theme, setTheme] = useState<ThemeKey>(
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light", () => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
@@ -288,13 +295,21 @@ export function App() {
<ProductDetail <ProductDetail
item={selected} item={selected}
data={data} data={data}
onClose={() => setSelected(null)} onClose={() => { setSelected(null); setDrawerBack(null); }}
onConsume={openConsume} onConsume={openConsume}
onMarkGone={openMarkGone} onMarkGone={openMarkGone}
onAudit={openAudit} onAudit={openAudit}
onEdit={openEdit} onEdit={openEdit}
onCheckout={openCheckout} onCheckout={openCheckout}
onCheckin={openCheckin} onCheckin={openCheckin}
backLabel={drawerBack?.kind === "brand" ? drawerBack.brand.name : drawerBack?.kind === "shop" ? drawerBack.shop.name : drawerBack?.kind === "sku" ? `SKU ${drawerBack.product.sku}` : undefined}
onBack={drawerBack ? () => {
setSelected(null);
if (drawerBack.kind === "brand") setSelectedBrand(drawerBack.brand);
else if (drawerBack.kind === "shop") setSelectedShop(drawerBack.shop);
else if (drawerBack.kind === "sku") setSelectedSku(drawerBack.product);
setDrawerBack(null);
} : undefined}
/> />
)} )}
@@ -302,7 +317,7 @@ export function App() {
<SkuDetail <SkuDetail
product={selectedSku} product={selectedSku}
data={data} data={data}
onClose={() => setSelectedSku(null)} onClose={() => { setSelectedSku(null); setDrawerBack(null); }}
onEdit={() => { onEdit={() => {
setModalProduct(selectedSku); setModalProduct(selectedSku);
setModal("editSku"); setModal("editSku");
@@ -310,13 +325,22 @@ export function App() {
onDelete={() => { onDelete={() => {
api.deleteProduct(selectedSku.id).then(() => { api.deleteProduct(selectedSku.id).then(() => {
setSelectedSku(null); setSelectedSku(null);
setDrawerBack(null);
queryClient.invalidateQueries({ queryKey: ["bootstrap"] }); queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
}); });
}} }}
onSelectItem={(i) => { onSelectItem={(i) => {
setDrawerBack({ kind: "sku", product: selectedSku });
setSelectedSku(null); setSelectedSku(null);
setSelected(i); setSelected(i);
}} }}
backLabel={drawerBack?.kind === "brand" ? drawerBack.brand.name : drawerBack?.kind === "shop" ? drawerBack.shop.name : undefined}
onBack={drawerBack ? () => {
setSelectedSku(null);
if (drawerBack.kind === "brand") setSelectedBrand(drawerBack.brand);
else if (drawerBack.kind === "shop") setSelectedShop(drawerBack.shop);
setDrawerBack(null);
} : undefined}
/> />
)} )}
@@ -324,7 +348,7 @@ export function App() {
<BrandDetail <BrandDetail
brand={selectedBrand} brand={selectedBrand}
data={data} data={data}
onClose={() => setSelectedBrand(null)} onClose={() => { setSelectedBrand(null); setDrawerBack(null); }}
onEdit={() => { onEdit={() => {
setModalBrand(selectedBrand); setModalBrand(selectedBrand);
setModal("editBrand"); setModal("editBrand");
@@ -332,17 +356,26 @@ export function App() {
onDelete={() => { onDelete={() => {
api.deleteBrand(selectedBrand.id).then(() => { api.deleteBrand(selectedBrand.id).then(() => {
setSelectedBrand(null); setSelectedBrand(null);
setDrawerBack(null);
queryClient.invalidateQueries({ queryKey: ["bootstrap"] }); queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
}); });
}} }}
onSelectSku={(p) => { onSelectSku={(p) => {
setDrawerBack({ kind: "brand", brand: selectedBrand });
setSelectedBrand(null); setSelectedBrand(null);
setSelectedSku(p); setSelectedSku(p);
}} }}
onSelectItem={(i) => { onSelectItem={(i) => {
setDrawerBack({ kind: "brand", brand: selectedBrand });
setSelectedBrand(null); setSelectedBrand(null);
setSelected(i); setSelected(i);
}} }}
backLabel={drawerBack?.kind === "shop" ? drawerBack.shop.name : undefined}
onBack={drawerBack ? () => {
setSelectedBrand(null);
if (drawerBack.kind === "shop") setSelectedShop(drawerBack.shop);
setDrawerBack(null);
} : undefined}
/> />
)} )}
@@ -350,7 +383,7 @@ export function App() {
<ShopDetail <ShopDetail
shop={selectedShop} shop={selectedShop}
data={data} data={data}
onClose={() => setSelectedShop(null)} onClose={() => { setSelectedShop(null); setDrawerBack(null); }}
onEdit={() => { onEdit={() => {
setModalShop(selectedShop); setModalShop(selectedShop);
setModal("editShop"); setModal("editShop");
@@ -358,14 +391,17 @@ export function App() {
onDelete={() => { onDelete={() => {
api.deleteShop(selectedShop.id).then(() => { api.deleteShop(selectedShop.id).then(() => {
setSelectedShop(null); setSelectedShop(null);
setDrawerBack(null);
queryClient.invalidateQueries({ queryKey: ["bootstrap"] }); queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
}); });
}} }}
onSelectBrand={(b) => { onSelectBrand={(b) => {
setDrawerBack({ kind: "shop", shop: selectedShop });
setSelectedShop(null); setSelectedShop(null);
setSelectedBrand(b); setSelectedBrand(b);
}} }}
onSelectItem={(i) => { onSelectItem={(i) => {
setDrawerBack({ kind: "shop", shop: selectedShop });
setSelectedShop(null); setSelectedShop(null);
setSelected(i); setSelected(i);
}} }}
+9
View File
@@ -147,6 +147,15 @@ export const api = {
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
reactivateInventoryItem: (
id: string,
body: { binId: string },
) =>
request<{ ok: true }>(`/inventory/${id}/reactivate`, {
method: "POST",
body: JSON.stringify(body),
}),
auditInventoryItem: ( auditInventoryItem: (
id: string, id: string,
body: { date: string; mode: AuditMode; value: number; confirmedBy?: string }, body: { date: string; mode: AuditMode; value: number; confirmedBy?: string },
+42 -8
View File
@@ -5,6 +5,8 @@ import { getToday, getStoredTimezone } from "../tz.js";
import { fmt, TYPE_GLYPHS } from "../format.js"; import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Pill, Icon } from "./primitives/index.js"; import { Btn, Pill, Icon } from "./primitives/index.js";
import { remainingShort } from "../stats.js"; import { remainingShort } from "../stats.js";
import { useExitAnimation } from "../hooks/useExitAnimation.js";
import { useFocusTrap } from "../hooks/useFocusTrap.js";
export function BrandDetail({ export function BrandDetail({
brand, brand,
@@ -14,6 +16,8 @@ export function BrandDetail({
onDelete, onDelete,
onSelectSku, onSelectSku,
onSelectItem, onSelectItem,
backLabel,
onBack,
}: { }: {
brand: Brand; brand: Brand;
data: Bootstrap; data: Bootstrap;
@@ -22,6 +26,8 @@ export function BrandDetail({
onDelete: () => void; onDelete: () => void;
onSelectSku: (p: Product) => void; onSelectSku: (p: Product) => void;
onSelectItem: (i: Item) => void; onSelectItem: (i: Item) => void;
backLabel?: string;
onBack?: () => void;
}) { }) {
const products = data.products.filter((p) => p.brandId === brand.id); const products = data.products.filter((p) => p.brandId === brand.id);
const strainMap = new Map(data.strains.map((s) => [s.id, s])); const strainMap = new Map(data.strains.map((s) => [s.id, s]));
@@ -47,13 +53,16 @@ export function BrandDetail({
const todayStr = getToday(getStoredTimezone()); const todayStr = getToday(getStoredTimezone());
const tz = getStoredTimezone(); const tz = getStoredTimezone();
const { closing, triggerClose } = useExitAnimation(220, onClose);
const trapRef = useFocusTrap<HTMLDivElement>();
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") triggerClose();
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]); }, [triggerClose]);
const statCards: [string, React.ReactNode][] = [ const statCards: [string, React.ReactNode][] = [
["SKUs", String(products.length)], ["SKUs", String(products.length)],
@@ -64,6 +73,9 @@ export function BrandDetail({
return ( return (
<div <div
ref={trapRef}
role="dialog"
aria-modal="true"
style={{ style={{
position: "fixed", position: "fixed",
inset: 0, inset: 0,
@@ -71,16 +83,16 @@ export function BrandDetail({
zIndex: 50, zIndex: 50,
display: "flex", display: "flex",
justifyContent: "flex-end", justifyContent: "flex-end",
animation: "backdrop-in 200ms ease-out", animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
}} }}
onClick={onClose} onClick={triggerClose}
> >
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
width: "min(720px, 100vw)", width: "min(720px, 100vw)",
height: "100%", height: "100%",
animation: "drawer-in 250ms ease-out", animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
background: "var(--bg)", background: "var(--bg)",
borderLeft: "1px solid var(--line)", borderLeft: "1px solid var(--line)",
overflow: "auto", overflow: "auto",
@@ -92,14 +104,34 @@ export function BrandDetail({
padding: "20px 32px", padding: "20px 32px",
borderBottom: "1px solid var(--line)", borderBottom: "1px solid var(--line)",
display: "flex", display: "flex",
alignItems: "center", flexDirection: "column",
justifyContent: "space-between", gap: onBack ? 8 : 0,
position: "sticky", position: "sticky",
top: 0, top: 0,
background: "var(--bg)", background: "var(--bg)",
zIndex: 1, zIndex: 1,
}} }}
> >
{onBack && backLabel && (
<button
onClick={onBack}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
background: "none",
border: "none",
padding: 0,
fontSize: 12,
color: "var(--sage)",
cursor: "pointer",
alignSelf: "flex-start",
}}
>
<span style={{ transform: "scaleX(-1)", display: "inline-flex" }}><Icon name="arrow" size={12} /></span> Back to {backLabel}
</button>
)}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}> <div className="smallcaps" style={{ color: "var(--ink-3)" }}>
Brand Brand
</div> </div>
@@ -110,9 +142,11 @@ export function BrandDetail({
icon="bin" icon="bin"
disabled={hasItems} disabled={hasItems}
onClick={onDelete} onClick={onDelete}
title={hasItems ? "Cannot delete — has inventory items" : undefined}
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined} style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
/> />
<Btn variant="ghost" icon="close" onClick={onClose} /> <Btn variant="ghost" icon="close" onClick={triggerClose} />
</div>
</div> </div>
</div> </div>
+31 -16
View File
@@ -20,6 +20,8 @@ export function ProductDetail({
onEdit, onEdit,
onCheckout, onCheckout,
onCheckin, onCheckin,
backLabel,
onBack,
}: { }: {
item: Item; item: Item;
data: Bootstrap; data: Bootstrap;
@@ -30,6 +32,8 @@ export function ProductDetail({
onEdit: (i: Item) => void; onEdit: (i: Item) => void;
onCheckout: (i: Item) => void; onCheckout: (i: Item) => void;
onCheckin: (i: Item) => void; onCheckin: (i: Item) => void;
backLabel?: string;
onBack?: () => void;
}) { }) {
const bin = data.bins.find((b) => b.id === item.binId); const bin = data.bins.find((b) => b.id === item.binId);
const cfg = TYPES.find((t) => t.id === item.type); const cfg = TYPES.find((t) => t.id === item.type);
@@ -136,25 +140,45 @@ export function ProductDetail({
padding: "20px 32px", padding: "20px 32px",
borderBottom: "1px solid var(--line)", borderBottom: "1px solid var(--line)",
display: "flex", display: "flex",
alignItems: "center", flexDirection: "column",
justifyContent: "space-between", gap: onBack ? 8 : 0,
position: "sticky", position: "sticky",
top: 0, top: 0,
background: "var(--bg)", background: "var(--bg)",
zIndex: 1, zIndex: 1,
}} }}
> >
{onBack && backLabel && (
<button
onClick={onBack}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
background: "none",
border: "none",
padding: 0,
fontSize: 12,
color: "var(--sage)",
cursor: "pointer",
alignSelf: "flex-start",
}}
>
<span style={{ transform: "scaleX(-1)", display: "inline-flex" }}><Icon name="arrow" size={12} /></span> Back to {backLabel}
</button>
)}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}> <div className="smallcaps" style={{ color: "var(--ink-3)" }}>
Inventory · <span className="mono">{item.assetId}</span> Inventory · <span className="mono">{item.assetId}</span>
</div> </div>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}> <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
{isActive && overdue && ( {isActive && (
<Btn variant="sage" icon="search" onClick={() => onAudit(item)}> <Btn variant={overdue ? "sage" : "ghost"} icon="search" onClick={() => onAudit(item)}>
Audit Audit
</Btn> </Btn>
)} )}
{isActive && !overdue && ( {isActive && (
<Btn variant="secondary" icon="pocket" onClick={() => onCheckout(item)}> <Btn variant={overdue ? "ghost" : "secondary"} icon="pocket" onClick={() => onCheckout(item)}>
Check out Check out
</Btn> </Btn>
)} )}
@@ -164,16 +188,6 @@ export function ProductDetail({
</Btn> </Btn>
)} )}
<div style={{ width: 1, height: 20, background: "var(--line)", margin: "0 2px" }} /> <div style={{ width: 1, height: 20, background: "var(--line)", margin: "0 2px" }} />
{isActive && !overdue && (
<Btn variant="ghost" icon="search" onClick={() => onAudit(item)}>
Audit
</Btn>
)}
{isActive && overdue && (
<Btn variant="ghost" icon="pocket" onClick={() => onCheckout(item)}>
Check out
</Btn>
)}
{(isActive || isCheckedOut) && ( {(isActive || isCheckedOut) && (
<Btn variant="ghost" icon="leaf" onClick={() => onConsume(item)}> <Btn variant="ghost" icon="leaf" onClick={() => onConsume(item)}>
Consume Consume
@@ -189,6 +203,7 @@ export function ProductDetail({
</Btn> </Btn>
<Btn variant="ghost" icon="close" onClick={triggerClose} /> <Btn variant="ghost" icon="close" onClick={triggerClose} />
</div> </div>
</div>
</div> </div>
<div style={{ padding: "32px 32px 60px" }}> <div style={{ padding: "32px 32px 60px" }}>
+15 -6
View File
@@ -5,6 +5,8 @@ import { getToday, getStoredTimezone } from "../tz.js";
import { fmt } from "../format.js"; import { fmt } from "../format.js";
import { Btn, Pill, Icon } from "./primitives/index.js"; import { Btn, Pill, Icon } from "./primitives/index.js";
import { remainingShort } from "../stats.js"; import { remainingShort } from "../stats.js";
import { useExitAnimation } from "../hooks/useExitAnimation.js";
import { useFocusTrap } from "../hooks/useFocusTrap.js";
export function ShopDetail({ export function ShopDetail({
shop, shop,
@@ -50,13 +52,16 @@ export function ShopDetail({
const todayStr = getToday(getStoredTimezone()); const todayStr = getToday(getStoredTimezone());
const tz = getStoredTimezone(); const tz = getStoredTimezone();
const { closing, triggerClose } = useExitAnimation(220, onClose);
const trapRef = useFocusTrap<HTMLDivElement>();
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") triggerClose();
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]); }, [triggerClose]);
const statCards: [string, React.ReactNode][] = [ const statCards: [string, React.ReactNode][] = [
["Purchases", String(allItems.length)], ["Purchases", String(allItems.length)],
@@ -66,6 +71,9 @@ export function ShopDetail({
return ( return (
<div <div
ref={trapRef}
role="dialog"
aria-modal="true"
style={{ style={{
position: "fixed", position: "fixed",
inset: 0, inset: 0,
@@ -73,16 +81,16 @@ export function ShopDetail({
zIndex: 50, zIndex: 50,
display: "flex", display: "flex",
justifyContent: "flex-end", justifyContent: "flex-end",
animation: "backdrop-in 200ms ease-out", animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
}} }}
onClick={onClose} onClick={triggerClose}
> >
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
width: "min(720px, 100vw)", width: "min(720px, 100vw)",
height: "100%", height: "100%",
animation: "drawer-in 250ms ease-out", animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
background: "var(--bg)", background: "var(--bg)",
borderLeft: "1px solid var(--line)", borderLeft: "1px solid var(--line)",
overflow: "auto", overflow: "auto",
@@ -112,9 +120,10 @@ export function ShopDetail({
icon="bin" icon="bin"
disabled={hasItems} disabled={hasItems}
onClick={onDelete} onClick={onDelete}
title={hasItems ? "Cannot delete — has inventory items" : undefined}
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined} style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
/> />
<Btn variant="ghost" icon="close" onClick={onClose} /> <Btn variant="ghost" icon="close" onClick={triggerClose} />
</div> </div>
</div> </div>
+2 -2
View File
@@ -88,8 +88,8 @@ export function Sidebar({
))} ))}
<div className="nav-section">Quick</div> <div className="nav-section">Quick</div>
<div className="nav-divider" /> <div className="nav-divider" />
<button className="nav-link nav-action" onClick={onAddProduct} title="Add product"> <button className="nav-link nav-action" onClick={onAddProduct} title="Add inventory">
<Icon name="plus" size={16} /> <span className="nav-label">Add product</span> <Icon name="plus" size={16} /> <span className="nav-label">Add inventory</span>
</button> </button>
<button className="nav-link nav-action" onClick={onAudit} title="Audit"> <button className="nav-link nav-action" onClick={onAudit} title="Audit">
<Icon name="search" size={16} /> <span className="nav-label">Audit</span> <Icon name="search" size={16} /> <span className="nav-label">Audit</span>
+42 -8
View File
@@ -5,6 +5,8 @@ import { getToday, getStoredTimezone } from "../tz.js";
import { fmt, TYPE_GLYPHS } from "../format.js"; import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Pill, Icon } from "./primitives/index.js"; import { Btn, Pill, Icon } from "./primitives/index.js";
import { remainingShort } from "../stats.js"; import { remainingShort } from "../stats.js";
import { useExitAnimation } from "../hooks/useExitAnimation.js";
import { useFocusTrap } from "../hooks/useFocusTrap.js";
export function SkuDetail({ export function SkuDetail({
product, product,
@@ -13,6 +15,8 @@ export function SkuDetail({
onEdit, onEdit,
onDelete, onDelete,
onSelectItem, onSelectItem,
backLabel,
onBack,
}: { }: {
product: Product; product: Product;
data: Bootstrap; data: Bootstrap;
@@ -20,6 +24,8 @@ export function SkuDetail({
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onSelectItem: (i: Item) => void; onSelectItem: (i: Item) => void;
backLabel?: string;
onBack?: () => void;
}) { }) {
const strain = data.strains.find((s) => s.id === product.strainId); const strain = data.strains.find((s) => s.id === product.strainId);
const cfg = TYPES.find((t) => t.id === product.type); const cfg = TYPES.find((t) => t.id === product.type);
@@ -65,13 +71,16 @@ export function SkuDetail({
(a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate), (a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate),
); );
const { closing, triggerClose } = useExitAnimation(220, onClose);
const trapRef = useFocusTrap<HTMLDivElement>();
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") triggerClose();
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]); }, [triggerClose]);
const statCards: [string, React.ReactNode][] = [ const statCards: [string, React.ReactNode][] = [
["Purchases", String(items.length)], ["Purchases", String(items.length)],
@@ -85,6 +94,9 @@ export function SkuDetail({
return ( return (
<div <div
ref={trapRef}
role="dialog"
aria-modal="true"
style={{ style={{
position: "fixed", position: "fixed",
inset: 0, inset: 0,
@@ -92,16 +104,16 @@ export function SkuDetail({
zIndex: 50, zIndex: 50,
display: "flex", display: "flex",
justifyContent: "flex-end", justifyContent: "flex-end",
animation: "backdrop-in 200ms ease-out", animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
}} }}
onClick={onClose} onClick={triggerClose}
> >
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
width: "min(720px, 100vw)", width: "min(720px, 100vw)",
height: "100%", height: "100%",
animation: "drawer-in 250ms ease-out", animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
background: "var(--bg)", background: "var(--bg)",
borderLeft: "1px solid var(--line)", borderLeft: "1px solid var(--line)",
overflow: "auto", overflow: "auto",
@@ -113,14 +125,34 @@ export function SkuDetail({
padding: "20px 32px", padding: "20px 32px",
borderBottom: "1px solid var(--line)", borderBottom: "1px solid var(--line)",
display: "flex", display: "flex",
alignItems: "center", flexDirection: "column",
justifyContent: "space-between", gap: onBack ? 8 : 0,
position: "sticky", position: "sticky",
top: 0, top: 0,
background: "var(--bg)", background: "var(--bg)",
zIndex: 1, zIndex: 1,
}} }}
> >
{onBack && backLabel && (
<button
onClick={onBack}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
background: "none",
border: "none",
padding: 0,
fontSize: 12,
color: "var(--sage)",
cursor: "pointer",
alignSelf: "flex-start",
}}
>
<span style={{ transform: "scaleX(-1)", display: "inline-flex" }}><Icon name="arrow" size={12} /></span> Back to {backLabel}
</button>
)}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}> <div className="smallcaps" style={{ color: "var(--ink-3)" }}>
SKU · <span className="mono">{product.sku}</span> SKU · <span className="mono">{product.sku}</span>
</div> </div>
@@ -131,9 +163,11 @@ export function SkuDetail({
icon="bin" icon="bin"
disabled={hasItems} disabled={hasItems}
onClick={onDelete} onClick={onDelete}
title={hasItems ? "Cannot delete — has inventory items" : undefined}
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined} style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
/> />
<Btn variant="ghost" icon="close" onClick={onClose} /> <Btn variant="ghost" icon="close" onClick={triggerClose} />
</div>
</div> </div>
</div> </div>
+31 -3
View File
@@ -3,14 +3,20 @@ import { Icon } from "./primitives/index.js";
type ToastType = "success" | "error"; type ToastType = "success" | "error";
interface ToastAction {
label: string;
onClick: () => void;
}
interface Toast { interface Toast {
id: number; id: number;
message: string; message: string;
type: ToastType; type: ToastType;
action?: ToastAction;
} }
const ToastContext = createContext<{ const ToastContext = createContext<{
toast: (message: string, type?: ToastType) => void; toast: (message: string, type?: ToastType, action?: ToastAction) => void;
}>({ toast: () => {} }); }>({ toast: () => {} });
export const useToast = () => useContext(ToastContext); export const useToast = () => useContext(ToastContext);
@@ -44,9 +50,9 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
} }
}, []); }, []);
const toast = useCallback((message: string, type: ToastType = "success") => { const toast = useCallback((message: string, type: ToastType = "success", action?: ToastAction) => {
const id = nextId++; const id = nextId++;
setToasts((prev) => [...prev, { id, message, type }]); setToasts((prev) => [...prev, { id, message, type, action }]);
startTimer(id); startTimer(id);
}, [startTimer]); }, [startTimer]);
@@ -94,6 +100,28 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
color={t.type === "error" ? "var(--terracotta)" : "var(--sage)"} color={t.type === "error" ? "var(--terracotta)" : "var(--sage)"}
/> />
<span style={{ flex: 1 }}>{t.message}</span> <span style={{ flex: 1 }}>{t.message}</span>
{t.action && (
<button
onClick={() => {
t.action!.onClick();
dismiss(t.id);
}}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "2px 6px",
fontSize: 12,
fontWeight: 600,
color: "var(--sage)",
flexShrink: 0,
textDecoration: "underline",
textUnderlineOffset: 2,
}}
>
{t.action.label}
</button>
)}
<button <button
onClick={() => dismiss(t.id)} onClick={() => dismiss(t.id)}
aria-label="Dismiss notification" aria-label="Dismiss notification"
+20 -4
View File
@@ -139,6 +139,8 @@ function SelectProductStep({
const [newBrandName, setNewBrandName] = useState(""); const [newBrandName, setNewBrandName] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { setError(null); }, [newSku, newName, newBrandId, newBrandName]);
const handleScan = (result: ScanResult) => { const handleScan = (result: ScanResult) => {
if (result.kind === "product") { if (result.kind === "product") {
onPickProduct(result.product.id); onPickProduct(result.product.id);
@@ -383,10 +385,13 @@ function InstanceDetailsStep({
const [newShopLocation, setNewShopLocation] = useState(""); const [newShopLocation, setNewShopLocation] = useState("");
const [newBinName, setNewBinName] = useState(""); const [newBinName, setNewBinName] = useState("");
const [newBinCapacity, setNewBinCapacity] = useState(10); const [newBinCapacity, setNewBinCapacity] = useState(10);
const [countOriginal, setCountOriginal] = useState(last?.countOriginal ?? 1);
const [containerWeight, setContainerWeight] = useState(""); const [containerWeight, setContainerWeight] = useState("");
const [totalCannaManual, setTotalCannaManual] = useState(!!last); const [totalCannaManual, setTotalCannaManual] = useState(!!last);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { setError(null); }, [assetId]);
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) => const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
setForm((f) => { setForm((f) => {
const next = { ...f, [k]: v }; const next = { ...f, [k]: v };
@@ -429,9 +434,9 @@ function InstanceDetailsStep({
binId, binId,
weight: isDiscrete ? undefined : form.weight, weight: isDiscrete ? undefined : form.weight,
containerWeight: !isDiscrete && containerWeight !== "" ? parseFloat(containerWeight) : undefined, containerWeight: !isDiscrete && containerWeight !== "" ? parseFloat(containerWeight) : undefined,
countOriginal: isDiscrete ? 1 : undefined, countOriginal: isDiscrete ? countOriginal : undefined,
unitWeight: isDiscrete ? form.unitWeight : undefined, unitWeight: isDiscrete ? form.unitWeight : undefined,
price: form.price, price: isDiscrete ? form.price * countOriginal : form.price,
thc: form.thc, thc: form.thc,
cbd: form.cbd, cbd: form.cbd,
totalCannabinoids: form.totalCannabinoids, totalCannabinoids: form.totalCannabinoids,
@@ -590,7 +595,17 @@ function InstanceDetailsStep({
}} }}
> >
{isDiscrete ? ( {isDiscrete ? (
<Field label={`Unit weight (${cfg?.weightUnit ?? "g"})`} span={2} hint="Weight of one unit — for grams stats"> <>
<Field label="Count" hint="How many units in this purchase">
<Input
type="number"
step="1"
min="1"
value={countOriginal}
onChange={(e) => setCountOriginal(Math.max(1, Math.floor(+e.target.value || 1)))}
/>
</Field>
<Field label={`Unit weight (${cfg?.weightUnit ?? "g"})`} hint="Weight of one unit — for grams stats">
<Input <Input
type="number" type="number"
step="0.1" step="0.1"
@@ -598,6 +613,7 @@ function InstanceDetailsStep({
onChange={(e) => update("unitWeight", +e.target.value)} onChange={(e) => update("unitWeight", +e.target.value)}
/> />
</Field> </Field>
</>
) : ( ) : (
<Field label={`Size (${cfg?.unit ?? "g"})`} span={2}> <Field label={`Size (${cfg?.unit ?? "g"})`} span={2}>
<Input <Input
@@ -610,7 +626,7 @@ function InstanceDetailsStep({
)} )}
<Field <Field
label={isDiscrete ? "Price per unit ($)" : "Total price ($)"} label={isDiscrete ? "Price per unit ($)" : "Total price ($)"}
hint={isDiscrete && form.price > 0 ? `1 × ${fmt.money(form.price)} = ${fmt.money(form.price)} total` : undefined} hint={isDiscrete && form.price > 0 && countOriginal > 1 ? `${countOriginal} × ${fmt.money(form.price)} = ${fmt.money(form.price * countOriginal)} total` : undefined}
> >
<Input <Input
type="number" type="number"
+14
View File
@@ -148,6 +148,20 @@ export function AuditFlow({
onClose={onClose} onClose={onClose}
/> />
{queue && queue.length > 1 && (
<div style={{ height: 3, background: "var(--bg-3)" }}>
<div
style={{
height: "100%",
width: `${((queueIdx + 1) / queue.length) * 100}%`,
background: "var(--sage)",
borderRadius: "0 2px 2px 0",
transition: "width 300ms ease",
}}
/>
</div>
)}
<div style={{ padding: 32 }}> <div style={{ padding: 32 }}>
<ScanField <ScanField
items={overdueFirst} items={overdueFirst}
+10 -3
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js"; import type { Bootstrap, Item } from "../../types.js";
import { TYPES, helpers, enrichItems } from "../../types.js"; import { TYPES, helpers, enrichItems } from "../../types.js";
@@ -24,12 +24,15 @@ export function CheckinFlow({
const allItems = enrichItems(data); const allItems = enrichItems(data);
const checkedOut = allItems.filter((i) => i.status === "checked-out"); const checkedOut = allItems.filter((i) => i.status === "checked-out");
const [itemId, setItemId] = useState(initialItem?.id ?? ""); const [itemId, setItemId] = useState(initialItem?.id ?? "");
const [binId, setBinId] = useState(data.bins[0]?.id ?? ""); const [binId, setBinId] = useState(initialItem?.prevBinId ?? data.bins[0]?.id ?? "");
const [date, setDate] = useState(getToday(getStoredTimezone())); const [date, setDate] = useState(getToday(getStoredTimezone()));
const [remaining, setRemaining] = useState(""); const [remaining, setRemaining] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const item = allItems.find((i) => i.id === itemId); const item = allItems.find((i) => i.id === itemId);
useEffect(() => { setError(null); }, [itemId]);
const isBulk = item?.kind === "bulk"; const isBulk = item?.kind === "bulk";
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined; const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined;
const est = item ? helpers.estimatedRemaining(item, getToday(getStoredTimezone())) : 0; const est = item ? helpers.estimatedRemaining(item, getToday(getStoredTimezone())) : 0;
@@ -58,6 +61,7 @@ export function CheckinFlow({
if (result.kind === "item") { if (result.kind === "item") {
setItemId(result.item.id); setItemId(result.item.id);
const scanned = allItems.find((i) => i.id === result.item.id); const scanned = allItems.find((i) => i.id === result.item.id);
if (scanned?.prevBinId) setBinId(scanned.prevBinId);
if (scanned?.kind === "bulk") { if (scanned?.kind === "bulk") {
setRemaining(helpers.estimatedRemaining(scanned, getToday(getStoredTimezone())).toFixed(2)); setRemaining(helpers.estimatedRemaining(scanned, getToday(getStoredTimezone())).toFixed(2));
} }
@@ -129,7 +133,10 @@ export function CheckinFlow({
marginTop: 24, marginTop: 24,
}} }}
> >
<Field label="Return to bin"> <Field
label="Return to bin"
hint={item.prevBinId ? `Was in ${data.bins.find((b) => b.id === item.prevBinId)?.name ?? "unknown"} before checkout` : undefined}
>
<Select value={binId} onChange={(e) => setBinId(e.target.value)}> <Select value={binId} onChange={(e) => setBinId(e.target.value)}>
{data.bins.map((b) => ( {data.bins.map((b) => (
<option key={b.id} value={b.id}> <option key={b.id} value={b.id}>
+13 -2
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js"; import type { Bootstrap, Item } from "../../types.js";
import { helpers, enrichItems } from "../../types.js"; import { helpers, enrichItems } from "../../types.js";
@@ -29,11 +29,22 @@ export function CheckoutFlow({
const item = allItems.find((i) => i.id === itemId); const item = allItems.find((i) => i.id === itemId);
useEffect(() => { setError(null); }, [itemId]);
const checkout = useMutation({ const checkout = useMutation({
mutationFn: () => api.checkoutInventoryItem(itemId, { date }), mutationFn: () => api.checkoutInventoryItem(itemId, { date }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] }); qc.invalidateQueries({ queryKey: ["bootstrap"] });
toast(`Checked out ${item?.name ?? "item"}`); const undoItemId = itemId;
const undoBinId = item?.binId ?? data.bins[0]?.id ?? "";
toast(`Checked out ${item?.name ?? "item"}`, "success", {
label: "Undo",
onClick: () => {
api.reactivateInventoryItem(undoItemId, { binId: undoBinId }).then(() => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
});
},
});
onClose(); onClose();
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
+58 -16
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js"; import type { Bootstrap, Item } from "../../types.js";
import { helpers, enrichItems } from "../../types.js"; import { helpers, enrichItems } from "../../types.js";
@@ -24,18 +24,31 @@ export function ConsumeFlow({
const allItems = enrichItems(data); const allItems = enrichItems(data);
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out"); const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
const [itemId, setItemId] = useState(initialItem?.id ?? ""); const [itemId, setItemId] = useState(initialItem?.id ?? "");
const [rating, setRating] = useState(4); const [rating, setRating] = useState<number | null>(null);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [date, setDate] = useState(getToday(getStoredTimezone())); const [date, setDate] = useState(getToday(getStoredTimezone()));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const item = allItems.find((i) => i.id === itemId); const item = allItems.find((i) => i.id === itemId);
useEffect(() => { setError(null); setConfirming(false); }, [itemId]);
const finish = useMutation({ const finish = useMutation({
mutationFn: () => api.finishInventoryItem(itemId, { date, rating, notes }), mutationFn: () => api.finishInventoryItem(itemId, { date, rating: rating ?? undefined, notes }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] }); qc.invalidateQueries({ queryKey: ["bootstrap"] });
toast(`Marked ${item?.name ?? "item"} as consumed${rating}/5 stars`); const ratingStr = rating != null ? `${rating}/5 stars` : "";
const undoItemId = itemId;
const undoBinId = item?.binId ?? data.bins[0]?.id ?? "";
toast(`Marked ${item?.name ?? "item"} as consumed${ratingStr}`, "success", {
label: "Undo",
onClick: () => {
api.reactivateInventoryItem(undoItemId, { binId: undoBinId }).then(() => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
});
},
});
onClose(); onClose();
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
@@ -110,7 +123,7 @@ export function ConsumeFlow({
<Field label="Date finished"> <Field label="Date finished">
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} /> <Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</Field> </Field>
<Field label="Rating"> <Field label="Rating" hint="Optional — click to rate, click again to clear">
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -125,13 +138,13 @@ export function ConsumeFlow({
{[1, 2, 3, 4, 5].map((n) => ( {[1, 2, 3, 4, 5].map((n) => (
<button <button
key={n} key={n}
onClick={() => setRating(n)} onClick={() => setRating(rating === n ? null : n)}
style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }} style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }}
> >
<Icon <Icon
name="star" name="star"
size={20} size={20}
color={n <= rating ? "var(--amber)" : "var(--ink-4)"} color={rating != null && n <= rating ? "var(--amber)" : "var(--ink-4)"}
/> />
</button> </button>
))} ))}
@@ -143,7 +156,7 @@ export function ConsumeFlow({
fontFamily: "var(--mono)", fontFamily: "var(--mono)",
}} }}
> >
{rating}/5 {rating != null ? `${rating}/5` : "—"}
</span> </span>
</div> </div>
</Field> </Field>
@@ -160,6 +173,21 @@ export function ConsumeFlow({
</> </>
)} )}
{confirming && item && (
<div
style={{
marginTop: 20,
padding: 14,
background: "var(--amber-soft)",
border: "1px solid var(--amber)",
borderRadius: "var(--r-md)",
fontSize: 13,
}}
>
Mark <strong>{item.name}</strong> (<span className="mono">{item.assetId}</span>) as consumed? This cannot be undone.
</div>
)}
{error && ( {error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div> <div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)} )}
@@ -171,14 +199,28 @@ export function ConsumeFlow({
</div> </div>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn> <Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn {confirming ? (
variant="primary" <>
icon="check" <Btn variant="ghost" onClick={() => setConfirming(false)}>Back</Btn>
disabled={finish.isPending || !item} <Btn
onClick={() => finish.mutate()} variant="danger"
> icon="check"
{finish.isPending ? "Saving…" : error ? "Try again" : "Mark consumed"} disabled={finish.isPending}
</Btn> onClick={() => finish.mutate()}
>
{finish.isPending ? "Saving…" : "Confirm"}
</Btn>
</>
) : (
<Btn
variant="primary"
icon="check"
disabled={!item}
onClick={() => setConfirming(true)}
>
{error ? "Try again" : "Mark consumed"}
</Btn>
)}
</div> </div>
</ModalFooter> </ModalFooter>
</div> </div>
+119 -37
View File
@@ -1,11 +1,13 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js"; import type { Bootstrap, Item } from "../../types.js";
import { helpers, enrichItems } from "../../types.js"; import { helpers, enrichItems } from "../../types.js";
import { getToday, getStoredTimezone } from "../../tz.js"; import { getToday, getStoredTimezone } from "../../tz.js";
import { remainingShort } from "../../stats.js"; import { remainingShort } from "../../stats.js";
import { fmt } from "../../format.js";
import { api } from "../../api.js"; import { api } from "../../api.js";
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js"; import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
import { ScanField, type ScanResult } from "../ScanField.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
import { useToast } from "../Toast.js"; import { useToast } from "../Toast.js";
@@ -30,24 +32,42 @@ export function MarkGoneFlow({
const { toast } = useToast(); const { toast } = useToast();
const allItems = enrichItems(data); const allItems = enrichItems(data);
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out"); const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? ""); const [itemId, setItemId] = useState(initialItem?.id ?? "");
const [reason, setReason] = useState("lost"); const [reason, setReason] = useState("lost");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [date, setDate] = useState(getToday(getStoredTimezone())); const [date, setDate] = useState(getToday(getStoredTimezone()));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const item = allItems.find((i) => i.id === itemId); const item = allItems.find((i) => i.id === itemId);
useEffect(() => { setError(null); setConfirming(false); }, [itemId]);
const mark = useMutation({ const mark = useMutation({
mutationFn: () => api.markInventoryItemGone(itemId, { date, reason, notes }), mutationFn: () => api.markInventoryItemGone(itemId, { date, reason, notes }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] }); qc.invalidateQueries({ queryKey: ["bootstrap"] });
toast(`Marked ${item?.name ?? "item"} as gone`); const undoItemId = itemId;
const undoBinId = item?.binId ?? data.bins[0]?.id ?? "";
toast(`Marked ${item?.name ?? "item"} as gone`, "success", {
label: "Undo",
onClick: () => {
api.reactivateInventoryItem(undoItemId, { binId: undoBinId }).then(() => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
});
},
});
onClose(); onClose();
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
}); });
if (!item) return null; const handleScan = (result: ScanResult) => {
if (result.kind === "item") {
setItemId(result.item.id);
}
};
const bin = item ? data.bins.find((b) => b.id === item.binId) : undefined;
return ( return (
<ModalBackdrop onClose={onClose}> <ModalBackdrop onClose={onClose}>
@@ -83,38 +103,91 @@ export function MarkGoneFlow({
<strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate. <strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate.
</div> </div>
<Field label="Inventory item"> <ScanField
<Select value={itemId} onChange={(e) => setItemId(e.target.value)}> items={active}
{active.map((i) => ( products={[]}
<option key={i.id} value={i.id}> matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
{i.assetId} · {i.name} {helpers.brandName(data, i.brandId)} ({remainingShort(i)} left) onMatch={handleScan}
</option> mode="assetId"
))} />
</Select>
</Field>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 16 }}> {active.length === 0 ? (
<Field label="Reason"> <div style={{ marginTop: 24, textAlign: "center", color: "var(--ink-3)", fontSize: 13, fontStyle: "italic", padding: "24px 0" }}>
<Select value={reason} onChange={(e) => setReason(e.target.value)}> No active items to mark as gone.
{REASONS.map(([k, l]) => ( </div>
<option key={k} value={k}>{l}</option> ) : !item ? (
))} <div style={{ marginTop: 24, textAlign: "center", color: "var(--ink-3)", fontSize: 13, fontStyle: "italic", padding: "24px 0" }}>
</Select> Scan or type an asset ID to continue.
</Field> </div>
<Field label="Date"> ) : (
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} /> <>
</Field> <div
</div> style={{
marginTop: 16,
padding: 16,
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
{item.name}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
<span className="mono">{item.assetId}</span> · {helpers.brandName(data, item.brandId)} · {bin?.name ?? "no bin"} · {remainingShort(item)} left
</div>
</div>
<div style={{ textAlign: "right" }}>
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>PURCHASED</div>
<div className="serif" style={{ fontSize: 18 }}>
{fmt.dateShort(item.purchaseDate, getStoredTimezone())}
</div>
</div>
</div>
<div style={{ marginTop: 16 }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24 }}>
<Field label="Notes (optional)" hint="What happened"> <Field label="Reason">
<Textarea <Select value={reason} onChange={(e) => setReason(e.target.value)}>
value={notes} {REASONS.map(([k, l]) => (
onChange={(e) => setNotes(e.target.value)} <option key={k} value={k}>{l}</option>
placeholder="e.g. Pack went through the wash" ))}
/> </Select>
</Field> </Field>
</div> <Field label="Date">
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</Field>
</div>
<div style={{ marginTop: 16 }}>
<Field label="Notes (optional)" hint="What happened">
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="e.g. Pack went through the wash"
/>
</Field>
</div>
</>
)}
{confirming && item && (
<div
style={{
marginTop: 20,
padding: 14,
background: "var(--amber-soft)",
border: "1px solid var(--amber)",
borderRadius: "var(--r-md)",
fontSize: 13,
}}
>
Mark <strong>{item.name}</strong> (<span className="mono">{item.assetId}</span>) as gone? This cannot be undone.
</div>
)}
{error && ( {error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div> <div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
@@ -125,9 +198,18 @@ export function MarkGoneFlow({
<div /> <div />
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn> <Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="danger" icon="bin" disabled={mark.isPending} onClick={() => mark.mutate()}> {confirming ? (
{mark.isPending ? "Saving…" : "Mark gone"} <>
</Btn> <Btn variant="ghost" onClick={() => setConfirming(false)}>Back</Btn>
<Btn variant="danger" icon="bin" disabled={mark.isPending} onClick={() => mark.mutate()}>
{mark.isPending ? "Saving…" : "Confirm"}
</Btn>
</>
) : (
<Btn variant="danger" icon="bin" disabled={!item} onClick={() => setConfirming(true)}>
Mark gone
</Btn>
)}
</div> </div>
</ModalFooter> </ModalFooter>
</div> </div>
+3
View File
@@ -315,6 +315,7 @@ export function Btn({
style, style,
type, type,
disabled, disabled,
title,
}: { }: {
children?: ReactNode; children?: ReactNode;
variant?: BtnVariant; variant?: BtnVariant;
@@ -323,6 +324,7 @@ export function Btn({
style?: CSSProperties; style?: CSSProperties;
type?: "button" | "submit" | "reset"; type?: "button" | "submit" | "reset";
disabled?: boolean; disabled?: boolean;
title?: string;
}) { }) {
const variants: Record<BtnVariant, CSSProperties> = { const variants: Record<BtnVariant, CSSProperties> = {
primary: disabled primary: disabled
@@ -339,6 +341,7 @@ export function Btn({
type={type} type={type}
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
title={title}
style={{ style={{
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
+7
View File
@@ -85,6 +85,13 @@
.inv-row:hover { .inv-row:hover {
background: var(--bg-2); background: var(--bg-2);
} }
.sortable-col .sort-hint {
opacity: 0;
transition: opacity 100ms;
}
.sortable-col:hover .sort-hint {
opacity: 0.4;
}
.inv-row .inv-row-chevron { .inv-row .inv-row-chevron {
opacity: 0; opacity: 0;
transition: opacity 100ms; transition: opacity 100ms;
+1
View File
@@ -48,6 +48,7 @@ export interface InventoryItem {
consumedDate: string | null; consumedDate: string | null;
goneDate: string | null; goneDate: string | null;
checkoutDate: string | null; checkoutDate: string | null;
prevBinId: string | null;
rating: number | null; rating: number | null;
notes: string | null; notes: string | null;
audits: Audit[]; audits: Audit[];
+15
View File
@@ -87,6 +87,17 @@ export function Dashboard({
)} )}
</div> </div>
{stats.purchaseCount === 0 ? (
<Card style={{ textAlign: "center", padding: "60px 32px" }}>
<div className="serif" style={{ fontSize: 28, fontWeight: 500, marginBottom: 8 }}>
No inventory yet
</div>
<div style={{ fontSize: 14, color: "var(--ink-3)", marginBottom: 24, maxWidth: 400, margin: "0 auto 24px" }}>
Add your first item to start tracking consumption, audits, and spending.
</div>
</Card>
) : (
<>
<div <div
style={{ style={{
display: "grid", display: "grid",
@@ -333,6 +344,7 @@ export function Dashboard({
return ( return (
<div <div
key={i.id} key={i.id}
className="inv-row"
onClick={() => onSelectItem(i)} onClick={() => onSelectItem(i)}
style={{ style={{
padding: "12px 24px", padding: "12px 24px",
@@ -388,6 +400,7 @@ export function Dashboard({
{lowDiscrete.slice(0, 2).map((g) => ( {lowDiscrete.slice(0, 2).map((g) => (
<div <div
key={g.key} key={g.key}
className="inv-row"
onClick={() => onSelectItem(g.items[0]!)} onClick={() => onSelectItem(g.items[0]!)}
style={{ style={{
padding: "12px 24px", padding: "12px 24px",
@@ -422,6 +435,8 @@ export function Dashboard({
</div> </div>
</Card> </Card>
</div> </div>
</>
)}
</div> </div>
); );
} }
+8 -5
View File
@@ -129,10 +129,9 @@ export function Inventory({
const { selected, toggle, toggleAll, toggleGroup, clear, isAllSelected, isIndeterminate } = const { selected, toggle, toggleAll, toggleGroup, clear, isAllSelected, isIndeterminate } =
useSelection(visibleIds); useSelection(visibleIds);
// Clear selection when filters / search / view change // Selection is automatically pruned by useSelection when visibleIds changes.
useEffect(() => { // No need to force-clear — items that leave the visible set are removed,
clear(); // but items that remain stay selected.
}, [filter, typeFilter, search, view]);
// Escape to deselect // Escape to deselect
useEffect(() => { useEffect(() => {
@@ -465,6 +464,7 @@ function HeaderRow({
return ( return (
<button <button
key={i} key={i}
className="sortable-col"
onClick={() => onSort(sk)} onClick={() => onSort(sk)}
style={{ style={{
background: "none", background: "none",
@@ -482,7 +482,10 @@ function HeaderRow({
}} }}
> >
{label} {label}
{active && <span style={{ fontSize: 9 }}>{sortAsc ? "▲" : "▼"}</span>} {active
? <span style={{ fontSize: 9 }}>{sortAsc ? "▲" : "▼"}</span>
: <span className="sort-hint" style={{ fontSize: 9 }}></span>
}
</button> </button>
); );
})} })}