Track inventory at the instance level, not by product
Build and push image / build (push) Successful in 46s

The products table conflated catalog ("kind of thing you scan") with
instance ("this jar I bought") — splitting it lets us record every
purchase as its own asset and autofill brand/shop/price/THC from the
last instance when scanning a known SKU.

- products: sku + strain + name + type + kind (catalog only)
- inventory_items: physical jars with short-UUID asset ids, per-batch
  brand/shop/bin/price/cannabinoids/weight, audits, lifecycle
- audits now key on inventory_id; strains lose brand_id and type
- migration: rename existing products/audits/strains to *_legacy on
  first boot so users keep historical reference, fresh start otherwise
- two-step add flow: scan SKU → select/create product → instance
  details (autofilled from last instance) → generated asset id shown
- ScanField matches asset id first, falls back to SKU
- inventory list defaults flat, "By product" toggle groups instances

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 05:59:46 -04:00
parent 1abfda7989
commit 02dc6e523f
28 changed files with 2315 additions and 1355 deletions
+82 -31
View File
@@ -15,41 +15,26 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = {
bootstrap: () => request<Bootstrap>("/bootstrap"),
// Catalog: products
createProduct: (body: {
sku: string;
name: string;
brandId: string;
shopId: string;
binId: string;
type: string;
kind: "bulk" | "discrete";
weight?: number;
countOriginal?: number;
unitWeight?: number;
price: number;
thc: number;
cbd: number;
totalCannabinoids: number;
purchaseDate: string;
sku?: string;
assetTag?: string;
strainId?: string | null;
strainName?: string;
defaultThc?: number;
defaultCbd?: number;
defaultTotalCannabinoids?: number;
}) => 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;
type: string;
kind: "bulk" | "discrete";
strainId: string | null;
}>,
) =>
request<{ ok: true }>(`/products/${id}`, {
@@ -57,27 +42,93 @@ export const api = {
body: JSON.stringify(body),
}),
finishProduct: (id: string, body: { date: string; rating?: number; notes?: string }) =>
request<{ ok: true }>(`/products/${id}/finish`, {
deleteProduct: (id: string) =>
request<{ ok: true }>(`/products/${id}`, { method: "DELETE" }),
updateStrain: (
id: string,
body: Partial<{
name: string;
defaultThc: number | null;
defaultCbd: number | null;
defaultTotalCannabinoids: number | null;
notes: string | null;
}>,
) =>
request<{ ok: true }>(`/strains/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
// Inventory items (instances)
createInventoryItem: (body: {
productId: string;
brandId?: string | null;
shopId?: string | null;
binId?: string | null;
price: number;
thc?: number;
cbd?: number;
totalCannabinoids?: number;
weight?: number;
countOriginal?: number;
unitWeight?: number;
purchaseDate: string;
}) =>
request<{ id: string; assetId: string }>("/inventory", {
method: "POST",
body: JSON.stringify(body),
}),
markGone: (id: string, body: { date: string; reason: string; notes?: string }) =>
request<{ ok: true }>(`/products/${id}/gone`, {
updateInventoryItem: (
id: string,
body: Partial<{
brandId: string | null;
shopId: string | null;
binId: string | null;
price: number;
thc: number;
cbd: number;
totalCannabinoids: number;
weight: number;
countOriginal: number;
unitWeight: number;
purchaseDate: string;
}>,
) =>
request<{ ok: true }>(`/inventory/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
finishInventoryItem: (
id: string,
body: { date: string; rating?: number; notes?: string },
) =>
request<{ ok: true }>(`/inventory/${id}/finish`, {
method: "POST",
body: JSON.stringify(body),
}),
auditProduct: (
markInventoryItemGone: (
id: string,
body: { date: string; reason: string; notes?: string },
) =>
request<{ ok: true }>(`/inventory/${id}/gone`, {
method: "POST",
body: JSON.stringify(body),
}),
auditInventoryItem: (
id: string,
body: { date: string; mode: AuditMode; value: number; confirmedBy?: string },
) =>
request<{ ok: true }>(`/products/${id}/audit`, {
request<{ ok: true }>(`/inventory/${id}/audit`, {
method: "POST",
body: JSON.stringify(body),
}),
// Catalog tables (brand/shop/bin) — unchanged
createBrand: (name: string) =>
request<{ id: string; name: string }>("/brands", {
method: "POST",