4044de7bfc
Build and push image / build (push) Successful in 56s
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>
732 lines
23 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|