Files
Apothecary/web/src/components/modals/AddInventoryFlow.tsx
T
josh 4044de7bfc
Build and push image / build (push) Successful in 56s
Add timezone preference and fix all date handling to be timezone-aware
Dates were computed using browser/server local time with no explicit timezone,
causing inconsistencies when server runs in UTC. Now all "today" computations
and date formatting use the user's chosen IANA timezone, persisted in
localStorage and selectable from Settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 22:30:01 -04:00

732 lines
23 KiB
TypeScript

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<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 === "details"
? "Batch details"
: step === "done"
? "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);
// 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}
mode="sku"
/>
{!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 barcode, or create a new product."}
</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>
</>
)}
</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 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<string | null>(null);
const update = <K extends keyof typeof form>(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 (
<>
<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={`Unit weight (${cfg?.weightUnit ?? "g"})`} span={2} hint="Weight of one unit — 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="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>
)}
{cfg?.showCannabinoidPct !== false && (
<>
<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>
</>
);
}