From 3bdf8570992c032e1d864dd71fa17aceb2166306 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 8 May 2026 10:12:35 -0400 Subject: [PATCH] Add SKUs section with list view, detail drawer, and CRUD modals New catalog-level view for browsing, searching, and managing product SKUs with per-SKU insights (spend, lifecycle, ratings) and full inventory linking. Co-Authored-By: Claude Opus 4.6 --- web/src/App.tsx | 53 +++- web/src/components/Sidebar.tsx | 2 + web/src/components/SkuDetail.tsx | 406 ++++++++++++++++++++++++ web/src/components/modals/SkuModals.tsx | 329 +++++++++++++++++++ web/src/components/primitives/index.tsx | 1 + web/src/views/SkusView.tsx | 364 +++++++++++++++++++++ 6 files changed, 1153 insertions(+), 2 deletions(-) create mode 100644 web/src/components/SkuDetail.tsx create mode 100644 web/src/components/modals/SkuModals.tsx create mode 100644 web/src/views/SkusView.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 743515e..97d3f82 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,14 +1,15 @@ import { useEffect, useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Routes, Route } from "react-router-dom"; import { api } from "./api.js"; -import type { Bin, Bootstrap, Brand, Item, Shop } from "./types.js"; +import type { Bin, Bootstrap, Brand, Item, Product, Shop } from "./types.js"; import { enrichItems } from "./types.js"; import { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js"; import { computeStats } from "./stats.js"; import { Sidebar } from "./components/Sidebar.js"; import { Dashboard } from "./views/Dashboard.js"; import { Inventory } from "./views/Inventory.js"; +import { SkusView } from "./views/SkusView.js"; import { BinsView } from "./views/BinsView.js"; import { BrandsView } from "./views/BrandsView.js"; import { ShopsView } from "./views/ShopsView.js"; @@ -16,6 +17,7 @@ import { ChartsView } from "./views/ChartsView.js"; import { SettingsView } from "./views/SettingsView.js"; import type { ThemeKey } from "./views/SettingsView.js"; import { ProductDetail } from "./components/ProductDetail.js"; +import { SkuDetail } from "./components/SkuDetail.js"; import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js"; import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js"; import { ConsumeFlow } from "./components/modals/ConsumeFlow.js"; @@ -32,6 +34,7 @@ import { EditBrandModal, EditShopModal, } from "./components/modals/CatalogModals.js"; +import { AddSkuModal, EditSkuModal } from "./components/modals/SkuModals.js"; import { BulkEditModal } from "./components/modals/BulkEditModal.js"; import { BulkConsumeModal } from "./components/modals/BulkConsumeModal.js"; import { BulkCheckoutModal } from "./components/modals/BulkCheckoutModal.js"; @@ -57,9 +60,12 @@ type ModalKey = | "bulkCheckout" | "bulkCheckin" | "bulkGone" + | "addSku" + | "editSku" | null; export function App() { + const queryClient = useQueryClient(); const [selected, setSelected] = useState(null); const [modal, setModal] = useState(null); const [modalItem, setModalItem] = useState(null); @@ -67,6 +73,8 @@ export function App() { const [modalBrand, setModalBrand] = useState(null); const [modalShop, setModalShop] = useState(null); const [bulkItems, setBulkItems] = useState([]); + const [selectedSku, setSelectedSku] = useState(null); + const [modalProduct, setModalProduct] = useState(null); const [theme, setTheme] = useState( () => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light", @@ -100,6 +108,13 @@ export function App() { } }, [data, items]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (selectedSku && data) { + const fresh = data.products.find((p) => p.id === selectedSku.id); + if (fresh && fresh !== selectedSku) setSelectedSku(fresh); + } + }, [data]); // eslint-disable-line react-hooks/exhaustive-deps + const openAdd = () => { setModalItem(null); setModal("add"); @@ -217,6 +232,13 @@ export function App() { onBulkGone={openBulkGone} /> } /> + setModal("addSku")} + /> + } /> } /> @@ -250,6 +272,28 @@ export function App() { /> )} + {selectedSku && ( + setSelectedSku(null)} + onEdit={() => { + setModalProduct(selectedSku); + setModal("editSku"); + }} + onDelete={() => { + api.deleteProduct(selectedSku.id).then(() => { + setSelectedSku(null); + queryClient.invalidateQueries({ queryKey: ["bootstrap"] }); + }); + }} + onSelectItem={(i) => { + setSelectedSku(null); + setSelected(i); + }} + /> + )} + {modal === "add" && setModal(null)} />} {modal === "edit" && modalItem && ( setModal(null)} /> @@ -297,6 +341,11 @@ export function App() { {modal === "bulkGone" && ( setModal(null)} /> )} + + {modal === "addSku" && setModal(null)} />} + {modal === "editSku" && modalProduct && ( + setModal(null)} /> + )} ); } diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 1d026f4..7747fb7 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { Icon } from "./primitives/index.js"; export type ViewKey = | "dashboard" | "inventory" + | "skus" | "custody" | "bins" | "shops" @@ -14,6 +15,7 @@ export type ViewKey = const NAV: { path: string; label: string; icon: string }[] = [ { path: "/", label: "Dashboard", icon: "home" }, { path: "/inventory", label: "Inventory", icon: "box" }, + { path: "/skus", label: "SKUs", icon: "barcode" }, { path: "/custody", label: "My Custody", icon: "pocket" }, { path: "/bins", label: "Bins", icon: "bin" }, { path: "/shops", label: "Shops", icon: "shop" }, diff --git a/web/src/components/SkuDetail.tsx b/web/src/components/SkuDetail.tsx new file mode 100644 index 0000000..e06876f --- /dev/null +++ b/web/src/components/SkuDetail.tsx @@ -0,0 +1,406 @@ +import { useEffect } from "react"; +import type { Bootstrap, Product, Item } from "../types.js"; +import { TYPES, helpers, enrichItems } from "../types.js"; +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"; + +export function SkuDetail({ + product, + data, + onClose, + onEdit, + onDelete, + onSelectItem, +}: { + product: Product; + data: Bootstrap; + onClose: () => void; + onEdit: () => void; + onDelete: () => void; + onSelectItem: (i: Item) => void; +}) { + const strain = data.strains.find((s) => s.id === product.strainId); + const cfg = TYPES.find((t) => t.id === product.type); + const items = enrichItems(data).filter((i) => i.productId === product.id); + const active = items.filter((i) => i.status === "active" || i.status === "checked-out"); + const consumed = items.filter((i) => i.status === "consumed"); + const gone = items.filter((i) => i.status === "gone"); + const hasItems = items.length > 0; + + const totalSpend = items.reduce((s, i) => s + i.price, 0); + const avgPrice = hasItems ? totalSpend / items.length : 0; + + let avgCostPerGram: number | null = null; + if (product.kind === "bulk") { + const totalGrams = items.reduce((s, i) => s + i.weight, 0); + if (totalGrams > 0) avgCostPerGram = totalSpend / totalGrams; + } else { + const totalGrams = items.reduce((s, i) => s + i.countOriginal * i.unitWeight, 0); + if (totalGrams > 0) avgCostPerGram = totalSpend / totalGrams; + } + + const rated = items.filter((i) => i.rating != null); + const avgRating = rated.length > 0 + ? rated.reduce((s, i) => s + i.rating!, 0) / rated.length + : null; + + const lifespans = consumed + .filter((i) => i.consumedDate) + .map((i) => Math.max(1, Math.round((+new Date(i.consumedDate!) - +new Date(i.purchaseDate)) / 86_400_000))); + const avgLifespan = lifespans.length > 0 ? lifespans.reduce((a, b) => a + b, 0) / lifespans.length : null; + + const todayStr = getToday(getStoredTimezone()); + const tz = getStoredTimezone(); + + const totalGramsConsumed = consumed.reduce((s, i) => { + if (i.kind === "bulk") return s + i.weight; + return s + i.countOriginal * i.unitWeight; + }, 0); + const totalDaysConsumed = lifespans.reduce((a, b) => a + b, 0); + const consumptionRate = totalDaysConsumed > 0 ? totalGramsConsumed / totalDaysConsumed : null; + + const sortedItems = [...items].sort( + (a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate), + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + const statCards: [string, React.ReactNode][] = [ + ["Purchases", String(items.length)], + ["Total spent", hasItems ? fmt.money(totalSpend) : "—"], + ["Avg price", hasItems ? fmt.money(avgPrice) : "—"], + [ + `Avg /${cfg?.unit ?? "g"}`, + avgCostPerGram != null ? fmt.money(avgCostPerGram) : "—", + ], + ]; + + return ( +
+
e.stopPropagation()} + style={{ + width: "min(720px, 100vw)", + height: "100%", + animation: "drawer-in 250ms ease-out", + background: "var(--bg)", + borderLeft: "1px solid var(--line)", + overflow: "auto", + boxShadow: "var(--shadow-lg)", + }} + > +
+
+ SKU · {product.sku} +
+
+ + + +
+
+ +
+
+
+ {TYPE_GLYPHS[product.type]} {product.type} · {product.kind} +
+
+

