@blamejs/blamejs-shop 0.4.14 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/admin.js CHANGED
@@ -37,6 +37,7 @@ var loyaltyEarnRulesModule = require("./loyalty-earn-rules");
37
37
  var loyaltyRedemptionModule = require("./loyalty-redemption");
38
38
  var trustBadgesModule = require("./trust-badges");
39
39
  var cartModule = require("./cart"); // ABANDONED_* window/limit constants for the /admin/carts console
40
+ var inventoryWriteoffsModule = require("./inventory-writeoffs"); // WRITEOFF_REASONS enum for the /admin/inventory/writeoffs console
40
41
  var textGuard = require("./text-guard");
41
42
  var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
42
43
 
@@ -236,6 +237,30 @@ function _strictMinorInt(value, prefix, label) {
236
237
  return n;
237
238
  }
238
239
 
240
+ // Convert an operator-entered major-unit amount (e.g. "19.99") into integer
241
+ // minor units via the money primitive — so the cents math is the
242
+ // framework's, not a hand-rolled `* 100` that loses precision under IEEE
243
+ // 754, and the conversion honours the target currency's exponent (JPY=0,
244
+ // KWD=3, USD=2). Refuses a missing / non-decimal-shaped / negative value
245
+ // with a TypeError the browser path surfaces as a 400 notice. `b.money.of`
246
+ // rejects Number inputs at the boundary, so the value is normalized to a
247
+ // trimmed decimal string first; the resulting BigInt minor units is range-
248
+ // checked back to a safe integer (quote money fits comfortably).
249
+ function _dollarsToMinor(value, label, currency) {
250
+ var cur = typeof currency === "string" && /^[A-Z]{3}$/.test(currency) ? currency : "USD";
251
+ var s = typeof value === "number" ? String(value) : (typeof value === "string" ? value.trim() : "");
252
+ if (!/^\d+(?:\.\d+)?$/.test(s)) {
253
+ throw new TypeError("admin: " + label + " must be a non-negative amount (e.g. 19.99)");
254
+ }
255
+ var minor;
256
+ try { minor = b.money.of(s, cur).toMinorUnits(); }
257
+ catch (_e) { throw new TypeError("admin: " + label + " has more decimal places than " + cur + " allows"); }
258
+ if (minor > BigInt(Number.MAX_SAFE_INTEGER)) {
259
+ throw new TypeError("admin: " + label + " is out of range");
260
+ }
261
+ return Number(minor);
262
+ }
263
+
239
264
  // Strict non-negative integer for a form field (money minor units,
240
265
  // dimensions). Refuses "", floats, and parseInt's loose-prefix "12abc"
241
266
  // → 12 — the /^\d+$/ test is anchored so the whole string must be
@@ -574,6 +599,11 @@ function mount(router, deps) {
574
599
  var searchSuggestions = deps.searchSuggestions || null; // featured-suggestion curation + popular-searches view disabled when absent
575
600
  var trustBadges = deps.trustBadges || null; // trust-badge authoring console disabled when absent
576
601
  var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
602
+ var inventoryLocations = deps.inventoryLocations || null; // stock-location CRUD + per-location levels console disabled when absent
603
+ var inventoryReceive = deps.inventoryReceive || null; // inbound-stock receive console disabled when absent
604
+ var stockTransfers = deps.stockTransfers || null; // location→location transfer console (dispatch/receive FSM) disabled when absent
605
+ var inventoryWriteoffs = deps.inventoryWriteoffs || null; // reason-coded write-off / shrinkage console disabled when absent
606
+ var quotes = deps.quotes || null; // RFQ negotiation console (queue/detail/respond/withdraw/convert) disabled when absent
577
607
  // Read-only activity log at /admin/audit. Defaults ON — the framework
578
608
  // audit chain is always booted by createApp, so the screen always has a
579
609
  // data source (unlike the optional primitives above, which default off).
@@ -593,7 +623,7 @@ function mount(router, deps) {
593
623
  // `reports` is always present in the nav (read-only sales summary needs no
594
624
  // extra dep); its route mounts unconditionally and renders an unconfigured
595
625
  // notice when the salesReports primitive isn't wired.
596
- var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart };
626
+ var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes };
597
627
 
598
628
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
599
629
 
@@ -5273,6 +5303,406 @@ function mount(router, deps) {
5273
5303
  });
5274
5304
  }
5275
5305
 
