Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js · zod

Inventory Management System Demo

Track 17 products across three warehouses. Record sales and receipts, try to oversell and watch the CHECK constraint stop it, move stock between warehouses with a forced failure halfway, receive a purchase order, export the low-stock list as CSV and switch to the staff role to see the 403s. The dev panel shows every request and its SQL.

Read the case study →

Interactive demo

samiullah.tech/lab/inventory-management
Loading warehouse data…

## How it works

Stock never changes on its own. Every change is a row in stock_movements (receipt, sale, adjustment, transfer out, transfer in), and the matching stock_levels row is updated in the same transaction. The Ledger tab can prove it: the check compares every level with the sum of its movements.

stock_levels has CHECK (qty >= 0). An oversell makes PostgreSQL raise 23514; the exception filter turns that into 422 INSUFFICIENT_STOCK, and because the ledger row was written in the same transaction, it rolls back too.

A transfer debits one warehouse and credits another. With “simulate failure halfway” ticked, the service throws after the debit. The dev panel shows ROLLBACK and both levels stay as they were. Receiving a purchase order uses UPDATE … WHERE status = 'sent', so pressing receive twice is a 409, not double stock.

## View source

The code running in the window above, exactly as deployed.

app/lab/inventory-management/api.ts623 lines
/**
 * The inventory API, written the way the NestJS service would be: DTO validation (pipes), a roles guard,
 * services that run inside a transaction, and an exception filter that maps PostgreSQL errors to HTTP.
 *
 * Every handler takes the current DB, returns the next DB plus the HTTP response and the SQL the real
 * service would send. A failed transaction returns the *original* DB untouched, exactly like ROLLBACK.
 */
import { z } from "zod";
import {
  levelKey,
  onHand,
  qtyAt,
  type DB,
  type Movement,
  type MovementKind,
  type Product,
  type PurchaseOrder,
} from "./db";

export type Role = "admin" | "staff";
export type Ctx = { role: Role; user: string; now: number };
export type ApiResponse = { status: number; body: unknown; sql: string[] };
export type Outcome = { db: DB; res: ApiResponse };

/* ------------------------------------------------------------------ */
/* Errors: HttpException (Nest) and a PostgreSQL error                  */
/* ------------------------------------------------------------------ */

export class HttpError extends Error {
  constructor(
    public status: number,
    message: string | string[],
    public error: string,
  ) {
    super(Array.isArray(message) ? message.join("; ") : message);
    this.details = Array.isArray(message) ? message : [message];
  }
  details: string[];
}

/** Shape of an error thrown by node-postgres: SQLSTATE code plus constraint name. */
export class PgError extends Error {
  constructor(
    public code: string,
    message: string,
    public constraint?: string,
  ) {
    super(message);
  }
}

/** The global exception filter: SQLSTATE -> HTTP status. */
function toHttp(e: unknown): { status: number; body: unknown } {
  if (e instanceof HttpError) {
    return { status: e.status, body: { statusCode: e.status, error: e.error, message: e.details.length > 1 ? e.details : e.message } };
  }
  if (e instanceof PgError) {
    if (e.code === "23514" && e.constraint === "stock_levels_qty_nonneg") {
      return { status: 422, body: { statusCode: 422, error: "Unprocessable Entity", code: "INSUFFICIENT_STOCK", message: "Not enough stock in that warehouse. Nothing was written." } };
    }
    if (e.code === "23505") return { status: 409, body: { statusCode: 409, error: "Conflict", message: e.message } };
    return { status: 500, body: { statusCode: 500, error: "Internal Server Error", message: "Transaction rolled back.", cause: e.message } };
  }
  return { status: 500, body: { statusCode: 500, error: "Internal Server Error" } };
}

/* ------------------------------------------------------------------ */
/* Guard + pipe + transaction helpers                                   */
/* ------------------------------------------------------------------ */

/** @Roles('admin') + RolesGuard. Nest's default message for a failed guard is "Forbidden resource". */
function requireRole(ctx: Ctx, role: Role) {
  if (ctx.role !== role) throw new HttpError(403, `Forbidden resource: requires role "${role}"`, "Forbidden");
}

/** ValidationPipe: parse the DTO or answer 400 with one message per issue. */
function parse<T>(schema: z.ZodType<T, z.ZodTypeDef, unknown>, input: unknown): T {
  const r = schema.safeParse(input);
  if (!r.success) {
    throw new HttpError(400, r.error.issues.map((i) => `${i.path.join(".") || "body"}: ${i.message}`), "Bad Request");
  }
  return r.data;
}

const lit = (v: unknown) => (typeof v === "string" ? `'${v.replace(/'/g, "''")}'` : String(v));
/** Format a parameterised statement the way it would appear in a query log. */
const q = (text: string, params: unknown[] = []) => (params.length ? `${text}  -- [${params.map(lit).join(", ")}]` : text);

type Tx = { db: DB; sql: string[]; ctx: Ctx };

/** Read-only handler: no BEGIN/COMMIT, errors still go through the filter. */
function query(db: DB, ctx: Ctx, fn: (tx: Tx) => { status?: number; body: unknown }): Outcome {
  const sql: string[] = [];
  try {
    const r = fn({ db, sql, ctx });
    return { db, res: { status: r.status ?? 200, body: r.body, sql } };
  } catch (e) {
    return { db, res: { ...toHttp(e), sql } };
  }
}

/**
 * DataSource.transaction(): work on a copy, COMMIT swaps it in, any throw is a ROLLBACK and the
 * original state is returned untouched. Guards and pipes run before BEGIN, as they do in Nest.
 */
function transaction(
  db: DB,
  ctx: Ctx,
  before: () => void,
  fn: (tx: Tx) => { status?: number; body: unknown },
): Outcome {
  const sql: string[] = [];
  try {
    before();
  } catch (e) {
    return { db, res: { ...toHttp(e), sql } };
  }
  const draft = structuredClone(db);
  sql.push("BEGIN;");
  try {
    const r = fn({ db: draft, sql, ctx });
    sql.push("COMMIT;");
    return { db: draft, res: { status: r.status ?? 200, body: r.body, sql } };
  } catch (e) {
    if (e instanceof PgError) sql.push(`ERROR:  ${e.message}${e.code ? `  (SQLSTATE ${e.code})` : ""}`);
    sql.push("ROLLBACK;");
    return { db, res: { ...toHttp(e), sql } };
  }
}

/* ------------------------------------------------------------------ */
/* Repository-level writes (what the SQL does, applied to the draft)    */
/* ------------------------------------------------------------------ */

/** Upsert a stock level by a signed delta; the CHECK (qty >= 0) constraint is enforced here. */
function bumpLevel(tx: Tx, productId: number, warehouseId: string, delta: number) {
  tx.sql.push(
    q(
      "INSERT INTO stock_levels (product_id, warehouse_id, qty) VALUES ($1, $2, $3) ON CONFLICT (product_id, warehouse_id) DO UPDATE SET qty = stock_levels.qty + EXCLUDED.qty;",
      [productId, warehouseId, delta],
    ),
  );
  const next = qtyAt(tx.db, productId, warehouseId) + delta;
  if (next < 0) {
    throw new PgError(
      "23514",
      'new row for relation "stock_levels" violates check constraint "stock_levels_qty_nonneg"',
      "stock_levels_qty_nonneg",
    );
  }
  tx.db.levels[levelKey(productId, warehouseId)] = next;
}

function insertMovement(tx: Tx, m: Omit<Movement, "id" | "createdAt" | "createdBy">): Movement {
  const row: Movement = { ...m, id: ++tx.db.seq.movement, createdAt: tx.ctx.now, createdBy: tx.ctx.user };
  tx.sql.push(
    q(
      "INSERT INTO stock_movements (product_id, warehouse_id, kind, qty, reason, ref, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id;",
      [m.productId, m.warehouseId, m.kind, m.qty, m.reason, m.ref ?? "NULL", tx.ctx.user],
    ),
  );
  tx.db.movements.push(row);
  return row;
}

function findProduct(tx: Tx, id: number): Product {
  tx.sql.push(q("SELECT * FROM products WHERE id = $1 AND archived_at IS NULL;", [id]));
  const p = tx.db.products.find((x) => x.id === id && x.archivedAt === null);
  if (!p) throw new HttpError(404, `Product ${id} not found`, "Not Found");
  return p;
}

function assertWarehouse(db: DB, id: string) {
  if (!db.warehouses.some((w) => w.id === id)) throw new HttpError(404, `Warehouse ${id} not found`, "Not Found");
}

/* ------------------------------------------------------------------ */
/* DTOs                                                                */
/* ------------------------------------------------------------------ */

const qty = z.number({ invalid_type_error: "must be a number" }).int("must be a whole number").positive("must be greater than 0").max(100_000);

export const CreateProductDto = z.object({
  sku: z.string().regex(/^[A-Z]{3}-\d{3}$/, "must look like ABC-123"),
  name: z.string().trim().min(3, "must be at least 3 characters").max(80),
  category: z.string().min(1),
  unit: z.string().trim().min(1, "is required").max(20),
  costCents: z.number().int().min(0, "cannot be negative"),
  reorderLevel: z.number().int().min(0, "cannot be negative"),
});
export const UpdateProductDto = CreateProductDto.pick({ name: true, costCents: true, reorderLevel: true }).partial();

export const MovementDto = z.object({
  productId: z.number().int(),
  warehouseId: z.string(),
  kind: z.enum(["receipt", "sale", "adjustment"]),
  qty,
  /** Adjustments can go either way; sales always remove, receipts always add. */
  direction: z.enum(["in", "out"]).default("out"),
  reason: z.string().trim().min(3, "give a reason (at least 3 characters)").max(120),
});

export const TransferDto = z
  .object({
    productId: z.number().int(),
    fromWarehouseId: z.string(),
    toWarehouseId: z.string(),
    qty,
    /** Demo only: throw between the two halves of the transfer to prove the rollback. */
    simulateFailure: z.boolean().default(false),
  })
  .refine((d) => d.fromWarehouseId !== d.toWarehouseId, { message: "must differ from the source warehouse", path: ["toWarehouseId"] });

export const CreatePoDto = z.object({
  supplier: z.string().trim().min(3, "must be at least 3 characters").max(80),
  warehouseId: z.string(),
  lines: z
    .array(z.object({ productId: z.number().int(), qty, unitCostCents: z.number().int().min(0) }))
    .min(1, "add at least one line"),
});

/* ------------------------------------------------------------------ */
/* ProductsController                                                  */
/* ------------------------------------------------------------------ */

export type ProductRow = Product & { byWarehouse: Record<string, number>; onHand: number; low: boolean };

