Skip to main content

Products module

The Products tab — the third module shipped. One tab, two faces. A Ledger Pill at the top of the ledger toggles between Inventory (Face A: the stock side) and Purchasing (Face B: the PO side). Built on the same sales-3zone chassis (Search · Ledger · Till) as Customers; the section-pill modal pattern carries through unchanged.

Tab labelProducts
Tab accent#0d9488 (teal)
Display order20 (after Customers)
Foldersrc/modules/products/
Migrations018_products_catalog.sql, 019_inventory.sql, 020_purchasing.sql, 021_po_line_variant_nullable.sql, 022_products_perms_consolidate.sql, 023_purchasing_view_perm.sql
Process designationsBIKE.L2-0040..0051, 0083..0092, 0144..0150, L3-0006/0011 — 31 processes covered

What it owns

src/modules/products/
index.ts — registers the module with the Kernel
view.ts — markup + styles for the two-face shell
client.ts — browser JS (face switch, modals, ledger handlers)
api.ts — server-side routes (/api/m/products/*)
movements.ts — the inventory-movement engine (every on-hand change writes a row)

on_hand is a stored snapshot on inventory.quantity_on_hand (per variant per location). It never changes inline. Every change goes through movements.ts — Adjust, Cycle count, Write-off, plus Receive (from a PO), Sale / Return / Transfer / RMA when those modules ring through later. Each call writes one immutable row to inventory_movements; the movement ledger on Face A is just that table, filtered and paginated.

The Ledger Pill — one tab, two faces

The pill sits in the ledger header row (the slot the Customers tab uses for the Interactions filter), sized to match. Two segments:

  • Inventory → the stock side. Search products by name / SKU / barcode; pick one → the Stock Card opens in the Till; the ledger shows that product's movement history.
  • Purchasing → the PO side. The PO ledger lists open + recent purchase orders; the Till holds either the Order Card (when a PO is selected) or the Bucket (when items have been queued to order but the PO hasn't been built yet).

The face is a data-face attribute on the shared .till element; CSS toggles which pane shows. The whole module loads once; the switch is instant.

Inventory (Face A) — Stock Card, movements, and the three stock actions

Search (process 0144). Free-text across product name, variant SKU, barcode. Returns matching products + variants with on-hand readout per result row. Picking one loads it into the Stock Card.

Stock Card (process 0146) — read-only by section, edited by pill. Same no-inline-editing pattern as the customer card. Sections in display order:

SectionEdit pill opens
ProductName, brand, model, category, subcategory, manufacturer (0147)
VariantSKU, size, colour, year, UPC / barcode (0147)
Pricing & reorderPrice (cents), cost, reorder point, reorder qty, supplier (0147)
TaxTax category (BC's two-rate model: fully taxable / bike PST-exempt / GST only / exempt — see migration 018)

Each section's Edit pill opens a focused popup; one Save per popup; every save lands in the audit chain.

Stock tiles — a 2×2 grid above the sections shows the live numbers: on-hand, reorder point, on-order (Σ of open PO lines for this variant), and the low-stock flag. The tiles use tabular-numerals so the values align cleanly when an operator is scanning.

The three stock actions (movement engine). The only way quantity_on_hand changes from this surface:

ActionProcessWhat it does
AdjustBIKE.L2-0046Signed delta with a reason; writes a movement_type='adjust' row, applies the delta. Live "current → new" preview in the popup.
Cycle countBIKE.L2-0084Set the on-hand to the counted number; movement records the implied delta against the previous value.
Write-offBIKE.L2-0085Removes units with a reason (damage, theft, demo). Outbound movement with movement_type='write_off'.

Every action goes through movements.ts — write the movement row, then update inventory.quantity_on_hand, then audit. The on-hand can never drift from the sum of its movements.

Movement ledger. The ledger pane below the search shows this variant's movement history when a product is selected, or recent movements across the catalog when nothing's loaded. Each row carries a type chip (receive, sale, return, adjust, write_off, transfer, rma) with colour-coded backgrounds for at-a-glance scanning, signed quantities, and a reference back to the originating record (PO line, sale id, etc.). A Type filter dropdown in the ledger header narrows by movement type; filters compose with the keyset pager.

Purchasing (Face B) — Bucket → PO → receive

The Bucket — staging area for reorders. From an Inventory Stock Card, an operator clicks + Add to PO and the variant drops into the Bucket. The Bucket is just order_queue rows, grouped in the UI by supplier (the variant's is_primary_supplier row in supplier_products decides which group it lands in). Operators add a lot of variants across a day; each Bucket group accumulates until someone builds a PO from it.

Build & issue a PO. Clicking a Bucket group → opens a draft PO with that supplier's lines pre-loaded; the operator can adjust quantities, costs, notes; Issue stamps status='issued', writes the timestamp, drops the rows from order_queue. The PO is now in the ledger.

The PO ledger. Shows POs by status — draft, issued, received, closed, cancelled — with colour-coded chips. Clicking a row loads the Order Card in the Till.

The Order Card. Header (supplier · status · totals · dates) above a oc-lines table — SKU, description, qty ordered, cost, qty received. Read-only display; edits route to focused popups (supplier editing, line adjustments).

Receive (the closing loop). When stock arrives, Receive on an issued PO marks line quantities received → each line writes a movement_type='receive' row through movements.tsquantity_on_hand updates → the PO transitions toward received. The Bucket → PO → on-hand loop is closed.

Create a new product from scratch (0045). New product flow on the Inventory side: a 3-step popup walks the operator from product → variant → pricing & supplier, with the same kb-modal chassis the section-edit popups use. The Stock Card loads with the new product selected.

Clone a product (BIKE.L2-0045). Duplicate an existing product as a starting point — useful for size/colour variants of an existing model that should share categorisation + tax setup.

Bulk price update (BIKE.L2-0087). Multi-select variants from a search result and apply a percentage or fixed price change in one popup; one audit row per variant.

Data model overview (migrations 018–020)

Catalog (018). tax_categories + tax_rates (BC two-rate model; fully-taxable / bike-pst-exempt / gst-only / exempt; bike-pst-exempt cites BC PST Bulletin 204), categories + subcategories, manufacturers, products, product_variants (the canonical SKU), product_barcodes. Leaned vs the prior schema: primary_photo_id dropped (no photos yet); price-tier columns moved to inventory (they're now dormant — discounts replaced pricing tiers per migration 012); external_aim_idaim_id for the import.

Inventory (019). inventory_locations (single-shop build seeds "Main Shop", Campbell River BC), inventory (per variant per location: quantity_on_hand, reorder thresholds, the dormant price tiers), inventory_movements (the immutable ledger: type, signed quantity, before / after on-hand, reason, originating reference). Leaned vs the prior schema: dropped quantity_in_transit / quantity_pending_approval (dead columns); dropped retired price_tier_a/b/c.

Purchasing (020). suppliers (with display_color_hex for Bucket group-header colour + logo_url for the Order Card chrome), supplier_products (variant ↔ supplier with cost_cents, supplier_sku, is_primary_supplier), purchase_orders (header), po_lines (per-line qty / cost / received), order_queue (the Bucket). Leaned: supplier_products.vendor_quantity dropped (never populated in production).

Permissions

Every CTA + every sensitive display is gated, both client-side (to hide / disable affordances) and server-side. The keyed permissions on this module:

PermissionWhat it gates
screen.productsThe Products tab is visible at all
products.readThe catalog + Stock Card + movement ledger
products.editStock Card section-edit pills + the three stock actions + new / clone product + bulk price
purchasing.viewThe Inventory ↔ Purchasing Ledger Pill switch — without this, the operator only sees Face A
purchasing.editBucket actions + draft / issue / receive POs

Custom roles (added in v1.3.0 via Settings → Editable Permissions Inspector) can compose these to surface Inventory-only or Purchasing-only views. The default roles (sys-admin / owner / manager / sales / mechanic / junior) get the appropriate grants seeded in migrations 007 + 022.

How it composes with the Kernel

Same Module interface as Customers + Settings, with one addition: the screen function receives a ModuleContext so it can check screen.products permission before rendering anything heavy:

function productsScreenFn(ctx: ModuleContext): string {
if (!ctx.perms.can("screen.products")) {
return `<div style="padding:48px;text-align:center;color:var(--text-muted)"><b>No access.</b></div>`;
}
return productsStyles + productsScreen + `<script>${productsClient}</script>`;
}

register({
tab: { name: "products", label: "Products", accent: "#0d9488", order: 20 },
screen: productsScreenFn,
api: productsApi,
});

The Kernel doesn't know about catalogs, variants, suppliers, or PO lifecycles. It hands the request to productsApi for any /api/m/products/* URL; everything else is contained in the folder.

Composes with the rest of the Hub

  • Settings → Discount builder authors discounts that apply to Sales / Service (per Settings); the variant's pricing in this module is the canonical price the till starts from before discount.
  • Customers → Equipment section reads from product_variants for bike-on-record selection; new variants registered here become selectable there.
  • Sales / Service (when built) will read on-hand from inventory and ring movement_type='sale' / 'return' through movements.ts — the same engine that powers Face A's ledger.

See also