diff --git a/.gitignore b/.gitignore index b52e0d2..08f3d60 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ web/dist # Claude Code local settings .claude/settings.local.json +.gstack/ diff --git a/server/src/db.ts b/server/src/db.ts index 38406ff..9ba02f0 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -15,6 +15,7 @@ archiveLegacyIfPresent(); archiveV1IfPresent(); migrateAddCheckoutDate(); migrateAddContainerWeight(); +migrateAddPrevBinId(); const schema = readFileSync(join(__dirname, "schema.sql"), "utf8"); db.exec(schema); @@ -35,6 +36,14 @@ function migrateAddContainerWeight(): void { 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, // bin_id, etc.) directly on `products`. The split schema separates products // (catalog) from inventory_items (instance). When we detect the old shape, diff --git a/server/src/routes/bootstrap.ts b/server/src/routes/bootstrap.ts index 4c23b28..62fa53e 100644 --- a/server/src/routes/bootstrap.ts +++ b/server/src/routes/bootstrap.ts @@ -34,6 +34,7 @@ type InventoryRow = { consumed_date: string | null; gone_date: string | null; checkout_date: string | null; + prev_bin_id: string | null; rating: number | null; notes: string | null; }; @@ -112,6 +113,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => { consumedDate: i.consumed_date, goneDate: i.gone_date, checkoutDate: i.checkout_date, + prevBinId: i.prev_bin_id, rating: i.rating, notes: i.notes, audits: (auditsByInventory.get(i.id) ?? []).map((a) => ({ diff --git a/server/src/routes/inventory.ts b/server/src/routes/inventory.ts index a885f1f..0751cc1 100644 --- a/server/src/routes/inventory.ts +++ b/server/src/routes/inventory.ts @@ -223,7 +223,7 @@ function doCheckout(id: string, date: string): void { const result = db .prepare( `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'`, ) .run(date, id); @@ -249,7 +249,7 @@ function doCheckin(id: string, date: string, binId: string, remainingWeight?: nu db.prepare( `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 = ?`, ).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) => { const { id } = req.params; const { date, mode, value, confirmedBy } = req.body as { diff --git a/server/src/schema.sql b/server/src/schema.sql index 319c169..8518e74 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS inventory_items ( consumed_date TEXT, gone_date TEXT, checkout_date TEXT, + prev_bin_id TEXT REFERENCES bins(id), rating INTEGER, notes TEXT ); diff --git a/web/src/App.tsx b/web/src/App.tsx index ae747d7..b1b8333 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,12 @@ import { Routes, Route } from "react-router-dom"; import { api } from "./api.js"; import type { Bin, Bootstrap, Brand, Item, Product, Shop } 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 { computeStats } from "./stats.js"; import { Sidebar } from "./components/Sidebar.js"; @@ -79,6 +85,7 @@ export function App() { const [selectedBrand, setSelectedBrand] = useState(null); const [selectedShop, setSelectedShop] = useState(null); const [modalProduct, setModalProduct] = useState(null); + const [drawerBack, setDrawerBack] = useState(null); const [theme, setTheme] = useState( () => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light", @@ -288,13 +295,21 @@ export function App() { setSelected(null)} + onClose={() => { setSelected(null); setDrawerBack(null); }} onConsume={openConsume} onMarkGone={openMarkGone} onAudit={openAudit} onEdit={openEdit} onCheckout={openCheckout} 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() { setSelectedSku(null)} + onClose={() => { setSelectedSku(null); setDrawerBack(null); }} onEdit={() => { setModalProduct(selectedSku); setModal("editSku"); @@ -310,13 +325,22 @@ export function App() { onDelete={() => { api.deleteProduct(selectedSku.id).then(() => { setSelectedSku(null); + setDrawerBack(null); queryClient.invalidateQueries({ queryKey: ["bootstrap"] }); }); }} onSelectItem={(i) => { + setDrawerBack({ kind: "sku", product: selectedSku }); setSelectedSku(null); 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() { setSelectedBrand(null)} + onClose={() => { setSelectedBrand(null); setDrawerBack(null); }} onEdit={() => { setModalBrand(selectedBrand); setModal("editBrand"); @@ -332,17 +356,26 @@ export function App() { onDelete={() => { api.deleteBrand(selectedBrand.id).then(() => { setSelectedBrand(null); + setDrawerBack(null); queryClient.invalidateQueries({ queryKey: ["bootstrap"] }); }); }} onSelectSku={(p) => { + setDrawerBack({ kind: "brand", brand: selectedBrand }); setSelectedBrand(null); setSelectedSku(p); }} onSelectItem={(i) => { + setDrawerBack({ kind: "brand", brand: selectedBrand }); setSelectedBrand(null); 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() { setSelectedShop(null)} + onClose={() => { setSelectedShop(null); setDrawerBack(null); }} onEdit={() => { setModalShop(selectedShop); setModal("editShop"); @@ -358,14 +391,17 @@ export function App() { onDelete={() => { api.deleteShop(selectedShop.id).then(() => { setSelectedShop(null); + setDrawerBack(null); queryClient.invalidateQueries({ queryKey: ["bootstrap"] }); }); }} onSelectBrand={(b) => { + setDrawerBack({ kind: "shop", shop: selectedShop }); setSelectedShop(null); setSelectedBrand(b); }} onSelectItem={(i) => { + setDrawerBack({ kind: "shop", shop: selectedShop }); setSelectedShop(null); setSelected(i); }} diff --git a/web/src/api.ts b/web/src/api.ts index cc59c0e..4d14063 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -147,6 +147,15 @@ export const api = { body: JSON.stringify(body), }), + reactivateInventoryItem: ( + id: string, + body: { binId: string }, + ) => + request<{ ok: true }>(`/inventory/${id}/reactivate`, { + method: "POST", + body: JSON.stringify(body), + }), + auditInventoryItem: ( id: string, body: { date: string; mode: AuditMode; value: number; confirmedBy?: string }, diff --git a/web/src/components/BrandDetail.tsx b/web/src/components/BrandDetail.tsx index 7c5d456..2f88536 100644 --- a/web/src/components/BrandDetail.tsx +++ b/web/src/components/BrandDetail.tsx @@ -5,6 +5,8 @@ import { getToday, getStoredTimezone } from "../tz.js"; import { fmt, TYPE_GLYPHS } from "../format.js"; import { Btn, Pill, Icon } from "./primitives/index.js"; import { remainingShort } from "../stats.js"; +import { useExitAnimation } from "../hooks/useExitAnimation.js"; +import { useFocusTrap } from "../hooks/useFocusTrap.js"; export function BrandDetail({ brand, @@ -14,6 +16,8 @@ export function BrandDetail({ onDelete, onSelectSku, onSelectItem, + backLabel, + onBack, }: { brand: Brand; data: Bootstrap; @@ -22,6 +26,8 @@ export function BrandDetail({ onDelete: () => void; onSelectSku: (p: Product) => void; onSelectItem: (i: Item) => void; + backLabel?: string; + onBack?: () => void; }) { const products = data.products.filter((p) => p.brandId === brand.id); const strainMap = new Map(data.strains.map((s) => [s.id, s])); @@ -47,13 +53,16 @@ export function BrandDetail({ const todayStr = getToday(getStoredTimezone()); const tz = getStoredTimezone(); + const { closing, triggerClose } = useExitAnimation(220, onClose); + const trapRef = useFocusTrap(); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") triggerClose(); }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [onClose]); + }, [triggerClose]); const statCards: [string, React.ReactNode][] = [ ["SKUs", String(products.length)], @@ -64,6 +73,9 @@ export function BrandDetail({ return (
e.stopPropagation()} style={{ width: "min(720px, 100vw)", height: "100%", - animation: "drawer-in 250ms ease-out", + animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out", background: "var(--bg)", borderLeft: "1px solid var(--line)", overflow: "auto", @@ -92,14 +104,34 @@ export function BrandDetail({ padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", - alignItems: "center", - justifyContent: "space-between", + flexDirection: "column", + gap: onBack ? 8 : 0, position: "sticky", top: 0, background: "var(--bg)", zIndex: 1, }} > + {onBack && backLabel && ( + + )} +
Brand
@@ -110,9 +142,11 @@ export function BrandDetail({ icon="bin" disabled={hasItems} onClick={onDelete} + title={hasItems ? "Cannot delete — has inventory items" : undefined} style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined} /> - + +
diff --git a/web/src/components/ProductDetail.tsx b/web/src/components/ProductDetail.tsx index 7de2139..a48bf78 100644 --- a/web/src/components/ProductDetail.tsx +++ b/web/src/components/ProductDetail.tsx @@ -20,6 +20,8 @@ export function ProductDetail({ onEdit, onCheckout, onCheckin, + backLabel, + onBack, }: { item: Item; data: Bootstrap; @@ -30,6 +32,8 @@ export function ProductDetail({ onEdit: (i: Item) => void; onCheckout: (i: Item) => void; onCheckin: (i: Item) => void; + backLabel?: string; + onBack?: () => void; }) { const bin = data.bins.find((b) => b.id === item.binId); const cfg = TYPES.find((t) => t.id === item.type); @@ -136,25 +140,45 @@ export function ProductDetail({ padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", - alignItems: "center", - justifyContent: "space-between", + flexDirection: "column", + gap: onBack ? 8 : 0, position: "sticky", top: 0, background: "var(--bg)", zIndex: 1, }} > + {onBack && backLabel && ( + + )} +
Inventory · {item.assetId}
- {isActive && overdue && ( - onAudit(item)}> + {isActive && ( + onAudit(item)}> Audit )} - {isActive && !overdue && ( - onCheckout(item)}> + {isActive && ( + onCheckout(item)}> Check out )} @@ -164,16 +188,6 @@ export function ProductDetail({ )}
- {isActive && !overdue && ( - onAudit(item)}> - Audit - - )} - {isActive && overdue && ( - onCheckout(item)}> - Check out - - )} {(isActive || isCheckedOut) && ( onConsume(item)}> Consume @@ -189,6 +203,7 @@ export function ProductDetail({
+
diff --git a/web/src/components/ShopDetail.tsx b/web/src/components/ShopDetail.tsx index 0d2c4b7..1a323c4 100644 --- a/web/src/components/ShopDetail.tsx +++ b/web/src/components/ShopDetail.tsx @@ -5,6 +5,8 @@ import { getToday, getStoredTimezone } from "../tz.js"; import { fmt } from "../format.js"; import { Btn, Pill, Icon } from "./primitives/index.js"; import { remainingShort } from "../stats.js"; +import { useExitAnimation } from "../hooks/useExitAnimation.js"; +import { useFocusTrap } from "../hooks/useFocusTrap.js"; export function ShopDetail({ shop, @@ -50,13 +52,16 @@ export function ShopDetail({ const todayStr = getToday(getStoredTimezone()); const tz = getStoredTimezone(); + const { closing, triggerClose } = useExitAnimation(220, onClose); + const trapRef = useFocusTrap(); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") triggerClose(); }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [onClose]); + }, [triggerClose]); const statCards: [string, React.ReactNode][] = [ ["Purchases", String(allItems.length)], @@ -66,6 +71,9 @@ export function ShopDetail({ return (
e.stopPropagation()} style={{ width: "min(720px, 100vw)", height: "100%", - animation: "drawer-in 250ms ease-out", + animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out", background: "var(--bg)", borderLeft: "1px solid var(--line)", overflow: "auto", @@ -112,9 +120,10 @@ export function ShopDetail({ icon="bin" disabled={hasItems} onClick={onDelete} + title={hasItems ? "Cannot delete — has inventory items" : undefined} style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined} /> - +
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 53d1c43..a0c7b10 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -88,8 +88,8 @@ export function Sidebar({ ))}
Quick
- + )} +
SKU · {product.sku}
@@ -131,9 +163,11 @@ export function SkuDetail({ icon="bin" disabled={hasItems} onClick={onDelete} + title={hasItems ? "Cannot delete — has inventory items" : undefined} style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined} /> - + +
diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx index 773585c..539f409 100644 --- a/web/src/components/Toast.tsx +++ b/web/src/components/Toast.tsx @@ -3,14 +3,20 @@ import { Icon } from "./primitives/index.js"; type ToastType = "success" | "error"; +interface ToastAction { + label: string; + onClick: () => void; +} + interface Toast { id: number; message: string; type: ToastType; + action?: ToastAction; } const ToastContext = createContext<{ - toast: (message: string, type?: ToastType) => void; + toast: (message: string, type?: ToastType, action?: ToastAction) => void; }>({ toast: () => {} }); 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++; - setToasts((prev) => [...prev, { id, message, type }]); + setToasts((prev) => [...prev, { id, message, type, action }]); startTimer(id); }, [startTimer]); @@ -94,6 +100,28 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { color={t.type === "error" ? "var(--terracotta)" : "var(--sage)"} /> {t.message} + {t.action && ( + + )} ))} @@ -143,7 +156,7 @@ export function ConsumeFlow({ fontFamily: "var(--mono)", }} > - {rating}/5 + {rating != null ? `${rating}/5` : "—"} @@ -160,6 +173,21 @@ export function ConsumeFlow({ )} + {confirming && item && ( +
+ Mark {item.name} ({item.assetId}) as consumed? This cannot be undone. +
+ )} + {error && (
{error}
)} @@ -171,14 +199,28 @@ export function ConsumeFlow({
Cancel - finish.mutate()} - > - {finish.isPending ? "Saving…" : error ? "Try again" : "Mark consumed"} - + {confirming ? ( + <> + setConfirming(false)}>Back + finish.mutate()} + > + {finish.isPending ? "Saving…" : "Confirm"} + + + ) : ( + setConfirming(true)} + > + {error ? "Try again" : "Mark consumed"} + + )}
diff --git a/web/src/components/modals/MarkGoneFlow.tsx b/web/src/components/modals/MarkGoneFlow.tsx index 856ac3c..9b38c3d 100644 --- a/web/src/components/modals/MarkGoneFlow.tsx +++ b/web/src/components/modals/MarkGoneFlow.tsx @@ -1,11 +1,13 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Item } from "../../types.js"; import { helpers, enrichItems } from "../../types.js"; import { getToday, getStoredTimezone } from "../../tz.js"; import { remainingShort } from "../../stats.js"; +import { fmt } from "../../format.js"; import { api } from "../../api.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 { useToast } from "../Toast.js"; @@ -30,24 +32,42 @@ export function MarkGoneFlow({ const { toast } = useToast(); const allItems = enrichItems(data); 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 [notes, setNotes] = useState(""); const [date, setDate] = useState(getToday(getStoredTimezone())); const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(false); const item = allItems.find((i) => i.id === itemId); + useEffect(() => { setError(null); setConfirming(false); }, [itemId]); + const mark = useMutation({ mutationFn: () => api.markInventoryItemGone(itemId, { date, reason, notes }), onSuccess: () => { 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(); }, 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 ( @@ -83,38 +103,91 @@ export function MarkGoneFlow({ spend but not as consumption, so daily averages stay accurate. - - - + -
- - - - - setDate(e.target.value)} /> - -
+ {active.length === 0 ? ( +
+ No active items to mark as gone. +
+ ) : !item ? ( +
+ Scan or type an asset ID to continue. +
+ ) : ( + <> +
+
+
+ {item.name} +
+
+ {item.assetId} · {helpers.brandName(data, item.brandId)} · {bin?.name ?? "no bin"} · {remainingShort(item)} left +
+
+
+
PURCHASED
+
+ {fmt.dateShort(item.purchaseDate, getStoredTimezone())} +
+
+
-
- -