/** GET /products?q=&category=&warehouse= */
export function listProducts(db: DB, ctx: Ctx, f: { q?: string; category?: string; warehouse?: string }): Outcome {
  return query(db, ctx, (tx) => {
    const where = ["p.archived_at IS NULL"];
    const params: unknown[] = [];
    if (f.q) {
      params.push(`%${f.q}%`);
      where.push(`(p.sku ILIKE $${params.length} OR p.name ILIKE $${params.length})`);
    }
    if (f.category) {
      params.push(f.category);
      where.push(`p.category = $${params.length}`);
    }
    if (f.warehouse) {
      params.push(f.warehouse);
      where.push(`l.warehouse_id = $${params.length}`);
    }
    tx.sql.push(
      q(
        `SELECT p.*, l.warehouse_id, l.qty FROM products p LEFT JOIN stock_levels l ON l.product_id = p.id WHERE ${where.join(" AND ")} ORDER BY p.sku;`,
        params,
      ),
    );
    const needle = f.q?.trim().toLowerCase() ?? "";
    const rows: ProductRow[] = tx.db.products
      .filter((p) => p.archivedAt === null)
      .filter((p) => !needle || p.sku.toLowerCase().includes(needle) || p.name.toLowerCase().includes(needle))
      .filter((p) => !f.category || p.category === f.category)
      .map((p) => {
        const byWarehouse = Object.fromEntries(tx.db.warehouses.map((w) => [w.id, qtyAt(tx.db, p.id, w.id)]));
        const total = onHand(tx.db, p.id);
        return { ...p, byWarehouse, onHand: total, low: total < p.reorderLevel };
      })
      .filter((p) => !f.warehouse || (p.byWarehouse[f.warehouse] ?? 0) > 0)
      .sort((a, b) => a.sku.localeCompare(b.sku));
    return { body: rows };
  });
}

/** POST /products  @Roles('admin') */
export function createProduct(db: DB, ctx: Ctx, input: unknown): Outcome {
  let dto!: z.infer<typeof CreateProductDto>;
  return transaction(
    db,
    ctx,
    () => {
      requireRole(ctx, "admin");
      dto = parse(CreateProductDto, input);
    },
    (tx) => {
      tx.sql.push(
        q("INSERT INTO products (sku, name, category, unit, cost_cents, reorder_level) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id;", [
          dto.sku,
          dto.name,
          dto.category,
          dto.unit,
          dto.costCents,
          dto.reorderLevel,
        ]),
      );
      if (tx.db.products.some((p) => p.sku === dto.sku)) {
        throw new PgError("23505", `duplicate key value violates unique constraint "products_sku_key": Key (sku)=(${dto.sku}) already exists.`, "products_sku_key");
      }
      const p: Product = { ...dto, id: ++tx.db.seq.product, archivedAt: null };
      tx.db.products.push(p);
      return { status: 201, body: p };
    },
  );
}

/** PATCH /products/:id  @Roles('admin'): staff may record movements but never change prices. */
export function updateProduct(db: DB, ctx: Ctx, id: number, input: unknown): Outcome {
  let dto!: z.infer<typeof UpdateProductDto>;
  return transaction(
    db,
    ctx,
    () => {
      requireRole(ctx, "admin");
      dto = parse(UpdateProductDto, input);
    },
    (tx) => {
      const p = findProduct(tx, id);
      const sets = Object.entries({ name: dto.name, cost_cents: dto.costCents, reorder_level: dto.reorderLevel }).filter(([, v]) => v !== undefined);
      tx.sql.push(
        q(`UPDATE products SET ${sets.map(([k], i) => `${k} = $${i + 2}`).join(", ")} WHERE id = $1 RETURNING *;`, [id, ...sets.map(([, v]) => v)]),
      );
      Object.assign(p, Object.fromEntries(Object.entries(dto).filter(([, v]) => v !== undefined)));
      return { body: p };
    },
  );
}

/** DELETE /products/:id  @Roles('admin'): a soft delete, because the ledger keeps referencing the row. */
export function archiveProduct(db: DB, ctx: Ctx, id: number): Outcome {
  return transaction(
    db,
    ctx,
    () => requireRole(ctx, "admin"),
    (tx) => {
      const p = findProduct(tx, id);
      tx.sql.push(q("SELECT coalesce(sum(qty), 0) FROM stock_levels WHERE product_id = $1;", [id]));
      const total = onHand(tx.db, id);
      if (total > 0) throw new HttpError(409, `${p.sku} still has ${total} on hand. Adjust or transfer it to zero first.`, "Conflict");
      tx.sql.push(q("UPDATE products SET archived_at = now() WHERE id = $1;", [id]));
      p.archivedAt = tx.ctx.now;
      return { status: 204, body: null };
    },
  );
}

/* ------------------------------------------------------------------ */
/* MovementsController                                                 */
/* ------------------------------------------------------------------ */

/** POST /movements: receipts, sales and adjustments. Stock never changes without a ledger row. */
export function recordMovement(db: DB, ctx: Ctx, input: unknown): Outcome {
  let dto!: z.infer<typeof MovementDto>;
  return transaction(
    db,
    ctx,
    () => {
      dto = parse(MovementDto, input);
      assertWarehouse(db, dto.warehouseId);
    },
    (tx) => {
      findProduct(tx, dto.productId);
      const sign = dto.kind === "receipt" ? 1 : dto.kind === "sale" ? -1 : dto.direction === "in" ? 1 : -1;
      const m = insertMovement(tx, {
        productId: dto.productId,
        warehouseId: dto.warehouseId,
        kind: dto.kind,
        qty: sign * dto.qty,
        reason: dto.reason,
        ref: null,
      });
      bumpLevel(tx, dto.productId, dto.warehouseId, sign * dto.qty);
      return { status: 201, body: { movement: m, level: qtyAt(tx.db, dto.productId, dto.warehouseId) } };
    },
  );
}

/**
 * POST /transfers: two movements and two level updates in ONE transaction.
 * Rows are locked in a fixed order (by warehouse id) so two opposite transfers cannot deadlock.
 */
export function transferStock(db: DB, ctx: Ctx, input: unknown): Outcome {
  let dto!: z.infer<typeof TransferDto>;
  return transaction(
    db,
    ctx,
    () => {
      dto = parse(TransferDto, input);
      assertWarehouse(db, dto.fromWarehouseId);
      assertWarehouse(db, dto.toWarehouseId);
    },
    (tx) => {
      const p = findProduct(tx, dto.productId);
      const ids = [dto.fromWarehouseId, dto.toWarehouseId].sort();
      tx.sql.push(
        q("SELECT warehouse_id, qty FROM stock_levels WHERE product_id = $1 AND warehouse_id = ANY($2) ORDER BY warehouse_id FOR UPDATE;", [
          dto.productId,
          `{${ids.join(",")}}`,
        ]),
      );
      const ref = `TR-${++tx.db.seq.transfer}`;
      const reason = `Transfer ${dto.fromWarehouseId} → ${dto.toWarehouseId}`;

      bumpLevel(tx, p.id, dto.fromWarehouseId, -dto.qty);
      insertMovement(tx, { productId: p.id, warehouseId: dto.fromWarehouseId, kind: "transfer_out", qty: -dto.qty, reason, ref });

      if (dto.simulateFailure) {
        // Stands in for a crash, a lost connection or a failing trigger between the two halves.
        throw new PgError("57P01", "terminating connection due to administrator command (simulated failure after the first half)");
      }

      bumpLevel(tx, p.id, dto.toWarehouseId, dto.qty);
      insertMovement(tx, { productId: p.id, warehouseId: dto.toWarehouseId, kind: "transfer_in", qty: dto.qty, reason, ref });

      return {
        status: 201,
        body: {
          ref,
          sku: p.sku,
          qty: dto.qty,
          levels: { [dto.fromWarehouseId]: qtyAt(tx.db, p.id, dto.fromWarehouseId), [dto.toWarehouseId]: qtyAt(tx.db, p.id, dto.toWarehouseId) },
        },
      };
    },
  );
}

/** GET /movements?productId=&kind= */
export function listMovements(db: DB, ctx: Ctx, f: { productId?: number; kind?: MovementKind; limit?: number }): Outcome {
  return query(db, ctx, (tx) => {
    const where: string[] = [];
    const params: unknown[] = [];
    if (f.productId) {
      params.push(f.productId);
      where.push(`product_id = $${params.length}`);
    }
    if (f.kind) {
      params.push(f.kind);
      where.push(`kind = $${params.length}`);
    }
    params.push(f.limit ?? 50);
    tx.sql.push(
      q(`SELECT * FROM stock_movements${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC, id DESC LIMIT $${params.length};`, params),
    );
    const rows = tx.db.movements
      .filter((m) => !f.productId || m.productId === f.productId)
      .filter((m) => !f.kind || m.kind === f.kind)
      .sort((a, b) => b.createdAt - a.createdAt || b.id - a.id)
      .slice(0, f.limit ?? 50);
    return { body: rows };
  });
}

/* ------------------------------------------------------------------ */
/* PurchaseOrdersController                                            */
/* ------------------------------------------------------------------ */

/** POST /purchase-orders: anyone can draft; sending needs an admin. */
export function createPurchaseOrder(db: DB, ctx: Ctx, input: unknown): Outcome {
  let dto!: z.infer<typeof CreatePoDto>;
  return transaction(
    db,
    ctx,
    () => {
      dto = parse(CreatePoDto, input);
      assertWarehouse(db, dto.warehouseId);
    },
    (tx) => {
      dto.lines.forEach((l) => findProduct(tx, l.productId));
      const id = tx.db.purchaseOrders.length ? Math.max(...tx.db.purchaseOrders.map((p) => p.id)) + 1 : 1;
      const number = `PO-${++tx.db.seq.po}`;
      tx.sql.push(
        q("INSERT INTO purchase_orders (number, supplier, warehouse_id, status, created_by) VALUES ($1, $2, $3, 'draft', $4) RETURNING id;", [
          number,
          dto.supplier,
          dto.warehouseId,
          ctx.user,
        ]),
      );
      tx.sql.push(
        `INSERT INTO purchase_order_lines (po_id, product_id, qty, unit_cost_cents) VALUES ${dto.lines
          .map((l) => `(${id}, ${l.productId}, ${l.qty}, ${l.unitCostCents})`)
          .join(", ")};`,
      );
      const po: PurchaseOrder = {
        id,
        number,
        supplier: dto.supplier,
        warehouseId: dto.warehouseId,
        status: "draft",
        lines: dto.lines,
        createdBy: ctx.user,
        createdAt: ctx.now,
        receivedAt: null,
      };
      tx.db.purchaseOrders.push(po);
      return { status: 201, body: po };
    },
  );
}

/**
 * State changes use a conditional UPDATE ... WHERE status = <expected>. Zero rows updated means someone
 * else got there first (or the button was pressed twice), so the request is a 409 and nothing else runs.
 */
function transition(tx: Tx, id: number, from: PurchaseOrder["status"], to: PurchaseOrder["status"]): PurchaseOrder {
  tx.sql.push(q(`UPDATE purchase_orders SET status = $2${to === "received" ? ", received_at = now()" : ""} WHERE id = $1 AND status = $3 RETURNING *;`, [id, to, from]));
  const po = tx.db.purchaseOrders.find((p) => p.id === id);
  if (!po) throw new HttpError(404, `Purchase order ${id} not found`, "Not Found");
  if (po.status !== from) throw new HttpError(409, `${po.number} is ${po.status}, expected ${from}. 0 rows updated.`, "Conflict");
  po.status = to;
  if (to === "received") po.receivedAt = tx.ctx.now;
  return po;
}

/** POST /purchase-orders/:id/send  @Roles('admin') */
export function sendPurchaseOrder(db: DB, ctx: Ctx, id: number): Outcome {
  return transaction(db, ctx, () => requireRole(ctx, "admin"), (tx) => ({ body: transition(tx, id, "draft", "sent") }));
}