5306
+ // ---- inventory-ops back-office --------------------------------------
5307
+ //
5308
+ // Per-location stock on top of the single-bucket catalog inventory. The
5309
+ // catalog `inventory.stock_on_hand` stays the storefront source of truth;
5310
+ // these screens keep it in step with the per-location detail so a
5311
+ // multi-location operator's warehouse breakdown never diverges from the
5312
+ // count the storefront sells against. A store that never defines a
5313
+ // location keeps using /admin/inventory unchanged — the default location
5314
+ // is implicit, zero config.
5315
+
5316
+ // Credit ALL THREE ledgers for an inbound receive of one (sku,
5317
+ // location) line:
5318
+ // 1. inventoryReceive.draft + apply — the batched-receipt record the
5319
+ // "Recent receipts" history reads, AND the storefront-aggregate
5320
+ // credit (apply composes catalog.inventory.restock, which fires the
5321
+ // low-stock observer). A unique reference is auto-generated so two
5322
+ // receives in the same millisecond don't collide on the UNIQUE
5323
+ // constraint.
5324
+ // 2. inventoryLocations.adjustStock(+qty) — the per-location detail +
5325
+ // its reason-coded inventory_adjustments audit row.
5326
+ //
5327
+ // The location credit lands first: it's the verb that refuses an unknown
5328
+ // / deactivated location (a credit never refuses on insufficiency), so a
5329
+ // bad location is rejected before any receipt row is written. If the
5330
+ // receipt record then fails (duplicate reference race, DB error) the
5331
+ // location credit is reversed so the per-location detail doesn't sit
5332
+ // ahead of the aggregate. Returns the location adjust result.
5333
+ async function _receiveToLocation(sku, locationCode, qty, reason) {
5334
+ var adj = await inventoryLocations.adjustStock({
5335
+ sku: sku, location_code: locationCode, delta: qty,
5336
+ reason: reason ? ("receive: " + reason) : "receive",
5337
+ });
5338
+ try {
5339
+ var ref = "RCV-" + Date.now().toString(36).toUpperCase() + "-" +
5340
+ b.uuid.v7().slice(0, 8);
5341
+ var draft = await inventoryReceive.draft({
5342
+ reference: ref,
5343
+ supplier: reason ? reason.slice(0, 256) : "",
5344
+ received_by: "admin-console",
5345
+ notes: locationCode ? ("location: " + locationCode) : "",
5346
+ lines: [{ sku: sku, qty_received: qty }],
5347
+ });
5348
+ // apply credits the storefront aggregate (restock → low-stock
5349
+ // observer). restock is a no-op for an un-tracked SKU (no inventory
5350
+ // row) — fine: an un-tracked SKU is unlimited in the storefront, so
5351
+ // the per-location detail is the only ledger that needed the credit.
5352
+ await inventoryReceive.apply(draft.id);
5353
+ } catch (e) {
5354
+ // Compensate the per-location credit so the detail doesn't disagree
5355
+ // with the aggregate, then surface the original failure.
5356
+ try {
5357
+ await inventoryLocations.adjustStock({
5358
+ sku: sku, location_code: locationCode, delta: -qty,
5359
+ reason: "receive: rollback",
5360
+ });
5361
+ } catch (_e2) { /* drop-silent — the original error is the operator's signal */ }
5362
+ throw e;
5363
+ }
5364
+ return adj;
5365
+ }
5366
+
5367
+ // Debit BOTH ledgers for a write-off: the per-location detail (via the
5368
+ // writeoffs primitive, which composes inventoryLocations.adjustStock and
5369
+ // owns the reason-coded audit row) AND the storefront aggregate (catalog
5370
+ // adjustOnHand, which fires the low-stock observer + refuses to eat into
5371
+ // held stock).
5372
+ //
5373
+ // The aggregate mirror is applied BEFORE the write-off is committed as a
5374
+ // real record so the two ledgers never silently diverge. adjustOnHand
5375
+ // returns `{ adjusted: false }` (not a throw) when the debit would eat
5376
+ // into stock already held for paid-but-unfulfilled orders — that's a real
5377
+ // operational conflict (you're writing off units promised to a paid
5378
+ // order), so we reverse the per-location debit and surface it as a
5379
+ // refusal rather than committing a write-off that leaves the storefront
5380
+ // count ahead of the physical shelf. A null result means the SKU is
5381
+ // un-tracked in the catalog aggregate (no inventory row) — the
5382
+ // per-location detail is then the only ledger, and the write-off stands.
5383
+ async function _writeoffFromLocation(input) {
5384
+ var row = await inventoryWriteoffs.recordWriteoff(input);
5385
+ if (input.location_code) {
5386
+ // The per-location debit already landed inside recordWriteoff. Mirror
5387
+ // it onto the storefront aggregate.
5388
+ var mirror = await catalog.inventory.adjustOnHand(input.sku, -input.quantity);
5389
+ if (mirror && mirror.adjusted === false) {
5390
+ // The aggregate refused — reverse the per-location debit AND the
5391
+ // write-off record so neither ledger carries a half-applied move,
5392
+ // then surface the conflict.
5393
+ try {
5394
+ await inventoryWriteoffs.reverseWriteoff({
5395
+ id: row.id,
5396
+ reason: "aggregate-conflict: would oversell held stock",
5397
+ });
5398
+ } catch (_e) { /* drop-silent — the thrown conflict is the operator's signal */ }
5399
+ throw new TypeError("admin.inventory.writeoff: " + input.quantity +
5400
+ " unit(s) of " + input.sku + " can't be written off — that would eat " +
5401
+ "into stock already held for paid orders. Fulfil or cancel those orders first.");
5402
+ }
5403
+ }
5404
+ return row;
5405
+ }
5406
+
5407
+ if (inventoryLocations) {
5408
+ // GET /admin/inventory/locations — location list with per-location
5409
+ // levels for an optional ?sku= drill-down. JSON for the bearer token.
5410
+ router.get("/admin/inventory/locations", _pageOrApi(true,
5411
+ R(async function (req, res) {
5412
+ var rows = await inventoryLocations.listLocations({});
5413
+ _json(res, 200, { rows: rows });
5414
+ }),
5415
+ async function (req, res) {
5416
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5417
+ var sku = url && url.searchParams.get("sku");
5418
+ var rows = await inventoryLocations.listLocations({});
5419
+ var levels = null;
5420
+ if (sku) {
5421
+ try { levels = await inventoryLocations.stockForSku(sku); }
5422
+ catch (e) { if (!(e instanceof TypeError)) throw e; levels = null; }
5423
+ }
5424
+ _sendHtml(res, 200, renderAdminInvLocations({
5425
+ shop_name: deps.shop_name, nav_available: navAvailable,
5426
+ locations: rows, sku: sku || "", levels: levels,
5427
+ saved: url && url.searchParams.get("saved"),
5428
+ notice: url && url.searchParams.get("err") ? "That location couldn't be saved — check the fields and try again." : null,
5429
+ }));
5430
+ },
5431
+ ));
5432
+
5433
+ // POST /admin/inventory/locations — defineLocation, or updateLocation
5434
+ // when the code already exists (operator-friendly upsert). A bad shape
5435
+ // is a plain TypeError → 400.
5436
+ router.post("/admin/inventory/locations", _pageOrApi(false,
5437
+ W("inventory.location.save", async function (req, res) {
5438
+ var loc;
5439
+ try { loc = await _saveInvLocationFromBody(req.body || {}); }
5440
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5441
+ _json(res, 201, loc);
5442
+ return loc;
5443
+ }),
5444
+ async function (req, res) {
5445
+ try { await _saveInvLocationFromBody(req.body || {}); }
5446
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/locations?err=1"); throw e; }
5447
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.location.save", outcome: "success", metadata: { code: (req.body || {}).code } });
5448
+ _redirect(res, "/admin/inventory/locations?saved=1");
5449
+ },
5450
+ ));
5451
+
5452
+ async function _saveInvLocationFromBody(body) {
5453
+ var input = {
5454
+ code: body.code,
5455
+ name: body.name,
5456
+ type: body.type,
5457
+ priority: body.priority == null || body.priority === "" ? undefined : (parseInt(body.priority, 10)),
5458
+ };
5459
+ if (input.priority !== undefined && !Number.isFinite(input.priority)) {
5460
+ throw new TypeError("admin.inventory.location.save: priority must be an integer");
5461
+ }
5462
+ var existing = await inventoryLocations.getLocation(body.code);
5463
+ if (existing) {
5464
+ var patch = { name: input.name, type: input.type };
5465
+ if (input.priority !== undefined) patch.priority = input.priority;
5466
+ return inventoryLocations.updateLocation(body.code, patch);
5467
+ }
5468
+ return inventoryLocations.defineLocation(input);
5469
+ }
5470
+
5471
+ // POST /admin/inventory/locations/:code/deactivate — soft-delete. The
5472
+ // row survives so historical adjustments still resolve their location.
5473
+ router.post("/admin/inventory/locations/:code/deactivate", _pageOrApi(false,
5474
+ W("inventory.location.deactivate", async function (req, res) {
5475
+ var code = req.params.code;
5476
+ var existing;
5477
+ try { existing = await inventoryLocations.getLocation(code); }
5478
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5479
+ if (!existing) return _problem(res, 404, "inventory-location-not-found");
5480
+ var row = await inventoryLocations.deactivateLocation(code);
5481
+ _json(res, 200, row);
5482
+ return row;
5483
+ }),
5484
+ async function (req, res) {
5485
+ var code = req.params.code;
5486
+ var existing;
5487
+ try { existing = await inventoryLocations.getLocation(code); }
5488
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/locations?err=1"); throw e; }
5489
+ if (!existing) return _redirect(res, "/admin/inventory/locations?err=1");
5490
+ await inventoryLocations.deactivateLocation(code);
5491
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.location.deactivate", outcome: "success", metadata: { code: code } });
5492
+ _redirect(res, "/admin/inventory/locations?saved=1");
5493
+ },
5494
+ ));
5495
+ }
5496
+
5497
+ if (inventoryReceive && inventoryLocations) {
5498
+ // GET /admin/inventory/receive — the receive form + recent receipt
5499
+ // history (the inventory-receive ledger). The form credits a single
5500
+ // (sku, location) line; the history reads the batched-receipt records.
5501
+ router.get("/admin/inventory/receive", _pageOrApi(true,
5502
+ R(async function (req, res) {
5503
+ var page = await inventoryReceive.list({ limit: 50 });
5504
+ _json(res, 200, { rows: page.rows, next_cursor: page.next_cursor });
5505
+ }),
5506
+ async function (req, res) {
5507
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5508
+ var locs = await inventoryLocations.listLocations({ active_only: true });
5509
+ var page = await inventoryReceive.list({ limit: 50 });
5510
+ _sendHtml(res, 200, renderAdminInvReceive({
5511
+ shop_name: deps.shop_name, nav_available: navAvailable,
5512
+ locations: locs, receipts: page.rows || [],
5513
+ saved: url && url.searchParams.get("saved"),
5514
+ notice: url && url.searchParams.get("err") ? "That receipt couldn't be recorded — check the SKU, location, and quantity." : null,
5515
+ }));
5516
+ },
5517
+ ));
5518
+
5519
+ // POST /admin/inventory/receive — credit a single (sku, location) line
5520
+ // to both ledgers. quantity must be a positive integer; the location
5521
+ // must exist (adjustStock refuses an unknown one).
5522
+ router.post("/admin/inventory/receive", _pageOrApi(false,
5523
+ W("inventory.receive", async function (req, res) {
5524
+ var body = req.body || {};
5525
+ var qty = parseInt(body.quantity, 10);
5526
+ if (!Number.isInteger(qty) || qty <= 0) return _problem(res, 400, "bad-request", "quantity must be a positive integer");
5527
+ var out;
5528
+ try { out = await _receiveToLocation(body.sku, body.location_code, qty, body.reason); }
5529
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5530
+ _json(res, 201, out);
5531
+ return out;
5532
+ }),
5533
+ async function (req, res) {
5534
+ var body = req.body || {};
5535
+ var qty = parseInt(body.quantity, 10);
5536
+ if (!Number.isInteger(qty) || qty <= 0) return _redirect(res, "/admin/inventory/receive?err=1");
5537
+ try { await _receiveToLocation(body.sku, body.location_code, qty, body.reason); }
5538
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/receive?err=1"); throw e; }
5539
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.receive", outcome: "success", metadata: { sku: body.sku, location_code: body.location_code, quantity: qty } });
5540
+ _redirect(res, "/admin/inventory/receive?saved=1");
5541
+ },
5542
+ ));
5543
+ }
5544
+
5545
+ if (stockTransfers && inventoryLocations) {
5546
+ // GET /admin/inventory/transfers — the open-transfer queue + the open
5547
+ // form. The queue shows non-terminal transfers with the FSM actions
5548
+ // legal from each status.
5549
+ router.get("/admin/inventory/transfers", _pageOrApi(true,
5550
+ R(async function (req, res) {
5551
+ var rows = await stockTransfers.listOpen({});
5552
+ _json(res, 200, { rows: rows });
5553
+ }),
5554
+ async function (req, res) {
5555
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5556
+ var locs = await inventoryLocations.listLocations({ active_only: true });
5557
+ var rows = await stockTransfers.listOpen({});
5558
+ _sendHtml(res, 200, renderAdminInvTransfers({
5559
+ shop_name: deps.shop_name, nav_available: navAvailable,
5560
+ locations: locs, transfers: rows,
5561
+ saved: url && url.searchParams.get("saved"),
5562
+ notice: url && url.searchParams.get("err") ? "That transfer action couldn't be completed — check stock at the source and the transfer state." : null,
5563
+ }));
5564
+ },
5565
+ ));
5566
+
5567
+ // POST /admin/inventory/transfers — openTransfer for one (sku, qty)
5568
+ // line. Debits the source immediately (stock leaves at dispatch).
5569
+ router.post("/admin/inventory/transfers", _pageOrApi(false,
5570
+ W("inventory.transfer.open", async function (req, res) {
5571
+ var body = req.body || {};
5572
+ var qty = parseInt(body.quantity, 10);
5573
+ if (!Number.isInteger(qty) || qty <= 0) return _problem(res, 400, "bad-request", "quantity must be a positive integer");
5574
+ var t;
5575
+ try {
5576
+ t = await stockTransfers.openTransfer({
5577
+ from_location: body.from_location, to_location: body.to_location,
5578
+ lines: [{ sku: body.sku, quantity: qty }], reason: body.reason,
5579
+ });
5580
+ } catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5581
+ _json(res, 201, t);
5582
+ return t;
5583
+ }),
5584
+ async function (req, res) {
5585
+ var body = req.body || {};
5586
+ var qty = parseInt(body.quantity, 10);
5587
+ if (!Number.isInteger(qty) || qty <= 0) return _redirect(res, "/admin/inventory/transfers?err=1");
5588
+ try {
5589
+ await stockTransfers.openTransfer({
5590
+ from_location: body.from_location, to_location: body.to_location,
5591
+ lines: [{ sku: body.sku, quantity: qty }], reason: body.reason,
5592
+ });
5593
+ } catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/transfers?err=1"); throw e; }
5594
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.transfer.open", outcome: "success", metadata: { sku: body.sku, from: body.from_location, to: body.to_location, quantity: qty } });
5595
+ _redirect(res, "/admin/inventory/transfers?saved=1");
5596
+ },
5597
+ ));
5598
+
5599
+ // The FSM-action routes: ship → in-transit → receive → reconcile, plus
5600
+ // exception. Each resolves the transfer first (unknown id → err) and
5601
+ // calls the matching primitive verb; a wrong-state transition is a
5602
+ // plain TypeError surfaced as an error redirect / 400.
5603
+ function _transferAction(action, verb) {
5604
+ router.post("/admin/inventory/transfers/:id/" + action, _pageOrApi(false,
5605
+ W("inventory.transfer." + action, async function (req, res) {
5606
+ var id = req.params.id;
5607
+ var out;
5608
+ try { out = await verb(id, req.body || {}); }
5609
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5610
+ _json(res, 200, out);
5611
+ return out;
5612
+ }),
5613
+ async function (req, res) {
5614
+ var id = req.params.id;
5615
+ try { await verb(id, req.body || {}); }
5616
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/transfers?err=1"); throw e; }
5617
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.transfer." + action, outcome: "success", metadata: { transfer_id: id } });
5618
+ _redirect(res, "/admin/inventory/transfers?saved=1");
5619
+ },
5620
+ ));
5621
+ }
5622
+ _transferAction("ship", function (id, body) {
5623
+ return stockTransfers.markShipped({ transfer_id: id, carrier: body.carrier || null, tracking_number: body.tracking_number || null });
5624
+ });
5625
+ _transferAction("in-transit", function (id, body) {
5626
+ return stockTransfers.markInTransit({ transfer_id: id, location: body.location || null });
5627
+ });
5628
+ _transferAction("receive", function (id, body) {
5629
+ // Single-line transfers from the open form: receive the full shipped
5630
+ // qty unless the operator overrides quantity_received. Resolve the
5631
+ // shipped line so a blank field receives everything (the happy path).
5632
+ return (async function () {
5633
+ var t = await stockTransfers.getTransfer(id);
5634
+ if (!t) throw new TypeError("transfer not found");
5635
+ var rx = t.lines.map(function (l) {
5636
+ var got = body["rx_" + l.sku];
5637
+ var q = got == null || got === "" ? l.quantity_shipped : parseInt(got, 10);
5638
+ if (!Number.isInteger(q) || q < 0) throw new TypeError("quantity_received must be a non-negative integer");
5639
+ return { sku: l.sku, quantity_received: q };
5640
+ });
5641
+ return stockTransfers.markReceived({ transfer_id: id, received_lines: rx });
5642
+ })();
5643
+ });
5644
+ _transferAction("reconcile", function (id) {
5645
+ return stockTransfers.reconcile({ transfer_id: id });
5646
+ });
5647
+ _transferAction("exception", function (id, body) {
5648
+ return stockTransfers.markException({ transfer_id: id, reason: body.reason });
5649
+ });
5650
+ }
5651
+
5652
+ if (inventoryWriteoffs && inventoryLocations) {
5653
+ // GET /admin/inventory/writeoffs — the write-off log + the record form.
5654
+ router.get("/admin/inventory/writeoffs", _pageOrApi(true,
5655
+ R(async function (req, res) {
5656
+ var page = await inventoryWriteoffs.listWriteoffs({ limit: 50 });
5657
+ _json(res, 200, { rows: page.rows, next_cursor: page.next_cursor });
5658
+ }),
5659
+ async function (req, res) {
5660
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5661
+ var locs = await inventoryLocations.listLocations({ active_only: true });
5662
+ var page = await inventoryWriteoffs.listWriteoffs({ limit: 50 });
5663
+ _sendHtml(res, 200, renderAdminInvWriteoffs({
5664
+ shop_name: deps.shop_name, nav_available: navAvailable,
5665
+ locations: locs, writeoffs: page.rows || [],
5666
+ reasons: inventoryWriteoffsModule.WRITEOFF_REASONS,
5667
+ saved: url && url.searchParams.get("saved"),
5668
+ notice: url && url.searchParams.get("err") ? "That write-off couldn't be recorded — check the SKU, location, quantity, and reason." : null,
5669
+ }));
5670
+ },
5671
+ ));
5672
+
5673
+ // POST /admin/inventory/writeoffs — recordWriteoff against a location +
5674
+ // mirror the debit onto the storefront aggregate.
5675
+ router.post("/admin/inventory/writeoffs", _pageOrApi(false,
5676
+ W("inventory.writeoff", async function (req, res) {
5677
+ var body = req.body || {};
5678
+ var qty = parseInt(body.quantity, 10);
5679
+ if (!Number.isInteger(qty) || qty <= 0) return _problem(res, 400, "bad-request", "quantity must be a positive integer");
5680
+ var row;
5681
+ try {
5682
+ row = await _writeoffFromLocation({
5683
+ sku: body.sku, location_code: body.location_code, quantity: qty,
5684
+ reason: body.reason, actor: body.actor || "admin-console", notes: body.notes || null,
5685
+ });
5686
+ } catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5687
+ _json(res, 201, row);
5688
+ return row;
5689
+ }),
5690
+ async function (req, res) {
5691
+ var body = req.body || {};
5692
+ var qty = parseInt(body.quantity, 10);
5693
+ if (!Number.isInteger(qty) || qty <= 0) return _redirect(res, "/admin/inventory/writeoffs?err=1");
5694
+ try {
5695
+ await _writeoffFromLocation({
5696
+ sku: body.sku, location_code: body.location_code, quantity: qty,
5697
+ reason: body.reason, actor: body.actor || "admin-console", notes: body.notes || null,
5698
+ });
5699
+ } catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/writeoffs?err=1"); throw e; }
5700
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.writeoff", outcome: "success", metadata: { sku: body.sku, location_code: body.location_code, quantity: qty, reason: body.reason } });
5701
+ _redirect(res, "/admin/inventory/writeoffs?saved=1");
5702
+ },
5703
+ ));
5704
+ }
5705
+
5276
5706
  // ---- gift wraps -----------------------------------------------------
