User-supplied asset ids; brand on product; strain is the name
Build and push image / build (push) Successful in 48s
Build and push image / build (push) Successful in 48s
Four UX changes after using the rework for a bit: 1. Asset ids are 6-digit numbers from a roll of physical labels — server no longer generates them. POST /api/inventory requires assetId; the add-inventory form has a digits-only input that auto-focuses on entry. 2. Strain and product name are the same thing. Drop products.name; the strain's name supplies the display. Product creation just asks for "Name (strain)" and matches/creates a strain by that name. 3. Brand moves from inventory_items to products. SKUs are brand-specific, so all instances of a product share the brand. Brand selector lives on the product create/edit form, not the per-instance form. 4. Scanning an unknown SKU on the add-inventory step now opens the create-product subform with the SKU prefilled — one less click. Migration: detect prior shape (products.name column present) and rename products/inventory_items/audits to *_v1 archives, recreate empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+38
-28
@@ -12,15 +12,16 @@ db.pragma("journal_mode = WAL");
|
|||||||
db.pragma("foreign_keys = ON");
|
db.pragma("foreign_keys = ON");
|
||||||
|
|
||||||
archiveLegacyIfPresent();
|
archiveLegacyIfPresent();
|
||||||
|
archiveV1IfPresent();
|
||||||
|
|
||||||
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
|
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
|
||||||
db.exec(schema);
|
db.exec(schema);
|
||||||
|
|
||||||
// One-shot migration: the old schema put per-instance fields (weight, bin_id,
|
// One-shot migration: the original schema put per-instance fields (weight,
|
||||||
// etc.) directly on `products`. The new schema splits products (catalog) from
|
// bin_id, etc.) directly on `products`. The split schema separates products
|
||||||
// inventory_items (instance). When we detect the old shape, rename the live
|
// (catalog) from inventory_items (instance). When we detect the old shape,
|
||||||
// tables out of the way so the new CREATE TABLE IF NOT EXISTS statements can
|
// rename the live tables out of the way so the new CREATE TABLE IF NOT EXISTS
|
||||||
// build the empty new ones. Legacy tables stay queryable for reference.
|
// statements can build the empty new ones. Legacy tables stay queryable.
|
||||||
function archiveLegacyIfPresent(): void {
|
function archiveLegacyIfPresent(): void {
|
||||||
const productCols = db
|
const productCols = db
|
||||||
.prepare(`PRAGMA table_info(products)`)
|
.prepare(`PRAGMA table_info(products)`)
|
||||||
@@ -43,6 +44,34 @@ function archiveLegacyIfPresent(): void {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Second-pass migration: between v1 (split-products) and the current shape we
|
||||||
|
// (a) move brand from instance to product, (b) drop product.name in favor of
|
||||||
|
// strain.name as the canonical display, and (c) require user-supplied 6-digit
|
||||||
|
// asset ids. Detect by `products` having a `name` column. Archive to *_v1
|
||||||
|
// and let the schema rebuild the empty current shape.
|
||||||
|
function archiveV1IfPresent(): void {
|
||||||
|
const productCols = db
|
||||||
|
.prepare(`PRAGMA table_info(products)`)
|
||||||
|
.all() as { name: string }[];
|
||||||
|
const looksLikeV1 =
|
||||||
|
productCols.some((c) => c.name === "name") &&
|
||||||
|
!productCols.some((c) => c.name === "weight");
|
||||||
|
if (!looksLikeV1) return;
|
||||||
|
|
||||||
|
const v1Exists = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT name FROM sqlite_master WHERE type='table' AND name='products_v1'`,
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (v1Exists) return;
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE products RENAME TO products_v1;
|
||||||
|
ALTER TABLE inventory_items RENAME TO inventory_items_v1;
|
||||||
|
ALTER TABLE audits RENAME TO audits_v1;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
const PAD: Record<string, number> = {
|
const PAD: Record<string, number> = {
|
||||||
prd: 4,
|
prd: 4,
|
||||||
pdt: 4,
|
pdt: 4,
|
||||||
@@ -65,26 +94,7 @@ export function nextId(prefix: string, table: string): string {
|
|||||||
return `${prefix}-${n}`;
|
return `${prefix}-${n}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crockford base32 alphabet — drops I, L, O, U so labels are unambiguous when
|
// Asset ids are 6-digit strings printed on a roll of physical asset tags
|
||||||
// read off a sticker. 32^6 ≈ 1B addresses, so collisions on any reasonable
|
// the user owns. The system never generates them; it just enforces format
|
||||||
// inventory are vanishingly rare; we still loop just to be safe.
|
// and uniqueness at insert time.
|
||||||
const ASSET_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
export const ASSET_ID_RE = /^\d{6}$/;
|
||||||
export function generateAssetId(): string {
|
|
||||||
for (let attempt = 0; attempt < 16; attempt++) {
|
|
||||||
let id = "";
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
id += ASSET_ALPHABET[Math.floor(Math.random() * ASSET_ALPHABET.length)];
|
|
||||||
}
|
|
||||||
const taken = db
|
|
||||||
.prepare<[string], { id: string }>(
|
|
||||||
`SELECT id FROM inventory_items WHERE asset_id = ?`,
|
|
||||||
)
|
|
||||||
.get(id);
|
|
||||||
if (!taken) return id;
|
|
||||||
}
|
|
||||||
throw new Error("could not generate a unique asset id after 16 attempts");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function randomSku(): string {
|
|
||||||
return "SKU-" + Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export const bootstrapRouter: Router = Router();
|
|||||||
type ProductRow = {
|
type ProductRow = {
|
||||||
id: string;
|
id: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
strain_id: string | null;
|
strain_id: string;
|
||||||
name: string;
|
brand_id: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -17,7 +17,6 @@ type InventoryRow = {
|
|||||||
id: string;
|
id: string;
|
||||||
asset_id: string;
|
asset_id: string;
|
||||||
product_id: string;
|
product_id: string;
|
||||||
brand_id: string | null;
|
|
||||||
shop_id: string | null;
|
shop_id: string | null;
|
||||||
bin_id: string | null;
|
bin_id: string | null;
|
||||||
price: number;
|
price: number;
|
||||||
@@ -84,7 +83,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
sku: p.sku,
|
sku: p.sku,
|
||||||
strainId: p.strain_id,
|
strainId: p.strain_id,
|
||||||
name: p.name,
|
brandId: p.brand_id,
|
||||||
type: p.type,
|
type: p.type,
|
||||||
kind: p.kind,
|
kind: p.kind,
|
||||||
createdAt: p.created_at,
|
createdAt: p.created_at,
|
||||||
@@ -94,7 +93,6 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
|
|||||||
id: i.id,
|
id: i.id,
|
||||||
assetId: i.asset_id,
|
assetId: i.asset_id,
|
||||||
productId: i.product_id,
|
productId: i.product_id,
|
||||||
brandId: i.brand_id,
|
|
||||||
shopId: i.shop_id,
|
shopId: i.shop_id,
|
||||||
binId: i.bin_id,
|
binId: i.bin_id,
|
||||||
price: i.price,
|
price: i.price,
|
||||||
|
|||||||
@@ -35,12 +35,12 @@ catalogRouter.patch("/brands/:id", (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deleting a brand unparents any inventory items that reference it
|
// Deleting a brand unparents any products that reference it
|
||||||
// (brand_id → NULL), so users never lose inventory when reorganizing.
|
// (brand_id → NULL on products), so users never lose inventory.
|
||||||
catalogRouter.delete("/brands/:id", (req, res) => {
|
catalogRouter.delete("/brands/:id", (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const tx = db.transaction(() => {
|
const tx = db.transaction(() => {
|
||||||
db.prepare("UPDATE inventory_items SET brand_id = NULL WHERE brand_id = ?").run(id);
|
db.prepare("UPDATE products SET brand_id = NULL WHERE brand_id = ?").run(id);
|
||||||
const result = db.prepare("DELETE FROM brands WHERE id = ?").run(id);
|
const result = db.prepare("DELETE FROM brands WHERE id = ?").run(id);
|
||||||
if (result.changes === 0) throw new Error("not found");
|
if (result.changes === 0) throw new Error("not found");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { db, generateAssetId, nextId } from "../db.js";
|
import { ASSET_ID_RE, db, nextId } from "../db.js";
|
||||||
|
|
||||||
export const inventoryRouter: Router = Router();
|
export const inventoryRouter: Router = Router();
|
||||||
|
|
||||||
type CreateBody = {
|
type CreateBody = {
|
||||||
|
assetId: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
brandId?: string | null;
|
|
||||||
shopId?: string | null;
|
shopId?: string | null;
|
||||||
binId?: string | null;
|
binId?: string | null;
|
||||||
price: number;
|
price: number;
|
||||||
@@ -25,6 +25,18 @@ inventoryRouter.post("/inventory", (req, res) => {
|
|||||||
if (!Number.isFinite(body.price) || body.price < 0) {
|
if (!Number.isFinite(body.price) || body.price < 0) {
|
||||||
return res.status(400).json({ error: "price required" });
|
return res.status(400).json({ error: "price required" });
|
||||||
}
|
}
|
||||||
|
const assetId = (body.assetId ?? "").trim();
|
||||||
|
if (!ASSET_ID_RE.test(assetId)) {
|
||||||
|
return res.status(400).json({ error: "assetId must be exactly 6 digits" });
|
||||||
|
}
|
||||||
|
const taken = db
|
||||||
|
.prepare<[string], { id: string }>(
|
||||||
|
`SELECT id FROM inventory_items WHERE asset_id = ?`,
|
||||||
|
)
|
||||||
|
.get(assetId);
|
||||||
|
if (taken) {
|
||||||
|
return res.status(409).json({ error: "asset id already in use", id: taken.id });
|
||||||
|
}
|
||||||
|
|
||||||
const product = db
|
const product = db
|
||||||
.prepare<[string], { id: string; kind: string }>(
|
.prepare<[string], { id: string; kind: string }>(
|
||||||
@@ -35,19 +47,18 @@ inventoryRouter.post("/inventory", (req, res) => {
|
|||||||
|
|
||||||
const isDiscrete = product.kind === "discrete";
|
const isDiscrete = product.kind === "discrete";
|
||||||
const id = nextId("inv", "inventory_items");
|
const id = nextId("inv", "inventory_items");
|
||||||
const assetId = generateAssetId();
|
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO inventory_items (
|
`INSERT INTO inventory_items (
|
||||||
id, asset_id, product_id,
|
id, asset_id, product_id,
|
||||||
brand_id, shop_id, bin_id,
|
shop_id, bin_id,
|
||||||
price, thc, cbd, total_cannabinoids,
|
price, thc, cbd, total_cannabinoids,
|
||||||
weight, last_audit_weight,
|
weight, last_audit_weight,
|
||||||
count_original, count_last_audit, unit_weight,
|
count_original, count_last_audit, unit_weight,
|
||||||
purchase_date, status
|
purchase_date, status
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @assetId, @productId,
|
@id, @assetId, @productId,
|
||||||
@brandId, @shopId, @binId,
|
@shopId, @binId,
|
||||||
@price, @thc, @cbd, @totalCannabinoids,
|
@price, @thc, @cbd, @totalCannabinoids,
|
||||||
@weight, @lastAuditWeight,
|
@weight, @lastAuditWeight,
|
||||||
@countOriginal, @countLastAudit, @unitWeight,
|
@countOriginal, @countLastAudit, @unitWeight,
|
||||||
@@ -57,7 +68,6 @@ inventoryRouter.post("/inventory", (req, res) => {
|
|||||||
id,
|
id,
|
||||||
assetId,
|
assetId,
|
||||||
productId: body.productId,
|
productId: body.productId,
|
||||||
brandId: body.brandId ?? null,
|
|
||||||
shopId: body.shopId ?? null,
|
shopId: body.shopId ?? null,
|
||||||
binId: body.binId ?? null,
|
binId: body.binId ?? null,
|
||||||
price: body.price,
|
price: body.price,
|
||||||
@@ -76,7 +86,6 @@ inventoryRouter.post("/inventory", (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
type UpdateBody = Partial<{
|
type UpdateBody = Partial<{
|
||||||
brandId: string | null;
|
|
||||||
shopId: string | null;
|
shopId: string | null;
|
||||||
binId: string | null;
|
binId: string | null;
|
||||||
price: number;
|
price: number;
|
||||||
@@ -95,7 +104,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
|
|||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
brand_id: string | null;
|
|
||||||
shop_id: string | null;
|
shop_id: string | null;
|
||||||
bin_id: string | null;
|
bin_id: string | null;
|
||||||
product_id: string;
|
product_id: string;
|
||||||
@@ -113,7 +121,7 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
|
|||||||
|
|
||||||
const existing = db
|
const existing = db
|
||||||
.prepare<[string], Row>(
|
.prepare<[string], Row>(
|
||||||
`SELECT id, brand_id, shop_id, bin_id, product_id, price, thc, cbd,
|
`SELECT id, shop_id, bin_id, product_id, price, thc, cbd,
|
||||||
total_cannabinoids, weight, last_audit_weight, count_original,
|
total_cannabinoids, weight, last_audit_weight, count_original,
|
||||||
count_last_audit, unit_weight, purchase_date
|
count_last_audit, unit_weight, purchase_date
|
||||||
FROM inventory_items WHERE id = ?`,
|
FROM inventory_items WHERE id = ?`,
|
||||||
@@ -132,8 +140,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
|
|||||||
)
|
)
|
||||||
.get(id)!.n;
|
.get(id)!.n;
|
||||||
|
|
||||||
const nextBrandId =
|
|
||||||
body.brandId === undefined ? existing.brand_id : body.brandId || null;
|
|
||||||
const nextShopId =
|
const nextShopId =
|
||||||
body.shopId === undefined ? existing.shop_id : body.shopId || null;
|
body.shopId === undefined ? existing.shop_id : body.shopId || null;
|
||||||
const nextBinId =
|
const nextBinId =
|
||||||
@@ -174,7 +180,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
|
|||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE inventory_items SET
|
`UPDATE inventory_items SET
|
||||||
brand_id = @brandId,
|
|
||||||
shop_id = @shopId,
|
shop_id = @shopId,
|
||||||
bin_id = @binId,
|
bin_id = @binId,
|
||||||
price = @price,
|
price = @price,
|
||||||
@@ -190,7 +195,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
|
|||||||
WHERE id = @id`,
|
WHERE id = @id`,
|
||||||
).run({
|
).run({
|
||||||
id,
|
id,
|
||||||
brandId: nextBrandId,
|
|
||||||
shopId: nextShopId,
|
shopId: nextShopId,
|
||||||
binId: nextBinId,
|
binId: nextBinId,
|
||||||
price: nextPrice,
|
price: nextPrice,
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ export const productsRouter: Router = Router();
|
|||||||
|
|
||||||
type CreateBody = {
|
type CreateBody = {
|
||||||
sku: string;
|
sku: string;
|
||||||
name: string;
|
// strain identity: either link to an existing strain via id, or supply a
|
||||||
type: string;
|
// name and the server creates / matches one. Strain.name doubles as the
|
||||||
kind: "bulk" | "discrete";
|
// product's display name.
|
||||||
strainId?: string | null;
|
strainId?: string | null;
|
||||||
strainName?: string;
|
strainName?: string;
|
||||||
|
brandId?: string | null;
|
||||||
|
type: string;
|
||||||
|
kind: "bulk" | "discrete";
|
||||||
defaultThc?: number;
|
defaultThc?: number;
|
||||||
defaultCbd?: number;
|
defaultCbd?: number;
|
||||||
defaultTotalCannabinoids?: number;
|
defaultTotalCannabinoids?: number;
|
||||||
@@ -18,14 +21,15 @@ type CreateBody = {
|
|||||||
productsRouter.post("/products", (req, res) => {
|
productsRouter.post("/products", (req, res) => {
|
||||||
const body = req.body as CreateBody;
|
const body = req.body as CreateBody;
|
||||||
if (!body.sku?.trim()) return res.status(400).json({ error: "sku required" });
|
if (!body.sku?.trim()) return res.status(400).json({ error: "sku required" });
|
||||||
if (!body.name?.trim()) return res.status(400).json({ error: "name required" });
|
|
||||||
if (!body.type) return res.status(400).json({ error: "type required" });
|
if (!body.type) return res.status(400).json({ error: "type required" });
|
||||||
if (body.kind !== "bulk" && body.kind !== "discrete") {
|
if (body.kind !== "bulk" && body.kind !== "discrete") {
|
||||||
return res.status(400).json({ error: "kind must be bulk or discrete" });
|
return res.status(400).json({ error: "kind must be bulk or discrete" });
|
||||||
}
|
}
|
||||||
|
if (!body.strainId && !body.strainName?.trim()) {
|
||||||
|
return res.status(400).json({ error: "strainId or strainName required" });
|
||||||
|
}
|
||||||
|
|
||||||
const sku = body.sku.trim();
|
const sku = body.sku.trim();
|
||||||
const name = body.name.trim();
|
|
||||||
const todayIso = new Date().toISOString().slice(0, 10);
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const existingSku = db
|
const existingSku = db
|
||||||
@@ -40,9 +44,9 @@ productsRouter.post("/products", (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const tx = db.transaction(() => {
|
const tx = db.transaction(() => {
|
||||||
let strainId = body.strainId ?? null;
|
let strainId = body.strainId ?? null;
|
||||||
const strainName = body.strainName?.trim() || name;
|
|
||||||
|
|
||||||
if (!strainId && strainName) {
|
if (!strainId) {
|
||||||
|
const strainName = body.strainName!.trim();
|
||||||
const found = db
|
const found = db
|
||||||
.prepare<[string], { id: string }>(
|
.prepare<[string], { id: string }>(
|
||||||
`SELECT id FROM strains WHERE name = ? COLLATE NOCASE`,
|
`SELECT id FROM strains WHERE name = ? COLLATE NOCASE`,
|
||||||
@@ -70,9 +74,9 @@ productsRouter.post("/products", (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO products (id, sku, strain_id, name, type, kind, created_at)
|
`INSERT INTO products (id, sku, strain_id, brand_id, type, kind, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(id, sku, strainId, name, body.type, body.kind, todayIso);
|
).run(id, sku, strainId, body.brandId ?? null, body.type, body.kind, todayIso);
|
||||||
});
|
});
|
||||||
tx();
|
tx();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -87,10 +91,11 @@ productsRouter.post("/products", (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
type UpdateBody = Partial<{
|
type UpdateBody = Partial<{
|
||||||
name: string;
|
|
||||||
type: string;
|
type: string;
|
||||||
kind: "bulk" | "discrete";
|
kind: "bulk" | "discrete";
|
||||||
strainId: string | null;
|
strainId: string | null;
|
||||||
|
strainName: string;
|
||||||
|
brandId: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
productsRouter.patch("/products/:id", (req, res) => {
|
productsRouter.patch("/products/:id", (req, res) => {
|
||||||
@@ -100,24 +105,47 @@ productsRouter.patch("/products/:id", (req, res) => {
|
|||||||
const existing = db
|
const existing = db
|
||||||
.prepare<
|
.prepare<
|
||||||
[string],
|
[string],
|
||||||
{ id: string; name: string; type: string; kind: string; strain_id: string | null }
|
{ id: string; type: string; kind: string; strain_id: string; brand_id: string | null }
|
||||||
>(
|
>(
|
||||||
`SELECT id, name, type, kind, strain_id FROM products WHERE id = ?`,
|
`SELECT id, type, kind, strain_id, brand_id FROM products WHERE id = ?`,
|
||||||
)
|
)
|
||||||
.get(id);
|
.get(id);
|
||||||
if (!existing) return res.status(404).json({ error: "product not found" });
|
if (!existing) return res.status(404).json({ error: "product not found" });
|
||||||
|
|
||||||
const nextName =
|
|
||||||
typeof body.name === "string" && body.name.trim() ? body.name.trim() : existing.name;
|
|
||||||
const nextType = typeof body.type === "string" && body.type ? body.type : existing.type;
|
const nextType = typeof body.type === "string" && body.type ? body.type : existing.type;
|
||||||
const nextKind: "bulk" | "discrete" =
|
const nextKind: "bulk" | "discrete" =
|
||||||
body.kind === "bulk" || body.kind === "discrete" ? body.kind : (existing.kind as "bulk" | "discrete");
|
body.kind === "bulk" || body.kind === "discrete"
|
||||||
const nextStrainId =
|
? body.kind
|
||||||
body.strainId === undefined ? existing.strain_id : body.strainId;
|
: (existing.kind as "bulk" | "discrete");
|
||||||
|
const nextBrandId =
|
||||||
|
body.brandId === undefined ? existing.brand_id : body.brandId;
|
||||||
|
|
||||||
|
// Strain handling: prefer explicit strainId; else if strainName given,
|
||||||
|
// match-by-name (case-insensitive) or create a new strain.
|
||||||
|
let nextStrainId = existing.strain_id;
|
||||||
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
if (body.strainId !== undefined && body.strainId) {
|
||||||
|
nextStrainId = body.strainId;
|
||||||
|
} else if (typeof body.strainName === "string" && body.strainName.trim()) {
|
||||||
|
const name = body.strainName.trim();
|
||||||
|
const found = db
|
||||||
|
.prepare<[string], { id: string }>(
|
||||||
|
`SELECT id FROM strains WHERE name = ? COLLATE NOCASE`,
|
||||||
|
)
|
||||||
|
.get(name);
|
||||||
|
if (found) {
|
||||||
|
nextStrainId = found.id;
|
||||||
|
} else {
|
||||||
|
nextStrainId = nextId("str", "strains");
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO strains (id, name, created_at) VALUES (?, ?, ?)`,
|
||||||
|
).run(nextStrainId, name, todayIso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE products SET name = ?, type = ?, kind = ?, strain_id = ? WHERE id = ?`,
|
`UPDATE products SET type = ?, kind = ?, strain_id = ?, brand_id = ? WHERE id = ?`,
|
||||||
).run(nextName, nextType, nextKind, nextStrainId, id);
|
).run(nextType, nextKind, nextStrainId, nextBrandId, id);
|
||||||
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-10
@@ -30,30 +30,33 @@ CREATE TABLE IF NOT EXISTS strains (
|
|||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Products: catalog entries. One row per (sku, strain) combination —
|
-- Products: catalog entries. One row per SKU. The strain (required)
|
||||||
-- a "kind of thing you can scan". Type/kind describe the physical form
|
-- supplies the display name — there is no separate product name field.
|
||||||
-- (e.g. Flower/bulk, Pre-roll/discrete) since the same strain can ship in
|
-- Brand sits here too: a SKU is brand-specific (UPC barcodes belong to
|
||||||
-- different forms with different SKUs.
|
-- a single brand), so all instances of a product share the brand.
|
||||||
|
-- Type/kind describe the physical form (e.g. Flower/bulk, Pre-roll/discrete);
|
||||||
|
-- the same strain can ship in different forms with different SKUs.
|
||||||
CREATE TABLE IF NOT EXISTS products (
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
sku TEXT NOT NULL UNIQUE,
|
sku TEXT NOT NULL UNIQUE,
|
||||||
strain_id TEXT REFERENCES strains(id),
|
strain_id TEXT NOT NULL REFERENCES strains(id),
|
||||||
name TEXT NOT NULL,
|
brand_id TEXT REFERENCES brands(id),
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
kind TEXT NOT NULL,
|
kind TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_products_strain ON products(strain_id);
|
CREATE INDEX IF NOT EXISTS idx_products_strain ON products(strain_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_brand ON products(brand_id);
|
||||||
|
|
||||||
-- Inventory items: physical things you bought. One row per acquired
|
-- Inventory items: physical things you bought. One row per acquired
|
||||||
-- jar/pack/cart, with its own short-UUID asset id (label-printed).
|
-- jar/pack/cart, with a user-supplied 6-digit asset id (the label number
|
||||||
-- Per-batch values (price, THC, weight or count, brand, shop, bin)
|
-- on a roll of pre-printed asset tags). Per-batch values (price, THC,
|
||||||
-- live here, since they vary between purchases of the same product.
|
-- weight/count, shop, bin) live here, since they vary between purchases
|
||||||
|
-- of the same product. Brand is on the product, not here.
|
||||||
CREATE TABLE IF NOT EXISTS inventory_items (
|
CREATE TABLE IF NOT EXISTS inventory_items (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
asset_id TEXT NOT NULL UNIQUE,
|
asset_id TEXT NOT NULL UNIQUE,
|
||||||
product_id TEXT NOT NULL REFERENCES products(id),
|
product_id TEXT NOT NULL REFERENCES products(id),
|
||||||
brand_id TEXT REFERENCES brands(id),
|
|
||||||
shop_id TEXT REFERENCES shops(id),
|
shop_id TEXT REFERENCES shops(id),
|
||||||
bin_id TEXT REFERENCES bins(id),
|
bin_id TEXT REFERENCES bins(id),
|
||||||
price REAL NOT NULL,
|
price REAL NOT NULL,
|
||||||
|
|||||||
+4
-4
@@ -18,11 +18,11 @@ export const api = {
|
|||||||
// Catalog: products
|
// Catalog: products
|
||||||
createProduct: (body: {
|
createProduct: (body: {
|
||||||
sku: string;
|
sku: string;
|
||||||
name: string;
|
|
||||||
type: string;
|
type: string;
|
||||||
kind: "bulk" | "discrete";
|
kind: "bulk" | "discrete";
|
||||||
strainId?: string | null;
|
strainId?: string | null;
|
||||||
strainName?: string;
|
strainName?: string;
|
||||||
|
brandId?: string | null;
|
||||||
defaultThc?: number;
|
defaultThc?: number;
|
||||||
defaultCbd?: number;
|
defaultCbd?: number;
|
||||||
defaultTotalCannabinoids?: number;
|
defaultTotalCannabinoids?: number;
|
||||||
@@ -31,10 +31,11 @@ export const api = {
|
|||||||
updateProduct: (
|
updateProduct: (
|
||||||
id: string,
|
id: string,
|
||||||
body: Partial<{
|
body: Partial<{
|
||||||
name: string;
|
|
||||||
type: string;
|
type: string;
|
||||||
kind: "bulk" | "discrete";
|
kind: "bulk" | "discrete";
|
||||||
strainId: string | null;
|
strainId: string | null;
|
||||||
|
strainName: string;
|
||||||
|
brandId: string | null;
|
||||||
}>,
|
}>,
|
||||||
) =>
|
) =>
|
||||||
request<{ ok: true }>(`/products/${id}`, {
|
request<{ ok: true }>(`/products/${id}`, {
|
||||||
@@ -62,8 +63,8 @@ export const api = {
|
|||||||
|
|
||||||
// Inventory items (instances)
|
// Inventory items (instances)
|
||||||
createInventoryItem: (body: {
|
createInventoryItem: (body: {
|
||||||
|
assetId: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
brandId?: string | null;
|
|
||||||
shopId?: string | null;
|
shopId?: string | null;
|
||||||
binId?: string | null;
|
binId?: string | null;
|
||||||
price: number;
|
price: number;
|
||||||
@@ -83,7 +84,6 @@ export const api = {
|
|||||||
updateInventoryItem: (
|
updateInventoryItem: (
|
||||||
id: string,
|
id: string,
|
||||||
body: Partial<{
|
body: Partial<{
|
||||||
brandId: string | null;
|
|
||||||
shopId: string | null;
|
shopId: string | null;
|
||||||
binId: string | null;
|
binId: string | null;
|
||||||
price: number;
|
price: number;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function ProductDetail({
|
|||||||
["Asset id", <span className="mono">{item.assetId}</span>],
|
["Asset id", <span className="mono">{item.assetId}</span>],
|
||||||
["SKU", <span className="mono">{item.sku}</span>],
|
["SKU", <span className="mono">{item.sku}</span>],
|
||||||
["Type", `${item.type} · ${item.kind}`],
|
["Type", `${item.type} · ${item.kind}`],
|
||||||
["Strain", item.strainName ?? <span style={{ color: "var(--ink-3)" }}>Unlinked</span>],
|
["Strain", item.name],
|
||||||
["Brand", helpers.brandName(data, item.brandId)],
|
["Brand", helpers.brandName(data, item.brandId)],
|
||||||
["Shop", helpers.shopName(data, item.shopId)],
|
["Shop", helpers.shopName(data, item.shopId)],
|
||||||
["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`],
|
["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`],
|
||||||
|
|||||||
@@ -14,11 +14,16 @@ export function ScanField({
|
|||||||
items,
|
items,
|
||||||
products,
|
products,
|
||||||
onMatch,
|
onMatch,
|
||||||
|
onScanNoMatch,
|
||||||
matchedLabel,
|
matchedLabel,
|
||||||
}: {
|
}: {
|
||||||
items: Item[];
|
items: Item[];
|
||||||
products?: Product[];
|
products?: Product[];
|
||||||
onMatch: (result: ScanResult) => void;
|
onMatch: (result: ScanResult) => void;
|
||||||
|
// Fired once after a debounce when the scanned text doesn't resolve to
|
||||||
|
// any known asset id or SKU. The parent can use the raw value (e.g. to
|
||||||
|
// open a "create new product" form prefilled with the scanned SKU).
|
||||||
|
onScanNoMatch?: (raw: string) => void;
|
||||||
matchedLabel: string | null;
|
matchedLabel: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [scan, setScan] = useState("");
|
const [scan, setScan] = useState("");
|
||||||
@@ -32,11 +37,10 @@ export function ScanField({
|
|||||||
}
|
}
|
||||||
const hit = lookup(trimmed, items, products);
|
const hit = lookup(trimmed, items, products);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
|
const label = hit.kind === "item" ? hit.item.name : hit.product.sku;
|
||||||
onMatch(hit);
|
onMatch(hit);
|
||||||
setScan("");
|
setScan("");
|
||||||
const name =
|
setFeedback({ type: "matched", text: `Matched ${label}` });
|
||||||
hit.kind === "item" ? hit.item.name : hit.product.name;
|
|
||||||
setFeedback({ type: "matched", text: `Matched ${name}` });
|
|
||||||
}
|
}
|
||||||
}, [scan]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [scan]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -45,10 +49,16 @@ export function ScanField({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scan.trim() || feedback?.type === "matched") return;
|
if (!scan.trim() || feedback?.type === "matched") return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const trimmed = scan.trim().toLowerCase();
|
const raw = scan.trim();
|
||||||
if (!lookup(trimmed, items, products)) {
|
if (!lookup(raw.toLowerCase(), items, products)) {
|
||||||
|
if (onScanNoMatch) {
|
||||||
|
onScanNoMatch(raw);
|
||||||
|
setScan("");
|
||||||
|
setFeedback(null);
|
||||||
|
} else {
|
||||||
setFeedback({ type: "miss", text: "No asset id or SKU matches that." });
|
setFeedback({ type: "miss", text: "No asset id or SKU matches that." });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, 400);
|
}, 400);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [scan, items, products]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [scan, items, products]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, InventoryItem, Item, Product, Strain } from "../../types.js";
|
import type { Bootstrap, InventoryItem, Item, Product, Strain } from "../../types.js";
|
||||||
import { TYPES, TODAY_STR, enrichItems, getLastInstance } from "../../types.js";
|
import {
|
||||||
|
ASSET_ID_RE,
|
||||||
|
TYPES,
|
||||||
|
TODAY_STR,
|
||||||
|
enrichItems,
|
||||||
|
getLastInstance,
|
||||||
|
} from "../../types.js";
|
||||||
import { fmt } from "../../format.js";
|
import { fmt } from "../../format.js";
|
||||||
import { api } from "../../api.js";
|
import { api } from "../../api.js";
|
||||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||||
@@ -25,6 +31,10 @@ export function AddInventoryFlow({ data, onClose }: { data: Bootstrap; onClose:
|
|||||||
const product = productId
|
const product = productId
|
||||||
? data.products.find((p) => p.id === productId) ?? null
|
? data.products.find((p) => p.id === productId) ?? null
|
||||||
: null;
|
: null;
|
||||||
|
const productName =
|
||||||
|
product?.strainId
|
||||||
|
? data.strains.find((s) => s.id === product.strainId)?.name ?? ""
|
||||||
|
: "";
|
||||||
|
|
||||||
const goToDetails = (id: string) => {
|
const goToDetails = (id: string) => {
|
||||||
setProductId(id);
|
setProductId(id);
|
||||||
@@ -48,7 +58,7 @@ export function AddInventoryFlow({ data, onClose }: { data: Bootstrap; onClose:
|
|||||||
step === "select"
|
step === "select"
|
||||||
? "Add inventory"
|
? "Add inventory"
|
||||||
: step === "details"
|
: step === "details"
|
||||||
? `Add ${product?.name ?? ""}`
|
? `Add ${productName}`
|
||||||
: "Saved"
|
: "Saved"
|
||||||
}
|
}
|
||||||
eyebrow={
|
eyebrow={
|
||||||
@@ -75,6 +85,7 @@ export function AddInventoryFlow({ data, onClose }: { data: Bootstrap; onClose:
|
|||||||
data={data}
|
data={data}
|
||||||
items={items}
|
items={items}
|
||||||
product={product}
|
product={product}
|
||||||
|
productName={productName}
|
||||||
onBack={() => setStep("select")}
|
onBack={() => setStep("select")}
|
||||||
onSaved={(assetId) => {
|
onSaved={(assetId) => {
|
||||||
setSavedAssetId(assetId);
|
setSavedAssetId(assetId);
|
||||||
@@ -87,7 +98,7 @@ export function AddInventoryFlow({ data, onClose }: { data: Bootstrap; onClose:
|
|||||||
{step === "done" && savedAssetId && product && (
|
{step === "done" && savedAssetId && product && (
|
||||||
<DonePane
|
<DonePane
|
||||||
assetId={savedAssetId}
|
assetId={savedAssetId}
|
||||||
productName={product.name}
|
productName={productName}
|
||||||
onAddAnother={() => {
|
onAddAnother={() => {
|
||||||
setSavedAssetId(null);
|
setSavedAssetId(null);
|
||||||
setProductId(null);
|
setProductId(null);
|
||||||
@@ -123,10 +134,12 @@ function SelectProductStep({
|
|||||||
|
|
||||||
// New-product subform
|
// New-product subform
|
||||||
const [newSku, setNewSku] = useState("");
|
const [newSku, setNewSku] = useState("");
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState(""); // strain name; doubles as display name
|
||||||
const [newType, setNewType] = useState("Flower");
|
const [newType, setNewType] = useState("Flower");
|
||||||
const [newStrain, setNewStrain] = useState(""); // typed strain name
|
const [newBrandId, setNewBrandId] = useState<string>(
|
||||||
const [newStrainId, setNewStrainId] = useState<string>(""); // empty = match-by-name / create
|
data.brands[0]?.id ?? NEW_BRAND,
|
||||||
|
);
|
||||||
|
const [newBrandName, setNewBrandName] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleScan = (result: ScanResult) => {
|
const handleScan = (result: ScanResult) => {
|
||||||
@@ -140,29 +153,43 @@ function SelectProductStep({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNoMatch = (raw: string) => {
|
||||||
|
// Scanned a SKU we've never seen — open the create form prefilled.
|
||||||
|
setNewSku(raw.toUpperCase());
|
||||||
|
setCreating(true);
|
||||||
|
};
|
||||||
|
|
||||||
const matchedStrain: Strain | null = useMemo(() => {
|
const matchedStrain: Strain | null = useMemo(() => {
|
||||||
const q = newStrain.trim().toLowerCase();
|
const q = newName.trim().toLowerCase();
|
||||||
if (!q) return null;
|
if (!q) return null;
|
||||||
return data.strains.find((s) => s.name.trim().toLowerCase() === q) ?? null;
|
return data.strains.find((s) => s.name.trim().toLowerCase() === q) ?? null;
|
||||||
}, [newStrain, data.strains]);
|
}, [newName, data.strains]);
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const sku = newSku.trim();
|
const sku = newSku.trim();
|
||||||
const name = newName.trim();
|
const name = newName.trim();
|
||||||
const strainName = newStrain.trim() || name;
|
|
||||||
if (!sku) throw new Error("SKU required");
|
if (!sku) throw new Error("SKU required");
|
||||||
if (!name) throw new Error("Product name required");
|
if (!name) throw new Error("Name (strain) required");
|
||||||
const cfg = TYPES.find((t) => t.id === newType);
|
const cfg = TYPES.find((t) => t.id === newType);
|
||||||
if (!cfg) throw new Error("Type required");
|
if (!cfg) throw new Error("Type required");
|
||||||
|
|
||||||
|
let brandId: string | null = null;
|
||||||
|
if (newBrandId === NEW_BRAND) {
|
||||||
|
if (!newBrandName.trim()) throw new Error("New brand name required");
|
||||||
|
const b = await api.createBrand(newBrandName.trim());
|
||||||
|
brandId = b.id;
|
||||||
|
} else {
|
||||||
|
brandId = newBrandId || null;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await api.createProduct({
|
const result = await api.createProduct({
|
||||||
sku,
|
sku,
|
||||||
name,
|
|
||||||
type: newType,
|
type: newType,
|
||||||
kind: cfg.kind,
|
kind: cfg.kind,
|
||||||
strainId: newStrainId || matchedStrain?.id || undefined,
|
strainId: matchedStrain?.id,
|
||||||
strainName: newStrainId || matchedStrain ? undefined : strainName,
|
strainName: matchedStrain ? undefined : name,
|
||||||
|
brandId,
|
||||||
});
|
});
|
||||||
return result.id;
|
return result.id;
|
||||||
},
|
},
|
||||||
@@ -173,6 +200,8 @@ function SelectProductStep({
|
|||||||
onError: (e: Error) => setError(e.message),
|
onError: (e: Error) => setError(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isNewBrand = newBrandId === NEW_BRAND;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ padding: 32 }}>
|
<div style={{ padding: 32 }}>
|
||||||
@@ -180,6 +209,7 @@ function SelectProductStep({
|
|||||||
items={items}
|
items={items}
|
||||||
products={data.products}
|
products={data.products}
|
||||||
onMatch={handleScan}
|
onMatch={handleScan}
|
||||||
|
onScanNoMatch={creating ? undefined : handleNoMatch}
|
||||||
matchedLabel={null}
|
matchedLabel={null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -190,11 +220,15 @@ function SelectProductStep({
|
|||||||
value={pickedProductId}
|
value={pickedProductId}
|
||||||
onChange={(e) => setPickedProductId(e.target.value)}
|
onChange={(e) => setPickedProductId(e.target.value)}
|
||||||
>
|
>
|
||||||
{data.products.map((p) => (
|
{data.products.map((p) => {
|
||||||
|
const strainName =
|
||||||
|
data.strains.find((s) => s.id === p.strainId)?.name ?? "?";
|
||||||
|
return (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.name} · {p.sku} ({p.type})
|
{strainName} · {p.sku} ({p.type})
|
||||||
</option>
|
</option>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,6 +261,7 @@ function SelectProductStep({
|
|||||||
value={newSku}
|
value={newSku}
|
||||||
placeholder="SKU-XXXXXX"
|
placeholder="SKU-XXXXXX"
|
||||||
onChange={(e) => setNewSku(e.target.value)}
|
onChange={(e) => setNewSku(e.target.value)}
|
||||||
|
autoFocus={!newSku}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Type">
|
<Field label="Type">
|
||||||
@@ -238,41 +273,43 @@ function SelectProductStep({
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Product name" span={2}>
|
|
||||||
<Input
|
|
||||||
value={newName}
|
|
||||||
placeholder="e.g. Garden Ghost 3.5g"
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field
|
<Field
|
||||||
label="Strain"
|
label="Name (strain)"
|
||||||
span={2}
|
span={2}
|
||||||
hint={
|
hint={
|
||||||
matchedStrain
|
matchedStrain
|
||||||
? `Will link to existing strain "${matchedStrain.name}".`
|
? `Will link to existing strain "${matchedStrain.name}".`
|
||||||
: "Will create a new strain entry from this name (defaults blank — link from product later)."
|
: "Will create a new strain entry from this name."
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Select
|
|
||||||
value={newStrainId}
|
|
||||||
onChange={(e) => setNewStrainId(e.target.value)}
|
|
||||||
style={{ marginBottom: 8 }}
|
|
||||||
>
|
|
||||||
<option value="">— Match by name typed below —</option>
|
|
||||||
{data.strains.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Input
|
<Input
|
||||||
value={newStrain}
|
value={newName}
|
||||||
placeholder={`Strain name (defaults to product name if blank)`}
|
placeholder="e.g. Garden Ghost"
|
||||||
onChange={(e) => setNewStrain(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
disabled={!!newStrainId}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Brand">
|
||||||
|
<Select
|
||||||
|
value={newBrandId}
|
||||||
|
onChange={(e) => setNewBrandId(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>
|
||||||
|
{isNewBrand && (
|
||||||
|
<Field label="New brand name">
|
||||||
|
<Input
|
||||||
|
value={newBrandName}
|
||||||
|
onChange={(e) => setNewBrandName(e.target.value)}
|
||||||
|
placeholder="e.g. Foxglove Farms"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ marginTop: 12, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
<div style={{ marginTop: 12, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
@@ -329,12 +366,14 @@ function InstanceDetailsStep({
|
|||||||
data,
|
data,
|
||||||
items,
|
items,
|
||||||
product,
|
product,
|
||||||
|
productName,
|
||||||
onBack,
|
onBack,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
items: Item[];
|
items: Item[];
|
||||||
product: Product;
|
product: Product;
|
||||||
|
productName: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onSaved: (assetId: string) => void;
|
onSaved: (assetId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -353,8 +392,8 @@ function InstanceDetailsStep({
|
|||||||
return last.price;
|
return last.price;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const [assetId, setAssetId] = useState("");
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
brandId: last?.brandId ?? data.brands[0]?.id ?? NEW_BRAND,
|
|
||||||
shopId: last?.shopId ?? data.shops[0]?.id ?? NEW_SHOP,
|
shopId: last?.shopId ?? data.shops[0]?.id ?? NEW_SHOP,
|
||||||
binId: data.bins[0]?.id ?? NEW_BIN,
|
binId: data.bins[0]?.id ?? NEW_BIN,
|
||||||
weight: last?.weight ?? (isDiscrete ? 0 : 3.5),
|
weight: last?.weight ?? (isDiscrete ? 0 : 3.5),
|
||||||
@@ -366,7 +405,6 @@ function InstanceDetailsStep({
|
|||||||
totalCannabinoids: last?.totalCannabinoids ?? 26,
|
totalCannabinoids: last?.totalCannabinoids ?? 26,
|
||||||
purchaseDate: TODAY_STR,
|
purchaseDate: TODAY_STR,
|
||||||
});
|
});
|
||||||
const [newBrand, setNewBrand] = useState("");
|
|
||||||
const [newShopName, setNewShopName] = useState("");
|
const [newShopName, setNewShopName] = useState("");
|
||||||
const [newShopLocation, setNewShopLocation] = useState("");
|
const [newShopLocation, setNewShopLocation] = useState("");
|
||||||
const [newBinName, setNewBinName] = useState("");
|
const [newBinName, setNewBinName] = useState("");
|
||||||
@@ -378,15 +416,15 @@ function InstanceDetailsStep({
|
|||||||
|
|
||||||
const totalPrice = isDiscrete ? form.price * form.countOriginal : form.price;
|
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 assetIdValid = ASSET_ID_RE.test(assetId);
|
||||||
|
const assetIdConflict =
|
||||||
|
assetIdValid && data.inventoryItems.some((i) => i.assetId === assetId);
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
let { brandId, shopId, binId } = form;
|
if (!assetIdValid) throw new Error("Asset id must be exactly 6 digits");
|
||||||
if (brandId === NEW_BRAND) {
|
if (assetIdConflict) throw new Error("Asset id already used");
|
||||||
if (!newBrand.trim()) throw new Error("New brand name required");
|
let { shopId, binId } = form;
|
||||||
const b = await api.createBrand(newBrand.trim());
|
|
||||||
brandId = b.id;
|
|
||||||
}
|
|
||||||
if (shopId === NEW_SHOP) {
|
if (shopId === NEW_SHOP) {
|
||||||
if (!newShopName.trim()) throw new Error("New shop name required");
|
if (!newShopName.trim()) throw new Error("New shop name required");
|
||||||
const s = await api.createShop({
|
const s = await api.createShop({
|
||||||
@@ -404,8 +442,8 @@ function InstanceDetailsStep({
|
|||||||
binId = b.id;
|
binId = b.id;
|
||||||
}
|
}
|
||||||
return api.createInventoryItem({
|
return api.createInventoryItem({
|
||||||
|
assetId,
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
brandId,
|
|
||||||
shopId,
|
shopId,
|
||||||
binId,
|
binId,
|
||||||
weight: isDiscrete ? undefined : form.weight,
|
weight: isDiscrete ? undefined : form.weight,
|
||||||
@@ -422,13 +460,11 @@ function InstanceDetailsStep({
|
|||||||
onError: (e: Error) => setError(e.message),
|
onError: (e: Error) => setError(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isNewBrand = form.brandId === NEW_BRAND;
|
|
||||||
const isNewShop = form.shopId === NEW_SHOP;
|
const isNewShop = form.shopId === NEW_SHOP;
|
||||||
const isNewBin = form.binId === NEW_BIN;
|
const isNewBin = form.binId === NEW_BIN;
|
||||||
|
|
||||||
// Find prior instances of this product (excluding any from before bootstrap;
|
|
||||||
// safe — we just count to surface "we've had this N times before").
|
|
||||||
const priorCount = items.filter((i) => i.productId === product.id).length;
|
const priorCount = items.filter((i) => i.productId === product.id).length;
|
||||||
|
const brandName = data.brands.find((b) => b.id === product.brandId)?.name ?? "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -448,17 +484,46 @@ function InstanceDetailsStep({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<strong style={{ color: "var(--ink-2)" }}>{product.name}</strong> · {product.type} ·{" "}
|
<strong style={{ color: "var(--ink-2)" }}>{productName}</strong> · {brandName} · {product.type} ·{" "}
|
||||||
<span className="mono">{product.sku}</span>
|
<span className="mono">{product.sku}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{priorCount > 0
|
{priorCount > 0
|
||||||
? `${priorCount} prior instance${priorCount === 1 ? "" : "s"} — fields autofilled from most recent.`
|
? `${priorCount} prior instance${priorCount === 1 ? "" : "s"} — fields autofilled.`
|
||||||
: "First instance of this product."}
|
: "First instance of this product."}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
|
||||||
|
Asset tag
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
label="Asset id (6 digits)"
|
||||||
|
hint={
|
||||||
|
assetIdConflict
|
||||||
|
? "That asset id is already in use."
|
||||||
|
: "From the next sticker on your roll. Required."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={assetId}
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="000000"
|
||||||
|
onChange={(e) => setAssetId(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
borderColor: assetIdConflict ? "var(--terracotta)" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="smallcaps"
|
||||||
|
style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}
|
||||||
|
>
|
||||||
Source
|
Source
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -469,16 +534,6 @@ function InstanceDetailsStep({
|
|||||||
marginBottom: 28,
|
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">
|
<Field label="Shop">
|
||||||
<Select value={form.shopId} onChange={(e) => update("shopId", e.target.value)}>
|
<Select value={form.shopId} onChange={(e) => update("shopId", e.target.value)}>
|
||||||
{data.shops.map((s) => (
|
{data.shops.map((s) => (
|
||||||
@@ -489,15 +544,16 @@ function InstanceDetailsStep({
|
|||||||
<option value={NEW_SHOP}>+ Add new shop…</option>
|
<option value={NEW_SHOP}>+ Add new shop…</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
{isNewBrand && (
|
<Field label="Bin">
|
||||||
<Field label="New brand name" span={2}>
|
<Select value={form.binId} onChange={(e) => update("binId", e.target.value)}>
|
||||||
<Input
|
{data.bins.map((b) => (
|
||||||
value={newBrand}
|
<option key={b.id} value={b.id}>
|
||||||
onChange={(e) => setNewBrand(e.target.value)}
|
{b.name}
|
||||||
placeholder="e.g. Foxglove Farms"
|
</option>
|
||||||
/>
|
))}
|
||||||
|
<option value={NEW_BIN}>+ Add new bin…</option>
|
||||||
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
|
||||||
{isNewShop && (
|
{isNewShop && (
|
||||||
<>
|
<>
|
||||||
<Field label="New shop name">
|
<Field label="New shop name">
|
||||||
@@ -516,16 +572,6 @@ function InstanceDetailsStep({
|
|||||||
</Field>
|
</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 && (
|
{isNewBin && (
|
||||||
<>
|
<>
|
||||||
<Field label="New bin name">
|
<Field label="New bin name">
|
||||||
@@ -672,7 +718,7 @@ function InstanceDetailsStep({
|
|||||||
<Btn
|
<Btn
|
||||||
variant="primary"
|
variant="primary"
|
||||||
icon="check"
|
icon="check"
|
||||||
disabled={save.isPending}
|
disabled={save.isPending || !assetIdValid || assetIdConflict}
|
||||||
onClick={() => save.mutate()}
|
onClick={() => save.mutate()}
|
||||||
>
|
>
|
||||||
{save.isPending ? "Saving…" : "Save inventory item"}
|
{save.isPending ? "Saving…" : "Save inventory item"}
|
||||||
@@ -712,9 +758,9 @@ function DonePane({
|
|||||||
{assetId}
|
{assetId}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 13, color: "var(--ink-2)", marginBottom: 8 }}>
|
<div style={{ fontSize: 13, color: "var(--ink-2)", marginBottom: 8 }}>
|
||||||
Label this {productName} with{" "}
|
Stick label{" "}
|
||||||
<span className="mono" style={{ color: "var(--ink)" }}>{assetId}</span>{" "}
|
<span className="mono" style={{ color: "var(--ink)" }}>{assetId}</span>{" "}
|
||||||
so you can scan it later.
|
on the {productName}.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { api } from "../../api.js";
|
|||||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
|
||||||
const NEW_BRAND = "__new_brand__";
|
|
||||||
const NEW_SHOP = "__new_shop__";
|
const NEW_SHOP = "__new_shop__";
|
||||||
const NEW_BIN = "__new_bin__";
|
const NEW_BIN = "__new_bin__";
|
||||||
|
|
||||||
@@ -30,7 +29,6 @@ export function EditInventoryFlow({
|
|||||||
: item.price;
|
: item.price;
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
brandId: item.brandId ?? NEW_BRAND,
|
|
||||||
shopId: item.shopId ?? NEW_SHOP,
|
shopId: item.shopId ?? NEW_SHOP,
|
||||||
binId: item.binId ?? NEW_BIN,
|
binId: item.binId ?? NEW_BIN,
|
||||||
weight: item.weight,
|
weight: item.weight,
|
||||||
@@ -42,7 +40,6 @@ export function EditInventoryFlow({
|
|||||||
totalCannabinoids: item.totalCannabinoids,
|
totalCannabinoids: item.totalCannabinoids,
|
||||||
purchaseDate: item.purchaseDate,
|
purchaseDate: item.purchaseDate,
|
||||||
});
|
});
|
||||||
const [newBrand, setNewBrand] = useState("");
|
|
||||||
const [newShopName, setNewShopName] = useState("");
|
const [newShopName, setNewShopName] = useState("");
|
||||||
const [newShopLocation, setNewShopLocation] = useState("");
|
const [newShopLocation, setNewShopLocation] = useState("");
|
||||||
const [newBinName, setNewBinName] = useState("");
|
const [newBinName, setNewBinName] = useState("");
|
||||||
@@ -58,12 +55,7 @@ export function EditInventoryFlow({
|
|||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
let { brandId, shopId, binId } = form;
|
let { 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 (shopId === NEW_SHOP) {
|
||||||
if (!newShopName.trim()) throw new Error("New shop name required");
|
if (!newShopName.trim()) throw new Error("New shop name required");
|
||||||
const s = await api.createShop({
|
const s = await api.createShop({
|
||||||
@@ -81,7 +73,6 @@ export function EditInventoryFlow({
|
|||||||
binId = b.id;
|
binId = b.id;
|
||||||
}
|
}
|
||||||
return api.updateInventoryItem(item.id, {
|
return api.updateInventoryItem(item.id, {
|
||||||
brandId,
|
|
||||||
shopId,
|
shopId,
|
||||||
binId,
|
binId,
|
||||||
weight: isDiscrete ? undefined : form.weight,
|
weight: isDiscrete ? undefined : form.weight,
|
||||||
@@ -101,7 +92,6 @@ export function EditInventoryFlow({
|
|||||||
onError: (e: Error) => setError(e.message),
|
onError: (e: Error) => setError(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isNewBrand = form.brandId === NEW_BRAND;
|
|
||||||
const isNewShop = form.shopId === NEW_SHOP;
|
const isNewShop = form.shopId === NEW_SHOP;
|
||||||
const isNewBin = form.binId === NEW_BIN;
|
const isNewBin = form.binId === NEW_BIN;
|
||||||
|
|
||||||
@@ -159,16 +149,6 @@ export function EditInventoryFlow({
|
|||||||
marginBottom: 28,
|
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">
|
<Field label="Shop">
|
||||||
<Select value={form.shopId} onChange={(e) => update("shopId", e.target.value)}>
|
<Select value={form.shopId} onChange={(e) => update("shopId", e.target.value)}>
|
||||||
{data.shops.map((s) => (
|
{data.shops.map((s) => (
|
||||||
@@ -179,15 +159,16 @@ export function EditInventoryFlow({
|
|||||||
<option value={NEW_SHOP}>+ Add new shop…</option>
|
<option value={NEW_SHOP}>+ Add new shop…</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
{isNewBrand && (
|
<Field label="Bin">
|
||||||
<Field label="New brand name" span={2}>
|
<Select value={form.binId} onChange={(e) => update("binId", e.target.value)}>
|
||||||
<Input
|
{data.bins.map((b) => (
|
||||||
value={newBrand}
|
<option key={b.id} value={b.id}>
|
||||||
onChange={(e) => setNewBrand(e.target.value)}
|
{b.name}
|
||||||
placeholder="e.g. Foxglove Farms"
|
</option>
|
||||||
/>
|
))}
|
||||||
|
<option value={NEW_BIN}>+ Add new bin…</option>
|
||||||
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
|
||||||
{isNewShop && (
|
{isNewShop && (
|
||||||
<>
|
<>
|
||||||
<Field label="New shop name">
|
<Field label="New shop name">
|
||||||
@@ -206,16 +187,6 @@ export function EditInventoryFlow({
|
|||||||
</Field>
|
</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 && (
|
{isNewBin && (
|
||||||
<>
|
<>
|
||||||
<Field label="New bin name">
|
<Field label="New bin name">
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, Product } from "../../types.js";
|
import type { Bootstrap, Product, Strain } from "../../types.js";
|
||||||
import { TYPES } from "../../types.js";
|
import { TYPES } from "../../types.js";
|
||||||
import { TYPE_GLYPHS } from "../../format.js";
|
import { TYPE_GLYPHS } from "../../format.js";
|
||||||
import { api } from "../../api.js";
|
import { api } from "../../api.js";
|
||||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
|
||||||
// Catalog-level edit. SKU is immutable (it's a barcode). Type and kind are
|
const NEW_BRAND = "__new_brand__";
|
||||||
// editable but should rarely change after first instances exist — we don't
|
|
||||||
// block since data integrity isn't really at risk (kind only flips behavior).
|
// Catalog-level edit. SKU is immutable (it's a barcode). Type can change but
|
||||||
|
// rarely should. Name is the strain name — typing it either matches an
|
||||||
|
// existing strain or creates a new one. Brand can be reassigned at any time.
|
||||||
export function EditProductFlow({
|
export function EditProductFlow({
|
||||||
data,
|
data,
|
||||||
product,
|
product,
|
||||||
@@ -21,21 +23,45 @@ export function EditProductFlow({
|
|||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const [name, setName] = useState(product.name);
|
const initialStrain = data.strains.find((s) => s.id === product.strainId);
|
||||||
|
|
||||||
|
const [name, setName] = useState(initialStrain?.name ?? "");
|
||||||
const [type, setType] = useState(product.type);
|
const [type, setType] = useState(product.type);
|
||||||
const [strainId, setStrainId] = useState<string>(product.strainId ?? "");
|
const [brandId, setBrandId] = useState<string>(product.brandId ?? NEW_BRAND);
|
||||||
|
const [newBrandName, setNewBrandName] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const cfg = TYPES.find((t) => t.id === type);
|
const cfg = TYPES.find((t) => t.id === type);
|
||||||
|
|
||||||
|
const matchedStrain: Strain | null = useMemo(() => {
|
||||||
|
const q = name.trim().toLowerCase();
|
||||||
|
if (!q) return null;
|
||||||
|
return data.strains.find((s) => s.name.trim().toLowerCase() === q) ?? null;
|
||||||
|
}, [name, data.strains]);
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: async () => {
|
||||||
api.updateProduct(product.id, {
|
if (!name.trim()) throw new Error("Name (strain) required");
|
||||||
name: name.trim(),
|
let nextBrandId: string | null = null;
|
||||||
|
if (brandId === NEW_BRAND) {
|
||||||
|
if (newBrandName.trim()) {
|
||||||
|
const b = await api.createBrand(newBrandName.trim());
|
||||||
|
nextBrandId = b.id;
|
||||||
|
} else {
|
||||||
|
nextBrandId = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextBrandId = brandId || null;
|
||||||
|
}
|
||||||
|
return api.updateProduct(product.id, {
|
||||||
type,
|
type,
|
||||||
kind: cfg?.kind ?? product.kind,
|
kind: cfg?.kind ?? product.kind,
|
||||||
strainId: strainId || null,
|
// Server resolves matched-by-name to existing strain, else creates one
|
||||||
}),
|
strainId: matchedStrain?.id,
|
||||||
|
strainName: matchedStrain ? undefined : name.trim(),
|
||||||
|
brandId: nextBrandId,
|
||||||
|
});
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
onClose();
|
onClose();
|
||||||
@@ -43,6 +69,8 @@ export function EditProductFlow({
|
|||||||
onError: (e: Error) => setError(e.message),
|
onError: (e: Error) => setError(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isNewBrand = brandId === NEW_BRAND;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalBackdrop onClose={onClose}>
|
<ModalBackdrop onClose={onClose}>
|
||||||
<div
|
<div
|
||||||
@@ -81,17 +109,25 @@ export function EditProductFlow({
|
|||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
SKU <span className="mono" style={{ color: "var(--ink-2)" }}>{product.sku}</span>{" "}
|
SKU <span className="mono" style={{ color: "var(--ink-2)" }}>{product.sku}</span>{" "}
|
||||||
is locked. Edit individual purchases (price, brand, batch THC) from the
|
is locked. Edit individual purchases (price, batch THC) from the
|
||||||
inventory drawer.
|
inventory drawer.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16 }}>
|
||||||
<Field label="Product name" span={2}>
|
<Field
|
||||||
|
label="Name (strain)"
|
||||||
|
span={2}
|
||||||
|
hint={
|
||||||
|
matchedStrain
|
||||||
|
? `Will link to existing strain "${matchedStrain.name}".`
|
||||||
|
: "Will create a new strain entry from this name."
|
||||||
|
}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g. Garden Ghost 3.5g"
|
placeholder="e.g. Garden Ghost"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Type">
|
<Field label="Type">
|
||||||
@@ -103,16 +139,25 @@ export function EditProductFlow({
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Strain">
|
<Field label="Brand">
|
||||||
<Select value={strainId} onChange={(e) => setStrainId(e.target.value)}>
|
<Select value={brandId} onChange={(e) => setBrandId(e.target.value)}>
|
||||||
<option value="">— Unlinked —</option>
|
{data.brands.map((b) => (
|
||||||
{data.strains.map((s) => (
|
<option key={b.id} value={b.id}>
|
||||||
<option key={s.id} value={s.id}>
|
{b.name}
|
||||||
{s.name}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
<option value={NEW_BRAND}>+ Add new brand…</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
{isNewBrand && (
|
||||||
|
<Field label="New brand name (or leave blank for unbranded)" span={2}>
|
||||||
|
<Input
|
||||||
|
value={newBrandName}
|
||||||
|
onChange={(e) => setNewBrandName(e.target.value)}
|
||||||
|
placeholder="e.g. Foxglove Farms"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
+18
-14
@@ -11,25 +11,26 @@ export interface Audit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Product = catalog entry. Identified by SKU. One row per "kind of thing
|
// Product = catalog entry. Identified by SKU. One row per "kind of thing
|
||||||
// you can scan" — strain + form factor (Flower/bulk, Pre-roll/discrete, …).
|
// you can scan" — strain + brand + form factor (Flower/bulk, Pre-roll/discrete).
|
||||||
|
// Display name comes from the linked strain (strainId is required).
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
strainId: string | null;
|
strainId: string;
|
||||||
name: string;
|
brandId: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
kind: ProductKind;
|
kind: ProductKind;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// InventoryItem = a physical jar/pack you bought. Has its own asset id
|
// InventoryItem = a physical jar/pack you bought. Has a 6-digit asset id
|
||||||
// (label-printed). Carries everything that varies per purchase: brand, shop,
|
// (printed on a roll of labels the user owns). Carries per-batch values:
|
||||||
// bin, price, cannabinoids, weight/count, lifecycle, audits.
|
// shop, bin, price, cannabinoids, weight/count, lifecycle, audits. Brand
|
||||||
|
// lives on the product, not here.
|
||||||
export interface InventoryItem {
|
export interface InventoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
brandId: string | null;
|
|
||||||
shopId: string | null;
|
shopId: string | null;
|
||||||
binId: string | null;
|
binId: string | null;
|
||||||
price: number;
|
price: number;
|
||||||
@@ -52,15 +53,15 @@ export interface InventoryItem {
|
|||||||
|
|
||||||
// Item = InventoryItem with its product's catalog fields denormalized in.
|
// Item = InventoryItem with its product's catalog fields denormalized in.
|
||||||
// Built once from bootstrap (`enrichItems`) so views can access `name`,
|
// Built once from bootstrap (`enrichItems`) so views can access `name`,
|
||||||
// `sku`, `type`, `kind` without a per-row lookup. This is the shape the
|
// `sku`, `type`, `kind`, `brandId` without a per-row lookup. The display
|
||||||
// UI and helpers operate on.
|
// `name` is the strain's name. This is the shape the UI and helpers operate on.
|
||||||
export interface Item extends InventoryItem {
|
export interface Item extends InventoryItem {
|
||||||
name: string;
|
name: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
type: string;
|
type: string;
|
||||||
kind: ProductKind;
|
kind: ProductKind;
|
||||||
strainId: string | null;
|
brandId: string | null;
|
||||||
strainName: string | null;
|
strainId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Strain {
|
export interface Strain {
|
||||||
@@ -118,6 +119,9 @@ export const TYPES: TypeConfig[] = [
|
|||||||
{ id: "Vaporizer", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false },
|
{ id: "Vaporizer", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// User-supplied 6-digit asset ids are printed on a roll of physical tags.
|
||||||
|
export const ASSET_ID_RE = /^\d{6}$/;
|
||||||
|
|
||||||
// Local-time YYYY-MM-DD captured once at module load. Used as the default
|
// Local-time YYYY-MM-DD captured once at module load. Used as the default
|
||||||
// value for date inputs and as the "today" anchor for days-since math.
|
// value for date inputs and as the "today" anchor for days-since math.
|
||||||
export const TODAY_STR = (() => {
|
export const TODAY_STR = (() => {
|
||||||
@@ -138,15 +142,15 @@ export function enrichItems(data: Bootstrap): Item[] {
|
|||||||
for (const inv of data.inventoryItems) {
|
for (const inv of data.inventoryItems) {
|
||||||
const product = productMap.get(inv.productId);
|
const product = productMap.get(inv.productId);
|
||||||
if (!product) continue;
|
if (!product) continue;
|
||||||
const strain = product.strainId ? strainMap.get(product.strainId) ?? null : null;
|
const strain = strainMap.get(product.strainId);
|
||||||
out.push({
|
out.push({
|
||||||
...inv,
|
...inv,
|
||||||
name: product.name,
|
name: strain?.name ?? "(unknown strain)",
|
||||||
sku: product.sku,
|
sku: product.sku,
|
||||||
type: product.type,
|
type: product.type,
|
||||||
kind: product.kind,
|
kind: product.kind,
|
||||||
|
brandId: product.brandId,
|
||||||
strainId: product.strainId,
|
strainId: product.strainId,
|
||||||
strainName: strain?.name ?? null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -68,7 +68,13 @@ export function BrandsView({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.brands.map((b) => {
|
{data.brands.map((b) => {
|
||||||
const itemCount = data.inventoryItems.filter((i) => i.brandId === b.id).length;
|
// Count instances whose product points at this brand.
|
||||||
|
const productIds = new Set(
|
||||||
|
data.products.filter((p) => p.brandId === b.id).map((p) => p.id),
|
||||||
|
);
|
||||||
|
const itemCount = data.inventoryItems.filter((i) =>
|
||||||
|
productIds.has(i.productId),
|
||||||
|
).length;
|
||||||
return (
|
return (
|
||||||
<Card key={b.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<Card key={b.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ export function Inventory({
|
|||||||
brand.includes(q) ||
|
brand.includes(q) ||
|
||||||
shop.includes(q) ||
|
shop.includes(q) ||
|
||||||
i.sku.toLowerCase().includes(q) ||
|
i.sku.toLowerCase().includes(q) ||
|
||||||
i.assetId.toLowerCase().includes(q) ||
|
i.assetId.toLowerCase().includes(q)
|
||||||
(i.strainName?.toLowerCase().includes(q) ?? false)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user