/** POST /purchase-orders/:id/cancel  @Roles('admin') */
export function cancelPurchaseOrder(db: DB, ctx: Ctx, id: number): Outcome {
  return transaction(
    db,
    ctx,
    () => requireRole(ctx, "admin"),
    (tx) => {
      const po = tx.db.purchaseOrders.find((p) => p.id === id);
      return { body: transition(tx, id, po?.status === "sent" ? "sent" : "draft", "cancelled") };
    },
  );
}

/** POST /purchase-orders/:id/receive: every line becomes a receipt movement, all or nothing. */
export function receivePurchaseOrder(db: DB, ctx: Ctx, id: number): Outcome {
  return transaction(
    db,
    ctx,
    () => undefined,
    (tx) => {
      const po = transition(tx, id, "sent", "received");
      for (const l of po.lines) {
        insertMovement(tx, { productId: l.productId, warehouseId: po.warehouseId, kind: "receipt", qty: l.qty, reason: `Received ${po.number}`, ref: po.number });
        bumpLevel(tx, l.productId, po.warehouseId, l.qty);
      }
      return { body: po };
    },
  );
}

/* ------------------------------------------------------------------ */
/* ReportsController                                                   */
/* ------------------------------------------------------------------ */

export type LowStockRow = { productId: number; sku: string; name: string; category: string; onHand: number; reorderLevel: number; suggestedQty: number; costCents: number };

/** GET /reports/low-stock: products whose total on hand is below their reorder level. */
export function lowStock(db: DB, ctx: Ctx): Outcome {
  return query(db, ctx, (tx) => {
    tx.sql.push(
      "SELECT p.id, p.sku, p.name, p.reorder_level, coalesce(sum(l.qty), 0) AS on_hand FROM products p LEFT JOIN stock_levels l ON l.product_id = p.id WHERE p.archived_at IS NULL GROUP BY p.id HAVING coalesce(sum(l.qty), 0) < p.reorder_level ORDER BY on_hand::numeric / NULLIF(p.reorder_level, 0);",
    );
    const rows: LowStockRow[] = tx.db.products
      .filter((p) => p.archivedAt === null)
      .map((p) => ({ p, total: onHand(tx.db, p.id) }))
      .filter(({ p, total }) => total < p.reorderLevel)
      .sort((a, b) => a.total / (a.p.reorderLevel || 1) - b.total / (b.p.reorderLevel || 1))
      .map(({ p, total }) => ({
        productId: p.id,
        sku: p.sku,
        name: p.name,
        category: p.category,
        onHand: total,
        reorderLevel: p.reorderLevel,
        // Order back up to twice the reorder level.
        suggestedQty: p.reorderLevel * 2 - total,
        costCents: p.costCents,
      }));
    return { body: rows };
  });
}

export type Mismatch = { productId: number; warehouseId: string; level: number; ledger: number };

/** GET /reports/reconciliation: stock_levels must equal the sum of the ledger for every pair. */
export function reconcile(db: DB, ctx: Ctx): Outcome {
  return query(db, ctx, (tx) => {
    tx.sql.push(
      "SELECT l.product_id, l.warehouse_id, l.qty AS level, coalesce(sum(m.qty), 0) AS ledger FROM stock_levels l LEFT JOIN stock_movements m USING (product_id, warehouse_id) GROUP BY 1, 2, 3 HAVING l.qty <> coalesce(sum(m.qty), 0);",
    );
    const ledger: Record<string, number> = {};
    for (const m of tx.db.movements) ledger[levelKey(m.productId, m.warehouseId)] = (ledger[levelKey(m.productId, m.warehouseId)] ?? 0) + m.qty;
    const keys = new Set([...Object.keys(ledger), ...Object.keys(tx.db.levels)]);
    const mismatches: Mismatch[] = [];
    for (const k of keys) {
      const [pid, wid] = k.split(":");
      const level = tx.db.levels[k] ?? 0;
      const sum = ledger[k] ?? 0;
      if (level !== sum) mismatches.push({ productId: Number(pid), warehouseId: wid!, level, ledger: sum });
    }
    return { body: { checked: keys.size, mismatches } };
  });
}

/**
 * Demo-only "bad code path": a direct UPDATE that skips the ledger, the bug the reconciliation report
 * exists to catch. In production nothing outside MovementsService has UPDATE rights on stock_levels.
 */
export function tamperLevel(db: DB, ctx: Ctx, productId: number, warehouseId: string, delta: number): Outcome {
  return transaction(
    db,
    ctx,
    () => undefined,
    (tx) => {
      tx.sql.push(q("UPDATE stock_levels SET qty = qty + $3 WHERE product_id = $1 AND warehouse_id = $2;  -- no ledger row!", [productId, warehouseId, delta]));
      tx.db.levels[levelKey(productId, warehouseId)] = Math.max(0, qtyAt(tx.db, productId, warehouseId) + delta);
      return { body: { productId, warehouseId, level: qtyAt(tx.db, productId, warehouseId) } };
    },
  );
}

/** RFC 4180 CSV with a BOM and CRLF line endings, so Excel and LibreOffice both open it cleanly. */
export function toCsv(rows: LowStockRow[]): string {
  const esc = (v: string | number) => {
    const s = String(v);
    return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  };
  const head = ["SKU", "Name", "Category", "On hand", "Reorder level", "Suggested order", "Unit cost"];
  const body = rows.map((r) => [r.sku, r.name, r.category, r.onHand, r.reorderLevel, r.suggestedQty, (r.costCents / 100).toFixed(2)].map(esc).join(","));
  return "" + [head.join(","), ...body].join("\r\n") + "\r\n";
}
app/lab/inventory-management/db.ts238 lines
/**
 * The data layer of the inventory demo: typed tables that mirror the PostgreSQL schema, plus seed data.
 * In production these are real tables (see the DDL below); here they are plain objects kept in localStorage.
 *
 *   CREATE TABLE warehouses (id text PRIMARY KEY, name text NOT NULL);
 *   CREATE TABLE products (
 *     id            serial PRIMARY KEY,
 *     sku           text NOT NULL UNIQUE CHECK (sku ~ '^[A-Z]{3}-[0-9]{3}$'),
 *     name          text NOT NULL,
 *     category      text NOT NULL,
 *     unit          text NOT NULL,
 *     cost_cents    integer NOT NULL CHECK (cost_cents >= 0),
 *     reorder_level integer NOT NULL CHECK (reorder_level >= 0),
 *     archived_at   timestamptz
 *   );
 *   CREATE TYPE movement_kind AS ENUM ('receipt','sale','adjustment','transfer_out','transfer_in');
 *   CREATE TABLE stock_movements (
 *     id          bigserial PRIMARY KEY,
 *     product_id  integer NOT NULL REFERENCES products(id),
 *     warehouse_id text NOT NULL REFERENCES warehouses(id),
 *     kind        movement_kind NOT NULL,
 *     qty         integer NOT NULL CHECK (qty <> 0),      -- signed: + in, - out
 *     reason      text NOT NULL,
 *     ref         text,                                   -- PO number or transfer id
 *     created_by  text NOT NULL,
 *     created_at  timestamptz NOT NULL DEFAULT now()
 *   );
 *   CREATE TABLE stock_levels (
 *     product_id   integer REFERENCES products(id),
 *     warehouse_id text REFERENCES warehouses(id),
 *     qty          integer NOT NULL,
 *     PRIMARY KEY (product_id, warehouse_id),
 *     CONSTRAINT stock_levels_qty_nonneg CHECK (qty >= 0)
 *   );
 */

export type Warehouse = { id: string; name: string };

export type Product = {
  id: number;
  sku: string;
  name: string;
  category: string;
  unit: string;
  costCents: number;
  reorderLevel: number;
  archivedAt: number | null;
};

export type MovementKind = "receipt" | "sale" | "adjustment" | "transfer_out" | "transfer_in";

export type Movement = {
  id: number;
  productId: number;
  warehouseId: string;
  kind: MovementKind;
  /** Signed quantity: positive adds stock, negative removes it. */
  qty: number;
  reason: string;
  ref: string | null;
  createdBy: string;
  createdAt: number;
};

export type PoStatus = "draft" | "sent" | "received" | "cancelled";
export type PoLine = { productId: number; qty: number; unitCostCents: number };
export type PurchaseOrder = {
  id: number;
  number: string;
  supplier: string;
  warehouseId: string;
  status: PoStatus;
  lines: PoLine[];
  createdBy: string;
  createdAt: number;
  receivedAt: number | null;
};

export type DB = {
  warehouses: Warehouse[];
  products: Product[];
  movements: Movement[];
  /** stock_levels, keyed "productId:warehouseId". */
  levels: Record<string, number>;
  purchaseOrders: PurchaseOrder[];
  seq: { product: number; movement: number; po: number; transfer: number };
};

export const levelKey = (productId: number, warehouseId: string) => `${productId}:${warehouseId}`;
export const qtyAt = (db: DB, productId: number, warehouseId: string) => db.levels[levelKey(productId, warehouseId)] ?? 0;
export const onHand = (db: DB, productId: number) =>
  db.warehouses.reduce((sum, w) => sum + qtyAt(db, productId, w.id), 0);

export const CATEGORIES = ["Fasteners", "Electrical", "Packaging", "Safety", "Tools", "Plumbing"] as const;

const WAREHOUSES: Warehouse[] = [
  { id: "WH-N", name: "North DC" },
  { id: "WH-S", name: "South DC" },
  { id: "WH-C", name: "City Store" },
];

// [sku, name, category, unit, cost in cents, reorder level, opening stock per warehouse N/S/C]
const PRODUCTS: [string, string, string, string, number, number, [number, number, number]][] = [
  ["FAS-001", "Steel bracket 40 mm", "Fasteners", "pcs", 85, 200, [420, 260, 90]],
  ["FAS-002", "Hex bolt M8 x 30", "Fasteners", "box of 100", 640, 40, [55, 30, 12]],
  ["FAS-003", "Wall anchor 6 mm", "Fasteners", "box of 50", 310, 30, [14, 6, 4]],
  ["ELE-001", "LED panel 600 x 600", "Electrical", "pcs", 2450, 25, [10, 6, 2]],
  ["ELE-002", "Cable 2.5 mm² (100 m)", "Electrical", "roll", 5900, 10, [18, 9, 3]],
  ["ELE-003", "Circuit breaker 16 A", "Electrical", "pcs", 720, 50, [80, 60, 25]],
  ["PAC-001", "Carton 40 x 30 x 30", "Packaging", "pcs", 95, 500, [1200, 800, 150]],
  ["PAC-002", "Stretch film 500 mm", "Packaging", "roll", 1150, 20, [26, 12, 0]],
  ["PAC-003", "Packing tape 48 mm", "Packaging", "roll", 140, 120, [300, 180, 60]],
  ["SAF-001", "Safety gloves (L)", "Safety", "pair", 260, 100, [150, 90, 40]],
  ["SAF-002", "Hi-vis vest", "Safety", "pcs", 480, 40, [70, 35, 20]],
  ["SAF-003", "Dust mask FFP2", "Safety", "box of 20", 1320, 30, [12, 8, 0]],
  ["TOO-001", "Cordless drill 18 V", "Tools", "pcs", 6800, 8, [9, 5, 3]],
  ["TOO-002", "Tape measure 5 m", "Tools", "pcs", 390, 30, [60, 25, 18]],
  ["TOO-003", "Utility knife", "Tools", "pcs", 210, 40, [80, 50, 30]],
  ["PLU-001", "PVC pipe 32 mm (3 m)", "Plumbing", "length", 560, 60, [140, 90, 0]],
  ["PLU-002", "Ball valve 1/2 in", "Plumbing", "pcs", 430, 50, [30, 14, 6]],
];