5277
5707
  //
5278
5708
  // The operator-defined gift-wrap catalog: define / update / archive a wrap
@@ -6457,6 +6887,187 @@ function mount(router, deps) {
6457
6887
  ));
6458
6888
  }
6459
6889
 
6890
+ // ---- quotes (B2B request-for-quote negotiation) ---------------------
6891
+ // The operator side of the RFQ lifecycle. The list is the response queue
6892
+ // (oldest-waiting requests first) plus a recent-activity view; the detail
6893
+ // screen shows the requested lines + the customer message, and — for a
6894
+ // still-requested quote — a per-line pricing form that responds with a
6895
+ // priced quote + validity window. Responded/accepted quotes show the
6896
+ // quoted totals; an operator can withdraw a quote that hasn't been
6897
+ // accepted, or convert an accepted one into a pending order. Content-
6898
+ // negotiated like the other consoles (bearer → JSON, browser → HTML).
6899
+ if (quotes) {
6900
+ // Build the respondToQuote line_prices array from the per-line
6901
+ // `price_<sku>` dollar fields the detail form posts, converting each to
6902
+ // minor units. Every quote line must be priced; a missing / non-numeric
6903
+ // field throws a TypeError the route maps to a 400 re-render. The dollar
6904
+ // string is parsed to integer cents WITHOUT floating-point (split on the
6905
+ // decimal point) so 19.99 never lands as 1998 via float drift.
6906
+ function _quoteLinePricesFromForm(body, lines, currency) {
6907
+ var out = [];
6908
+ for (var i = 0; i < lines.length; i += 1) {
6909
+ var sku = lines[i].sku;
6910
+ var raw = body["price_" + sku];
6911
+ if (raw == null || (typeof raw === "string" && !raw.trim().length)) {
6912
+ throw new TypeError("quotes: a unit price is required for every line (missing " + sku + ")");
6913
+ }
6914
+ out.push({ sku: sku, unit_price_minor: _dollarsToMinor(raw, "unit price for " + sku, currency) });
6915
+ }
6916
+ return out;
6917
+ }
6918
+
6919
+ router.get("/admin/quotes", _pageOrApi(true,
6920
+ R(async function (req, res) {
6921
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6922
+ var cid = url && url.searchParams.get("customer_id");
6923
+ var rows = cid
6924
+ ? await quotes.quotesForCustomer(cid, { limit: 200 })
6925
+ : await quotes.pendingResponse({ limit: 200 });
6926
+ _json(res, 200, { rows: rows });
6927
+ }),
6928
+ async function (req, res) {
6929
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6930
+ var cid = url && url.searchParams.get("customer_id");
6931
+ var rows = [];
6932
+ try {
6933
+ rows = cid
6934
+ ? await quotes.quotesForCustomer(cid, { limit: 200 })
6935
+ : await quotes.pendingResponse({ limit: 200 });
6936
+ } catch (e) { if (!(e instanceof TypeError)) throw e; }
6937
+ _sendHtml(res, 200, renderAdminQuotes({
6938
+ shop_name: deps.shop_name, nav_available: navAvailable, quotes: rows,
6939
+ customer_filter: cid,
6940
+ responded: url && url.searchParams.get("responded"),
6941
+ withdrawn: url && url.searchParams.get("withdrawn"),
6942
+ converted: url && url.searchParams.get("converted"),
6943
+ notice: (url && url.searchParams.get("err"))
6944
+ ? "That action couldn't be completed for the quote." : null,
6945
+ }));
6946
+ },
6947
+ ));
6948
+
6949
+ // Detail: the quote + its lines, plus the per-line respond form (when
6950
+ // still requested) and the withdraw / convert actions.
6951
+ router.get("/admin/quotes/:id", _pageOrApi(true,
6952
+ R(async function (req, res) {
6953
+ var row = null;
6954
+ try { row = await quotes.getQuote(req.params.id); }
6955
+ catch (e) { if (e instanceof TypeError) return _problem(res, 404, "quote-not-found"); throw e; }
6956
+ if (!row) return _problem(res, 404, "quote-not-found");
6957
+ _json(res, 200, row);
6958
+ }),
6959
+ async function (req, res) {
6960
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6961
+ var row = null;
6962
+ try { row = await quotes.getQuote(req.params.id); }
6963
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
6964
+ if (!row) return _sendHtml(res, 404, renderAdminQuoteDetail({
6965
+ shop_name: deps.shop_name, nav_available: navAvailable, quote: null,
6966
+ }));
6967
+ _sendHtml(res, 200, renderAdminQuoteDetail({
6968
+ shop_name: deps.shop_name, nav_available: navAvailable, quote: row,
6969
+ notice: (url && url.searchParams.get("err"))
6970
+ ? "That action couldn't be completed for the quote." : null,
6971
+ }));
6972
+ },
6973
+ ));
6974
+
6975
+ // Respond: price every line + set shipping / tax / validity. The browser
6976
+ // form posts dollar amounts (converted to minor units here) + a
6977
+ // validity-in-days; the bearer JSON contract takes the primitive's native
6978
+ // shape (minor units + an absolute valid_until). A bad shape is a clean
6979
+ // 400 (bearer) / err re-render (browser), never a 500.
6980
+ router.post("/admin/quotes/:id/respond", _pageOrApi(false,
6981
+ W("quote.respond", async function (req, res) {
6982
+ var row;
6983
+ try { row = await quotes.respondToQuote(Object.assign({}, req.body || {}, { quote_id: req.params.id })); }
6984
+ catch (e) {
6985
+ if (e && e.code === "QUOTE_NOT_FOUND") return _problem(res, 404, "quote-not-found");
6986
+ if (e && e.code === "QUOTE_TRANSITION_REFUSED") return _problem(res, 409, "quote-transition-refused", e.message);
6987
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
6988
+ throw e;
6989
+ }
6990
+ _json(res, 200, row);
6991
+ return { id: row.id };
6992
+ }),
6993
+ async function (req, res) {
6994
+ var id = req.params.id;
6995
+ var enc = encodeURIComponent(id);
6996
+ var body = req.body || {};
6997
+ var current = null;
6998
+ try { current = await quotes.getQuote(id); } catch (_e) { current = null; }
6999
+ if (!current) return _redirect(res, "/admin/quotes?err=1");
7000
+ try {
7001
+ var validDays = _strictNonNegIntField(body.valid_days, "valid_days");
7002
+ if (validDays <= 0) throw new TypeError("admin: valid_days must be at least 1");
7003
+ var quoteCurrency = typeof body.currency === "string" && body.currency
7004
+ ? body.currency.toUpperCase() : (current.currency || "USD");
7005
+ await quotes.respondToQuote({
7006
+ quote_id: id,
7007
+ line_prices: _quoteLinePricesFromForm(body, current.lines, quoteCurrency),
7008
+ shipping_minor: body.shipping == null || body.shipping === "" ? 0 : _dollarsToMinor(body.shipping, "shipping", quoteCurrency),
7009
+ tax_minor: body.tax == null || body.tax === "" ? 0 : _dollarsToMinor(body.tax, "tax", quoteCurrency),
7010
+ valid_until: Date.now() + b.constants.TIME.days(validDays),
7011
+ currency: quoteCurrency,
7012
+ operator_notes: body.operator_notes || null,
7013
+ });
7014
+ } catch (e) {
7015
+ if (!(e instanceof TypeError) && !(e && (e.code === "QUOTE_TRANSITION_REFUSED" || e.code === "QUOTE_NOT_FOUND"))) throw e;
7016
+ var msg = _safeNotice(e, "quote.respond");
7017
+ var fresh = await quotes.getQuote(id);
7018
+ return _sendHtml(res, msg.status, renderAdminQuoteDetail({
7019
+ shop_name: deps.shop_name, nav_available: navAvailable, quote: fresh,
7020
+ notice: msg.message.replace(/^(quotes|admin)[.:]\s*/, ""),
7021
+ }));
7022
+ }
7023
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quote.respond", outcome: "success", metadata: { quote_id: id } });
7024
+ // Fire the quote-responded email (drop-silent, fire-and-forget) when a
7025
+ // notifier is wired — the customer learns their quote is priced.
7026
+ if (typeof deps.notifyQuoteResponded === "function") {
7027
+ try { await deps.notifyQuoteResponded(id); }
7028
+ catch (_e) { /* drop-silent — the response already persisted */ }
7029
+ }
7030
+ _redirect(res, "/admin/quotes/" + enc + "?responded=1");
7031
+ },
7032
+ ));
7033
+
7034
+ // Withdraw: cancel a quote that hasn't been accepted yet (requested or
7035
+ // responded). Accepted / terminal quotes refuse — the FSM gate is the
7036
+ // single source of truth, surfaced as a 409.
7037
+ router.post("/admin/quotes/:id/withdraw", _pageOrApi(false,
7038
+ W("quote.withdraw", async function (req, res) {
7039
+ var row;
7040
+ try {
7041
+ row = await quotes.cancelQuote({
7042
+ quote_id: req.params.id,
7043
+ cancel_reason: (req.body && req.body.cancel_reason) || "Withdrawn by operator",
7044
+ });
7045
+ } catch (e) {
7046
+ if (e && e.code === "QUOTE_NOT_FOUND") return _problem(res, 404, "quote-not-found");
7047
+ if (e && e.code === "QUOTE_TRANSITION_REFUSED") return _problem(res, 409, "quote-transition-refused", e.message);
7048
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
7049
+ throw e;
7050
+ }
7051
+ _json(res, 200, row);
7052
+ return { id: row.id };
7053
+ }),
7054
+ async function (req, res) {
7055
+ var id = req.params.id;
7056
+ try {
7057
+ await quotes.cancelQuote({
7058
+ quote_id: id,
7059
+ cancel_reason: (req.body && req.body.cancel_reason) || "Withdrawn by operator",
7060
+ });
7061
+ } catch (e) {
7062
+ if (!(e instanceof TypeError) && !(e && (e.code === "QUOTE_TRANSITION_REFUSED" || e.code === "QUOTE_NOT_FOUND"))) throw e;
7063
+ return _redirect(res, "/admin/quotes/" + encodeURIComponent(id) + "?err=1");
7064
+ }
7065
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quote.withdraw", outcome: "success", metadata: { quote_id: id } });
7066
+ _redirect(res, "/admin/quotes?withdrawn=1");
7067
+ },
7068
+ ));
7069
+ }
7070
+
6460
7071
  // ---- search ranking -------------------------------------------------