+ {strain?.name ?? "(unknown)"} +

+
+ {helpers.brandName(data, product.brandId)} +
+
+ Created {fmt.date(product.createdAt, tz)} +
+ + {hasItems && ( +
+ {statCards.map(([l, v], i) => ( +
+
{l}
+
+ {v} +
+
+ ))} +
+ )} + + {hasItems && ( +
+
+ Lifecycle +
+
+ {active.length > 0 && ( + {active.length} active + )} + {consumed.length > 0 && ( + {consumed.length} consumed + )} + {gone.length > 0 && ( + {gone.length} gone + )} +
+
+ + +
+
+ )} + + {avgRating != null && ( +
+
+ Ratings +
+
+
+ {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
+ + {avgRating.toFixed(1)} + + + from {rated.length} review{rated.length === 1 ? "" : "s"} + +
+
+ {rated + .sort((a, b) => +new Date(b.consumedDate ?? 0) - +new Date(a.consumedDate ?? 0)) + .map((item) => ( +
+ {item.assetId} + + {[1, 2, 3, 4, 5].map((n) => ( + + ))} + +
+ ))} +
+
+ )} + +
+
+ Strain defaults +
+
+ + + + {strain?.notes && } +
+
+ + {hasItems && ( +
+
+ Inventory ({items.length}) +
+
+ {sortedItems.map((item, idx) => { + const isInactive = item.status !== "active" && item.status !== "checked-out"; + return ( +
onSelectItem(item)} + className="inv-row" + style={{ + padding: "12px 16px", + borderBottom: idx < sortedItems.length - 1 ? "1px solid var(--line)" : "none", + display: "grid", + gridTemplateColumns: "auto 1fr auto auto auto", + alignItems: "center", + gap: 12, + background: "var(--surface)", + cursor: "pointer", + opacity: isInactive ? 0.55 : 1, + }} + > + {item.assetId} + + {item.status === "consumed" && Consumed} + {item.status === "gone" && Gone} + {item.status === "checked-out" && Checked out} + {item.status === "active" && helpers.auditOverdue(item, todayStr) && ( + Audit due + )} + {item.status === "active" && !helpers.auditOverdue(item, todayStr) && ( + Active + )} + + {fmt.money(item.price)} + + {fmt.dateShort(item.purchaseDate, tz)} + + + {(item.status === "active" || item.status === "checked-out") + ? remainingShort(item) + : ""} + +
+ ); + })} +
+
+ )} + + {!hasItems && ( +
+
No inventory items yet
+
+ Add inventory through the sidebar to start tracking this SKU. +
+
+ )} + + {hasItems && ( +
+ Cannot delete this SKU while it has inventory items. +
+ )} +
+
+
+ ); +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/web/src/components/modals/SkuModals.tsx b/web/src/components/modals/SkuModals.tsx new file mode 100644 index 0000000..92d9e0b --- /dev/null +++ b/web/src/components/modals/SkuModals.tsx @@ -0,0 +1,329 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../../api.js"; +import type { Bootstrap, Product } from "../../types.js"; +import { TYPES } from "../../types.js"; +import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js"; +import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; + +export function AddSkuModal({ + data, + onClose, +}: { + data: Bootstrap; + onClose: () => void; +}) { + const qc = useQueryClient(); + const [sku, setSku] = useState(""); + const [strainName, setStrainName] = useState(""); + const [brandId, setBrandId] = useState(""); + const [typeId, setTypeId] = useState("Flower"); + const [defaultThc, setDefaultThc] = useState(""); + const [defaultCbd, setDefaultCbd] = useState(""); + + const selectedType = TYPES.find((t) => t.id === typeId) ?? TYPES[0]!; + + const create = useMutation({ + mutationFn: () => + api.createProduct({ + sku: sku.trim(), + strainName: strainName.trim(), + brandId: brandId || null, + type: typeId, + kind: selectedType.kind, + defaultThc: defaultThc ? parseFloat(defaultThc) : undefined, + defaultCbd: defaultCbd ? parseFloat(defaultCbd) : undefined, + defaultTotalCannabinoids: + defaultThc || defaultCbd + ? (parseFloat(defaultThc) || 0) + (parseFloat(defaultCbd) || 0) + : undefined, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["bootstrap"] }); + onClose(); + }, + }); + + const matchingStrains = strainName.trim() + ? data.strains.filter((s) => + s.name.toLowerCase().includes(strainName.trim().toLowerCase()), + ).slice(0, 5) + : []; + + return ( + +
+ +
+
+ + setSku(e.target.value)} + placeholder="e.g. SKU-0042" + /> + + + + +
+ + 0 ? `Matching: ${matchingStrains.map((s) => s.name).join(", ")}` : undefined}> + setStrainName(e.target.value)} + placeholder="e.g. Blue Dream" + /> + + + + + + + {selectedType.showCannabinoidPct && ( +
+ + setDefaultThc(e.target.value)} + placeholder="e.g. 22.5" + /> + + + setDefaultCbd(e.target.value)} + placeholder="e.g. 0.5" + /> + +
+ )} + + {create.isError && ( +
+ {String(create.error instanceof Error ? create.error.message : create.error)} +
+ )} +
+ +
+
+ + Cancel + + create.mutate()} + > + {create.isPending ? "Saving..." : "Add SKU"} + +
+ +
+ + ); +} + +export function EditSkuModal({ + data, + product, + onClose, +}: { + data: Bootstrap; + product: Product; + onClose: () => void; +}) { + const qc = useQueryClient(); + const strain = data.strains.find((s) => s.id === product.strainId); + + const [strainName, setStrainName] = useState(strain?.name ?? ""); + const [brandId, setBrandId] = useState(product.brandId ?? ""); + const [typeId, setTypeId] = useState(product.type); + const [defaultThc, setDefaultThc] = useState( + strain?.defaultThc != null ? String(strain.defaultThc) : "", + ); + const [defaultCbd, setDefaultCbd] = useState( + strain?.defaultCbd != null ? String(strain.defaultCbd) : "", + ); + const [defaultTotal, setDefaultTotal] = useState( + strain?.defaultTotalCannabinoids != null ? String(strain.defaultTotalCannabinoids) : "", + ); + const [notes, setNotes] = useState(strain?.notes ?? ""); + + const selectedType = TYPES.find((t) => t.id === typeId) ?? TYPES[0]!; + + const updateProduct = useMutation({ + mutationFn: async () => { + await api.updateProduct(product.id, { + type: typeId, + kind: selectedType.kind, + strainName: strainName.trim(), + brandId: brandId || null, + }); + if (strain) { + await api.updateStrain(strain.id, { + name: strainName.trim(), + defaultThc: defaultThc ? parseFloat(defaultThc) : null, + defaultCbd: defaultCbd ? parseFloat(defaultCbd) : null, + defaultTotalCannabinoids: defaultTotal ? parseFloat(defaultTotal) : null, + notes: notes.trim() || null, + }); + } + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["bootstrap"] }); + onClose(); + }, + }); + + return ( + +
+ +
+ + setStrainName(e.target.value)} + placeholder="e.g. Blue Dream" + /> + + +
+ + + + + + +
+ + {selectedType.showCannabinoidPct && ( +
+ + setDefaultThc(e.target.value)} + /> + + + setDefaultCbd(e.target.value)} + /> + + + setDefaultTotal(e.target.value)} + /> + +
+ )} + + +