Compare commits
2 Commits
8ef8859c7d
...
edb8e2ac92
| Author | SHA1 | Date | |
|---|---|---|---|
| edb8e2ac92 | |||
| 592bb28740 |
@@ -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 };
|
||||||
|
|||||||
@@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user