@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/CHANGELOG.md +4 -0
- package/README.md +3 -2
- package/lib/admin.js +990 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/catalog.js +51 -1
- package/lib/email.js +51 -0
- package/lib/inventory-locations.js +103 -66
- package/lib/quotes.js +306 -82
- package/lib/storefront.js +416 -0
- package/package.json +1 -1
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 || [];
|