Per-unit pricing for discrete products
Build and push image / build (push) Successful in 51s

Pre-rolls, edibles, and vaporizers are sold and reasoned about per unit
("$10 each") rather than as a bag total. The Add and Edit forms now ask
for "Price per unit" when the kind is discrete, and the product drawer
displays the per-unit number with the total as a small subline. Bulk
products (flower, concentrate, tincture) still take and show a total.

The stored price column remains the total, so existing data, spend
totals, sort-by-price, and bin value calculations all keep working
unchanged. Conversion (pricePerUnit × countOriginal) happens at the
form boundary on save and the inverse on edit-modal load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 21:59:35 -04:00
parent edb8e2ac92
commit cd7aeb9d09
3 changed files with 61 additions and 9 deletions
+28 -4
View File
@@ -180,7 +180,31 @@ export function ProductDetail({
> >
{( {(
[ [
["Price", fmt.money(product.price)], [
"Price",
product.kind === "discrete" && product.countOriginal > 0 ? (
<>
{fmt.money(product.price / product.countOriginal)}
<span style={{ fontSize: 14, color: "var(--ink-3)", marginLeft: 4 }}>
/unit
</span>
<div
style={{
fontSize: 11,
color: "var(--ink-3)",
fontWeight: 400,
marginTop: 2,
fontFamily: "var(--mono)",
letterSpacing: 0,
}}
>
{fmt.money(product.price)} total
</div>
</>
) : (
fmt.money(product.price)
),
],
[ [
product.kind === "discrete" ? "Quantity" : "Size", product.kind === "discrete" ? "Quantity" : "Size",
product.kind === "discrete" product.kind === "discrete"
@@ -189,9 +213,9 @@ export function ProductDetail({
], ],
["THC", `${product.thc.toFixed(1)}%`], ["THC", `${product.thc.toFixed(1)}%`],
["CBD", `${product.cbd.toFixed(1)}%`], ["CBD", `${product.cbd.toFixed(1)}%`],
] as [string, string][] ] as [string, React.ReactNode][]
).map(([l, v]) => ( ).map(([l, v], i) => (
<div key={l} style={{ padding: "18px 16px", background: "var(--surface)" }}> <div key={i} style={{ padding: "18px 16px", background: "var(--surface)" }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{l}</div> <div className="smallcaps" style={{ color: "var(--ink-3)" }}>{l}</div>
<div className="serif" style={{ fontSize: 26, marginTop: 4, fontWeight: 500 }}>{v}</div> <div className="serif" style={{ fontSize: 26, marginTop: 4, fontWeight: 500 }}>{v}</div>
</div> </div>
+13 -1
View File
@@ -48,6 +48,8 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
const cfg = TYPES.find((t) => t.id === form.type); const cfg = TYPES.find((t) => t.id === form.type);
const isDiscrete = cfg?.kind === "discrete"; const isDiscrete = cfg?.kind === "discrete";
// form.price is total for bulk, per-unit for discrete.
const totalPrice = isDiscrete ? form.price * form.countOriginal : form.price;
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0; const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
// Find an existing strain matching the current name + brand + type. // Find an existing strain matching the current name + brand + type.
@@ -107,6 +109,7 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
shopId, shopId,
binId, binId,
kind: isDiscrete ? "discrete" : "bulk", kind: isDiscrete ? "discrete" : "bulk",
price: totalPrice,
}); });
}, },
onSuccess: () => { onSuccess: () => {
@@ -274,7 +277,7 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
/> />
</Field> </Field>
)} )}
<Field label="Price ($)"> <Field label={isDiscrete ? "Price per unit ($)" : "Price ($)"}>
<Input <Input
type="number" type="number"
step="0.01" step="0.01"
@@ -297,6 +300,15 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
<span className="mono" style={{ color: "var(--ink-2)" }}>{fmt.money(cpg)}</span> <span className="mono" style={{ color: "var(--ink-2)" }}>{fmt.money(cpg)}</span>
</div> </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 className="smallcaps" style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}>Cannabinoid profile</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
+20 -4
View File
@@ -22,6 +22,13 @@ export function EditProductFlow({
}) { }) {
const qc = useQueryClient(); const qc = useQueryClient();
const isDiscrete = product.kind === "discrete";
// form.price is total for bulk, per-unit for discrete. Convert at I/O boundaries.
const initialPrice =
isDiscrete && product.countOriginal > 0
? product.price / product.countOriginal
: product.price;
const [form, setForm] = useState({ const [form, setForm] = useState({
name: product.name, name: product.name,
brandId: product.brandId ?? NEW_BRAND, brandId: product.brandId ?? NEW_BRAND,
@@ -30,7 +37,7 @@ export function EditProductFlow({
weight: product.weight, weight: product.weight,
countOriginal: product.countOriginal, countOriginal: product.countOriginal,
unitWeight: product.unitWeight, unitWeight: product.unitWeight,
price: product.price, price: initialPrice,
thc: product.thc, thc: product.thc,
cbd: product.cbd, cbd: product.cbd,
totalCannabinoids: product.totalCannabinoids, totalCannabinoids: product.totalCannabinoids,
@@ -49,7 +56,7 @@ export function EditProductFlow({
setForm((f) => ({ ...f, [k]: v })); setForm((f) => ({ ...f, [k]: v }));
const cfg = TYPES.find((t) => t.id === product.type); const cfg = TYPES.find((t) => t.id === product.type);
const isDiscrete = product.kind === "discrete"; const totalPrice = isDiscrete ? form.price * form.countOriginal : form.price;
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0; const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
const save = useMutation({ const save = useMutation({
@@ -86,7 +93,7 @@ export function EditProductFlow({
weight: isDiscrete ? undefined : form.weight, weight: isDiscrete ? undefined : form.weight,
countOriginal: isDiscrete ? form.countOriginal : undefined, countOriginal: isDiscrete ? form.countOriginal : undefined,
unitWeight: isDiscrete ? form.unitWeight : undefined, unitWeight: isDiscrete ? form.unitWeight : undefined,
price: form.price, price: totalPrice,
thc: form.thc, thc: form.thc,
cbd: form.cbd, cbd: form.cbd,
totalCannabinoids: form.totalCannabinoids, totalCannabinoids: form.totalCannabinoids,
@@ -262,7 +269,7 @@ export function EditProductFlow({
/> />
</Field> </Field>
)} )}
<Field label="Price ($)"> <Field label={isDiscrete ? "Price per unit ($)" : "Price ($)"}>
<Input <Input
type="number" type="number"
step="0.01" step="0.01"
@@ -285,6 +292,15 @@ export function EditProductFlow({
<span className="mono" style={{ color: "var(--ink-2)" }}>{fmt.money(cpg)}</span> <span className="mono" style={{ color: "var(--ink-2)" }}>{fmt.money(cpg)}</span>
</div> </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 className="smallcaps" style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}>Cannabinoid profile</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>