From 592bb2874021e2c73ed2b5ff411e697fba771934 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 3 May 2026 21:42:33 -0400 Subject: [PATCH] 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) --- server/src/routes/products.ts | 193 ++++++++++ web/src/App.tsx | 11 + web/src/api.ts | 23 ++ web/src/components/ProductDetail.tsx | 5 + web/src/components/modals/EditProductFlow.tsx | 354 ++++++++++++++++++ 5 files changed, 586 insertions(+) create mode 100644 web/src/components/modals/EditProductFlow.tsx diff --git a/server/src/routes/products.ts b/server/src/routes/products.ts index 55ba245..f3b4d91 100644 --- a/server/src/routes/products.ts +++ b/server/src/routes/products.ts @@ -111,6 +111,199 @@ productsRouter.post("/products", (req, res) => { res.json({ id }); }); +type UpdateBody = Partial<{ + name: string; + brandId: string | null; + shopId: string | null; + binId: string | null; + assetTag: string | null; + weight: number; + countOriginal: number; + unitWeight: number; + price: number; + thc: number; + cbd: number; + totalCannabinoids: number; + purchaseDate: string; +}>; + +productsRouter.patch("/products/:id", (req, res) => { + const { id } = req.params; + const body = req.body as UpdateBody; + + type Row = { + id: string; + name: string; + brand_id: string | null; + shop_id: string | null; + bin_id: string | null; + asset_tag: string | null; + type: string; + kind: "bulk" | "discrete"; + weight: number; + last_audit_weight: number | null; + count_original: number; + count_last_audit: number | null; + unit_weight: number; + price: number; + thc: number; + cbd: number; + total_cannabinoids: number; + purchase_date: string; + strain_id: string | null; + }; + + const existing = db + .prepare<[string], Row>( + `SELECT id, name, brand_id, shop_id, bin_id, asset_tag, type, kind, + weight, last_audit_weight, count_original, count_last_audit, unit_weight, + price, thc, cbd, total_cannabinoids, purchase_date, strain_id + FROM products WHERE id = ?`, + ) + .get(id); + if (!existing) return res.status(404).json({ error: "product not found" }); + + const auditCount = db + .prepare<[string], { n: number }>("SELECT COUNT(*) AS n FROM audits WHERE product_id = ?") + .get(id)!.n; + + const trimOrUndef = (v: unknown) => + typeof v === "string" ? v.trim() : v; + + const nextName = + body.name !== undefined && (body.name as string).trim() + ? (body.name as string).trim() + : existing.name; + const nextBrandId = + body.brandId === undefined ? existing.brand_id : body.brandId || null; + const nextShopId = + body.shopId === undefined ? existing.shop_id : body.shopId || null; + const nextBinId = + body.binId === undefined ? existing.bin_id : body.binId || null; + const nextAssetTag = + body.assetTag === undefined + ? existing.asset_tag + : (trimOrUndef(body.assetTag) as string | null) || null; + const nextPrice = + Number.isFinite(body.price) && (body.price as number) >= 0 + ? (body.price as number) + : existing.price; + const nextPurchaseDate = + typeof body.purchaseDate === "string" && body.purchaseDate.trim() + ? body.purchaseDate.trim() + : existing.purchase_date; + const nextThc = + Number.isFinite(body.thc) ? (body.thc as number) : existing.thc; + const nextCbd = + Number.isFinite(body.cbd) ? (body.cbd as number) : existing.cbd; + const nextTotalCanna = Number.isFinite(body.totalCannabinoids) + ? (body.totalCannabinoids as number) + : existing.total_cannabinoids; + + const isDiscrete = existing.kind === "discrete"; + const nextWeight = + !isDiscrete && Number.isFinite(body.weight) && (body.weight as number) >= 0 + ? (body.weight as number) + : existing.weight; + const nextCountOriginal = + isDiscrete && Number.isFinite(body.countOriginal) && (body.countOriginal as number) >= 0 + ? Math.floor(body.countOriginal as number) + : existing.count_original; + const nextUnitWeight = + isDiscrete && Number.isFinite(body.unitWeight) && (body.unitWeight as number) >= 0 + ? (body.unitWeight as number) + : existing.unit_weight; + + // If no audits exist yet, keep the "last audit" mirror in lock-step with + // the original size — otherwise the next audit's prev_value would be stale. + const nextLastAuditWeight = + !isDiscrete && auditCount === 0 ? nextWeight : existing.last_audit_weight; + const nextCountLastAudit = + isDiscrete && auditCount === 0 ? nextCountOriginal : existing.count_last_audit; + + const tx = db.transaction(() => { + let nextStrainId = existing.strain_id; + const nameChanged = nextName !== existing.name; + const brandChanged = nextBrandId !== existing.brand_id; + if (nameChanged || brandChanged) { + const todayIso = new Date().toISOString().slice(0, 10); + const found = db + .prepare< + [string, string | null, string | null, string], + { id: string } + >( + `SELECT id FROM strains + WHERE name = ? COLLATE NOCASE + AND (brand_id IS ? OR brand_id = ?) + AND type = ?`, + ) + .get(nextName, nextBrandId, nextBrandId, existing.type); + if (found) { + nextStrainId = found.id; + } else { + nextStrainId = nextId("str", "strains"); + db.prepare(` + INSERT INTO strains ( + id, name, brand_id, type, + default_thc, default_cbd, default_total_cannabinoids, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + nextStrainId, + nextName, + nextBrandId, + existing.type, + nextThc, + nextCbd, + nextTotalCanna, + todayIso, + ); + } + } + + db.prepare(` + UPDATE products SET + name = @name, + brand_id = @brandId, + shop_id = @shopId, + bin_id = @binId, + asset_tag = @assetTag, + weight = @weight, + last_audit_weight = @lastAuditWeight, + count_original = @countOriginal, + count_last_audit = @countLastAudit, + unit_weight = @unitWeight, + price = @price, + thc = @thc, + cbd = @cbd, + total_cannabinoids = @totalCannabinoids, + purchase_date = @purchaseDate, + strain_id = @strainId + WHERE id = @id + `).run({ + id, + name: nextName, + brandId: nextBrandId, + shopId: nextShopId, + binId: nextBinId, + assetTag: nextAssetTag, + weight: nextWeight, + lastAuditWeight: nextLastAuditWeight, + countOriginal: nextCountOriginal, + countLastAudit: nextCountLastAudit, + unitWeight: nextUnitWeight, + price: nextPrice, + thc: nextThc, + cbd: nextCbd, + totalCannabinoids: nextTotalCanna, + purchaseDate: nextPurchaseDate, + strainId: nextStrainId, + }); + }); + tx(); + + res.json({ ok: true }); +}); + productsRouter.post("/products/:id/finish", (req, res) => { const { id } = req.params; const { date, rating, notes } = req.body as { date: string; rating?: number; notes?: string }; diff --git a/web/src/App.tsx b/web/src/App.tsx index e611506..327bac5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,7 @@ import { SettingsView } from "./views/SettingsView.js"; import type { ThemeKey } from "./views/SettingsView.js"; import { ProductDetail } from "./components/ProductDetail.js"; import { AddProductFlow } from "./components/modals/AddProductFlow.js"; +import { EditProductFlow } from "./components/modals/EditProductFlow.js"; import { ConsumeFlow } from "./components/modals/ConsumeFlow.js"; import { MarkGoneFlow } from "./components/modals/MarkGoneFlow.js"; import { AuditFlow } from "./components/modals/AuditFlow.js"; @@ -29,6 +30,7 @@ import { type ModalKey = | "add" + | "edit" | "consume" | "gone" | "audit" @@ -93,6 +95,11 @@ export function App() { setModalProduct(p ?? null); setModal("audit"); }; + const openEdit = (p: Product) => { + setModalProduct(p); + setSelected(null); + setModal("edit"); + }; if (isLoading) { return ( @@ -181,10 +188,14 @@ export function App() { onConsume={openConsume} onMarkGone={openMarkGone} onAudit={openAudit} + onEdit={openEdit} /> )} {modal === "add" && setModal(null)} />} + {modal === "edit" && modalProduct && ( + setModal(null)} /> + )} {modal === "consume" && ( setModal(null)} product={modalProduct} /> )} diff --git a/web/src/api.ts b/web/src/api.ts index 1a400a6..a014103 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -34,6 +34,29 @@ export const api = { assetTag?: string; }) => request<{ id: string }>("/products", { method: "POST", body: JSON.stringify(body) }), + updateProduct: ( + id: string, + body: Partial<{ + name: string; + brandId: string | null; + shopId: string | null; + binId: string | null; + assetTag: string | null; + weight: number; + countOriginal: number; + unitWeight: number; + price: number; + thc: number; + cbd: number; + totalCannabinoids: number; + purchaseDate: string; + }>, + ) => + request<{ ok: true }>(`/products/${id}`, { + method: "PATCH", + body: JSON.stringify(body), + }), + finishProduct: (id: string, body: { date: string; rating?: number; notes?: string }) => request<{ ok: true }>(`/products/${id}/finish`, { method: "POST", diff --git a/web/src/components/ProductDetail.tsx b/web/src/components/ProductDetail.tsx index e7e65c9..19a4cc8 100644 --- a/web/src/components/ProductDetail.tsx +++ b/web/src/components/ProductDetail.tsx @@ -10,6 +10,7 @@ export function ProductDetail({ onConsume, onMarkGone, onAudit, + onEdit, }: { product: Product; data: Bootstrap; @@ -17,6 +18,7 @@ export function ProductDetail({ onConsume: (p: Product) => void; onMarkGone: (p: Product) => void; onAudit: (p: Product) => void; + onEdit: (p: Product) => void; }) { const bin = data.bins.find((b) => b.id === product.binId); const cfg = TYPES.find((t) => t.id === product.type); @@ -128,6 +130,9 @@ export function ProductDetail({ Mark gone )} + onEdit(product)}> + Edit + diff --git a/web/src/components/modals/EditProductFlow.tsx b/web/src/components/modals/EditProductFlow.tsx new file mode 100644 index 0000000..eff36c9 --- /dev/null +++ b/web/src/components/modals/EditProductFlow.tsx @@ -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(null); + + const update = (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 ( + +
+ + +
+
+ + {TYPE_GLYPHS[product.type]} + + + Type {product.type} ({product.kind}) is locked. To change type, mark this product gone and add a new one. + +
+ +
Identity
+
+ + update("name", e.target.value)} + /> + + + + + + + + {isNewBrand && ( + + setNewBrand(e.target.value)} placeholder="e.g. Foxglove Farms" /> + + )} + {isNewShop && ( + <> + + setNewShopName(e.target.value)} placeholder="e.g. Greenleaf Co-op" /> + + + setNewShopLocation(e.target.value)} + placeholder="e.g. Capitol Hill" + /> + + + )} + + + + + update("assetTag", e.target.value)} + /> + + {isNewBin && ( + <> + + setNewBinName(e.target.value)} + placeholder="e.g. Top Drawer" + /> + + + setNewBinLocation(e.target.value)} + placeholder="e.g. Bedroom" + /> + + + + setNewBinCapacity(Math.max(1, Math.floor(+e.target.value || 1))) + } + /> + + + )} +
+ +
Acquisition
+
+ {isDiscrete ? ( + <> + + update("countOriginal", +e.target.value)} + /> + + + update("unitWeight", +e.target.value)} + /> + + + ) : ( + + update("weight", +e.target.value)} + /> + + )} + + update("price", +e.target.value)} + /> + + + update("purchaseDate", e.target.value)} + /> + +
+ + {!isDiscrete && cpg > 0 && ( +
+ Cost per {cfg?.unit ?? "g"}:{" "} + {fmt.money(cpg)} +
+ )} + +
Cannabinoid profile
+
+ + update("thc", +e.target.value)} + /> + + + update("cbd", +e.target.value)} + /> + + + update("totalCannabinoids", +e.target.value)} + /> + +
+ + {product.audits.length > 0 && ( +
+ {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. +
+ )} + + {error && ( +
{error}
+ )} +
+ + +
+
+ Cancel + save.mutate()} + > + {save.isPending ? "Saving…" : "Save changes"} + +
+ +
+ + ); +}