Files
Apothecary/web/src/components/modals/AddInventoryFlow.tsx
T
josh 80034b47c5
Build and push image / build (push) Successful in 48s
User-supplied asset ids; brand on product; strain is the name
Four UX changes after using the rework for a bit:

1. Asset ids are 6-digit numbers from a roll of physical labels — server
   no longer generates them. POST /api/inventory requires assetId; the
   add-inventory form has a digits-only input that auto-focuses on entry.
2. Strain and product name are the same thing. Drop products.name; the
   strain's name supplies the display. Product creation just asks for
   "Name (strain)" and matches/creates a strain by that name.
3. Brand moves from inventory_items to products. SKUs are brand-specific,
   so all instances of a product share the brand. Brand selector lives
   on the product create/edit form, not the per-instance form.
4. Scanning an unknown SKU on the add-inventory step now opens the
   create-product subform with the SKU prefilled — one less click.

Migration: detect prior shape (products.name column present) and rename
products/inventory_items/audits to *_v1 archives, recreate empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:17:12 -04:00

777 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
TODAY_STR,
enrichItems,
getLastInstance,
} from "../../types.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<Step>("select");
const [productId, setProductId] = useState<string | null>(null);
const [savedAssetId, setSavedAssetId] = useState<string | null>(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 (
<ModalBackdrop onClose={onClose}>
<div
style={{
width: "min(840px, 96vw)",
margin: "40px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader
title={
step === "select"
? "Add inventory"
: step === "details"
? `Add ${productName}`
: "Saved"
}
eyebrow={
step === "select"
? "Step 1 · Scan or pick a product"
: step === "details"
? "Step 2 · This batch's details"
: "Inventory item created"
}
onClose={onClose}
/>
{step === "select" && (
<SelectProductStep
data={data}
items={items}
onPickProduct={(id) => goToDetails(id)}
onClose={onClose}
/>
)}
{step === "details" && product && (
<InstanceDetailsStep
data={data}
items={items}
product={product}
productName={productName}
onBack={() => setStep("select")}
onSaved={(assetId) => {
setSavedAssetId(assetId);
qc.invalidateQueries({ queryKey: ["bootstrap"] });
setStep("done");
}}
/>
)}
{step === "done" && savedAssetId && product && (
<DonePane
assetId={savedAssetId}
productName={productName}
onAddAnother={() => {
setSavedAssetId(null);
setProductId(null);
setStep("select");
}}
onClose={onClose}
/>
)}
</div>
</ModalBackdrop>
);
}
// ─── 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);
const [pickedProductId, setPickedProductId] = useState<string>(
data.products[0]?.id ?? "",
);
// 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<string>(
data.brands[0]?.id ?? NEW_BRAND,
);
const [newBrandName, setNewBrandName] = useState("");
const [error, setError] = useState<string | null>(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 (
<>
<div style={{ padding: 32 }}>
<ScanField
items={items}
products={data.products}
onMatch={handleScan}
onScanNoMatch={creating ? undefined : handleNoMatch}
matchedLabel={null}
/>
{data.products.length > 0 && !creating && (
<div style={{ marginTop: 16 }}>
<Field label="Or pick an existing product">
<Select
value={pickedProductId}
onChange={(e) => setPickedProductId(e.target.value)}
>
{data.products.map((p) => {
const strainName =
data.strains.find((s) => s.id === p.strainId)?.name ?? "?";
return (
<option key={p.id} value={p.id}>
{strainName} · {p.sku} ({p.type})
</option>
);
})}
</Select>
</Field>
</div>
)}
{!creating && (
<div style={{ marginTop: 18 }}>
<Btn variant="ghost" icon="plus" onClick={() => setCreating(true)}>
Create a new product
</Btn>
</div>
)}
{creating && (
<div
style={{
marginTop: 24,
padding: 20,
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
New product (catalog entry)
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16 }}>
<Field label="SKU" hint="The barcode you'll scan">
<Input
value={newSku}
placeholder="SKU-XXXXXX"
onChange={(e) => setNewSku(e.target.value)}
autoFocus={!newSku}
/>
</Field>
<Field label="Type">
<Select value={newType} onChange={(e) => setNewType(e.target.value)}>
{TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.id} ({t.kind})
</option>
))}
</Select>
</Field>
<Field
label="Name (strain)"
span={2}
hint={
matchedStrain
? `Will link to existing strain "${matchedStrain.name}".`
: "Will create a new strain entry from this name."
}
>
<Input
value={newName}
placeholder="e.g. Garden Ghost"
onChange={(e) => setNewName(e.target.value)}
/>
</Field>
<Field label="Brand">
<Select
value={newBrandId}
onChange={(e) => setNewBrandId(e.target.value)}
>
{data.brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
<option value={NEW_BRAND}>+ Add new brand</option>
</Select>
</Field>
{isNewBrand && (
<Field label="New brand name">
<Input
value={newBrandName}
onChange={(e) => setNewBrandName(e.target.value)}
placeholder="e.g. Foxglove Farms"
/>
</Field>
)}
</div>
{error && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
</div>
)}
</div>
<ModalFooter>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{creating
? "Create the product, then we'll capture this batch's details."
: "Scan a SKU, pick a product, or create one."}
</div>
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>
Cancel
</Btn>
{creating ? (
<>
<Btn variant="ghost" onClick={() => setCreating(false)}>
Back
</Btn>
<Btn
variant="primary"
icon="check"
disabled={create.isPending}
onClick={() => create.mutate()}
>
{create.isPending ? "Creating…" : "Create product"}
</Btn>
</>
) : (
data.products.length > 0 && (
<Btn
variant="primary"
icon="plus"
disabled={!pickedProductId}
onClick={() => onPickProduct(pickedProductId)}
>
Add inventory
</Btn>
)
)}
</div>
</ModalFooter>
</>
);
}
// ─── 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 [assetId, setAssetId] = useState("");
const [form, setForm] = useState({
shopId: last?.shopId ?? data.shops[0]?.id ?? NEW_SHOP,
binId: data.bins[0]?.id ?? NEW_BIN,
weight: last?.weight ?? (isDiscrete ? 0 : 3.5),
countOriginal: last?.countOriginal ?? (isDiscrete ? 1 : 0),
unitWeight: last?.unitWeight ?? (isDiscrete ? 0.7 : 0),
price: initialPrice,
thc: last?.thc ?? 22,
cbd: last?.cbd ?? 0.4,
totalCannabinoids: last?.totalCannabinoids ?? 26,
purchaseDate: TODAY_STR,
});
const [newShopName, setNewShopName] = useState("");
const [newShopLocation, setNewShopLocation] = useState("");
const [newBinName, setNewBinName] = useState("");
const [newBinCapacity, setNewBinCapacity] = useState(10);
const [error, setError] = useState<string | null>(null);
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const totalPrice = isDiscrete ? form.price * form.countOriginal : form.price;
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 ? form.countOriginal : undefined,
unitWeight: isDiscrete ? form.unitWeight : undefined,
price: totalPrice,
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 (
<>
<div style={{ padding: 32 }}>
<div
style={{
marginBottom: 20,
padding: "12px 16px",
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-sm)",
fontSize: 12,
color: "var(--ink-3)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
<strong style={{ color: "var(--ink-2)" }}>{productName}</strong> · {brandName} · {product.type} ·{" "}
<span className="mono">{product.sku}</span>
</span>
<span>
{priorCount > 0
? `${priorCount} prior instance${priorCount === 1 ? "" : "s"} — fields autofilled.`
: "First instance of this product."}
</span>
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
Asset tag
</div>
<Field
label="Asset id (6 digits)"
hint={
assetIdConflict
? "That asset id is already in use."
: "From the next sticker on your roll. Required."
}
>
<Input
autoFocus
value={assetId}
inputMode="numeric"
maxLength={6}
placeholder="000000"
onChange={(e) => setAssetId(e.target.value.replace(/\D/g, "").slice(0, 6))}
style={{
fontFamily: "var(--mono)",
letterSpacing: "0.1em",
borderColor: assetIdConflict ? "var(--terracotta)" : undefined,
}}
/>
</Field>
<div
className="smallcaps"
style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}
>
Source
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: 16,
marginBottom: 28,
}}
>
<Field label="Shop">
<Select value={form.shopId} onChange={(e) => update("shopId", e.target.value)}>
{data.shops.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
<option value={NEW_SHOP}>+ Add new shop</option>
</Select>
</Field>
<Field label="Bin">
<Select value={form.binId} onChange={(e) => update("binId", e.target.value)}>
{data.bins.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
<option value={NEW_BIN}>+ Add new bin</option>
</Select>
</Field>
{isNewShop && (
<>
<Field label="New shop name">
<Input
value={newShopName}
onChange={(e) => setNewShopName(e.target.value)}
placeholder="e.g. Greenleaf Co-op"
/>
</Field>
<Field label="Location (optional)">
<Input
value={newShopLocation}
onChange={(e) => setNewShopLocation(e.target.value)}
placeholder="e.g. Capitol Hill"
/>
</Field>
</>
)}
{isNewBin && (
<>
<Field label="New bin name">
<Input
value={newBinName}
onChange={(e) => setNewBinName(e.target.value)}
placeholder="e.g. A1"
/>
</Field>
<Field label="Capacity">
<Input
type="number"
min={1}
step={1}
value={newBinCapacity}
onChange={(e) =>
setNewBinCapacity(Math.max(1, Math.floor(+e.target.value || 1)))
}
/>
</Field>
</>
)}
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
Acquisition
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 16,
marginBottom: 8,
}}
>
{isDiscrete ? (
<>
<Field label={`Quantity (${cfg!.unit})`}>
<Input
type="number"
step="1"
value={form.countOriginal}
onChange={(e) => update("countOriginal", +e.target.value)}
/>
</Field>
<Field label="Per-unit weight (g)" hint="For grams stats">
<Input
type="number"
step="0.1"
value={form.unitWeight}
onChange={(e) => update("unitWeight", +e.target.value)}
/>
</Field>
</>
) : (
<Field label={`Size (${cfg?.unit ?? "g"})`} span={2}>
<Input
type="number"
step="0.1"
value={form.weight}
onChange={(e) => update("weight", +e.target.value)}
/>
</Field>
)}
<Field label={isDiscrete ? "Price per unit ($)" : "Price ($)"}>
<Input
type="number"
step="0.01"
value={form.price}
onChange={(e) => update("price", +e.target.value)}
/>
</Field>
<Field label="Purchase date">
<Input
type="date"
value={form.purchaseDate}
onChange={(e) => update("purchaseDate", e.target.value)}
/>
</Field>
</div>
{!isDiscrete && cpg > 0 && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--ink-3)" }}>
Cost per {cfg?.unit ?? "g"}:{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>
{fmt.money(cpg)}
</span>
</div>
)}
{isDiscrete && form.price > 0 && form.countOriginal > 0 && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--ink-3)" }}>
Total:{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>
{fmt.money(totalPrice)}
</span>
<span style={{ marginLeft: 6 }}>
({form.countOriginal} × {fmt.money(form.price)})
</span>
</div>
)}
<div
className="smallcaps"
style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}
>
Cannabinoid profile
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
<Field label="THC %">
<Input
type="number"
step="0.1"
value={form.thc}
onChange={(e) => update("thc", +e.target.value)}
/>
</Field>
<Field label="CBD %">
<Input
type="number"
step="0.1"
value={form.cbd}
onChange={(e) => update("cbd", +e.target.value)}
/>
</Field>
<Field label="Total cannabinoids %">
<Input
type="number"
step="0.1"
value={form.totalCannabinoids}
onChange={(e) => update("totalCannabinoids", +e.target.value)}
/>
</Field>
</div>
{error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
</div>
<ModalFooter>
<Btn variant="ghost" onClick={onBack}>
Back
</Btn>
<Btn
variant="primary"
icon="check"
disabled={save.isPending || !assetIdValid || assetIdConflict}
onClick={() => save.mutate()}
>
{save.isPending ? "Saving…" : "Save inventory item"}
</Btn>
</ModalFooter>
</>
);
}
// ─── Step 3 ─────────────────────────────────────────────────────────
function DonePane({
assetId,
productName,
onAddAnother,
onClose,
}: {
assetId: string;
productName: string;
onAddAnother: () => void;
onClose: () => void;
}) {
return (
<>
<div style={{ padding: "48px 32px 32px", textAlign: "center" }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Asset id</div>
<div
className="mono"
style={{
fontSize: 64,
fontWeight: 600,
letterSpacing: "0.08em",
margin: "16px 0",
color: "var(--ink)",
}}
>
{assetId}
</div>
<div style={{ fontSize: 13, color: "var(--ink-2)", marginBottom: 8 }}>
Stick label{" "}
<span className="mono" style={{ color: "var(--ink)" }}>{assetId}</span>{" "}
on the {productName}.
</div>
</div>
<ModalFooter>
<Btn variant="ghost" onClick={onAddAnother}>
+ Add another
</Btn>
<Btn variant="primary" icon="check" onClick={onClose}>
Done
</Btn>
</ModalFooter>
</>
);
}