Compare commits

...

2 Commits

Author SHA1 Message Date
josh edb8e2ac92 Bin slot count: discrete products fill by unit, not by row
Build and push image / build (push) Successful in 54s
A 3ct pre-roll was filling one slot of its bin instead of three. Bins
visualise physical capacity, so each unit of a discrete product (rolls,
edibles, vapes) should take its own slot; bulk jars still take one slot
each. The slot tally and fill bar both use the current count
(countLastAudit ?? countOriginal) so the bin frees up as units are
audited away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:42:39 -04:00
josh 592bb28740 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>
2026-05-03 21:42:33 -04:00
6 changed files with 595 additions and 2 deletions
+193
View File
@@ -111,6 +111,199 @@ productsRouter.post("/products", (req, res) => {
res.json({ id }); 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) => { productsRouter.post("/products/:id/finish", (req, res) => {
const { id } = req.params; const { id } = req.params;
const { date, rating, notes } = req.body as { date: string; rating?: number; notes?: string }; const { date, rating, notes } = req.body as { date: string; rating?: number; notes?: string };
+11
View File
@@ -15,6 +15,7 @@ import { SettingsView } from "./views/SettingsView.js";
import type { ThemeKey } from "./views/SettingsView.js"; import type { ThemeKey } from "./views/SettingsView.js";
import { ProductDetail } from "./components/ProductDetail.js"; import { ProductDetail } from "./components/ProductDetail.js";
import { AddProductFlow } from "./components/modals/AddProductFlow.js"; import { AddProductFlow } from "./components/modals/AddProductFlow.js";
import { EditProductFlow } from "./components/modals/EditProductFlow.js";
import { ConsumeFlow } from "./components/modals/ConsumeFlow.js"; import { ConsumeFlow } from "./components/modals/ConsumeFlow.js";
import { MarkGoneFlow } from "./components/modals/MarkGoneFlow.js"; import { MarkGoneFlow } from "./components/modals/MarkGoneFlow.js";
import { AuditFlow } from "./components/modals/AuditFlow.js"; import { AuditFlow } from "./components/modals/AuditFlow.js";
@@ -29,6 +30,7 @@ import {
type ModalKey = type ModalKey =
| "add" | "add"
| "edit"
| "consume" | "consume"
| "gone" | "gone"
| "audit" | "audit"
@@ -93,6 +95,11 @@ export function App() {
setModalProduct(p ?? null); setModalProduct(p ?? null);
setModal("audit"); setModal("audit");
}; };
const openEdit = (p: Product) => {
setModalProduct(p);
setSelected(null);
setModal("edit");
};
if (isLoading) { if (isLoading) {
return ( return (
@@ -181,10 +188,14 @@ export function App() {
onConsume={openConsume} onConsume={openConsume}
onMarkGone={openMarkGone} onMarkGone={openMarkGone}
onAudit={openAudit} onAudit={openAudit}
onEdit={openEdit}
/> />
)} )}
{modal === "add" && <AddProductFlow data={data} onClose={() => setModal(null)} />} {modal === "add" && <AddProductFlow data={data} onClose={() => setModal(null)} />}
{modal === "edit" && modalProduct && (
<EditProductFlow data={data} product={modalProduct} onClose={() => setModal(null)} />
)}
{modal === "consume" && ( {modal === "consume" && (
<ConsumeFlow data={data} onClose={() => setModal(null)} product={modalProduct} /> <ConsumeFlow data={data} onClose={() => setModal(null)} product={modalProduct} />
)} )}
+23
View File
@@ -34,6 +34,29 @@ export const api = {
assetTag?: string; assetTag?: string;
}) => request<{ id: string }>("/products", { method: "POST", body: JSON.stringify(body) }), }) => 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 }) => finishProduct: (id: string, body: { date: string; rating?: number; notes?: string }) =>
request<{ ok: true }>(`/products/${id}/finish`, { request<{ ok: true }>(`/products/${id}/finish`, {
method: "POST", method: "POST",
+5
View File
@@ -10,6 +10,7 @@ export function ProductDetail({
onConsume, onConsume,
onMarkGone, onMarkGone,
onAudit, onAudit,
onEdit,
}: { }: {
product: Product; product: Product;
data: Bootstrap; data: Bootstrap;
@@ -17,6 +18,7 @@ export function ProductDetail({
onConsume: (p: Product) => void; onConsume: (p: Product) => void;
onMarkGone: (p: Product) => void; onMarkGone: (p: Product) => void;
onAudit: (p: Product) => void; onAudit: (p: Product) => void;
onEdit: (p: Product) => void;
}) { }) {
const bin = data.bins.find((b) => b.id === product.binId); const bin = data.bins.find((b) => b.id === product.binId);
const cfg = TYPES.find((t) => t.id === product.type); const cfg = TYPES.find((t) => t.id === product.type);
@@ -128,6 +130,9 @@ export function ProductDetail({
Mark gone Mark gone
</Btn> </Btn>
)} )}
<Btn variant="ghost" icon="edit" onClick={() => onEdit(product)}>
Edit
</Btn>
<Btn variant="ghost" icon="close" onClick={onClose} /> <Btn variant="ghost" icon="close" onClick={onClose} />
</div> </div>
</div> </div>
@@ -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>
);
}
+9 -2
View File
@@ -74,7 +74,14 @@ export function BinsView({
> >
{data.bins.map((bin) => { {data.bins.map((bin) => {
const items = data.products.filter((p) => p.binId === bin.id && p.status === "active"); const items = data.products.filter((p) => p.binId === bin.id && p.status === "active");
const fillPct = items.length / bin.capacity; // Discrete products (pre-rolls, edibles, vapes) take a slot per unit;
// bulk products take one slot per jar/container.
const slotsUsed = items.reduce(
(s, p) =>
s + (p.kind === "discrete" ? (p.countLastAudit ?? p.countOriginal) : 1),
0,
);
const fillPct = slotsUsed / bin.capacity;
const totalValue = items.reduce( const totalValue = items.reduce(
(s, p) => s + p.price * helpers.pctRemaining(p, TODAY_STR), (s, p) => s + p.price * helpers.pctRemaining(p, TODAY_STR),
0, 0,
@@ -95,7 +102,7 @@ export function BinsView({
{bin.name} {bin.name}
</h3> </h3>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}> <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<Pill tone="outline">{items.length} / {bin.capacity}</Pill> <Pill tone="outline">{slotsUsed} / {bin.capacity}</Pill>
<button <button
onClick={() => onEditBin(bin)} onClick={() => onEditBin(bin)}
title="Edit bin" title="Edit bin"