80034b47c5
Build and push image / build (push) Successful in 48s
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>
777 lines
24 KiB
TypeScript
777 lines
24 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,
|
||
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>
|
||
</>
|
||
);
|
||
}
|