/** Small deterministic PRNG so the seed is the same on every reset. */
function rng(seed: number) {
  let s = seed;
  return () => {
    s = (s * 48_271) % 2_147_483_647;
    return s / 2_147_483_647;
  };
}

const DAY = 86_400_000;

/** Rebuild stock_levels from the ledger (what a nightly reconciliation job or a migration would do). */
export function levelsFromMovements(movements: Movement[]): Record<string, number> {
  const out: Record<string, number> = {};
  for (const m of movements) {
    const k = levelKey(m.productId, m.warehouseId);
    out[k] = (out[k] ?? 0) + m.qty;
  }
  return out;
}

export function seed(now: number): DB {
  const rand = rng(1042);
  const products: Product[] = PRODUCTS.map(([sku, name, category, unit, costCents, reorderLevel], i) => ({
    id: i + 1,
    sku,
    name,
    category,
    unit,
    costCents,
    reorderLevel,
    archivedAt: null,
  }));

  const movements: Movement[] = [];
  let mid = 0;
  const add = (m: Omit<Movement, "id">) => movements.push({ ...m, id: ++mid });
  const users = ["ayesha", "bilal", "bilal", "omar"];

  PRODUCTS.forEach(([, , , , , , opening], i) => {
    const productId = i + 1;
    WAREHOUSES.forEach((w, wi) => {
      const open = opening[wi]!;
      if (open === 0) return;
      // Opening stock arrived ~60 days ago, then a few sales and the odd stock-count adjustment.
      const received = Math.round(open * 1.4);
      add({
        productId,
        warehouseId: w.id,
        kind: "receipt",
        qty: received,
        reason: "Opening stock",
        ref: "PO-0990",
        createdBy: "ayesha",
        createdAt: now - 60 * DAY + wi * 3_600_000,
      });
      let left = received - open; // total to sell so the level ends at `open`
      let day = 55;
      while (left > 0) {
        const chunk = Math.min(left, Math.max(1, Math.round(left * (0.3 + rand() * 0.4))));
        const isAdj = rand() < 0.12;
        add({
          productId,
          warehouseId: w.id,
          kind: isAdj ? "adjustment" : "sale",
          qty: -chunk,
          reason: isAdj ? "Stock count: damaged" : `Order #${1000 + Math.floor(rand() * 900)}`,
          ref: null,
          createdBy: users[Math.floor(rand() * users.length)]!,
          createdAt: now - day * DAY - Math.floor(rand() * 8) * 3_600_000,
        });
        left -= chunk;
        day = Math.max(1, day - 4 - Math.floor(rand() * 10));
      }
    });
  });

  movements.sort((a, b) => a.createdAt - b.createdAt);
  movements.forEach((m, i) => (m.id = i + 1));

  return {
    warehouses: WAREHOUSES.map((w) => ({ ...w })),
    products,
    movements,
    levels: levelsFromMovements(movements),
    purchaseOrders: [
      {
        id: 1,
        number: "PO-1001",
        supplier: "Northline Fixings Ltd",
        warehouseId: "WH-N",
        status: "sent",
        lines: [
          { productId: 3, qty: 40, unitCostCents: 300 },
          { productId: 17, qty: 60, unitCostCents: 420 },
        ],
        createdBy: "ayesha",
        createdAt: now - 3 * DAY,
        receivedAt: null,
      },
      {
        id: 2,
        number: "PO-1002",
        supplier: "Brightway Electrical",
        warehouseId: "WH-S",
        status: "draft",
        lines: [{ productId: 4, qty: 30, unitCostCents: 2400 }],
        createdBy: "bilal",
        createdAt: now - 1 * DAY,
        receivedAt: null,
      },
    ],
    seq: { product: products.length, movement: movements.length, po: 1002, transfer: 500 },
  };
}
app/lab/inventory-management/Demo.tsx220 lines
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { onHand, seed, type DB } from "./db";
import type { ApiResponse, LowStockRow, Outcome, Role } from "./api";
import { DevPanel, type LogEntry } from "./DevPanel";
import { LedgerPanel, LowStockPanel, PurchaseOrdersPanel, StockPanel, TransferPanel, cls, type Call, type Method } from "./Panels";

const KEY = "lab:inventory-management:v1";
/** Simulated network + database round trip, so loading states are visible. */
const LATENCY_MS = 220;
const USERS: Record<Role, string> = { admin: "ayesha", staff: "bilal" };

const TABS = [
  { id: "stock", label: "Stock" },
  { id: "transfer", label: "Transfer" },
  { id: "ledger", label: "Ledger" },
  { id: "po", label: "Purchase orders" },
  { id: "low", label: "Low stock" },
] as const;
type TabId = (typeof TABS)[number]["id"];

type Notice = { tone: "ok" | "err"; text: string };

export default function Demo() {
  const [db, setDb] = useState<DB | null>(null);
  const dbRef = useRef<DB | null>(null);
  const [role, setRole] = useState<Role>("admin");
  const [tab, setTab] = useState<TabId>("stock");
  const [busy, setBusy] = useState(false);
  const [notice, setNotice] = useState<Notice | null>(null);
  const [devOpen, setDevOpen] = useState(true);
  const [log, setLog] = useState<LogEntry[]>([]);
  const [poPrefill, setPoPrefill] = useState<LowStockRow[] | null>(null);
  const logId = useRef(0);
  const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});

  const commit = (next: DB) => {
    dbRef.current = next;
    setDb(next);
  };

  // Load after mount: the server render has no localStorage and no clock to seed from.
  useEffect(() => {
    let initial: DB | null = null;
    try {
      const raw = localStorage.getItem(KEY);
      if (raw) initial = JSON.parse(raw) as DB;
    } catch {
      /* blocked or corrupted storage: fall back to the seed */
    }
    commit(initial?.products?.length ? initial : seed(Date.now()));
  }, []);

  useEffect(() => {
    if (!db) return;
    try {
      localStorage.setItem(KEY, JSON.stringify(db));
    } catch {
      /* quota or private mode: the demo still works in memory */
    }
  }, [db]);

  const pushLog = useCallback(
    (method: Method, path: string, o: Outcome) =>
      setLog((l) => [{ id: ++logId.current, method, path, status: o.res.status, role, sql: o.res.sql, body: o.res.body }, ...l].slice(0, 40)),
    [role],
  );

  const call: Call = useCallback(
    (method, path, fn, ok) =>
      new Promise<ApiResponse>((resolve) => {
        setBusy(true);
        setTimeout(() => {
          const current = dbRef.current;
          if (!current) return resolve({ status: 503, body: null, sql: [] });
          const out = fn(current, { role, user: USERS[role], now: Date.now() });
          if (out.db !== current) commit(out.db);
          pushLog(method, path, out);
          const msg = (out.res.body as { message?: string | string[] } | null)?.message;
          setNotice(
            out.res.status < 400
              ? { tone: "ok", text: `${out.res.status} · ${ok ? ok(out.res.body) : "OK"}` }
              : { tone: "err", text: `${out.res.status} · ${Array.isArray(msg) ? msg.join("; ") : (msg ?? "Request failed")}` },
          );
          setBusy(false);
          resolve(out.res);
        }, LATENCY_MS);
      }),
    [role, pushLog],
  );

  function reset() {
    try {
      localStorage.removeItem(KEY);
    } catch {
      /* ignore */
    }
    commit(seed(Date.now()));
    setLog([]);
    setNotice({ tone: "ok", text: "Demo reset to the seeded data." });
    setPoPrefill(null);
  }

  function onTabKey(e: React.KeyboardEvent, i: number) {
    const dir = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = TABS[(i + dir + TABS.length) % TABS.length]!;
    setTab(next.id);
    tabRefs.current[next.id]?.focus();
  }

  if (!db) {
    return (
      <div className="flex h-96 items-center justify-center p-6 text-sm text-fg-muted" role="status">
        Loading warehouse data…
      </div>
    );
  }

  const lowCount = db.products.filter((p) => p.archivedAt === null && onHand(db, p.id) < p.reorderLevel).length;
  const stockValue = db.products.filter((p) => p.archivedAt === null).reduce((s, p) => s + onHand(db, p.id) * p.costCents, 0);
  const props = { db, role, busy, call, log: pushLog };

  return (
    <div>
      {/* Toolbar: who you are, the dev panel, reset. */}
      <div className="flex flex-wrap items-center gap-3 border-b border-border bg-bg-elev px-4 py-3 sm:px-6">
        <div role="radiogroup" aria-label="Signed in as" className="inline-flex rounded-md border border-border p-0.5 text-sm">
          {(["admin", "staff"] as const).map((r) => (
            <button
              key={r}
              type="button"
              role="radio"
              aria-checked={role === r}
              onClick={() => setRole(r)}
              className={`rounded px-3 py-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${role === r ? "bg-accent text-accent-fg" : "text-fg-muted hover:text-fg"}`}
            >
              {USERS[r]} <span className="font-mono text-xs opacity-80">({r})</span>
            </button>
          ))}
        </div>
        <dl className="flex gap-4 font-mono text-xs text-fg-muted">
          <div>
            <dt className="inline">stock value </dt>
            <dd className="inline text-fg">${(stockValue / 100).toLocaleString("en-US", { maximumFractionDigits: 0 })}</dd>
          </div>
          <div>
            <dt className="inline">movements </dt>
            <dd className="inline text-fg">{db.movements.length}</dd>
          </div>
        </dl>
        <div className="ml-auto flex gap-2">
          <button type="button" className={cls.btn} onClick={() => setDevOpen((o) => !o)} aria-pressed={devOpen}>
            Dev panel {devOpen ? "on" : "off"}
          </button>
          <button type="button" className={cls.btn} onClick={reset}>
            Reset demo
          </button>
        </div>
      </div>

      {/* Tabs */}
      <div role="tablist" aria-label="Inventory sections" className="flex overflow-x-auto border-b border-border px-2 sm:px-4">
        {TABS.map((t, i) => (
          <button
            key={t.id}
            ref={(el) => {
              tabRefs.current[t.id] = el;
            }}
            type="button"
            role="tab"
            id={`inv-tab-${t.id}`}
            aria-selected={tab === t.id}
            aria-controls={`inv-panel-${t.id}`}
            tabIndex={tab === t.id ? 0 : -1}
            onClick={() => setTab(t.id)}
            onKeyDown={(e) => onTabKey(e, i)}
            className={`-mb-px border-b-2 px-3 py-2.5 text-sm whitespace-nowrap focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
              tab === t.id ? "border-accent font-medium text-fg" : "border-transparent text-fg-muted hover:text-fg"
            }`}
          >
            {t.label}
            {t.id === "low" && lowCount > 0 && (
              <span className="ml-1.5 rounded-full bg-warn-bg px-1.5 font-mono text-[11px] text-warn-fg">{lowCount}</span>
            )}
          </button>
        ))}
      </div>

      <div
        aria-live="polite"
        role="status"
        className={`min-h-9 px-4 py-2 font-mono text-xs sm:px-6 ${notice?.tone === "err" ? "text-danger" : "text-accent"}`}
      >
        {busy ? <span className="text-fg-muted">sending request…</span> : notice?.text}
      </div>

      <div role="tabpanel" id={`inv-panel-${tab}`} aria-labelledby={`inv-tab-${tab}`} className="px-4 pb-6 sm:px-6">
        {tab === "stock" && <StockPanel {...props} />}
        {tab === "transfer" && <TransferPanel {...props} />}
        {tab === "ledger" && <LedgerPanel {...props} />}
        {tab === "po" && <PurchaseOrdersPanel {...props} prefill={poPrefill} onPrefillUsed={() => setPoPrefill(null)} />}
        {tab === "low" && (
          <LowStockPanel
            {...props}
            onDraftPo={(rows) => {
              setPoPrefill(rows);
              setTab("po");
            }}
          />
        )}
      </div>

      {devOpen && <DevPanel log={log} onClear={() => setLog([])} />}
    </div>
  );
}
app/lab/inventory-management/Panels.tsx1036 lines
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";
import {
  archiveProduct,
  cancelPurchaseOrder,
  createProduct,
  createPurchaseOrder,
  listMovements,
  listProducts,
  lowStock,
  receivePurchaseOrder,
  reconcile,
  recordMovement,
  sendPurchaseOrder,
  tamperLevel,
  toCsv,
  transferStock,
  updateProduct,
  type ApiResponse,
  type Ctx,
  type LowStockRow,
  type Mismatch,
  type Outcome,
  type ProductRow,
  type Role,
} from "./api";
import { CATEGORIES, qtyAt, type DB, type Movement, type MovementKind, type PurchaseOrder } from "./db";

