Track inventory at the instance level, not by product
Build and push image / build (push) Successful in 46s
Build and push image / build (push) Successful in 46s
The products table conflated catalog ("kind of thing you scan") with
instance ("this jar I bought") — splitting it lets us record every
purchase as its own asset and autofill brand/shop/price/THC from the
last instance when scanning a known SKU.
- products: sku + strain + name + type + kind (catalog only)
- inventory_items: physical jars with short-UUID asset ids, per-batch
brand/shop/bin/price/cannabinoids/weight, audits, lifecycle
- audits now key on inventory_id; strains lose brand_id and type
- migration: rename existing products/audits/strains to *_legacy on
first boot so users keep historical reference, fresh start otherwise
- two-step add flow: scan SKU → select/create product → instance
details (autofilled from last instance) → generated asset id shown
- ScanField matches asset id first, falls back to SKU
- inventory list defaults flat, "By product" toggle groups instances
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,730 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, InventoryItem, Item, Product, Strain } from "../../types.js";
|
||||
import { 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 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 ${product?.name ?? ""}`
|
||||
: "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}
|
||||
onBack={() => setStep("select")}
|
||||
onSaved={(assetId) => {
|
||||
setSavedAssetId(assetId);
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
setStep("done");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "done" && savedAssetId && product && (
|
||||
<DonePane
|
||||
assetId={savedAssetId}
|
||||
productName={product.name}
|
||||
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("");
|
||||
const [newType, setNewType] = useState("Flower");
|
||||
const [newStrain, setNewStrain] = useState(""); // typed strain name
|
||||
const [newStrainId, setNewStrainId] = useState<string>(""); // empty = match-by-name / create
|
||||
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 matchedStrain: Strain | null = useMemo(() => {
|
||||
const q = newStrain.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return data.strains.find((s) => s.name.trim().toLowerCase() === q) ?? null;
|
||||
}, [newStrain, data.strains]);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
const sku = newSku.trim();
|
||||
const name = newName.trim();
|
||||
const strainName = newStrain.trim() || name;
|
||||
if (!sku) throw new Error("SKU required");
|
||||
if (!name) throw new Error("Product name required");
|
||||
const cfg = TYPES.find((t) => t.id === newType);
|
||||
if (!cfg) throw new Error("Type required");
|
||||
|
||||
const result = await api.createProduct({
|
||||
sku,
|
||||
name,
|
||||
type: newType,
|
||||
kind: cfg.kind,
|
||||
strainId: newStrainId || matchedStrain?.id || undefined,
|
||||
strainName: newStrainId || matchedStrain ? undefined : strainName,
|
||||
});
|
||||
return result.id;
|
||||
},
|
||||
onSuccess: async (id) => {
|
||||
await qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onPickProduct(id);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: 32 }}>
|
||||
<ScanField
|
||||
items={items}
|
||||
products={data.products}
|
||||
onMatch={handleScan}
|
||||
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) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} · {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)}
|
||||
/>
|
||||
</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="Product name" span={2}>
|
||||
<Input
|
||||
value={newName}
|
||||
placeholder="e.g. Garden Ghost 3.5g"
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Strain"
|
||||
span={2}
|
||||
hint={
|
||||
matchedStrain
|
||||
? `Will link to existing strain "${matchedStrain.name}".`
|
||||
: "Will create a new strain entry from this name (defaults blank — link from product later)."
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={newStrainId}
|
||||
onChange={(e) => setNewStrainId(e.target.value)}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<option value="">— Match by name typed below —</option>
|
||||
{data.strains.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
value={newStrain}
|
||||
placeholder={`Strain name (defaults to product name if blank)`}
|
||||
onChange={(e) => setNewStrain(e.target.value)}
|
||||
disabled={!!newStrainId}
|
||||
/>
|
||||
</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,
|
||||
onBack,
|
||||
onSaved,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
items: Item[];
|
||||
product: Product;
|
||||
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 [form, setForm] = useState({
|
||||
brandId: last?.brandId ?? data.brands[0]?.id ?? NEW_BRAND,
|
||||
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 [newBrand, setNewBrand] = useState("");
|
||||
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 save = useMutation({
|
||||
mutationFn: async () => {
|
||||
let { brandId, shopId, binId } = form;
|
||||
if (brandId === NEW_BRAND) {
|
||||
if (!newBrand.trim()) throw new Error("New brand name required");
|
||||
const b = await api.createBrand(newBrand.trim());
|
||||
brandId = b.id;
|
||||
}
|
||||
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({
|
||||
productId: product.id,
|
||||
brandId,
|
||||
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 isNewBrand = form.brandId === NEW_BRAND;
|
||||
const isNewShop = form.shopId === NEW_SHOP;
|
||||
const isNewBin = form.binId === NEW_BIN;
|
||||
|
||||
// Find prior instances of this product (excluding any from before bootstrap;
|
||||
// safe — we just count to surface "we've had this N times before").
|
||||
const priorCount = items.filter((i) => i.productId === product.id).length;
|
||||
|
||||
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)" }}>{product.name}</strong> · {product.type} ·{" "}
|
||||
<span className="mono">{product.sku}</span>
|
||||
</span>
|
||||
<span>
|
||||
{priorCount > 0
|
||||
? `${priorCount} prior instance${priorCount === 1 ? "" : "s"} — fields autofilled from most recent.`
|
||||
: "First instance of this product."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
|
||||
Source
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
gap: 16,
|
||||
marginBottom: 28,
|
||||
}}
|
||||
>
|
||||
<Field label="Brand">
|
||||
<Select value={form.brandId} onChange={(e) => update("brandId", 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>
|
||||
<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>
|
||||
{isNewBrand && (
|
||||
<Field label="New brand name" span={2}>
|
||||
<Input
|
||||
value={newBrand}
|
||||
onChange={(e) => setNewBrand(e.target.value)}
|
||||
placeholder="e.g. Foxglove Farms"
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
{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}
|
||||
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 }}>
|
||||
Label this {productName} with{" "}
|
||||
<span className="mono" style={{ color: "var(--ink)" }}>{assetId}</span>{" "}
|
||||
so you can scan it later.
|
||||
</div>
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<Btn variant="ghost" onClick={onAddAnother}>
|
||||
+ Add another
|
||||
</Btn>
|
||||
<Btn variant="primary" icon="check" onClick={onClose}>
|
||||
Done
|
||||
</Btn>
|
||||
</ModalFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Product } from "../../types.js";
|
||||
import { TYPES, helpers, TODAY_STR } from "../../types.js";
|
||||
import type { Bootstrap, Item } from "../../types.js";
|
||||
import { TYPES, helpers, TODAY_STR, enrichItems } from "../../types.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||
import { ScanField } from "../ScanField.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
|
||||
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||
|
||||
const AUDIT_MODE_LABELS: Record<string, { title: string; desc: string }> = {
|
||||
weigh: {
|
||||
@@ -25,40 +25,41 @@ const AUDIT_MODE_LABELS: Record<string, { title: string; desc: string }> = {
|
||||
export function AuditFlow({
|
||||
data,
|
||||
onClose,
|
||||
product: initialProduct,
|
||||
item: initialItem,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onClose: () => void;
|
||||
product: Product | null;
|
||||
item: Item | null;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const overdueFirst = [...data.products]
|
||||
.filter((p) => p.status === "active")
|
||||
const allItems = enrichItems(data);
|
||||
const overdueFirst = [...allItems]
|
||||
.filter((i) => i.status === "active")
|
||||
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
|
||||
|
||||
const [productId, setProductId] = useState(initialProduct?.id ?? overdueFirst[0]?.id ?? "");
|
||||
const [itemId, setItemId] = useState(initialItem?.id ?? overdueFirst[0]?.id ?? "");
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
const [confirmedBy, setConfirmedBy] = useState<"SKU" | "asset" | "visual">("SKU");
|
||||
const [confirmedBy, setConfirmedBy] = useState<"asset" | "SKU" | "visual">("asset");
|
||||
|
||||
const product = data.products.find((p) => p.id === productId);
|
||||
const cfg = product ? TYPES.find((t) => t.id === product.type) : undefined;
|
||||
const item = allItems.find((i) => i.id === itemId);
|
||||
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined;
|
||||
|
||||
const initialValueFor = (p: Product | undefined): string => {
|
||||
if (!p) return "0";
|
||||
if (p.kind === "discrete") {
|
||||
return String(p.countLastAudit ?? p.countOriginal);
|
||||
const initialValueFor = (i: Item | undefined): string => {
|
||||
if (!i) return "0";
|
||||
if (i.kind === "discrete") {
|
||||
return String(i.countLastAudit ?? i.countOriginal);
|
||||
}
|
||||
return helpers.estimatedRemaining(p, TODAY_STR).toFixed(2);
|
||||
return helpers.estimatedRemaining(i, TODAY_STR).toFixed(2);
|
||||
};
|
||||
const [value, setValue] = useState<string>(initialValueFor(product));
|
||||
const [value, setValue] = useState<string>(initialValueFor(item));
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialValueFor(product));
|
||||
}, [productId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
setValue(initialValueFor(item));
|
||||
}, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const audit = useMutation({
|
||||
mutationFn: () =>
|
||||
api.auditProduct(productId, {
|
||||
api.auditInventoryItem(itemId, {
|
||||
date,
|
||||
mode: cfg?.auditMode ?? "weigh",
|
||||
value: Number(value),
|
||||
@@ -70,17 +71,29 @@ export function AuditFlow({
|
||||
},
|
||||
});
|
||||
|
||||
if (!product) return null;
|
||||
const handleScan = (result: ScanResult) => {
|
||||
if (result.kind === "item") {
|
||||
setItemId(result.item.id);
|
||||
} else {
|
||||
// SKU scan — pick the most recent active instance of that product.
|
||||
const candidate = overdueFirst
|
||||
.filter((i) => i.productId === result.product.id)
|
||||
.sort((a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate))[0];
|
||||
if (candidate) setItemId(candidate.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (!item) return null;
|
||||
const auditMode = cfg?.auditMode ?? "weigh";
|
||||
const ml = AUDIT_MODE_LABELS[auditMode] ?? AUDIT_MODE_LABELS.weigh!;
|
||||
|
||||
const last = helpers.lastAudit(product);
|
||||
const last = helpers.lastAudit(item);
|
||||
const prevValue =
|
||||
product.kind === "discrete"
|
||||
? product.countLastAudit ?? product.countOriginal
|
||||
item.kind === "discrete"
|
||||
? item.countLastAudit ?? item.countOriginal
|
||||
: last
|
||||
? last.value
|
||||
: product.weight;
|
||||
: item.weight;
|
||||
|
||||
const delta = Number(value) - prevValue;
|
||||
|
||||
@@ -100,21 +113,22 @@ export function AuditFlow({
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<ScanField
|
||||
products={overdueFirst}
|
||||
matchedProduct={product ?? null}
|
||||
onMatch={setProductId}
|
||||
items={overdueFirst}
|
||||
products={data.products}
|
||||
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
||||
onMatch={handleScan}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Field label="Or pick from list">
|
||||
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
|
||||
{overdueFirst.map((p) => {
|
||||
const od = helpers.auditOverdue(p);
|
||||
const sc = helpers.daysSinceCheck(p);
|
||||
<Select value={itemId} onChange={(e) => setItemId(e.target.value)}>
|
||||
{overdueFirst.map((i) => {
|
||||
const od = helpers.auditOverdue(i);
|
||||
const sc = helpers.daysSinceCheck(i);
|
||||
return (
|
||||
<option key={p.id} value={p.id}>
|
||||
<option key={i.id} value={i.id}>
|
||||
{od ? "⚠ " : ""}
|
||||
{p.name} — {helpers.brandName(data, p.brandId)} · {sc}d since check
|
||||
{i.assetId} · {i.name} — {helpers.brandName(data, i.brandId)} · {sc}d since check
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
@@ -134,16 +148,16 @@ export function AuditFlow({
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 20, fontWeight: 500 }}>
|
||||
{product.name}
|
||||
{item.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
{product.type} · {product.kind} · cadence every {cfg?.cadenceDays}d
|
||||
<span className="mono">{item.assetId}</span> · {item.type} · {item.kind} · cadence every {cfg?.cadenceDays}d
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LAST CHECKED</div>
|
||||
<div className="serif" style={{ fontSize: 18 }}>
|
||||
{last ? `${helpers.daysSinceCheck(product)}d ago` : "Never"}
|
||||
{last ? `${helpers.daysSinceCheck(item)}d ago` : "Never"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,7 +176,7 @@ export function AuditFlow({
|
||||
>
|
||||
<Field
|
||||
label={
|
||||
product.kind === "discrete"
|
||||
item.kind === "discrete"
|
||||
? `Count now (${cfg?.unit})`
|
||||
: auditMode === "weigh"
|
||||
? `Weight now (${cfg?.unit})`
|
||||
@@ -171,7 +185,7 @@ export function AuditFlow({
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
step={product.kind === "discrete" ? "1" : "0.1"}
|
||||
step={item.kind === "discrete" ? "1" : "0.1"}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
@@ -185,8 +199,8 @@ export function AuditFlow({
|
||||
value={confirmedBy}
|
||||
onChange={(e) => setConfirmedBy(e.target.value as typeof confirmedBy)}
|
||||
>
|
||||
<option value="asset">Asset id</option>
|
||||
<option value="SKU">SKU label</option>
|
||||
<option value="asset">Asset tag</option>
|
||||
<option value="visual">Visual ID</option>
|
||||
</Select>
|
||||
</Field>
|
||||
@@ -226,7 +240,7 @@ export function AuditFlow({
|
||||
color: delta < 0 ? "var(--terracotta)" : "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{delta.toFixed(product.kind === "discrete" ? 0 : 2)} {cfg?.unit}
|
||||
{delta.toFixed(item.kind === "discrete" ? 0 : 2)} {cfg?.unit}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Input } from "../primitives/index.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||
|
||||
export function AddBrandModal({ onClose }: { onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -1,43 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Product } from "../../types.js";
|
||||
import { helpers, TODAY_STR } from "../../types.js";
|
||||
import type { Bootstrap, Item } from "../../types.js";
|
||||
import { helpers, TODAY_STR, enrichItems } from "../../types.js";
|
||||
import { remainingShort } from "../../stats.js";
|
||||
import { fmt } from "../../format.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Icon, Input, Select, Textarea } from "../primitives/index.js";
|
||||
import { ScanField } from "../ScanField.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
|
||||
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||
|
||||
export function ConsumeFlow({
|
||||
data,
|
||||
onClose,
|
||||
product: initialProduct,
|
||||
item: initialItem,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onClose: () => void;
|
||||
product: Product | null;
|
||||
item: Item | null;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const active = data.products.filter((p) => p.status === "active");
|
||||
const [productId, setProductId] = useState(initialProduct?.id ?? active[0]?.id ?? "");
|
||||
const allItems = enrichItems(data);
|
||||
const active = allItems.filter((i) => i.status === "active");
|
||||
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
|
||||
const [rating, setRating] = useState(4);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
|
||||
const product = data.products.find((p) => p.id === productId);
|
||||
const item = allItems.find((i) => i.id === itemId);
|
||||
|
||||
const finish = useMutation({
|
||||
mutationFn: () => api.finishProduct(productId, { date, rating, notes }),
|
||||
mutationFn: () => api.finishInventoryItem(itemId, { date, rating, notes }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
if (!product) return null;
|
||||
const bin = data.bins.find((b) => b.id === product.binId);
|
||||
const lifespan = Math.round((+new Date(date) - +new Date(product.purchaseDate)) / 86_400_000);
|
||||
const handleScan = (result: ScanResult) => {
|
||||
if (result.kind === "item") {
|
||||
setItemId(result.item.id);
|
||||
} else {
|
||||
const candidate = active
|
||||
.filter((i) => i.productId === result.product.id)
|
||||
.sort((a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate))[0];
|
||||
if (candidate) setItemId(candidate.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (!item) return null;
|
||||
const bin = data.bins.find((b) => b.id === item.binId);
|
||||
const lifespan = Math.round((+new Date(date) - +new Date(item.purchaseDate)) / 86_400_000);
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
@@ -55,17 +67,18 @@ export function ConsumeFlow({
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<ScanField
|
||||
products={active}
|
||||
matchedProduct={product ?? null}
|
||||
onMatch={setProductId}
|
||||
items={active}
|
||||
products={data.products}
|
||||
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
||||
onMatch={handleScan}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Field label="Or pick from list">
|
||||
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
|
||||
{active.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} — {helpers.brandName(data, p.brandId)} ({remainingShort(p)} left)
|
||||
<Select value={itemId} onChange={(e) => setItemId(e.target.value)}>
|
||||
{active.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.assetId} · {i.name} — {helpers.brandName(data, i.brandId)} ({remainingShort(i)} left)
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -86,11 +99,11 @@ export function ConsumeFlow({
|
||||
>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||||
{product.name}
|
||||
{item.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
{helpers.brandName(data, product.brandId)} · {bin?.name} · purchased{" "}
|
||||
{fmt.dateShort(product.purchaseDate)}
|
||||
<span className="mono">{item.assetId}</span> · {helpers.brandName(data, item.brandId)} · {bin?.name} · purchased{" "}
|
||||
{fmt.dateShort(item.purchaseDate)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
|
||||
+151
-211
@@ -1,34 +1,46 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Strain } from "../../types.js";
|
||||
import { TYPES, TODAY_STR } from "../../types.js";
|
||||
import { fmt } from "../../format.js";
|
||||
import type { Bootstrap, Item } from "../../types.js";
|
||||
import { TYPES } from "../../types.js";
|
||||
import { fmt, TYPE_GLYPHS } from "../../format.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||
|
||||
const NEW_BRAND = "__new_brand__";
|
||||
const NEW_SHOP = "__new_shop__";
|
||||
const NEW_BIN = "__new_bin__";
|
||||
|
||||
export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () => void }) {
|
||||
export function EditInventoryFlow({
|
||||
data,
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
item: Item;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const isDiscrete = item.kind === "discrete";
|
||||
// form.price is total for bulk, per-unit for discrete. Convert at I/O boundaries.
|
||||
const initialPrice =
|
||||
isDiscrete && item.countOriginal > 0
|
||||
? item.price / item.countOriginal
|
||||
: item.price;
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
brandId: data.brands[0]?.id ?? NEW_BRAND,
|
||||
shopId: data.shops[0]?.id ?? NEW_SHOP,
|
||||
type: "Flower",
|
||||
weight: 3.5,
|
||||
countOriginal: 1,
|
||||
unitWeight: 0.7,
|
||||
price: 45,
|
||||
thc: 22,
|
||||
cbd: 0.4,
|
||||
totalCannabinoids: 26,
|
||||
purchaseDate: TODAY_STR,
|
||||
binId: data.bins[0]?.id ?? NEW_BIN,
|
||||
sku: "",
|
||||
assetTag: "",
|
||||
brandId: item.brandId ?? NEW_BRAND,
|
||||
shopId: item.shopId ?? NEW_SHOP,
|
||||
binId: item.binId ?? NEW_BIN,
|
||||
weight: item.weight,
|
||||
countOriginal: item.countOriginal,
|
||||
unitWeight: item.unitWeight,
|
||||
price: initialPrice,
|
||||
thc: item.thc,
|
||||
cbd: item.cbd,
|
||||
totalCannabinoids: item.totalCannabinoids,
|
||||
purchaseDate: item.purchaseDate,
|
||||
});
|
||||
const [newBrand, setNewBrand] = useState("");
|
||||
const [newShopName, setNewShopName] = useState("");
|
||||
@@ -37,49 +49,13 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
|
||||
const [newBinCapacity, setNewBinCapacity] = useState(10);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Track which cannabinoid fields the user has touched. Pre-fill from a
|
||||
// matched strain only writes into untouched fields, so we never overwrite
|
||||
// numbers the user just typed.
|
||||
const [edited, setEdited] = useState({ thc: false, cbd: false, total: false });
|
||||
|
||||
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const cfg = TYPES.find((t) => t.id === form.type);
|
||||
const isDiscrete = cfg?.kind === "discrete";
|
||||
// form.price is total for bulk, per-unit for discrete.
|
||||
const cfg = TYPES.find((t) => t.id === item.type);
|
||||
const totalPrice = isDiscrete ? form.price * form.countOriginal : form.price;
|
||||
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
|
||||
|
||||
// Find an existing strain matching the current name + brand + type.
|
||||
// Case-insensitive + trimmed, brand can be NEW_BRAND (no match) or null.
|
||||
const matchedStrain: Strain | null = useMemo(() => {
|
||||
const name = form.name.trim().toLowerCase();
|
||||
if (!name) return null;
|
||||
if (form.brandId === NEW_BRAND) return null;
|
||||
return (
|
||||
data.strains.find(
|
||||
(s) =>
|
||||
s.name.trim().toLowerCase() === name &&
|
||||
(s.brandId ?? null) === (form.brandId ?? null) &&
|
||||
s.type === form.type,
|
||||
) ?? null
|
||||
);
|
||||
}, [data.strains, form.name, form.brandId, form.type]);
|
||||
|
||||
// Pre-fill cannabinoids from the matched strain into untouched fields.
|
||||
useEffect(() => {
|
||||
if (!matchedStrain) return;
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
thc: edited.thc ? f.thc : matchedStrain.defaultThc ?? f.thc,
|
||||
cbd: edited.cbd ? f.cbd : matchedStrain.defaultCbd ?? f.cbd,
|
||||
totalCannabinoids: edited.total
|
||||
? f.totalCannabinoids
|
||||
: matchedStrain.defaultTotalCannabinoids ?? f.totalCannabinoids,
|
||||
}));
|
||||
}, [matchedStrain]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
let { brandId, shopId, binId } = form;
|
||||
@@ -90,7 +66,10 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
|
||||
}
|
||||
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() });
|
||||
const s = await api.createShop({
|
||||
name: newShopName.trim(),
|
||||
location: newShopLocation.trim(),
|
||||
});
|
||||
shopId = s.id;
|
||||
}
|
||||
if (binId === NEW_BIN) {
|
||||
@@ -101,13 +80,18 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
|
||||
});
|
||||
binId = b.id;
|
||||
}
|
||||
return api.createProduct({
|
||||
...form,
|
||||
return api.updateInventoryItem(item.id, {
|
||||
brandId,
|
||||
shopId,
|
||||
binId,
|
||||
kind: isDiscrete ? "discrete" : "bulk",
|
||||
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: () => {
|
||||
@@ -133,30 +117,54 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader title="Add a product" eyebrow="New entry" onClose={onClose} />
|
||||
<ModalHeader
|
||||
title={`Edit ${item.name}`}
|
||||
eyebrow={`Inventory · ${item.assetId}`}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>Identity</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16, marginBottom: 28 }}>
|
||||
<Field
|
||||
label="Strain"
|
||||
span={2}
|
||||
hint={
|
||||
matchedStrain
|
||||
? "Matched existing strain — cannabinoid defaults pre-filled."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={form.name}
|
||||
placeholder="e.g. Garden Ghost"
|
||||
onChange={(e) => update("name", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
padding: "10px 14px",
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-sm)",
|
||||
fontSize: 12,
|
||||
color: "var(--ink-3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: "var(--serif)", fontSize: 16 }}>
|
||||
{TYPE_GLYPHS[item.type]}
|
||||
</span>
|
||||
<span>
|
||||
Editing this physical instance of{" "}
|
||||
<strong style={{ color: "var(--ink-2)" }}>{item.name}</strong> ({item.type} ·{" "}
|
||||
{item.kind}). To change the product (SKU, name, type), edit the catalog entry.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
|
||||
Source
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
gap: 16,
|
||||
marginBottom: 28,
|
||||
}}
|
||||
>
|
||||
<Field label="Brand">
|
||||
<Select value={form.brandId} onChange={(e) => update("brandId", e.target.value)}>
|
||||
{data.brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_BRAND}>+ Add new brand…</option>
|
||||
</Select>
|
||||
@@ -164,20 +172,30 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
|
||||
<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 key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_SHOP}>+ Add new shop…</option>
|
||||
</Select>
|
||||
</Field>
|
||||
{isNewBrand && (
|
||||
<Field label="New brand name" span={2}>
|
||||
<Input value={newBrand} onChange={(e) => setNewBrand(e.target.value)} placeholder="e.g. Foxglove Farms" />
|
||||
<Input
|
||||
value={newBrand}
|
||||
onChange={(e) => setNewBrand(e.target.value)}
|
||||
placeholder="e.g. Foxglove Farms"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{isNewShop && (
|
||||
<>
|
||||
<Field label="New shop name">
|
||||
<Input value={newShopName} onChange={(e) => setNewShopName(e.target.value)} placeholder="e.g. Greenleaf Co-op" />
|
||||
<Input
|
||||
value={newShopName}
|
||||
onChange={(e) => setNewShopName(e.target.value)}
|
||||
placeholder="e.g. Greenleaf Co-op"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Location (optional)">
|
||||
<Input
|
||||
@@ -188,17 +206,12 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label="Type">
|
||||
<Select value={form.type} onChange={(e) => update("type", e.target.value)}>
|
||||
{TYPES.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.id} ({t.kind})</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 key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_BIN}>+ Add new bin…</option>
|
||||
</Select>
|
||||
@@ -225,20 +238,19 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label="SKU" hint="Leave blank — we'll generate one">
|
||||
<Input value={form.sku} placeholder="SKU-…" onChange={(e) => update("sku", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Asset tag (optional)" hint="If you've physically tagged the item">
|
||||
<Input
|
||||
value={form.assetTag}
|
||||
placeholder="AT-0000"
|
||||
onChange={(e) => update("assetTag", e.target.value)}
|
||||
/>
|
||||
</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 }}>
|
||||
<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})`}>
|
||||
@@ -288,30 +300,36 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
|
||||
{!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>
|
||||
<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 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
|
||||
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) => {
|
||||
setEdited((p) => ({ ...p, thc: true }));
|
||||
update("thc", +e.target.value);
|
||||
}}
|
||||
onChange={(e) => update("thc", +e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="CBD %">
|
||||
@@ -319,10 +337,7 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={form.cbd}
|
||||
onChange={(e) => {
|
||||
setEdited((p) => ({ ...p, cbd: true }));
|
||||
update("cbd", +e.target.value);
|
||||
}}
|
||||
onChange={(e) => update("cbd", +e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Total cannabinoids %">
|
||||
@@ -330,38 +345,44 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={form.totalCannabinoids}
|
||||
onChange={(e) => {
|
||||
setEdited((p) => ({ ...p, total: true }));
|
||||
update("totalCannabinoids", +e.target.value);
|
||||
}}
|
||||
onChange={(e) => update("totalCannabinoids", +e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{item.audits.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 18,
|
||||
fontSize: 12,
|
||||
color: "var(--ink-3)",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{item.audits.length} audit{item.audits.length === 1 ? "" : "s"} on file —
|
||||
audit history is preserved unchanged. Editing the original size only updates
|
||||
the percent-remaining math going forward.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
{form.name
|
||||
? `"${form.name}" → ${
|
||||
isNewBin
|
||||
? newBinName.trim() || "new bin"
|
||||
: data.bins.find((b) => b.id === form.binId)?.name ?? "—"
|
||||
}.`
|
||||
: "Fill in the name to continue."}
|
||||
</div>
|
||||
<div />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={!form.name || save.isPending}
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{save.isPending ? "Saving…" : "Save product"}
|
||||
{save.isPending ? "Saving…" : "Save changes"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
@@ -369,84 +390,3 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared modal chrome ──────────────────────────────────────────
|
||||
export function ModalBackdrop({
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "oklch(20% 0.02 60 / 0.4)",
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-start",
|
||||
overflow: "auto",
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: "100%", display: "flex", justifyContent: "center" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalHeader({
|
||||
title,
|
||||
eyebrow,
|
||||
eyebrowColor,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
eyebrowColor?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "20px 32px",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: eyebrowColor ?? "var(--ink-3)" }}>
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="serif" style={{ fontSize: 28, margin: "4px 0 0", fontWeight: 500 }}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalFooter({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 32px",
|
||||
borderTop: "1px solid var(--line)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
background: "var(--bg-2)",
|
||||
borderRadius: "0 0 var(--r-lg) var(--r-lg)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,14 @@ import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Product } from "../../types.js";
|
||||
import { TYPES } from "../../types.js";
|
||||
import { fmt, TYPE_GLYPHS } from "../../format.js";
|
||||
import { TYPE_GLYPHS } from "../../format.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
|
||||
|
||||
const NEW_BRAND = "__new_brand__";
|
||||
const NEW_SHOP = "__new_shop__";
|
||||
const NEW_BIN = "__new_bin__";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||
|
||||
// Catalog-level edit. SKU is immutable (it's a barcode). Type and kind are
|
||||
// editable but should rarely change after first instances exist — we don't
|
||||
// block since data integrity isn't really at risk (kind only flips behavior).
|
||||
export function EditProductFlow({
|
||||
data,
|
||||
product,
|
||||
@@ -22,82 +21,21 @@ export function EditProductFlow({
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const isDiscrete = product.kind === "discrete";
|
||||
// form.price is total for bulk, per-unit for discrete. Convert at I/O boundaries.
|
||||
const initialPrice =
|
||||
isDiscrete && product.countOriginal > 0
|
||||
? product.price / product.countOriginal
|
||||
: product.price;
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: product.name,
|
||||
brandId: product.brandId ?? NEW_BRAND,
|
||||
shopId: product.shopId ?? NEW_SHOP,
|
||||
binId: product.binId ?? NEW_BIN,
|
||||
weight: product.weight,
|
||||
countOriginal: product.countOriginal,
|
||||
unitWeight: product.unitWeight,
|
||||
price: initialPrice,
|
||||
thc: product.thc,
|
||||
cbd: product.cbd,
|
||||
totalCannabinoids: product.totalCannabinoids,
|
||||
purchaseDate: product.purchaseDate,
|
||||
assetTag: product.assetTag ?? "",
|
||||
});
|
||||
const [newBrand, setNewBrand] = useState("");
|
||||
const [newShopName, setNewShopName] = useState("");
|
||||
const [newShopLocation, setNewShopLocation] = useState("");
|
||||
const [newBinName, setNewBinName] = useState("");
|
||||
const [newBinCapacity, setNewBinCapacity] = useState(10);
|
||||
const [name, setName] = useState(product.name);
|
||||
const [type, setType] = useState(product.type);
|
||||
const [strainId, setStrainId] = useState<string>(product.strainId ?? "");
|
||||
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 cfg = TYPES.find((t) => t.id === product.type);
|
||||
const totalPrice = isDiscrete ? form.price * form.countOriginal : form.price;
|
||||
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
|
||||
const cfg = TYPES.find((t) => t.id === type);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
let { brandId, shopId, binId } = form;
|
||||
if (brandId === NEW_BRAND) {
|
||||
if (!newBrand.trim()) throw new Error("New brand name required");
|
||||
const b = await api.createBrand(newBrand.trim());
|
||||
brandId = b.id;
|
||||
}
|
||||
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.updateProduct(product.id, {
|
||||
name: form.name.trim(),
|
||||
brandId,
|
||||
shopId,
|
||||
binId,
|
||||
assetTag: form.assetTag.trim() || null,
|
||||
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,
|
||||
});
|
||||
},
|
||||
mutationFn: () =>
|
||||
api.updateProduct(product.id, {
|
||||
name: name.trim(),
|
||||
type,
|
||||
kind: cfg?.kind ?? product.kind,
|
||||
strainId: strainId || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
@@ -105,15 +43,11 @@ export function EditProductFlow({
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const isNewBrand = form.brandId === NEW_BRAND;
|
||||
const isNewShop = form.shopId === NEW_SHOP;
|
||||
const isNewBin = form.binId === NEW_BIN;
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(840px, 96vw)",
|
||||
width: "min(640px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
@@ -121,12 +55,16 @@ export function EditProductFlow({
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader title={`Edit ${product.name}`} eyebrow={`Product · ${product.sku}`} onClose={onClose} />
|
||||
<ModalHeader
|
||||
title={`Edit product`}
|
||||
eyebrow={`Catalog · ${product.sku}`}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
marginBottom: 20,
|
||||
padding: "10px 14px",
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--line)",
|
||||
@@ -142,200 +80,41 @@ export function EditProductFlow({
|
||||
{TYPE_GLYPHS[product.type]}
|
||||
</span>
|
||||
<span>
|
||||
Type <strong style={{ color: "var(--ink-2)" }}>{product.type}</strong> ({product.kind}) is locked. To change type, mark this product gone and add a new one.
|
||||
SKU <span className="mono" style={{ color: "var(--ink-2)" }}>{product.sku}</span>{" "}
|
||||
is locked. Edit individual purchases (price, brand, batch THC) from the
|
||||
inventory drawer.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>Identity</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16, marginBottom: 28 }}>
|
||||
<Field label="Strain" span={2}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16 }}>
|
||||
<Field label="Product name" span={2}>
|
||||
<Input
|
||||
value={form.name}
|
||||
placeholder="e.g. Garden Ghost"
|
||||
onChange={(e) => update("name", e.target.value)}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Garden Ghost 3.5g"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Brand">
|
||||
<Select value={form.brandId} onChange={(e) => update("brandId", e.target.value)}>
|
||||
{data.brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
<Field label="Type">
|
||||
<Select value={type} onChange={(e) => setType(e.target.value)}>
|
||||
{TYPES.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.id} ({t.kind})
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_BRAND}>+ Add new brand…</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<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>
|
||||
<Field label="Strain">
|
||||
<Select value={strainId} onChange={(e) => setStrainId(e.target.value)}>
|
||||
<option value="">— Unlinked —</option>
|
||||
{data.strains.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_SHOP}>+ Add new shop…</option>
|
||||
</Select>
|
||||
</Field>
|
||||
{isNewBrand && (
|
||||
<Field label="New brand name" span={2}>
|
||||
<Input value={newBrand} onChange={(e) => setNewBrand(e.target.value)} placeholder="e.g. Foxglove Farms" />
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
<Field label="Asset tag (optional)" hint="If you've physically tagged the item">
|
||||
<Input
|
||||
value={form.assetTag}
|
||||
placeholder="AT-0000"
|
||||
onChange={(e) => update("assetTag", e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{product.audits.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 18,
|
||||
fontSize: 12,
|
||||
color: "var(--ink-3)",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{product.audits.length} audit{product.audits.length === 1 ? "" : "s"} on file —
|
||||
audit history is preserved unchanged. Editing the original size only updates
|
||||
the percent-remaining math going forward.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||
)}
|
||||
@@ -344,14 +123,16 @@ export function EditProductFlow({
|
||||
<ModalFooter>
|
||||
<div />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={!form.name.trim() || save.isPending}
|
||||
disabled={!name.trim() || save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{save.isPending ? "Saving…" : "Save changes"}
|
||||
{save.isPending ? "Saving…" : "Save"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Product } from "../../types.js";
|
||||
import { helpers, TODAY_STR } from "../../types.js";
|
||||
import type { Bootstrap, Item } from "../../types.js";
|
||||
import { helpers, TODAY_STR, enrichItems } from "../../types.js";
|
||||
import { remainingShort } from "../../stats.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||
|
||||
const REASONS: [string, string][] = [
|
||||
["lost", "Lost / misplaced"],
|
||||
@@ -18,29 +18,30 @@ const REASONS: [string, string][] = [
|
||||
export function MarkGoneFlow({
|
||||
data,
|
||||
onClose,
|
||||
product: initialProduct,
|
||||
item: initialItem,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onClose: () => void;
|
||||
product: Product | null;
|
||||
item: Item | null;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const active = data.products.filter((p) => p.status === "active");
|
||||
const [productId, setProductId] = useState(initialProduct?.id ?? active[0]?.id ?? "");
|
||||
const allItems = enrichItems(data);
|
||||
const active = allItems.filter((i) => i.status === "active");
|
||||
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
|
||||
const [reason, setReason] = useState("lost");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
const product = data.products.find((p) => p.id === productId);
|
||||
const item = allItems.find((i) => i.id === itemId);
|
||||
|
||||
const mark = useMutation({
|
||||
mutationFn: () => api.markGone(productId, { date, reason, notes }),
|
||||
mutationFn: () => api.markInventoryItemGone(itemId, { date, reason, notes }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
if (!product) return null;
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
@@ -76,11 +77,11 @@ export function MarkGoneFlow({
|
||||
<strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate.
|
||||
</div>
|
||||
|
||||
<Field label="Product">
|
||||
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
|
||||
{active.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} — {helpers.brandName(data, p.brandId)} ({remainingShort(p)} left)
|
||||
<Field label="Inventory item">
|
||||
<Select value={itemId} onChange={(e) => setItemId(e.target.value)}>
|
||||
{active.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.assetId} · {i.name} — {helpers.brandName(data, i.brandId)} ({remainingShort(i)} left)
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Btn } from "../primitives/index.js";
|
||||
|
||||
export function ModalBackdrop({
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "oklch(20% 0.02 60 / 0.4)",
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-start",
|
||||
overflow: "auto",
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ width: "100%", display: "flex", justifyContent: "center" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalHeader({
|
||||
title,
|
||||
eyebrow,
|
||||
eyebrowColor,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
eyebrowColor?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "20px 32px",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: eyebrowColor ?? "var(--ink-3)" }}>
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="serif" style={{ fontSize: 28, margin: "4px 0 0", fontWeight: 500 }}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalFooter({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 32px",
|
||||
borderTop: "1px solid var(--line)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
background: "var(--bg-2)",
|
||||
borderRadius: "0 0 var(--r-lg) var(--r-lg)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user