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 label | Products |
| Tab accent | #0d9488 (teal) |
| Display order | 20 (after Customers) |
| Folder | src/modules/products/ |
| Migrations | 018_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 designations | BIKE.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:
| Section | Edit pill opens |
|---|---|
| Product | Name, brand, model, category, subcategory, manufacturer (0147) |
| Variant | SKU, size, colour, year, UPC / barcode (0147) |
| Pricing & reorder | Price (cents), cost, reorder point, reorder qty, supplier (0147) |
| Tax | Tax 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:
| Action | Process | What it does |
|---|---|---|
| Adjust | BIKE.L2-0046 | Signed delta with a reason; writes a movement_type='adjust' row, applies the delta. Live "current → new" preview in the popup. |
| Cycle count | BIKE.L2-0084 | Set the on-hand to the counted number; movement records the implied delta against the previous value. |
| Write-off | BIKE.L2-0085 | Removes 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.ts → quantity_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_id → aim_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:
| Permission | What it gates |
|---|---|
screen.products | The Products tab is visible at all |
products.read | The catalog + Stock Card + movement ledger |
products.edit | Stock Card section-edit pills + the three stock actions + new / clone product + bulk price |
purchasing.view | The Inventory ↔ Purchasing Ledger Pill switch — without this, the operator only sees Face A |
purchasing.edit | Bucket 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_variantsfor bike-on-record selection; new variants registered here become selectable there. - Sales / Service (when built) will read on-hand from
inventoryand ringmovement_type='sale'/'return'throughmovements.ts— the same engine that powers Face A's ledger.
See also
- Three strata — Kernel · Meta · Modules — the architectural shape
- No-inline-editing doctrine — the section-pill pattern this module honours
- Customers module — the first worked example; this module follows its
sales-3zonechassis - Settings module — discount authoring + permissions inspector + staff override roles
- Audit-everything principle — every movement, every PO transition, every section save lands in the chain