export type Method = "GET" | "POST" | "PATCH" | "DELETE";
export type Call = (
  method: Method,
  path: string,
  fn: (db: DB, ctx: Ctx) => Outcome,
  ok?: (body: unknown) => string,
) => Promise<ApiResponse>;
export type PanelProps = { db: DB; role: Role; busy: boolean; call: Call; log: (method: Method, path: string, o: Outcome) => void };

/* ------------------------------------------------------------------ */
/* Shared bits                                                         */
/* ------------------------------------------------------------------ */

export const cls = {
  input:
    "mt-1 w-full rounded-md border border-border bg-bg-elev px-3 py-2 text-sm outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40",
  btn: "rounded-md border border-border px-3 py-1.5 text-sm hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent",
  small:
    "rounded border border-border px-2 py-0.5 font-mono text-xs hover:border-accent hover:text-accent disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent",
  primary:
    "rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
  label: "block text-sm font-medium",
  th: "py-2 pr-3 font-normal",
};

export const money = (cents: number) => `$${(cents / 100).toFixed(2)}`;
const when = (ms: number) =>
  new Date(ms).toLocaleString("en-GB", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });

/** Errors from the API: Nest returns `message` as a string or, from the ValidationPipe, a string array. */
function errorsOf(res: ApiResponse | null): string[] {
  if (!res || res.status < 400) return [];
  const m = (res.body as { message?: string | string[] } | null)?.message;
  return Array.isArray(m) ? m : [m ?? `HTTP ${res.status}`];
}

function ErrorList({ res, id }: { res: ApiResponse | null; id?: string }) {
  const errs = errorsOf(res);
  if (!errs.length) return null;
  return (
    <div id={id} role="alert" className="rounded-md border border-danger/40 bg-danger/5 p-3 text-sm text-danger">
      <p className="font-mono text-xs">HTTP {res!.status}</p>
      <ul className="mt-1 list-disc pl-5">
        {errs.map((e) => (
          <li key={e}>{e}</li>
        ))}
      </ul>
    </div>
  );
}

const kindLabel: Record<MovementKind, string> = {
  receipt: "receipt",
  sale: "sale",
  adjustment: "adjustment",
  transfer_out: "transfer out",
  transfer_in: "transfer in",
};

function AdminTag({ role }: { role: Role }) {
  if (role === "admin") return null;
  return (
    <span className="ml-1 rounded bg-warn-bg px-1 font-mono text-[10px] text-warn-fg" title="Admin only: the API will answer 403 for staff">
      admin
    </span>
  );
}

/* ------------------------------------------------------------------ */
/* Stock tab                                                           */
/* ------------------------------------------------------------------ */