6461
7072
  // Operator-tunable storefront search ranking: named weight sets (one
6462
7073
  // active at a time), per-query manual pins, and a per-set metrics rollup.
@@ -11946,7 +12557,12 @@ var ADMIN_NAV_ITEMS = [
11946
12557
  { key: "dashboard", href: "/admin/dashboard", label: "Dashboard" },
11947
12558
  { key: "products", href: "/admin/products", label: "Products" },
11948
12559
  { key: "inventory", href: "/admin/inventory", label: "Inventory" },
12560
+ { key: "inventory-locations", href: "/admin/inventory/locations", label: "Stock locations", requires: "inventoryLocations" },
12561
+ { key: "inventory-receive", href: "/admin/inventory/receive", label: "Receive stock", requires: "inventoryReceive" },
12562
+ { key: "inventory-transfers", href: "/admin/inventory/transfers", label: "Transfers", requires: "stockTransfers" },
12563
+ { key: "inventory-writeoffs", href: "/admin/inventory/writeoffs", label: "Write-offs", requires: "inventoryWriteoffs" },
11949
12564
  { key: "orders", href: "/admin/orders", label: "Orders" },
12565
+ { key: "quotes", href: "/admin/quotes", label: "Quotes", requires: "quotes" },
11950
12566
  { key: "carts", href: "/admin/carts", label: "Abandoned carts", requires: "carts" },
11951
12567
  { key: "reports", href: "/admin/reports", label: "Reports" },
11952
12568
  { key: "analytics", href: "/admin/analytics", label: "Analytics", requires: "analytics" },
@@ -14687,6 +15303,236 @@ function renderAdminPickupLocations(opts) {
14687
15303
  return _renderAdminShell(opts.shop_name, "Pickup locations", body, "pickup-locations", opts.nav_available);
14688
15304
  }
14689
15305
 
15306
+ // Stock-location list + the define/update form + an optional per-SKU
15307
+ // levels drill-down. Location names and codes are operator free text —
15308
+ // escaped at the sink.
15309
+ function renderAdminInvLocations(opts) {
15310
+ opts = opts || {};
15311
+ var list = opts.locations || [];
15312
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Stock location saved.</div>" : "";
15313
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15314
+
15315
+ var rows = list.map(function (l) {
15316
+ return "<tr>" +
15317
+ "<td><code class=\"order-id\">" + _htmlEscape(l.code) + "</code></td>" +
15318
+ "<td>" + _htmlEscape(l.name) + "</td>" +
15319
+ "<td>" + _htmlEscape(l.type) + "</td>" +
15320
+ "<td>" + _htmlEscape(String(l.priority)) + "</td>" +
15321
+ "<td>" + (l.active ? "<span class=\"status-pill\">active</span>" : "<span class=\"meta\">inactive</span>") + "</td>" +
15322
+ "<td>" +
15323
+ (l.active
15324
+ ? "<form method=\"post\" action=\"/admin/inventory/locations/" + encodeURIComponent(l.code) + "/deactivate\" class=\"form-inline\">" +
15325
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Deactivate</button></form>"
15326
+ : "<span class=\"meta\">—</span>") +
15327
+ "</td>" +
15328
+ "</tr>";
15329
+ }).join("");
15330
+
15331
+ var table = list.length
15332
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Code</th><th scope=\"col\">Name</th><th scope=\"col\">Type</th><th scope=\"col\">Priority</th><th scope=\"col\">Status</th><th scope=\"col\">Action</th></tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
15333
+ : "<p class=\"empty\">No stock locations yet. A store with one warehouse needs no location — the default bucket is implicit. Add a location below once you ship from more than one place.</p>";
15334
+
15335
+ // Per-SKU levels drill-down.
15336
+ var levelsBlock = "";
15337
+ if (opts.levels) {
15338
+ var lv = opts.levels;
15339
+ var lvRows = (lv.by_location || []).map(function (r) {
15340
+ return "<tr><td><code class=\"order-id\">" + _htmlEscape(r.code) + "</code></td><td>" + _htmlEscape(String(r.quantity)) + "</td></tr>";
15341
+ }).join("");
15342
+ levelsBlock =
15343
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Levels for " + _htmlEscape(opts.sku) + " — total " + _htmlEscape(String(lv.total)) + "</h3>" +
15344
+ (lv.by_location && lv.by_location.length
15345
+ ? _tableWrap("<table><thead><tr><th scope=\"col\">Location</th><th scope=\"col\">On hand</th></tr></thead><tbody>" + lvRows + "</tbody></table>")
15346
+ : "<p class=\"empty\">No per-location stock recorded for this SKU.</p>") +
15347
+ "</div>";
15348
+ }
15349
+ var lookup =
15350
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Per-location levels</h3>" +
15351
+ "<form method=\"get\" action=\"/admin/inventory/locations\" class=\"form-inline\">" +
15352
+ _setupField("SKU", "sku", opts.sku || "", "text", "Show the per-location breakdown for one SKU.", " maxlength=\"128\"") +
15353
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Look up</button>" +
15354
+ "</form>" + levelsBlock +
15355
+ "</div>";
15356
+
15357
+ var form =
15358
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Add / update a stock location</h3>" +
15359
+ "<form method=\"post\" action=\"/admin/inventory/locations\">" +
15360
+ _setupField("Code", "code", "", "text", "Stable identifier (alnum + . _ -). Re-using a code updates that location.", " maxlength=\"64\" required") +
15361
+ _setupField("Name", "name", "", "text", "Operator-facing label (e.g. East warehouse).", " maxlength=\"128\" required") +
15362
+ "<label class=\"form-field\"><span>Type</span>" +
15363
+ "<select name=\"type\" required>" +
15364
+ "<option value=\"warehouse\">warehouse</option>" +
15365
+ "<option value=\"retail\">retail</option>" +
15366
+ "<option value=\"dropship\">dropship</option>" +
15367
+ "</select>" +
15368
+ "<small>Drives default routing weight; override with priority.</small>" +
15369
+ "</label>" +
15370
+ _setupField("Priority", "priority", "", "number", "Lower picks first (default 100).", " min=\"0\"") +
15371
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Save location</button></div>" +
15372
+ "</form>" +
15373
+ "</div>";
15374
+
15375
+ var body = "<section><h2>Stock locations</h2>" + saved + notice + table + lookup + form + "</section>";
15376
+ return _renderAdminShell(opts.shop_name, "Stock locations", body, "inventory-locations", opts.nav_available);
15377
+ }
15378
+
15379
+ // Receive inbound stock against a location. Credits both the per-location
15380
+ // detail and the storefront aggregate. Recent receipts (the batched
15381
+ // inventory-receive ledger) render below the form. Reference / supplier
15382
+ // are operator free text — escaped at the sink.
15383
+ function renderAdminInvReceive(opts) {
15384
+ opts = opts || {};
15385
+ var locs = opts.locations || [];
15386
+ var receipts = opts.receipts || [];
15387
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Stock received.</div>" : "";
15388
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15389
+
15390
+ var locOptions = locs.map(function (l) {
15391
+ return "<option value=\"" + _htmlEscape(l.code) + "\">" + _htmlEscape(l.name) + " (" + _htmlEscape(l.code) + ")</option>";
15392
+ }).join("");
15393
+
15394
+ var form = locs.length
15395
+ ? "<div class=\"panel\"><h3 class=\"subhead\">Receive stock</h3>" +
15396
+ "<form method=\"post\" action=\"/admin/inventory/receive\">" +
15397
+ _setupField("SKU", "sku", "", "text", "", " maxlength=\"128\" required") +
15398
+ "<label class=\"form-field\"><span>Location</span><select name=\"location_code\" required>" + locOptions + "</select></label>" +
15399
+ _setupField("Quantity", "quantity", "", "number", "Units received (positive).", " min=\"1\" required") +
15400
+ _setupField("Reason / reference", "reason", "", "text", "Optional — PO number, supplier, note.", " maxlength=\"256\"") +
15401
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Receive</button></div>" +
15402
+ "</form></div>"
15403
+ : "<p class=\"empty\">Add a stock location first, then receive stock against it.</p>";
15404
+
15405
+ var recRows = receipts.map(function (r) {
15406
+ return "<tr>" +
15407
+ "<td>" + _htmlEscape(String(r.reference || "")) + "</td>" +
15408
+ "<td>" + _htmlEscape(String(r.supplier || "")) + "</td>" +
15409
+ "<td><span class=\"status-pill\">" + _htmlEscape(String(r.status)) + "</span></td>" +
15410
+ "<td>" + _htmlEscape(String(r.total_qty)) + "</td>" +
15411
+ "<td>" + _htmlEscape(_fmtDate(r.received_at)) + "</td>" +
15412
+ "</tr>";
15413
+ }).join("");
15414
+ var history = receipts.length
15415
+ ? "<div class=\"panel mt\"><h3 class=\"subhead\">Recent receipts</h3>" +
15416
+ _tableWrap("<table><thead><tr><th scope=\"col\">Reference</th><th scope=\"col\">Supplier</th><th scope=\"col\">Status</th><th scope=\"col\">Qty</th><th scope=\"col\">Received</th></tr></thead><tbody>" + recRows + "</tbody></table>") +
15417
+ "</div>"
15418
+ : "";
15419
+
15420
+ var body = "<section><h2>Receive stock</h2>" + saved + notice + form + history + "</section>";
15421
+ return _renderAdminShell(opts.shop_name, "Receive stock", body, "inventory-receive", opts.nav_available);
15422
+ }
15423
+
15424
+ // Location→location transfer console: the open form + the open-transfer
15425
+ // queue with the FSM action legal from each row's status. Reasons,
15426
+ // carriers, and tracking numbers are operator free text — escaped.
15427
+ function renderAdminInvTransfers(opts) {
15428
+ opts = opts || {};
15429
+ var locs = opts.locations || [];
15430
+ var transfers = opts.transfers || [];
15431
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Transfer updated.</div>" : "";
15432
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15433
+
15434
+ var locOptions = locs.map(function (l) {
15435
+ return "<option value=\"" + _htmlEscape(l.code) + "\">" + _htmlEscape(l.name) + " (" + _htmlEscape(l.code) + ")</option>";
15436
+ }).join("");
15437
+
15438
+ function _transferActions(t) {
15439
+ var blocks = [];
15440
+ var act = function (action, label, extra) {
15441
+ return "<form method=\"post\" action=\"/admin/inventory/transfers/" + encodeURIComponent(t.id) + "/" + action + "\" class=\"form-inline\">" +
15442
+ (extra || "") + "<button class=\"btn btn--sm\" type=\"submit\">" + _htmlEscape(label) + "</button></form>";
15443
+ };
15444
+ if (t.status === "open") blocks.push(act("ship", "Mark shipped"));
15445
+ if (t.status === "shipped" || t.status === "in_transit") {
15446
+ blocks.push(act("receive", "Mark received"));
15447
+ }
15448
+ if (t.status === "received") blocks.push(act("reconcile", "Reconcile"));
15449
+ if (t.status !== "reconciled" && t.status !== "exception") {
15450
+ blocks.push(act("exception", "Exception",
15451
+ "<input type=\"text\" name=\"reason\" placeholder=\"reason\" maxlength=\"280\" required>"));
15452
+ }
15453
+ return blocks.length ? blocks.join("") : "<span class=\"meta\">—</span>";
15454
+ }
15455
+
15456
+ var rows = transfers.map(function (t) {
15457
+ var lineSummary = (t.lines || []).map(function (l) {
15458
+ return _htmlEscape(l.sku) + "×" + _htmlEscape(String(l.quantity_shipped));
15459
+ }).join(", ");
15460
+ return "<tr>" +
15461
+ "<td><code class=\"order-id\">" + _htmlEscape(String(t.id).slice(0, 8)) + "</code></td>" +
15462
+ "<td>" + _htmlEscape(t.from_location) + " → " + _htmlEscape(t.to_location) + "</td>" +
15463
+ "<td>" + lineSummary + "</td>" +
15464
+ "<td><span class=\"status-pill\">" + _htmlEscape(t.status) + "</span></td>" +
15465
+ "<td>" + _transferActions(t) + "</td>" +
15466
+ "</tr>";
15467
+ }).join("");
15468
+
15469
+ var queue = transfers.length
15470
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">ID</th><th scope=\"col\">Route</th><th scope=\"col\">Lines</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
15471
+ : "<p class=\"empty\">No open transfers.</p>";
15472
+
15473
+ var form = locs.length >= 2
15474
+ ? "<div class=\"panel mt\"><h3 class=\"subhead\">Open a transfer</h3>" +
15475
+ "<form method=\"post\" action=\"/admin/inventory/transfers\">" +
15476
+ "<label class=\"form-field\"><span>From</span><select name=\"from_location\" required>" + locOptions + "</select></label>" +
15477
+ "<label class=\"form-field\"><span>To</span><select name=\"to_location\" required>" + locOptions + "</select></label>" +
15478
+ _setupField("SKU", "sku", "", "text", "", " maxlength=\"128\" required") +
15479
+ _setupField("Quantity", "quantity", "", "number", "Units to move (leaves the source immediately).", " min=\"1\" required") +
15480
+ _setupField("Reason", "reason", "", "text", "Optional note.", " maxlength=\"280\"") +
15481
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Open transfer</button></div>" +
15482
+ "</form></div>"
15483
+ : "<p class=\"empty\">Define at least two stock locations to transfer between them.</p>";
15484
+
15485
+ var body = "<section><h2>Stock transfers</h2>" + saved + notice + queue + form + "</section>";
15486
+ return _renderAdminShell(opts.shop_name, "Transfers", body, "inventory-transfers", opts.nav_available);
15487
+ }
15488
+
15489
+ // Reason-coded write-off log + record form. SKU, reason, notes, and actor
15490
+ // are operator free text — escaped at the sink.
15491
+ function renderAdminInvWriteoffs(opts) {
15492
+ opts = opts || {};
15493
+ var locs = opts.locations || [];
15494
+ var list = opts.writeoffs || [];
15495
+ var reasons = opts.reasons || [];
15496
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Write-off recorded.</div>" : "";
15497
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15498
+
15499
+ var locOptions = locs.map(function (l) {
15500
+ return "<option value=\"" + _htmlEscape(l.code) + "\">" + _htmlEscape(l.name) + " (" + _htmlEscape(l.code) + ")</option>";
15501
+ }).join("");
15502
+ var reasonOptions = reasons.map(function (r) {
15503
+ return "<option value=\"" + _htmlEscape(r) + "\">" + _htmlEscape(r) + "</option>";
15504
+ }).join("");
15505
+
15506
+ var rows = list.map(function (w) {
15507
+ return "<tr>" +
15508
+ "<td>" + _htmlEscape(String(w.sku)) + "</td>" +
15509
+ "<td>" + (w.location_code ? "<code class=\"order-id\">" + _htmlEscape(String(w.location_code)) + "</code>" : "<span class=\"meta\">—</span>") + "</td>" +
15510
+ "<td>" + _htmlEscape(String(w.quantity)) + "</td>" +
15511
+ "<td>" + _htmlEscape(String(w.reason)) + "</td>" +
15512
+ "<td><span class=\"status-pill\">" + _htmlEscape(String(w.status)) + "</span></td>" +
15513
+ "<td>" + _htmlEscape(_fmtDate(w.occurred_at)) + "</td>" +
15514
+ "</tr>";
15515
+ }).join("");
15516
+ var table = list.length
15517
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\">Location</th><th scope=\"col\">Qty</th><th scope=\"col\">Reason</th><th scope=\"col\">Status</th><th scope=\"col\">When</th></tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
15518
+ : "<p class=\"empty\">No write-offs recorded.</p>";
15519
+
15520
+ var form = locs.length
15521
+ ? "<div class=\"panel mt\"><h3 class=\"subhead\">Record a write-off</h3>" +
15522
+ "<form method=\"post\" action=\"/admin/inventory/writeoffs\">" +
15523
+ _setupField("SKU", "sku", "", "text", "", " maxlength=\"128\" required") +
15524
+ "<label class=\"form-field\"><span>Location</span><select name=\"location_code\" required>" + locOptions + "</select></label>" +
15525
+ _setupField("Quantity", "quantity", "", "number", "Units removed (positive).", " min=\"1\" required") +
15526
+ "<label class=\"form-field\"><span>Reason</span><select name=\"reason\" required>" + reasonOptions + "</select></label>" +
15527
+ _setupField("Notes", "notes", "", "text", "Optional.", " maxlength=\"4096\"") +
15528
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn btn--danger\">Record write-off</button></div>" +
15529
+ "</form></div>"
15530
+ : "<p class=\"empty\">Add a stock location first, then record write-offs against it.</p>";
15531
+
15532
+ var body = "<section><h2>Write-offs</h2>" + saved + notice + table + form + "</section>";
15533
+ return _renderAdminShell(opts.shop_name, "Write-offs", body, "inventory-writeoffs", opts.nav_available);
15534
+ }
15535
+
14690
15536
  // Pickup queue for one location: a location selector + status chips, the
14691
15537
  // scheduled/ready rows, and the FSM action forms (ready / picked-up /
14692
15538
  // no-show) legal from each row's status. The no_show reason is operator-
@@ -18324,6 +19170,149 @@ function _standardSurveyQuestions(kind) {
18324
19170
  ];
18325
19171
  }
