Edit existing products

Adds PATCH /products/:id and an EditProductFlow modal opened from the
product drawer. Editable fields cover name, brand, shop, bin, asset tag,
price, purchase date, size (weight or count + unit weight), and the
cannabinoid profile. SKU, type, kind, and status-derived dates stay
locked because changing them would invalidate audit history math; type
changes are surfaced as "mark gone, add new" in the modal.

The strain row is re-resolved on name or brand change so analytics stay
aligned, and the last-audit mirror (last_audit_weight / count_last_audit)
only syncs with the original size when there are no audits yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 21:42:33 -04:00
parent 8ef8859c7d
commit 592bb28740
5 changed files with 586 additions and 0 deletions
@@ -0,0 +1,354 @@
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 { 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__";
export function EditProductFlow({
data,
product,
onClose,
}: {
data: Bootstrap;
product: Product;
onClose: () => void;
}) {
const qc = useQueryClient();
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: product.price,
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 [newBinLocation, setNewBinLocation] = 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 === product.type);
const isDiscrete = product.kind === "discrete";
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(),
location: newBinLocation.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: form.price,
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 ${product.name}`} eyebrow={`Product · ${product.sku}`} 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[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.
</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}>
<Input
value={form.name}
placeholder="e.g. Garden Ghost"
onChange={(e) => update("name", e.target.value)}
/>
</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>
))}
<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} {b.location}</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. Top Drawer"
/>
</Field>
<Field label="Location (optional)">
<Input
value={newBinLocation}
onChange={(e) => setNewBinLocation(e.target.value)}
placeholder="e.g. Bedroom"
/>
</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="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>
)}
<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>
)}
</div>
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn
variant="primary"
icon="check"
disabled={!form.name.trim() || save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? "Saving…" : "Save changes"}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}