export function StockPanel({ db, role, busy, call, log }: PanelProps) {
  const [q, setQ] = useState("");
  const [category, setCategory] = useState("");
  const [warehouse, setWarehouse] = useState("");
  const [editing, setEditing] = useState<{ id: number; cost: string; reorder: string } | null>(null);
  const [rowRes, setRowRes] = useState<ApiResponse | null>(null);
  const [moveFor, setMoveFor] = useState<number | null>(null);
  const [showNew, setShowNew] = useState(false);
  const searchRef = useRef<HTMLInputElement>(null);

  const filters = useMemo(() => ({ q: q.trim() || undefined, category: category || undefined, warehouse: warehouse || undefined }), [q, category, warehouse]);
  const path = `/api/products?${new URLSearchParams(Object.entries(filters).filter(([, v]) => v) as [string, string][]).toString()}`;

  // Rows are a pure read of the current state; the GET is logged (debounced) when the filters change.
  const rows = useMemo(
    () => listProducts(db, { role, user: "", now: 0 }, filters).res.body as ProductRow[],
    [db, role, filters],
  );
  const dbRef = useRef(db);
  dbRef.current = db;
  useEffect(() => {
    const t = setTimeout(() => log("GET", path, listProducts(dbRef.current, { role, user: "", now: 0 }, filters)), 350);
    return () => clearTimeout(t);
  }, [path, filters, role, log]);

  // "/" focuses the search box, like most admin tools.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const t = e.target as HTMLElement | null;
      if (e.key === "/" && !(t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) {
        e.preventDefault();
        searchRef.current?.focus();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  async function saveEdit() {
    if (!editing) return;
    const body = { costCents: Math.round(Number(editing.cost) * 100), reorderLevel: Number(editing.reorder) };
    const res = await call("PATCH", `/api/products/${editing.id}`, (d, c) => updateProduct(d, c, editing.id, body), () => "Product updated.");
    setRowRes(res);
    if (res.status < 400) setEditing(null);
  }

  async function archive(id: number, sku: string) {
    const res = await call("DELETE", `/api/products/${id}`, (d, c) => archiveProduct(d, c, id), () => `${sku} archived.`);
    setRowRes(res);
  }

  return (
    <div className="space-y-4">
      <div className="grid gap-3 sm:grid-cols-[1fr_11rem_11rem]">
        <label className={cls.label}>
          Search SKU or name <kbd className="ml-1 rounded border border-border px-1 font-mono text-[10px] text-fg-muted">/</kbd>
          <input ref={searchRef} type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="e.g. FAS- or glove" className={cls.input} />
        </label>
        <label className={cls.label}>
          Category
          <select value={category} onChange={(e) => setCategory(e.target.value)} className={cls.input}>
            <option value="">All categories</option>
            {CATEGORIES.map((c) => (
              <option key={c}>{c}</option>
            ))}
          </select>
        </label>
        <label className={cls.label}>
          In stock at
          <select value={warehouse} onChange={(e) => setWarehouse(e.target.value)} className={cls.input}>
            <option value="">Any warehouse</option>
            {db.warehouses.map((w) => (
              <option key={w.id} value={w.id}>
                {w.name}
              </option>
            ))}
          </select>
        </label>
      </div>

      <div className="flex flex-wrap items-center gap-2">
        <button type="button" className={cls.btn} onClick={() => setShowNew((s) => !s)} aria-expanded={showNew}>
          {showNew ? "Close form" : "+ New product"}
          <AdminTag role={role} />
        </button>
        <span className="text-xs text-fg-muted" aria-live="polite">
          {rows.length} product{rows.length === 1 ? "" : "s"}
        </span>
      </div>

      {showNew && <NewProductForm db={db} role={role} busy={busy} call={call} onDone={() => setShowNew(false)} />}

      <ErrorList res={rowRes} />

      <div className="overflow-x-auto">
        <table className="w-full min-w-[42rem] text-left text-sm">
          <caption className="sr-only">Products with stock per warehouse</caption>
          <thead className="font-mono text-xs text-fg-muted">
            <tr className="border-b border-border">
              <th className={cls.th}>SKU</th>
              <th className={cls.th}>product</th>
              {db.warehouses.map((w) => (
                <th key={w.id} className={`${cls.th} text-right`} title={w.name}>
                  {w.id}
                </th>
              ))}
              <th className={`${cls.th} text-right`}>total</th>
              <th className={`${cls.th} text-right`}>reorder</th>
              <th className={`${cls.th} text-right`}>cost</th>
              <th className={cls.th}>
                <span className="sr-only">Actions</span>
              </th>
            </tr>
          </thead>
          <tbody>
            {rows.length === 0 && (
              <tr>
                <td colSpan={8 + db.warehouses.length} className="py-8 text-center text-fg-muted">
                  No products match. Clear the search or pick another category.
                </td>
              </tr>
            )}
            {rows.map((p) => {
              const isEditing = editing?.id === p.id;
              return (
                <tr key={p.id} className="border-b border-border align-top">
                  <td className="py-2 pr-3 font-mono text-xs">{p.sku}</td>
                  <td className="py-2 pr-3">
                    <span className="font-medium">{p.name}</span>
                    {p.low && <span className="ml-2 rounded bg-warn-bg px-1.5 py-0.5 font-mono text-[10px] text-warn-fg">low</span>}
                    <span className="block text-xs text-fg-muted">
                      {p.category} · {p.unit}
                    </span>
                  </td>
                  {db.warehouses.map((w) => (
                    <td key={w.id} className={`py-2 pr-3 text-right font-mono ${p.byWarehouse[w.id] === 0 ? "text-fg-muted" : ""}`}>
                      {p.byWarehouse[w.id]}
                    </td>
                  ))}
                  <td className={`py-2 pr-3 text-right font-mono font-semibold ${p.low ? "text-warn-fg" : ""}`}>{p.onHand}</td>
                  <td className="py-2 pr-3 text-right font-mono">
                    {isEditing ? (
                      <input
                        aria-label={`Reorder level for ${p.sku}`}
                        inputMode="numeric"
                        value={editing.reorder}
                        onChange={(e) => setEditing({ ...editing, reorder: e.target.value })}
                        className="w-16 rounded border border-border bg-bg-elev px-1 text-right"
                      />
                    ) : (
                      p.reorderLevel
                    )}
                  </td>
                  <td className="py-2 pr-3 text-right font-mono">
                    {isEditing ? (
                      <input
                        aria-label={`Unit cost for ${p.sku} in dollars`}
                        inputMode="decimal"
                        value={editing.cost}
                        onChange={(e) => setEditing({ ...editing, cost: e.target.value })}
                        onKeyDown={(e) => e.key === "Enter" && saveEdit()}
                        className="w-20 rounded border border-border bg-bg-elev px-1 text-right"
                      />
                    ) : (
                      money(p.costCents)
                    )}
                  </td>
                  <td className="space-x-1 py-2 text-right whitespace-nowrap">
                    {isEditing ? (
                      <>
                        <button type="button" className={cls.small} disabled={busy} onClick={saveEdit}>
                          save
                        </button>
                        <button type="button" className={cls.small} onClick={() => setEditing(null)}>
                          cancel
                        </button>
                      </>
                    ) : (
                      <>
                        <button type="button" className={cls.small} onClick={() => {
                            setMoveFor(p.id);
                            requestAnimationFrame(() => document.getElementById("inv-move-qty")?.focus());
                          }}
                          aria-label={`Record movement for ${p.sku}`}>
                          move
                        </button>
                        <button
                          type="button"
                          className={cls.small}
                          onClick={() => {
                            setRowRes(null);
                            setEditing({ id: p.id, cost: (p.costCents / 100).toFixed(2), reorder: String(p.reorderLevel) });
                          }}
                          aria-label={`Edit price and reorder level for ${p.sku}`}
                        >
                          edit{role === "staff" && " 🔒"}
                        </button>
                        <button
                          type="button"
                          className={`${cls.small} hover:!border-danger hover:!text-danger`}
                          disabled={busy}
                          onClick={() => archive(p.id, p.sku)}
                          aria-label={`Archive ${p.sku}`}
                        >
                          ×
                        </button>
                      </>
                    )}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      <MovementForm key={moveFor ?? "none"} db={db} role={role} busy={busy} call={call} log={log} productId={moveFor ?? rows[0]?.id ?? 1} />
    </div>
  );
}

function NewProductForm({ db, busy, call, onDone }: Omit<PanelProps, "log"> & { onDone: () => void }) {
  const [f, setF] = useState({ sku: "", name: "", category: CATEGORIES[0] as string, unit: "pcs", cost: "", reorder: "10" });
  const [res, setRes] = useState<ApiResponse | null>(null);
  const errId = useId();
  const nextSku = `${f.category.slice(0, 3).toUpperCase()}-${String(db.products.filter((p) => p.category === f.category).length + 1).padStart(3, "0")}`;

  async function submit(e: React.FormEvent) {
    e.preventDefault();
    const body = {
      sku: f.sku.trim().toUpperCase(),
      name: f.name,
      category: f.category,
      unit: f.unit,
      costCents: f.cost === "" ? NaN : Math.round(Number(f.cost) * 100),
      reorderLevel: Number(f.reorder),
    };
    const r = await call("POST", "/api/products", (d, c) => createProduct(d, c, body), () => `${body.sku} created.`);
    setRes(r);
    if (r.status < 400) onDone();
  }

  const set = (k: keyof typeof f) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => setF({ ...f, [k]: e.target.value });
  return (
    <form onSubmit={submit} noValidate className="space-y-3 rounded-md border border-border bg-bg-muted p-4" aria-describedby={res ? errId : undefined}>
      <div className="grid gap-3 sm:grid-cols-3">
        <label className={cls.label}>
          SKU
          <input value={f.sku} onChange={set("sku")} placeholder={nextSku} className={`${cls.input} font-mono`} />
        </label>
        <label className={`${cls.label} sm:col-span-2`}>
          Name
          <input value={f.name} onChange={set("name")} placeholder="Steel hinge 75 mm" className={cls.input} />
        </label>
        <label className={cls.label}>
          Category
          <select value={f.category} onChange={set("category")} className={cls.input}>
            {CATEGORIES.map((c) => (
              <option key={c}>{c}</option>
            ))}
          </select>
        </label>
        <label className={cls.label}>
          Unit
          <input value={f.unit} onChange={set("unit")} className={cls.input} />
        </label>
        <div className="grid grid-cols-2 gap-3">
          <label className={cls.label}>
            Cost ($)
            <input value={f.cost} onChange={set("cost")} inputMode="decimal" placeholder="1.25" className={cls.input} />
          </label>
          <label className={cls.label}>
            Reorder at
            <input value={f.reorder} onChange={set("reorder")} inputMode="numeric" className={cls.input} />
          </label>
        </div>
      </div>
      <ErrorList res={res} id={errId} />
      <button type="submit" className={cls.primary} disabled={busy}>
        {busy ? "Saving…" : "Create product"}
      </button>
    </form>
  );
}

function MovementForm({ db, busy, call, productId }: PanelProps & { productId: number }) {
  const [f, setF] = useState({ productId: String(productId), warehouseId: db.warehouses[0]!.id, kind: "sale", direction: "out", qty: "", reason: "" });
  const [res, setRes] = useState<ApiResponse | null>(null);
  const errId = useId();
  const pid = Number(f.productId);
  const available = qtyAt(db, pid, f.warehouseId);

  async function submit(e: React.FormEvent) {
    e.preventDefault();
    const body = {
      productId: pid,
      warehouseId: f.warehouseId,
      kind: f.kind,
      direction: f.direction,
      qty: f.qty === "" ? undefined : Number(f.qty),
      reason: f.reason,
    };
    const r = await call("POST", "/api/movements", (d, c) => recordMovement(d, c, body), (b) => {
      const x = b as { movement: Movement; level: number };
      return `Movement #${x.movement.id} recorded. ${f.warehouseId} now has ${x.level}.`;
    });
    setRes(r);
    if (r.status < 400) setF((s) => ({ ...s, qty: "", reason: "" }));
  }

  const set = (k: keyof typeof f) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => setF({ ...f, [k]: e.target.value });
  return (
    <form onSubmit={submit} noValidate className="space-y-3 rounded-md border border-border p-4" aria-describedby={res ? errId : undefined}>
      <h3 className="font-mono text-sm font-semibold">
        record movement <span className="font-normal text-fg-muted">POST /api/movements</span>
      </h3>
      <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
        <label className={`${cls.label} lg:col-span-2`}>
          Product
          <select value={f.productId} onChange={set("productId")} className={cls.input}>
            {db.products
              .filter((p) => p.archivedAt === null)
              .map((p) => (
                <option key={p.id} value={p.id}>
                  {p.sku} · {p.name}
                </option>
              ))}
          </select>
        </label>
        <label className={cls.label}>
          Warehouse
          <select value={f.warehouseId} onChange={set("warehouseId")} className={cls.input}>
            {db.warehouses.map((w) => (
              <option key={w.id} value={w.id}>
                {w.name} ({qtyAt(db, pid, w.id)})
              </option>
            ))}
          </select>
        </label>
        <label className={cls.label}>
          Type
          <select value={f.kind} onChange={set("kind")} className={cls.input}>
            <option value="sale">Sale (out)</option>
            <option value="receipt">Receipt (in)</option>
            <option value="adjustment">Adjustment</option>
          </select>
        </label>
        {f.kind === "adjustment" && (
          <label className={cls.label}>
            Direction
            <select value={f.direction} onChange={set("direction")} className={cls.input}>
              <option value="out">Remove (damaged, lost)</option>
              <option value="in">Add (found in count)</option>
            </select>
          </label>
        )}
        <label className={cls.label}>
          Quantity
          <input id="inv-move-qty" value={f.qty} onChange={set("qty")} inputMode="numeric" placeholder={`available: ${available}`} className={cls.input} />
        </label>
        <label className={`${cls.label} sm:col-span-2 lg:col-span-3`}>
          Reason
          <input value={f.reason} onChange={set("reason")} placeholder="Order #1042" className={cls.input} />
        </label>
      </div>
      <ErrorList res={res} id={errId} />
      <div className="flex flex-wrap items-center gap-3">
        <button type="submit" className={cls.primary} disabled={busy}>
          {busy ? "Saving…" : "Record movement"}
        </button>
        <button
          type="button"
          className={cls.btn}
          onClick={() => setF({ ...f, kind: "sale", qty: String(available + 5), reason: "Order #1042 (too big)" })}
        >
          Fill an oversell
        </button>
        <span className="text-xs text-fg-muted">A sale larger than {available} hits the CHECK constraint and rolls back.</span>
      </div>
    </form>
  );
}

/* ------------------------------------------------------------------ */
/* Transfer tab                                                        */
/* ------------------------------------------------------------------ */

type TransferResult = { res: ApiResponse; before: [number, number]; after: [number, number]; from: string; to: string; sku: string };

export function TransferPanel({ db, busy, call }: PanelProps) {
  const active = db.products.filter((p) => p.archivedAt === null);
  const [f, setF] = useState({ productId: String(active[0]?.id ?? 1), from: "WH-N", to: "WH-C", qty: "20", fail: true });
  const [result, setResult] = useState<TransferResult | null>(null);
  const pid = Number(f.productId);
  const product = active.find((p) => p.id === pid);

  async function submit(e: React.FormEvent) {
    e.preventDefault();
    const before: [number, number] = [qtyAt(db, pid, f.from), qtyAt(db, pid, f.to)];
    const body = { productId: pid, fromWarehouseId: f.from, toWarehouseId: f.to, qty: f.qty === "" ? undefined : Number(f.qty), simulateFailure: f.fail };
    let after: [number, number] = before;
    const res = await call(
      "POST",
      "/api/transfers",
      (d, c) => {
        const o = transferStock(d, c, body);
        after = [qtyAt(o.db, pid, f.from), qtyAt(o.db, pid, f.to)];
        return o;
      },
      (b) => `Transfer ${(b as { ref: string }).ref} committed.`,
    );
    setResult({ res, before, after, from: f.from, to: f.to, sku: product?.sku ?? "" });
  }

  const set = (k: "productId" | "from" | "to" | "qty") => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => setF({ ...f, [k]: e.target.value });
  const wname = (id: string) => db.warehouses.find((w) => w.id === id)?.name ?? id;

  return (
    <div className="grid gap-6 lg:grid-cols-[1fr_1fr]">
      <form onSubmit={submit} noValidate className="space-y-3">
        <p className="text-sm text-fg-muted">
          A transfer is two ledger rows and two level updates. They commit together or not at all. Tick the box to kill the
          transaction after the first half and watch it roll back.
        </p>
        <label className={cls.label}>
          Product
          <select value={f.productId} onChange={set("productId")} className={cls.input}>
            {active.map((p) => (
              <option key={p.id} value={p.id}>
                {p.sku} · {p.name}
              </option>
            ))}
          </select>
        </label>
        <div className="grid grid-cols-2 gap-3">
          <label className={cls.label}>
            From
            <select value={f.from} onChange={set("from")} className={cls.input}>
              {db.warehouses.map((w) => (
                <option key={w.id} value={w.id}>
                  {w.name} ({qtyAt(db, pid, w.id)})
                </option>
              ))}
            </select>
          </label>
          <label className={cls.label}>
            To
            <select value={f.to} onChange={set("to")} className={cls.input}>
              {db.warehouses.map((w) => (
                <option key={w.id} value={w.id}>
                  {w.name} ({qtyAt(db, pid, w.id)})
                </option>
              ))}
            </select>
          </label>
        </div>
        <label className={cls.label}>
          Quantity
          <input value={f.qty} onChange={set("qty")} inputMode="numeric" className={cls.input} />
        </label>
        <label className="flex items-start gap-2 rounded-md border border-border bg-bg-muted p-3 text-sm">
          <input type="checkbox" checked={f.fail} onChange={(e) => setF({ ...f, fail: e.target.checked })} className="mt-1 accent-[var(--accent)]" />
          <span>
            <span className="font-medium">Simulate failure halfway</span>
            <span className="block text-xs text-fg-muted">
              Throws after the source warehouse is debited and before the destination is credited.
            </span>
          </span>
        </label>
        <button type="submit" className={cls.primary} disabled={busy}>
          {busy ? "Running transaction…" : f.fail ? "Run transfer (will fail)" : "Transfer stock"}
        </button>
      </form>

      <div aria-live="polite" className="space-y-3">
        {!result && (
          <div className="rounded-md border border-dashed border-border p-6 text-sm text-fg-muted">
            Run a transfer to compare the levels before and after. Open the dev panel to see BEGIN, the two UPDATEs and COMMIT or ROLLBACK.
          </div>
        )}
        {result && (
          <>
            <div
              className={`rounded-md border p-3 text-sm ${
                result.res.status < 400 ? "border-accent/50 bg-accent-soft" : "border-danger/40 bg-danger/5 text-danger"
              }`}
            >
              <p className="font-mono text-xs">
                HTTP {result.res.status} · {result.res.sql.includes("ROLLBACK;") ? "ROLLBACK" : result.res.sql.includes("COMMIT;") ? "COMMIT" : "rejected before BEGIN"}
              </p>
              <p className="mt-1">
                {result.res.status < 400
                  ? `Both halves committed for ${result.sku}.`
                  : errorsOf(result.res).join(" ")}
              </p>
            </div>
            <table className="w-full text-sm">
              <caption className="sr-only">Stock levels before and after the transfer</caption>
              <thead className="font-mono text-xs text-fg-muted">
                <tr className="border-b border-border">
                  <th className={`${cls.th} text-left`}>{result.sku}</th>
                  <th className={`${cls.th} text-right`}>before</th>
                  <th className={`${cls.th} text-right`}>after</th>
                </tr>
              </thead>
              <tbody className="font-mono">
                {[result.from, result.to].map((w, i) => (
                  <tr key={w} className="border-b border-border">
                    <td className="py-2 pr-3 font-sans">{wname(w)}</td>
                    <td className="py-2 pr-3 text-right">{result.before[i]}</td>
                    <td className={`py-2 pr-3 text-right font-semibold ${result.after[i] === result.before[i] ? "text-fg-muted" : "text-accent"}`}>
                      {result.after[i]}
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
            {result.res.sql.includes("ROLLBACK;") && (
              <p className="text-sm">
                ✓ Both levels are unchanged. The debit of {result.from} ran inside the transaction, so the ROLLBACK undid it and no
                stock disappeared in transit.
              </p>
            )}
          </>
        )}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Ledger tab                                                          */
/* ------------------------------------------------------------------ */

export function LedgerPanel({ db, role, busy, call }: PanelProps) {
  const [productId, setProductId] = useState("");
  const [kind, setKind] = useState("");
  const [check, setCheck] = useState<{ checked: number; mismatches: Mismatch[] } | null>(null);
  const rows = useMemo(
    () =>
      listMovements(db, { role, user: "", now: 0 }, { productId: productId ? Number(productId) : undefined, kind: (kind || undefined) as MovementKind | undefined, limit: 60 }).res
        .body as Movement[],
    [db, role, productId, kind],
  );
  const sku = (id: number) => db.products.find((p) => p.id === id)?.sku ?? `#${id}`;

  async function runCheck() {
    const res = await call("GET", "/api/reports/reconciliation", (d, c) => reconcile(d, c), (b) => {
      const x = b as { checked: number; mismatches: Mismatch[] };
      return x.mismatches.length ? `${x.mismatches.length} level(s) disagree with the ledger.` : `All ${x.checked} stock levels match the ledger.`;
    });
    setCheck(res.body as { checked: number; mismatches: Mismatch[] });
  }

  async function tamper() {
    await call("PATCH", "/internal/stock-levels (bypass)", (d, c) => tamperLevel(d, c, 1, "WH-N", 7), () => "A level was changed without a ledger row. Run the check.");
    setCheck(null);
  }

  return (
    <div className="space-y-4">
      <div className="grid gap-3 sm:grid-cols-2">
        <label className={cls.label}>
          Product
          <select value={productId} onChange={(e) => setProductId(e.target.value)} className={cls.input}>
            <option value="">All products</option>
            {db.products.map((p) => (
              <option key={p.id} value={p.id}>
                {p.sku} · {p.name}
              </option>
            ))}
          </select>
        </label>
        <label className={cls.label}>
          Type
          <select value={kind} onChange={(e) => setKind(e.target.value)} className={cls.input}>
            <option value="">All types</option>
            {Object.entries(kindLabel).map(([k, v]) => (
              <option key={k} value={k}>
                {v}
              </option>
            ))}
          </select>
        </label>
      </div>

      <div className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-bg-muted p-3">
        <button type="button" className={cls.btn} onClick={runCheck} disabled={busy}>
          Check: stock = sum(movements)
        </button>
        <button type="button" className={`${cls.btn} hover:!border-danger hover:!text-danger`} onClick={tamper} disabled={busy}>
          Break it: UPDATE a level directly
        </button>
        <div aria-live="polite" className="text-sm">
          {check &&
            (check.mismatches.length === 0 ? (
              <span className="text-accent">✓ {check.checked} levels checked, 0 mismatches.</span>
            ) : (
              <span className="text-danger">
                ✗ {check.mismatches.map((m) => `${sku(m.productId)} @ ${m.warehouseId}: level ${m.level}, ledger ${m.ledger}`).join("; ")}
              </span>
            ))}
        </div>
      </div>

      <div className="overflow-x-auto">
        <table className="w-full min-w-[40rem] text-left text-sm">
          <caption className="sr-only">Stock movements, newest first (latest 60)</caption>
          <thead className="font-mono text-xs text-fg-muted">
            <tr className="border-b border-border">
              <th className={cls.th}>#</th>
              <th className={cls.th}>when</th>
              <th className={cls.th}>SKU</th>
              <th className={cls.th}>wh</th>
              <th className={cls.th}>type</th>
              <th className={`${cls.th} text-right`}>qty</th>
              <th className={cls.th}>reason</th>
              <th className={cls.th}>by</th>
            </tr>
          </thead>
          <tbody>
            {rows.length === 0 && (
              <tr>
                <td colSpan={8} className="py-8 text-center text-fg-muted">
                  No movements for this filter.
                </td>
              </tr>
            )}
            {rows.map((m) => (
              <tr key={m.id} className="border-b border-border">
                <td className="py-1.5 pr-3 font-mono text-xs text-fg-muted">{m.id}</td>
                <td className="py-1.5 pr-3 text-xs whitespace-nowrap">{when(m.createdAt)}</td>
                <td className="py-1.5 pr-3 font-mono text-xs">{sku(m.productId)}</td>
                <td className="py-1.5 pr-3 font-mono text-xs">{m.warehouseId}</td>
                <td className="py-1.5 pr-3 text-xs">{kindLabel[m.kind]}</td>
                <td className={`py-1.5 pr-3 text-right font-mono ${m.qty > 0 ? "text-accent" : "text-danger"}`}>
                  {m.qty > 0 ? `+${m.qty}` : m.qty}
                </td>
                <td className="py-1.5 pr-3 text-xs">
                  {m.reason}
                  {m.ref && <span className="ml-1 font-mono text-fg-muted">{m.ref}</span>}
                </td>
                <td className="py-1.5 pr-3 text-xs text-fg-muted">{m.createdBy}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Purchase orders tab                                                 */
/* ------------------------------------------------------------------ */

const poTone: Record<PurchaseOrder["status"], string> = {
  draft: "bg-bg-muted text-fg-muted",
  sent: "bg-accent-soft text-accent",
  received: "bg-accent text-accent-fg",
  cancelled: "bg-danger/10 text-danger",
};

export function PurchaseOrdersPanel({ db, role, busy, call, log, prefill, onPrefillUsed }: PanelProps & { prefill: LowStockRow[] | null; onPrefillUsed: () => void }) {
  const [res, setRes] = useState<ApiResponse | null>(null);
  const sku = (id: number) => db.products.find((p) => p.id === id);
  const pos = [...db.purchaseOrders].sort((a, b) => b.id - a.id);

  async function act(po: PurchaseOrder, action: "send" | "receive" | "cancel") {
    const fn = action === "send" ? sendPurchaseOrder : action === "receive" ? receivePurchaseOrder : cancelPurchaseOrder;
    const r = await call("POST", `/api/purchase-orders/${po.id}/${action}`, (d, c) => fn(d, c, po.id), () =>
      action === "receive" ? `${po.number} received: ${po.lines.length} receipt movement(s) written.` : `${po.number} ${action === "send" ? "sent" : "cancelled"}.`,
    );
    setRes(r);
  }

  return (
    <div className="space-y-5">
      <NewPoForm key={prefill ? "prefill" : "blank"} db={db} role={role} busy={busy} call={call} log={log} prefill={prefill} onDone={onPrefillUsed} />
      <ErrorList res={res} />
      <ul className="space-y-3">
        {pos.map((po) => {
          const total = po.lines.reduce((s, l) => s + l.qty * l.unitCostCents, 0);
          return (
            <li key={po.id} className="rounded-md border border-border p-4">
              <div className="flex flex-wrap items-center gap-2">
                <span className="font-mono text-sm font-semibold">{po.number}</span>
                <span className={`rounded px-1.5 py-0.5 font-mono text-[11px] ${poTone[po.status]}`}>{po.status}</span>
                <span className="text-sm">{po.supplier}</span>
                <span className="text-xs text-fg-muted">
                  → {po.warehouseId} · by {po.createdBy}
                </span>
                <span className="ml-auto font-mono text-sm">{money(total)}</span>
              </div>
              <ul className="mt-2 space-y-0.5 text-xs text-fg-muted">
                {po.lines.map((l) => (
                  <li key={l.productId}>
                    <span className="font-mono">{sku(l.productId)?.sku}</span> {sku(l.productId)?.name} × {l.qty} @ {money(l.unitCostCents)}
                  </li>
                ))}
              </ul>
              <div className="mt-3 flex flex-wrap gap-2">
                {po.status === "draft" && (
                  <button type="button" className={cls.small} disabled={busy} onClick={() => act(po, "send")}>
                    send to supplier
                    <AdminTag role={role} />
                  </button>
                )}
                {po.status === "sent" && (
                  <button type="button" className={cls.small} disabled={busy} onClick={() => act(po, "receive")}>
                    receive goods
                  </button>
                )}
                {(po.status === "draft" || po.status === "sent") && (
                  <button type="button" className={cls.small} disabled={busy} onClick={() => act(po, "cancel")}>
                    cancel
                    <AdminTag role={role} />
                  </button>
                )}
                {po.status === "received" && (
                  <>
                    <span className="text-xs text-fg-muted">Received {po.receivedAt ? when(po.receivedAt) : ""}.</span>
                    <button
                      type="button"
                      className={cls.small}
                      disabled={busy}
                      onClick={() => act(po, "receive")}
                      title="Pressing receive twice must not double the stock"
                    >
                      receive again (double click)
                    </button>
                  </>
                )}
              </div>
            </li>
          );
        })}
      </ul>
    </div>
  );
}

type DraftLine = { productId: string; qty: string; cost: string };

function NewPoForm({ db, busy, call, prefill, onDone }: PanelProps & { prefill: LowStockRow[] | null; onDone: () => void }) {
  const active = db.products.filter((p) => p.archivedAt === null);
  const [open, setOpen] = useState(!!prefill);
  const [supplier, setSupplier] = useState(prefill ? "Reorder: Northline Fixings Ltd" : "");
  const [warehouseId, setWarehouseId] = useState("WH-N");
  const [lines, setLines] = useState<DraftLine[]>(
    prefill?.map((r) => ({ productId: String(r.productId), qty: String(r.suggestedQty), cost: (r.costCents / 100).toFixed(2) })) ?? [
      { productId: String(active[0]?.id ?? 1), qty: "10", cost: ((active[0]?.costCents ?? 0) / 100).toFixed(2) },
    ],
  );
  const [res, setRes] = useState<ApiResponse | null>(null);
  const errId = useId();

  if (!open) {
    return (
      <button type="button" className={cls.btn} onClick={() => setOpen(true)}>
        + New purchase order
      </button>
    );
  }

  async function submit(e: React.FormEvent) {
    e.preventDefault();
    const body = {
      supplier,
      warehouseId,
      lines: lines.map((l) => ({ productId: Number(l.productId), qty: l.qty === "" ? undefined : Number(l.qty), unitCostCents: Math.round(Number(l.cost) * 100) })),
    };
    const r = await call("POST", "/api/purchase-orders", (d, c) => createPurchaseOrder(d, c, body), (b) => `${(b as PurchaseOrder).number} saved as draft.`);
    setRes(r);
    if (r.status < 400) {
      setOpen(false);
      onDone();
    }
  }

  const setLine = (i: number, patch: Partial<DraftLine>) => setLines(lines.map((l, j) => (j === i ? { ...l, ...patch } : l)));

  return (
    <form onSubmit={submit} noValidate className="space-y-3 rounded-md border border-border bg-bg-muted p-4" aria-describedby={res ? errId : undefined}>
      <h3 className="font-mono text-sm font-semibold">
        new purchase order <span className="font-normal text-fg-muted">POST /api/purchase-orders</span>
      </h3>
      <div className="grid gap-3 sm:grid-cols-2">
        <label className={cls.label}>
          Supplier
          <input value={supplier} onChange={(e) => setSupplier(e.target.value)} placeholder="Northline Fixings Ltd" className={cls.input} />
        </label>
        <label className={cls.label}>
          Deliver to
          <select value={warehouseId} onChange={(e) => setWarehouseId(e.target.value)} className={cls.input}>
            {db.warehouses.map((w) => (
              <option key={w.id} value={w.id}>
                {w.name}
              </option>
            ))}
          </select>
        </label>
      </div>
      <fieldset className="space-y-2">
        <legend className="text-sm font-medium">Lines</legend>
        {lines.map((l, i) => (
          <div key={i} className="grid grid-cols-[1fr_4.5rem_5.5rem_auto] items-end gap-2">
            <label className="text-xs">
              <span className="sr-only">Line {i + 1} product</span>
              <select value={l.productId} onChange={(e) => setLine(i, { productId: e.target.value })} className={cls.input}>
                {active.map((p) => (
                  <option key={p.id} value={p.id}>
                    {p.sku} · {p.name}
                  </option>
                ))}
              </select>
            </label>
            <label className="text-xs">
              qty
              <input value={l.qty} onChange={(e) => setLine(i, { qty: e.target.value })} inputMode="numeric" className={cls.input} />
            </label>
            <label className="text-xs">
              unit $
              <input value={l.cost} onChange={(e) => setLine(i, { cost: e.target.value })} inputMode="decimal" className={cls.input} />
            </label>
            <button
              type="button"
              className={`${cls.small} mb-2`}
              onClick={() => setLines(lines.filter((_, j) => j !== i))}
              aria-label={`Remove line ${i + 1}`}
            >
              ×
            </button>
          </div>
        ))}
        <button
          type="button"
          className={cls.small}
          onClick={() => setLines([...lines, { productId: String(active[0]?.id ?? 1), qty: "10", cost: ((active[0]?.costCents ?? 0) / 100).toFixed(2) }])}
        >
          + add line
        </button>
      </fieldset>
      <ErrorList res={res} id={errId} />
      <div className="flex gap-2">
        <button type="submit" className={cls.primary} disabled={busy}>
          {busy ? "Saving…" : "Save draft"}
        </button>
        <button
          type="button"
          className={cls.btn}
          onClick={() => {
            setOpen(false);
            onDone();
          }}
        >
          Cancel
        </button>
      </div>
    </form>
  );
}

/* ------------------------------------------------------------------ */
/* Low stock tab                                                       */
/* ------------------------------------------------------------------ */

export function LowStockPanel({ db, role, call, onDraftPo }: PanelProps & { onDraftPo: (rows: LowStockRow[]) => void }) {
  const rows = useMemo(() => lowStock(db, { role, user: "", now: 0 }).res.body as LowStockRow[], [db, role]);

  async function exportCsv() {
    const res = await call("GET", "/api/reports/low-stock.csv", (d, c) => lowStock(d, c), (b) => `Exported ${(b as LowStockRow[]).length} rows to CSV.`);
    const blob = new Blob([toCsv(res.body as LowStockRow[])], { type: "text/csv;charset=utf-8" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "low-stock.csv";
    a.click();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  }

  return (
    <div className="space-y-4">
      <div className="flex flex-wrap items-center gap-2">
        <button type="button" className={cls.btn} onClick={exportCsv} disabled={!rows.length}>
          Export CSV
        </button>
        <button type="button" className={cls.btn} onClick={() => onDraftPo(rows)} disabled={!rows.length}>
          Draft a PO for all of these
        </button>
        <span className="text-xs text-fg-muted">Suggested order brings each product back to twice its reorder level.</span>
      </div>
      {rows.length === 0 ? (
        <p className="rounded-md border border-dashed border-border p-6 text-center text-sm text-fg-muted">
          Nothing is below its reorder level. Record a few sales to change that.
        </p>
      ) : (
        <div className="overflow-x-auto">
          <table className="w-full min-w-[32rem] text-left text-sm">
            <caption className="sr-only">Products below their reorder level</caption>
            <thead className="font-mono text-xs text-fg-muted">
              <tr className="border-b border-border">
                <th className={cls.th}>SKU</th>
                <th className={cls.th}>product</th>
                <th className={`${cls.th} text-right`}>on hand</th>
                <th className={`${cls.th} text-right`}>reorder</th>
                <th className={cls.th}>
                  <span className="sr-only">Level</span>
                </th>
                <th className={`${cls.th} text-right`}>suggest</th>
              </tr>
            </thead>
            <tbody>
              {rows.map((r) => (
                <tr key={r.productId} className="border-b border-border">
                  <td className="py-2 pr-3 font-mono text-xs">{r.sku}</td>
                  <td className="py-2 pr-3">{r.name}</td>
                  <td className="py-2 pr-3 text-right font-mono text-warn-fg">{r.onHand}</td>
                  <td className="py-2 pr-3 text-right font-mono">{r.reorderLevel}</td>
                  <td className="w-28 py-2 pr-3">
                    <span className="block h-2 rounded bg-bg-muted" aria-hidden="true">
                      <span className="block h-2 rounded bg-warn-fg" style={{ width: `${Math.min(100, (r.onHand / (r.reorderLevel || 1)) * 100)}%` }} />
                    </span>
                  </td>
                  <td className="py-2 pr-3 text-right font-mono">{r.suggestedQty}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
}
app/lab/inventory-management/DevPanel.tsx81 lines
"use client";

import { useState } from "react";

export type LogEntry = {
  id: number;
  method: "GET" | "POST" | "PATCH" | "DELETE";
  path: string;
  status: number;
  role: string;
  sql: string[];
  body: unknown;
};

const statusTone = (s: number) => (s >= 500 ? "text-[#f87171]" : s >= 400 ? "text-[#fbbf24]" : "text-[#4ade80]");

function sqlTone(line: string) {
  if (line.startsWith("ERROR")) return "text-[#f87171]";
  if (line === "ROLLBACK;") return "text-[#f87171] font-semibold";
  if (line === "BEGIN;" || line === "COMMIT;") return "text-[#c084fc] font-semibold";
  return "text-[#a1a1aa]";
}

/**
 * Always-dark "network + query log" panel. Each entry is one HTTP request with the SQL the NestJS
 * service would send to PostgreSQL for it, so you can watch transactions commit or roll back.
 */
export function DevPanel({ log, onClear }: { log: LogEntry[]; onClear: () => void }) {
  const [openId, setOpenId] = useState<number | null>(null);
  return (
    <section aria-label="Developer panel: requests and SQL" className="border-t border-border bg-[#0b0b0d] text-[#e4e4e7]">
      <div className="flex items-center justify-between border-b border-[#27272a] px-4 py-2">
        <p className="font-mono text-xs text-[#a1a1aa]">
          <span className="text-[#4ade80]">$</span> requests · SQL per transaction · newest first
        </p>
        <button
          type="button"
          onClick={onClear}
          className="rounded border border-[#3f3f46] px-2 py-0.5 font-mono text-xs text-[#a1a1aa] hover:border-[#4ade80] hover:text-[#4ade80] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#4ade80]"
        >
          clear
        </button>
      </div>
      <ol role="log" aria-live="polite" className="max-h-80 overflow-y-auto p-3 font-mono text-[12px] leading-relaxed">
        {log.length === 0 && <li className="text-[#71717a]">$ waiting for requests… try recording a sale or a transfer.</li>}
        {log.map((e) => (
          <li key={e.id} className="border-b border-[#18181b] py-2 last:border-0">
            <div className="flex flex-wrap items-baseline gap-x-2">
              <span className="text-[#60a5fa]">{e.method}</span>
              <span className="break-all text-[#e4e4e7]">{e.path}</span>
              <span className={statusTone(e.status)}>{e.status}</span>
              <span className="text-[#71717a]">role={e.role}</span>
              <button
                type="button"
                onClick={() => setOpenId(openId === e.id ? null : e.id)}
                aria-expanded={openId === e.id}
                className="ml-auto text-[#71717a] underline-offset-2 hover:text-[#e4e4e7] hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#4ade80]"
              >
                {openId === e.id ? "hide body" : "response"}
              </button>
            </div>
            <ul className="mt-1 space-y-0.5 pl-3">
              {e.sql.length === 0 && <li className="text-[#71717a]">(rejected before any SQL ran: guard or validation pipe)</li>}
              {e.sql.map((s, i) => (
                <li key={i} className={`break-all ${sqlTone(s)}`}>
                  {s}
                </li>
              ))}
            </ul>
            {openId === e.id && (
              <pre className="mt-2 max-h-48 overflow-auto rounded bg-[#18181b] p-2 text-[11px] text-[#d4d4d8]">
                {e.body === null ? "(no content)" : JSON.stringify(e.body, null, 2)}
              </pre>
            )}
          </li>
        ))}
      </ol>
    </section>
  );
}