18326
19172
 
19173
+ // Map a quote status to a status-pill modifier class (reusing the order
19174
+ // pills: paid=green for the live/positive states, cancelled=grey for the
19175
+ // terminal-without-sale ones). Purely cosmetic.
19176
+ function _quotePillClass(status) {
19177
+ if (status === "responded") return "paid";
19178
+ if (status === "accepted" || status === "converted") return "paid";
19179
+ if (status === "requested") return "pending";
19180
+ return "cancelled"; // rejected / expired / cancelled
19181
+ }
19182
+
19183
+ function renderAdminQuotes(opts) {
19184
+ opts = opts || {};
19185
+ var rows = opts.quotes || [];
19186
+ var responded = opts.responded ? "<div class=\"banner banner--ok\">Quote sent to the customer.</div>" : "";
19187
+ var withdrawn = opts.withdrawn ? "<div class=\"banner banner--ok\">Quote withdrawn.</div>" : "";
19188
+ var converted = opts.converted ? "<div class=\"banner banner--ok\">Quote converted to an order.</div>" : "";
19189
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
19190
+
19191
+ var cf = opts.customer_filter;
19192
+ var heading = cf ? "Quotes for this customer" : "Quotes awaiting a response";
19193
+ var chips = "<div class=\"order-filters\">" +
19194
+ "<a class=\"chip" + (cf == null ? " chip--on" : "") + "\" href=\"/admin/quotes\">Response queue</a>" +
19195
+ (cf ? "<a class=\"chip chip--on\" href=\"/admin/quotes?customer_id=" + _htmlEscape(encodeURIComponent(cf)) + "\">This customer</a>" : "") +
19196
+ "</div>";
19197
+
19198
+ var bodyRows = rows.map(function (q) {
19199
+ var enc = _htmlEscape(encodeURIComponent(q.id));
19200
+ var total = q.total_minor == null
19201
+ ? "<span class=\"u-mute\">—</span>"
19202
+ : _htmlEscape(pricing.format(q.total_minor, q.currency || "USD"));
19203
+ var lineCount = (q.lines || []).length;
19204
+ return "<tr>" +
19205
+ "<td><a href=\"/admin/quotes/" + enc + "\"><code class=\"order-id\">" + _htmlEscape(String(q.id).slice(0, 8)) + "</code></a></td>" +
19206
+ "<td><a href=\"/admin/customers/" + _htmlEscape(encodeURIComponent(q.customer_id)) + "\">" + _htmlEscape(String(q.customer_id).slice(0, 8)) + "</a></td>" +
19207
+ "<td><span class=\"status-pill " + _quotePillClass(q.status) + "\">" + _htmlEscape(q.status) + "</span></td>" +
19208
+ "<td class=\"num\">" + lineCount + "</td>" +
19209
+ "<td class=\"num\">" + total + "</td>" +
19210
+ "<td><a class=\"btn btn--ghost\" href=\"/admin/quotes/" + enc + "\">Open</a></td>" +
19211
+ "</tr>";
19212
+ }).join("");
19213
+
19214
+ var table = rows.length
19215
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Quote</th><th scope=\"col\">Customer</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Lines</th><th scope=\"col\" class=\"num\">Total</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table>") + "</div>"
19216
+ : "<p class=\"empty\">" + (cf ? "No quotes for this customer." : "No quotes are waiting for a response.") + "</p>";
19217
+
19218
+ var bodyHtml = "<section><h2>Quotes</h2>" + responded + withdrawn + converted + notice +
19219
+ "<p class=\"meta\">Request-for-quote negotiations. The response queue lists the requests waiting on you, oldest first — open one to price its lines and send the customer a quote.</p>" +
19220
+ chips + "<h3 class=\"subhead\">" + _htmlEscape(heading) + "</h3>" + table + "</section>";
19221
+ return _renderAdminShell(opts.shop_name, "Quotes", bodyHtml, "quotes", opts.nav_available);
19222
+ }
19223
+
19224
+ function renderAdminQuoteDetail(opts) {
19225
+ opts = opts || {};
19226
+ var q = opts.quote;
19227
+ if (!q) {
19228
+ var nf = "<section><h2>Quote</h2><p class=\"empty\">Quote not found.</p>" +
19229
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/quotes\">Back to quotes</a></div></section>";
19230
+ return _renderAdminShell(opts.shop_name, "Quote", nf, "quotes", opts.nav_available);
19231
+ }
19232
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
19233
+ var enc = _htmlEscape(encodeURIComponent(q.id));
19234
+ var currency = q.currency || "USD";
19235
+
19236
+ // Header summary.
19237
+ var summary =
19238
+ "<div class=\"panel\">" +
19239
+ "<p class=\"meta\">Status: <span class=\"status-pill " + _quotePillClass(q.status) + "\">" + _htmlEscape(q.status) + "</span></p>" +
19240
+ "<p class=\"meta\">Customer: <a href=\"/admin/customers/" + _htmlEscape(encodeURIComponent(q.customer_id)) + "\">" + _htmlEscape(q.customer_id) + "</a></p>" +
19241
+ (q.delivery_terms ? "<p class=\"meta\">Delivery terms: " + _htmlEscape(q.delivery_terms) + "</p>" : "") +
19242
+ (q.payment_terms ? "<p class=\"meta\">Payment terms: " + _htmlEscape(q.payment_terms) + "</p>" : "") +
19243
+ (q.message ? "<p class=\"meta\">Customer message: <q>" + _htmlEscape(q.message) + "</q></p>" : "") +
19244
+ (q.valid_until ? "<p class=\"meta\">Valid until: " + _htmlEscape(new Date(Number(q.valid_until)).toISOString()) + "</p>" : "") +
19245
+ (q.total_minor != null ? "<p class=\"meta\">Quoted total: <strong>" + _htmlEscape(pricing.format(q.total_minor, currency)) + "</strong></p>" : "") +
19246
+ (q.converted_order_id ? "<p class=\"meta\">Converted to order: <a href=\"/admin/orders/" + _htmlEscape(encodeURIComponent(q.converted_order_id)) + "\"><code class=\"order-id\">" + _htmlEscape(String(q.converted_order_id).slice(0, 8)) + "</code></a></p>" : "") +
19247
+ "</div>";
19248
+
19249
+ // Lines table — shows the requested qty + (once responded) the priced
19250
+ // unit + line total.
19251
+ var lineRows = (q.lines || []).map(function (l) {
19252
+ var unit = l.unit_price_minor == null
19253
+ ? "<span class=\"u-mute\">—</span>"
19254
+ : _htmlEscape(pricing.format(l.unit_price_minor, l.currency || currency));
19255
+ var lineTotal = l.unit_price_minor == null
19256
+ ? "<span class=\"u-mute\">—</span>"
19257
+ : _htmlEscape(pricing.format(l.unit_price_minor * l.quantity, l.currency || currency));
19258
+ return "<tr>" +
19259
+ "<td><code class=\"order-id\">" + _htmlEscape(l.sku) + "</code></td>" +
19260
+ "<td class=\"num\">" + _htmlEscape(String(l.quantity)) + "</td>" +
19261
+ "<td class=\"num\">" + unit + "</td>" +
19262
+ "<td class=\"num\">" + lineTotal + "</td>" +
19263
+ (l.notes ? "<td>" + _htmlEscape(l.notes) + "</td>" : "<td></td>") +
19264
+ "</tr>";
19265
+ }).join("");
19266
+ var linesPanel = "<div class=\"panel\">" +
19267
+ _tableWrap("<table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\" class=\"num\">Qty</th><th scope=\"col\" class=\"num\">Unit price</th><th scope=\"col\" class=\"num\">Line total</th><th scope=\"col\">Customer note</th></tr></thead><tbody>" + lineRows + "</tbody></table>") +
19268
+ "</div>";
19269
+
19270
+ // Respond form — only for a still-requested quote. One unit-price field per
19271
+ // line plus shipping / tax / validity. Major-unit dollar inputs (converted
19272
+ // to minor units server-side).
19273
+ var respondForm = "";
19274
+ if (q.status === "requested") {
19275
+ var priceFields = (q.lines || []).map(function (l) {
19276
+ return _setupField(l.sku + " — unit price (" + currency + ")", "price_" + l.sku, "", "text",
19277
+ "Quantity " + l.quantity + ".", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\" required");
19278
+ }).join("");
19279
+ respondForm =
19280
+ "<div class=\"panel mt mw-40\">" +
19281
+ "<h3 class=\"subhead\">Respond with a priced quote</h3>" +
19282
+ "<p class=\"meta\">Set a unit price for every line, plus optional shipping + tax and how long the quote stays valid. The customer is notified and can accept or decline.</p>" +
19283
+ "<form method=\"post\" action=\"/admin/quotes/" + enc + "/respond\">" +
19284
+ priceFields +
19285
+ _setupField("Shipping (" + currency + ")", "shipping", "", "text", "Optional. Leave blank for free shipping.", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\"") +
19286
+ _setupField("Tax (" + currency + ")", "tax", "", "text", "Optional.", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\"") +
19287
+ _setupField("Valid for (days)", "valid_days", "14", "number", "How many days the customer has to accept.", " min=\"1\" max=\"365\" required") +
19288
+ _setupField("Currency", "currency", currency, "text", "ISO-4217, e.g. USD.", " maxlength=\"3\" pattern=\"[A-Za-z]{3}\"") +
19289
+ "<label class=\"form-field\"><span>Note to the customer</span><textarea name=\"operator_notes\" maxlength=\"4000\" rows=\"3\"></textarea></label>" +
19290
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Send quote</button></div>" +
19291
+ "</form>" +
19292
+ "</div>";
19293
+ }
19294
+
19295
+ // Withdraw — available while the quote hasn't been accepted (requested or
19296
+ // responded). The FSM refuses it for accepted/terminal quotes; we only
19297
+ // render the button when it would succeed.
19298
+ var withdrawForm = "";
19299
+ if (q.status === "requested" || q.status === "responded") {
19300
+ withdrawForm =
19301
+ "<form method=\"post\" action=\"/admin/quotes/" + enc + "/withdraw\" class=\"form-inline\">" +
19302
+ "<button class=\"btn btn--danger\" type=\"submit\">Withdraw quote</button>" +
19303
+ "</form>";
19304
+ }
19305
+
19306
+ var actions = "<div class=\"actions-row\">" +
19307
+ "<a class=\"btn btn--ghost\" href=\"/admin/quotes\">Back to quotes</a>" +
19308
+ withdrawForm +
19309
+ "</div>";
19310
+
19311
+ var bodyHtml = "<section><h2>Quote " + _htmlEscape(String(q.id).slice(0, 8)) + "</h2>" +
19312
+ notice + summary + linesPanel + respondForm + actions + "</section>";
19313
+ return _renderAdminShell(opts.shop_name, "Quote " + String(q.id).slice(0, 8), bodyHtml, "quotes", opts.nav_available);
19314
+ }
19315
+
18327
19316
  function renderAdminSurveys(opts) {
18328
19317
  opts = opts || {};
18329
19318
  var rows = opts.surveys || [];