02dc6e523f
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>
393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
import { useState } from "react";
|
||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||
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 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({
|
||
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("");
|
||
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 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;
|
||
|
||
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.updateInventoryItem(item.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: () => {
|
||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||
onClose();
|
||
},
|
||
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)",
|
||
margin: "40px 20px",
|
||
background: "var(--bg)",
|
||
border: "1px solid var(--line)",
|
||
borderRadius: "var(--r-lg)",
|
||
boxShadow: "var(--shadow-lg)",
|
||
}}
|
||
>
|
||
<ModalHeader
|
||
title={`Edit ${item.name}`}
|
||
eyebrow={`Inventory · ${item.assetId}`}
|
||
onClose={onClose}
|
||
/>
|
||
|
||
<div style={{ padding: 32 }}>
|
||
<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 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>
|
||
|
||
{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 />
|
||
<div style={{ display: "flex", gap: 8 }}>
|
||
<Btn variant="ghost" onClick={onClose}>
|
||
Cancel
|
||
</Btn>
|
||
<Btn
|
||
variant="primary"
|
||
icon="check"
|
||
disabled={save.isPending}
|
||
onClick={() => save.mutate()}
|
||
>
|
||
{save.isPending ? "Saving…" : "Save changes"}
|
||
</Btn>
|
||
</div>
|
||
</ModalFooter>
|
||
</div>
|
||
</ModalBackdrop>
|
||
);
|
||
}
|