import { useEffect, useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, InventoryItem, Item, Product, Strain } from "../../types.js"; import { ASSET_ID_RE, TYPES, enrichItems, getLastInstance, } from "../../types.js"; import { getToday, getStoredTimezone } from "../../tz.js"; import { fmt } from "../../format.js"; import { api } from "../../api.js"; import { Btn, Field, Input, Select } from "../primitives/index.js"; import { ScanField, type ScanResult } from "../ScanField.js"; import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; const NEW_BRAND = "__new_brand__"; const NEW_SHOP = "__new_shop__"; const NEW_BIN = "__new_bin__"; type Step = "select" | "details" | "done"; export function AddInventoryFlow({ data, onClose }: { data: Bootstrap; onClose: () => void }) { const qc = useQueryClient(); const items = useMemo(() => enrichItems(data), [data]); const [step, setStep] = useState("select"); const [productId, setProductId] = useState(null); const [savedAssetId, setSavedAssetId] = useState(null); const product = productId ? data.products.find((p) => p.id === productId) ?? null : null; const productName = product?.strainId ? data.strains.find((s) => s.id === product.strainId)?.name ?? "" : ""; const goToDetails = (id: string) => { setProductId(id); setStep("details"); }; return (
{step === "select" && ( goToDetails(id)} onClose={onClose} /> )} {step === "details" && product && ( setStep("select")} onSaved={(assetId) => { setSavedAssetId(assetId); qc.invalidateQueries({ queryKey: ["bootstrap"] }); setStep("done"); }} /> )} {step === "done" && savedAssetId && product && ( { setSavedAssetId(null); setProductId(null); setStep("select"); }} onClose={onClose} /> )}
); } // ─── Step 1 ───────────────────────────────────────────────────────── function SelectProductStep({ data, items, onPickProduct, onClose, }: { data: Bootstrap; items: Item[]; onPickProduct: (productId: string) => void; onClose: () => void; }) { const qc = useQueryClient(); const [creating, setCreating] = useState(false); // New-product subform const [newSku, setNewSku] = useState(""); const [newName, setNewName] = useState(""); // strain name; doubles as display name const [newType, setNewType] = useState("Flower"); const [newBrandId, setNewBrandId] = useState( data.brands[0]?.id ?? NEW_BRAND, ); const [newBrandName, setNewBrandName] = useState(""); const [error, setError] = useState(null); const handleScan = (result: ScanResult) => { if (result.kind === "product") { onPickProduct(result.product.id); } else { // Asset scan in add-inventory flow — interpret as "I want another of // this kind", select that product so the form pre-fills from the // existing instance. onPickProduct(result.item.productId); } }; const handleNoMatch = (raw: string) => { // Scanned a SKU we've never seen — open the create form prefilled. setNewSku(raw.toUpperCase()); setCreating(true); }; const matchedStrain: Strain | null = useMemo(() => { const q = newName.trim().toLowerCase(); if (!q) return null; return data.strains.find((s) => s.name.trim().toLowerCase() === q) ?? null; }, [newName, data.strains]); const create = useMutation({ mutationFn: async () => { const sku = newSku.trim(); const name = newName.trim(); if (!sku) throw new Error("SKU required"); if (!name) throw new Error("Name (strain) required"); const cfg = TYPES.find((t) => t.id === newType); if (!cfg) throw new Error("Type required"); let brandId: string | null = null; if (newBrandId === NEW_BRAND) { if (!newBrandName.trim()) throw new Error("New brand name required"); const b = await api.createBrand(newBrandName.trim()); brandId = b.id; } else { brandId = newBrandId || null; } const result = await api.createProduct({ sku, type: newType, kind: cfg.kind, strainId: matchedStrain?.id, strainName: matchedStrain ? undefined : name, brandId, }); return result.id; }, onSuccess: async (id) => { await qc.invalidateQueries({ queryKey: ["bootstrap"] }); onPickProduct(id); }, onError: (e: Error) => setError(e.message), }); const isNewBrand = newBrandId === NEW_BRAND; return ( <>
{!creating && (
setCreating(true)}> Create a new product
)} {creating && (
New product (catalog entry)
setNewSku(e.target.value)} autoFocus={!newSku} /> setNewName(e.target.value)} /> {isNewBrand && ( setNewBrandName(e.target.value)} placeholder="e.g. Foxglove Farms" /> )}
{error && (
{error}
)}
)}
{creating ? "Create the product, then we'll capture this batch's details." : "Scan a SKU barcode, or create a new product."}
Cancel {creating && ( <> setCreating(false)}> Back create.mutate()} > {create.isPending ? "Creating…" : "Create product"} )}
); } // ─── Step 2 ───────────────────────────────────────────────────────── function InstanceDetailsStep({ data, items, product, productName, onBack, onSaved, }: { data: Bootstrap; items: Item[]; product: Product; productName: string; onBack: () => void; onSaved: (assetId: string) => void; }) { const last: InventoryItem | null = useMemo( () => getLastInstance(data.inventoryItems, product.id), [data.inventoryItems, product.id], ); const cfg = TYPES.find((t) => t.id === product.type); const isDiscrete = product.kind === "discrete"; // form.price is total for bulk, per-unit for discrete (matches existing UI). const initialPrice = (() => { if (!last) return isDiscrete ? 5 : 45; if (isDiscrete && last.countOriginal > 0) return last.price / last.countOriginal; return last.price; })(); const nextAssetId = useMemo(() => { const max = data.inventoryItems.reduce( (m, i) => Math.max(m, parseInt(i.assetId, 10) || 0), 0, ); return max > 0 ? String(max + 1).padStart(6, "0") : ""; }, [data.inventoryItems]); const [assetId, setAssetId] = useState(nextAssetId); const [form, setForm] = useState({ shopId: last?.shopId ?? data.shops[0]?.id ?? NEW_SHOP, binId: last?.binId ?? data.bins[0]?.id ?? NEW_BIN, weight: last?.weight ?? (isDiscrete ? 0 : 3.5), unitWeight: last?.unitWeight ?? (isDiscrete ? 0.7 : 0), price: initialPrice, thc: last?.thc ?? (cfg?.showCannabinoidPct !== false ? 22 : 0), cbd: last?.cbd ?? (cfg?.showCannabinoidPct !== false ? 0.4 : 0), totalCannabinoids: last?.totalCannabinoids ?? (cfg?.showCannabinoidPct !== false ? 26 : 0), purchaseDate: getToday(getStoredTimezone()), }); const [newShopName, setNewShopName] = useState(""); const [newShopLocation, setNewShopLocation] = useState(""); const [newBinName, setNewBinName] = useState(""); const [newBinCapacity, setNewBinCapacity] = useState(10); const [error, setError] = useState(null); const update = (k: K, v: (typeof form)[K]) => setForm((f) => ({ ...f, [k]: v })); const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0; const assetIdValid = ASSET_ID_RE.test(assetId); const assetIdConflict = assetIdValid && data.inventoryItems.some((i) => i.assetId === assetId); const save = useMutation({ mutationFn: async () => { if (!assetIdValid) throw new Error("Asset id must be exactly 6 digits"); if (assetIdConflict) throw new Error("Asset id already used"); let { shopId, binId } = form; if (shopId === NEW_SHOP) { if (!newShopName.trim()) throw new Error("New shop name required"); const s = await api.createShop({ name: newShopName.trim(), location: newShopLocation.trim(), }); shopId = s.id; } if (binId === NEW_BIN) { if (!newBinName.trim()) throw new Error("New bin name required"); const b = await api.createBin({ name: newBinName.trim(), capacity: newBinCapacity, }); binId = b.id; } return api.createInventoryItem({ assetId, productId: product.id, shopId, binId, weight: isDiscrete ? undefined : form.weight, countOriginal: isDiscrete ? 1 : undefined, unitWeight: isDiscrete ? form.unitWeight : undefined, price: form.price, thc: form.thc, cbd: form.cbd, totalCannabinoids: form.totalCannabinoids, purchaseDate: form.purchaseDate, }); }, onSuccess: (result) => onSaved(result.assetId), onError: (e: Error) => setError(e.message), }); const isNewShop = form.shopId === NEW_SHOP; const isNewBin = form.binId === NEW_BIN; const priorCount = items.filter((i) => i.productId === product.id).length; const brandName = data.brands.find((b) => b.id === product.brandId)?.name ?? "—"; return ( <>
{productName} · {brandName} · {product.type} ·{" "} {product.sku} {priorCount > 0 ? `${priorCount} prior instance${priorCount === 1 ? "" : "s"} — fields autofilled.` : "First instance of this product."}
Asset tag
setAssetId(e.target.value.replace(/\D/g, "").slice(0, 6))} style={{ fontFamily: "var(--mono)", letterSpacing: "0.1em", borderColor: assetIdConflict ? "var(--terracotta)" : undefined, }} />
Source
{isNewShop && ( <> setNewShopName(e.target.value)} placeholder="e.g. Greenleaf Co-op" /> setNewShopLocation(e.target.value)} placeholder="e.g. Capitol Hill" /> )} {isNewBin && ( <> setNewBinName(e.target.value)} placeholder="e.g. A1" /> setNewBinCapacity(Math.max(1, Math.floor(+e.target.value || 1))) } /> )}
Acquisition
{isDiscrete ? ( update("unitWeight", +e.target.value)} /> ) : ( update("weight", +e.target.value)} /> )} update("price", +e.target.value)} /> update("purchaseDate", e.target.value)} />
{!isDiscrete && cpg > 0 && (
Cost per {cfg?.unit ?? "g"}:{" "} {fmt.money(cpg)}
)} {cfg?.showCannabinoidPct !== false && ( <>
Cannabinoid profile
update("thc", +e.target.value)} /> update("cbd", +e.target.value)} /> update("totalCannabinoids", +e.target.value)} />
)} {error && (
{error}
)}
← Back save.mutate()} > {save.isPending ? "Saving…" : "Save inventory item"} ); } // ─── Step 3 ───────────────────────────────────────────────────────── function DonePane({ assetId, productName, onAddAnother, onClose, }: { assetId: string; productName: string; onAddAnother: () => void; onClose: () => void; }) { return ( <>
Asset id
{assetId}
Stick label{" "} {assetId}{" "} on the {productName}.
+ Add another Done ); }