Files
Apothecary/web/src/components/modals/EditInventoryFlow.tsx
T
josh 02dc6e523f
Build and push image / build (push) Successful in 46s
Track inventory at the instance level, not by product
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>
2026-05-04 05:59:46 -04:00

393 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}