@metron-labs/quote-react 0.1.47 → 0.1.48
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/dist/app/QuickQuote.js +2 -75
- package/dist/app/stages/BasketStage.js +40 -13
- package/dist/app/stages/ScheduleResultsStage.d.ts +1 -0
- package/dist/app/stages/ScheduleResultsStage.js +7 -2
- package/dist/embed/quote.js +16 -17
- package/dist/lib/cart/CartContext.d.ts +2 -0
- package/dist/lib/cart/CartContext.js +4 -0
- package/dist/styles.css +4 -5
- package/dist/util/pricing.d.ts +1 -1
- package/dist/util/pricing.js +4 -1
- package/package.json +1 -1
package/dist/app/QuickQuote.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { getQuoteConfig } from "../lib/config";
|
|
5
5
|
import { TrackContext, useTracker } from "../lib/analytics";
|
|
6
6
|
import { submitSchedule, pollScheduleJob } from "../lib/api/fetchSchedule";
|
|
7
7
|
import { pollJob } from "../lib/api/pollJob";
|
|
@@ -10,8 +10,6 @@ import { ENTRY_DRAFT_KEY } from "./stages/EntryStage";
|
|
|
10
10
|
import ScheduleResultsStage from "./stages/ScheduleResultsStage";
|
|
11
11
|
import BasketStage from "./stages/BasketStage";
|
|
12
12
|
import { CartProvider, useCart } from "../lib/cart/CartContext";
|
|
13
|
-
import { clientHeaders, readJson } from "../lib/api/boqClient";
|
|
14
|
-
import { getPricingBreakdown } from "../util/pricing";
|
|
15
13
|
const SAVED_QUOTE_KEY = "metron-quick-quote";
|
|
16
14
|
// Its own provider, separate from the estimating tools' - a quote in progress and a set
|
|
17
15
|
// of calculator results are different baskets.
|
|
@@ -39,82 +37,11 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
39
37
|
}
|
|
40
38
|
catch { }
|
|
41
39
|
};
|
|
42
|
-
const resolvedConversionInputs = useRef(new Map());
|
|
43
40
|
const rootRef = useRef(null);
|
|
44
41
|
const track = useTracker("quick-quote", rootRef);
|
|
45
42
|
useEffect(() => {
|
|
46
43
|
track("viewed");
|
|
47
44
|
}, [track]);
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
for (const item of Object.values(cart)) {
|
|
50
|
-
if (getPricingBreakdown(item).requiredSellUnits != null)
|
|
51
|
-
resolvedConversionInputs.current.delete(item.lineId);
|
|
52
|
-
}
|
|
53
|
-
const unresolved = Object.values(cart).filter((item) => {
|
|
54
|
-
if (item.productId === "-" || getPricingBreakdown(item).requiredSellUnits != null)
|
|
55
|
-
return false;
|
|
56
|
-
const input = `${item.productId}|${item.qty}|${item.unit}`;
|
|
57
|
-
if (resolvedConversionInputs.current.get(item.lineId) === input)
|
|
58
|
-
return false;
|
|
59
|
-
resolvedConversionInputs.current.set(item.lineId, input);
|
|
60
|
-
return true;
|
|
61
|
-
});
|
|
62
|
-
if (unresolved.length === 0)
|
|
63
|
-
return;
|
|
64
|
-
void fetch(apiUrl("/api/catalogue/resolve-conversions"), {
|
|
65
|
-
method: "POST",
|
|
66
|
-
headers: { "Content-Type": "application/json", ...clientHeaders() },
|
|
67
|
-
body: JSON.stringify({
|
|
68
|
-
lines: unresolved.map((item) => ({
|
|
69
|
-
line_id: item.lineId,
|
|
70
|
-
product_id: item.productId,
|
|
71
|
-
requested_description: item.requestedDescription ?? item.description,
|
|
72
|
-
requested_qty: item.qty,
|
|
73
|
-
requested_uom: item.unit || null,
|
|
74
|
-
selected_description: item.description,
|
|
75
|
-
selected_uoi: item.uoi,
|
|
76
|
-
selected_uoi_count: item.uoiCount,
|
|
77
|
-
linked_context: item.linkedContext ?? [],
|
|
78
|
-
selected_conversion: {
|
|
79
|
-
sales_uom_normalised: item.salesUomNormalised,
|
|
80
|
-
conversion_type: item.conversionType,
|
|
81
|
-
sales_unit_size: item.salesUnitSize,
|
|
82
|
-
sales_unit_size_uom: item.salesUnitSizeUom,
|
|
83
|
-
coverage_qty: item.coverageQty,
|
|
84
|
-
coverage_uom: item.coverageUom,
|
|
85
|
-
},
|
|
86
|
-
})),
|
|
87
|
-
}),
|
|
88
|
-
})
|
|
89
|
-
.then((response) => (response.ok ? readJson(response) : null))
|
|
90
|
-
.then((payload) => {
|
|
91
|
-
const updates = {};
|
|
92
|
-
for (const resolved of payload?.data?.items ?? payload?.items ?? []) {
|
|
93
|
-
const conversion = resolved?.conversion;
|
|
94
|
-
if (!conversion || typeof resolved?.line_id !== "string")
|
|
95
|
-
continue;
|
|
96
|
-
const current = cart[resolved.line_id];
|
|
97
|
-
updates[resolved.line_id] = {
|
|
98
|
-
conversionType: conversion.conversion_type,
|
|
99
|
-
requestUomsSupported: conversion.request_uoms_supported,
|
|
100
|
-
salesUomNormalised: conversion.sales_uom_normalised,
|
|
101
|
-
salesUnitSize: conversion.sales_unit_size,
|
|
102
|
-
salesUnitSizeUom: conversion.sales_unit_size_uom,
|
|
103
|
-
coverageQty: conversion.coverage_qty,
|
|
104
|
-
coverageUom: conversion.coverage_uom,
|
|
105
|
-
roundingRule: conversion.rounding_rule,
|
|
106
|
-
conversionConfidence: conversion.conversion_confidence,
|
|
107
|
-
conversionNotes: conversion.conversion_notes,
|
|
108
|
-
...(current?.qty === 0 && Number.isFinite(conversion.suggested_order_qty) && conversion.suggested_order_qty > 0
|
|
109
|
-
? { qty: conversion.suggested_order_qty, unit: conversion.suggested_order_uom || current.unit }
|
|
110
|
-
: {}),
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
if (Object.keys(updates).length > 0)
|
|
114
|
-
updateCartConversions(updates);
|
|
115
|
-
})
|
|
116
|
-
.catch((error) => console.error("Catalogue conversion resolution failed", error));
|
|
117
|
-
}, [cart, updateCartConversions]);
|
|
118
45
|
// The quote survives the widget restarting - the host re-rendering the page under us
|
|
119
46
|
// (Turbo restoring its own entry), or a refresh. It lives in this tab's sessionStorage and
|
|
120
47
|
// the URL hash says which step to show. Restored after mount, not in the initial state, so
|
|
@@ -321,7 +248,7 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
321
248
|
setEntryMode("schedule");
|
|
322
249
|
await runScheduleMatch(file);
|
|
323
250
|
}, [runScheduleMatch]);
|
|
324
|
-
return (_jsx(TrackContext.Provider, { value: track, children: _jsx("div", { ref: rootRef, className: `app-wrapper${fullPage ? "" : " app-wrapper--embedded"}${hideHeader ? " app-wrapper--no-header" : ""}${!fullPage && stage === "processing" ? " app-wrapper--loading" : ""}`, style: getQuoteConfig().theme, children: _jsxs("main", { className: "main-content", children: [error && (_jsxs("div", { className: "error-box", role: "alert", children: [_jsx("strong", { children: "Error:" }), " ", error] })), stage === "entry" && _jsx(EntryStage, { onPasteSchedule: handlePasteSchedule, onUploadSchedule: handleUploadSchedule, onEntryModeChange: setEntryMode, draft: entryDraft, onSubmitDraft: setEntryDraft, onCancelMatch: cancelMatch }), stage === "results" && entryMode === "schedule" && scheduleResult && (_jsx(ScheduleResultsStage, { scheduleResult: scheduleResult, onStartOver: resetToEntry, cart: cart, onAddToCart: addToCart, onRemoveFromCart: removeFromCart, onGoToBasket: goToBasket, cartSeeded: cartSeeded, onCartSeeded: () => setCartSeeded(true), reviewGateComplete: scheduleReviewGateComplete, onReviewGateComplete: () => setScheduleReviewGateComplete(true), internal: internal })), stage === "basket" && (_jsx(BasketStage, { cart: cart, onUpdateQty: updateCartQty, onUpdateSellUnits: updateCartSellUnits, onRemove: removeFromCart, onBack: backToResults, onStartNewQuote: resetToEntry, onQuoteRequested: () => {
|
|
251
|
+
return (_jsx(TrackContext.Provider, { value: track, children: _jsx("div", { ref: rootRef, className: `app-wrapper${fullPage ? "" : " app-wrapper--embedded"}${hideHeader ? " app-wrapper--no-header" : ""}${!fullPage && stage === "processing" ? " app-wrapper--loading" : ""}`, style: getQuoteConfig().theme, children: _jsxs("main", { className: "main-content", children: [error && (_jsxs("div", { className: "error-box", role: "alert", children: [_jsx("strong", { children: "Error:" }), " ", error] })), stage === "entry" && _jsx(EntryStage, { onPasteSchedule: handlePasteSchedule, onUploadSchedule: handleUploadSchedule, onEntryModeChange: setEntryMode, draft: entryDraft, onSubmitDraft: setEntryDraft, onCancelMatch: cancelMatch }), stage === "results" && entryMode === "schedule" && scheduleResult && (_jsx(ScheduleResultsStage, { scheduleResult: scheduleResult, onStartOver: resetToEntry, cart: cart, onAddToCart: addToCart, onRemoveFromCart: removeFromCart, onGoToBasket: goToBasket, cartSeeded: cartSeeded, onCartSeeded: () => setCartSeeded(true), reviewGateComplete: scheduleReviewGateComplete, onReviewGateComplete: () => setScheduleReviewGateComplete(true), internal: internal })), stage === "basket" && (_jsx(BasketStage, { cart: cart, onUpdateQty: updateCartQty, onUpdateSellUnits: updateCartSellUnits, onResolveConversions: updateCartConversions, onRemove: removeFromCart, onBack: backToResults, onStartNewQuote: resetToEntry, onQuoteRequested: () => {
|
|
325
252
|
setScheduleResult(null);
|
|
326
253
|
setScheduleReviewGateComplete(false);
|
|
327
254
|
setCartSeeded(false);
|
|
@@ -10,6 +10,7 @@ import { exportBasketExcel } from "../../util/exportScheduleExcel";
|
|
|
10
10
|
import { renderDisplayUnit, unitDisplayText } from "../components/UnitDisplay";
|
|
11
11
|
import { clientHeaders, getBoqClientId, readJson } from "../../lib/api/boqClient";
|
|
12
12
|
import { MAX_ORDER_QTY } from "../../lib/boq/inputLimits";
|
|
13
|
+
import { conversionInputKey } from "../../lib/cart/CartContext";
|
|
13
14
|
// ponytail: flat UK VAT rate, matching fmtCurrency's GBP-only assumption elsewhere in this app.
|
|
14
15
|
const VAT_RATE = 0.2;
|
|
15
16
|
const PAGE_SIZE = 10;
|
|
@@ -53,18 +54,28 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
53
54
|
const [calcOpen, setCalcOpen] = useState({});
|
|
54
55
|
const [wasteEnabled, setWasteEnabled] = useState(false);
|
|
55
56
|
const [wastePercent, setWastePercent] = useState("0");
|
|
56
|
-
const
|
|
57
|
+
const [resolvingConversionInputs, setResolvingConversionInputs] = useState({});
|
|
58
|
+
const resolvedConversionInputs = useRef(new Map());
|
|
57
59
|
useEffect(() => {
|
|
58
60
|
if (!onResolveConversions)
|
|
59
61
|
return;
|
|
60
62
|
for (const item of Object.values(cart)) {
|
|
61
63
|
if (getPricingBreakdown(item).requiredSellUnits != null)
|
|
62
|
-
|
|
64
|
+
resolvedConversionInputs.current.delete(item.lineId);
|
|
63
65
|
}
|
|
64
|
-
const unresolved = Object.values(cart).filter((item) =>
|
|
66
|
+
const unresolved = Object.values(cart).filter((item) => {
|
|
67
|
+
if (item.productId === "-" || getPricingBreakdown(item).requiredSellUnits != null)
|
|
68
|
+
return false;
|
|
69
|
+
const input = conversionInputKey(item);
|
|
70
|
+
if (resolvedConversionInputs.current.get(item.lineId) === input)
|
|
71
|
+
return false;
|
|
72
|
+
resolvedConversionInputs.current.set(item.lineId, input);
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
65
75
|
if (unresolved.length === 0)
|
|
66
76
|
return;
|
|
67
|
-
unresolved.
|
|
77
|
+
const inputByLineId = new Map(unresolved.map((item) => [item.lineId, conversionInputKey(item)]));
|
|
78
|
+
setResolvingConversionInputs((current) => ({ ...current, ...Object.fromEntries(inputByLineId) }));
|
|
68
79
|
void fetch(apiUrl("/api/catalogue/resolve-conversions"), {
|
|
69
80
|
method: "POST",
|
|
70
81
|
headers: { "Content-Type": "application/json", ...clientHeaders() },
|
|
@@ -95,7 +106,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
95
106
|
const updates = {};
|
|
96
107
|
for (const resolved of payload?.data?.items ?? payload?.items ?? []) {
|
|
97
108
|
const conversion = resolved?.conversion;
|
|
98
|
-
if (!conversion || typeof resolved?.line_id !== "string")
|
|
109
|
+
if (!conversion || typeof resolved?.line_id !== "string" || resolvedConversionInputs.current.get(resolved.line_id) !== inputByLineId.get(resolved.line_id))
|
|
99
110
|
continue;
|
|
100
111
|
const current = cart[resolved.line_id];
|
|
101
112
|
updates[resolved.line_id] = {
|
|
@@ -117,7 +128,16 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
117
128
|
if (Object.keys(updates).length > 0)
|
|
118
129
|
onResolveConversions(updates);
|
|
119
130
|
})
|
|
120
|
-
.catch((error) => console.error("Catalogue conversion resolution failed", error))
|
|
131
|
+
.catch((error) => console.error("Catalogue conversion resolution failed", error))
|
|
132
|
+
.finally(() => {
|
|
133
|
+
setResolvingConversionInputs((current) => {
|
|
134
|
+
const next = { ...current };
|
|
135
|
+
for (const [lineId, input] of inputByLineId)
|
|
136
|
+
if (next[lineId] === input)
|
|
137
|
+
delete next[lineId];
|
|
138
|
+
return next;
|
|
139
|
+
});
|
|
140
|
+
});
|
|
121
141
|
}, [cart, onResolveConversions]);
|
|
122
142
|
function clearQtyDraft(lineId) {
|
|
123
143
|
setQtyDrafts((prev) => {
|
|
@@ -156,6 +176,10 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
156
176
|
// Sends the basket to the merchant as a quote request (POST /v1/quote-requests). Success only
|
|
157
177
|
// shows once the API confirms the email went out; otherwise the customer is asked to retry.
|
|
158
178
|
async function submitQuoteRequest() {
|
|
179
|
+
if (pricedItems.some(({ pricing }) => pricing.lineTotal == null)) {
|
|
180
|
+
setQuoteContactError("Confirm the highlighted quantities before requesting a quote.");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
159
183
|
const email = reference.trim();
|
|
160
184
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
161
185
|
setQuoteContactError(INVALID_EMAIL_MESSAGE);
|
|
@@ -196,7 +220,8 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
196
220
|
: "Your request couldn't be sent. Please try again, or contact us if it keeps happening.");
|
|
197
221
|
return;
|
|
198
222
|
}
|
|
199
|
-
|
|
223
|
+
if (total != null)
|
|
224
|
+
track("quote_requested", { items: pricedItems.length, total: Math.round(total * 100) / 100 });
|
|
200
225
|
setQuoteRequestSent(true);
|
|
201
226
|
onQuoteRequested?.();
|
|
202
227
|
window.scrollTo(0, 0);
|
|
@@ -219,10 +244,12 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
219
244
|
wasteFactorEnabled: wasteEnabled,
|
|
220
245
|
wasteFactorOverride: Math.max(0, Number.parseFloat(wastePercent) || 0) / 100,
|
|
221
246
|
});
|
|
222
|
-
const vat = subtotal * VAT_RATE;
|
|
223
|
-
const total = subtotal +
|
|
247
|
+
const vat = subtotal == null ? null : subtotal * VAT_RATE;
|
|
248
|
+
const total = subtotal == null ? null : subtotal * (1 + VAT_RATE);
|
|
249
|
+
const unpriced = pricedItems.filter(({ pricing }) => pricing.lineTotal == null).length;
|
|
250
|
+
const resolvingCount = Object.keys(resolvingConversionInputs).length;
|
|
224
251
|
// Once per visit to the basket, with the figures it opened with.
|
|
225
|
-
const openedWith = useRef({ items: pricedItems.length, total: Math.round(total * 100) / 100 });
|
|
252
|
+
const openedWith = useRef({ items: pricedItems.length, ...(total == null ? {} : { total: Math.round(total * 100) / 100 }) });
|
|
226
253
|
useEffect(() => {
|
|
227
254
|
track("basket_viewed", openedWith.current);
|
|
228
255
|
}, [track]);
|
|
@@ -247,7 +274,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
247
274
|
return (_jsxs("article", { className: "basket-card", children: [_jsxs("div", { className: "basket-card-top", children: [_jsx("img", { className: "basket-card-thumb", src: productImageSrc(item.description, item.productId, item.pdpPhotoUrl), alt: "", width: 48, height: 48 }), _jsxs("div", { className: "basket-card-info", children: [_jsx("div", { className: "bom-component-name", children: item.description }), rawDiffersFromOrder && pricing.costingMode !== "review" && (_jsxs("button", { type: "button", className: "basket-calc-toggle", onClick: () => setCalcOpen((prev) => ({
|
|
248
275
|
...prev,
|
|
249
276
|
[item.lineId]: !prev[item.lineId],
|
|
250
|
-
})), "aria-expanded": !!calcOpen[item.lineId], children: ["How we calculated this", _jsx("span", { className: `basket-calc-toggle-chevron${calcOpen[item.lineId] ? " basket-calc-toggle-chevron--open" : ""}`, children: _jsx(ChevronIcon, {}) })] }))] }), _jsx("div", { className: "basket-card-actions", children: _jsx("button", { type: "button", className: "mf-remove-small-btn basket-card-remove", onClick: () => onRemove(item.lineId), "aria-label": `Remove ${item.description}`, children: _jsx(TrashIcon, {}) }) })] }), internal && (_jsxs("div", { className: "results-detail-block basket-internal-detail", children: [_jsxs("dl", { className: "results-detail-grid", children: [_jsxs("div", { children: [_jsx("dt", { children: "Conversion type" }), _jsx("dd", { children: item.conversionType ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Conversion confidence" }), _jsx("dd", { children: item.conversionConfidence ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Sales unit" }), _jsx("dd", { children: item.salesUnitSize != null ? (_jsxs(_Fragment, { children: [item.salesUnitSize, " ", renderDisplayUnit(item.salesUnitSizeUom, item.salesUnitSize)] })) : ("-") })] }), _jsxs("div", { children: [_jsx("dt", { children: "Coverage" }), _jsx("dd", { children: item.coverageQty != null ? (_jsxs(_Fragment, { children: [item.coverageQty, " ", renderDisplayUnit(item.coverageUom, item.coverageQty)] })) : ("-") })] }), _jsxs("div", { children: [_jsx("dt", { children: "Waste factor" }), _jsx("dd", { children: item.wasteFactorDefault ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Rounding rule" }), _jsx("dd", { children: item.roundingRule ?? "-" })] })] }), item.conversionNotes && _jsx("p", { className: "results-rerank-note", children: item.conversionNotes })] })), pricing.costingMode === "review" ? (_jsxs("p", { className: "basket-calc-text basket-calc-text--review", children: [_jsx("strong", { children: "Quantity needs confirmation." }), " ", reviewMessage(pricing)] })) : rawDiffersFromOrder && calcOpen[item.lineId] ? (_jsx("p", { className: "basket-calc-text", children: orderSummary(item, pricing) })) : null, _jsxs("div", { className: "basket-card-stats", children: [_jsxs("div", { className: "basket-stat", children: [_jsx("span", { className: "basket-stat-label", children: "Requested qty" }), _jsxs("div", { className: "basket-stat-value", children: [_jsxs("div", { className: "basket-qty-field", children: [_jsx("input", { className: "bom-qty-edit", type: "number", inputMode: "decimal", min: "0", placeholder: "-", value: requestedQtyValue, disabled: requestedLocked, onChange: (e) => setQtyDrafts((prev) => ({
|
|
277
|
+
})), "aria-expanded": !!calcOpen[item.lineId], children: ["How we calculated this", _jsx("span", { className: `basket-calc-toggle-chevron${calcOpen[item.lineId] ? " basket-calc-toggle-chevron--open" : ""}`, children: _jsx(ChevronIcon, {}) })] }))] }), _jsx("div", { className: "basket-card-actions", children: _jsx("button", { type: "button", className: "mf-remove-small-btn basket-card-remove", onClick: () => onRemove(item.lineId), "aria-label": `Remove ${item.description}`, children: _jsx(TrashIcon, {}) }) })] }), internal && (_jsxs("div", { className: "results-detail-block basket-internal-detail", children: [_jsxs("dl", { className: "results-detail-grid", children: [_jsxs("div", { children: [_jsx("dt", { children: "Conversion type" }), _jsx("dd", { children: item.conversionType ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Conversion confidence" }), _jsx("dd", { children: item.conversionConfidence ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Sales unit" }), _jsx("dd", { children: item.salesUnitSize != null ? (_jsxs(_Fragment, { children: [item.salesUnitSize, " ", renderDisplayUnit(item.salesUnitSizeUom, item.salesUnitSize)] })) : ("-") })] }), _jsxs("div", { children: [_jsx("dt", { children: "Coverage" }), _jsx("dd", { children: item.coverageQty != null ? (_jsxs(_Fragment, { children: [item.coverageQty, " ", renderDisplayUnit(item.coverageUom, item.coverageQty)] })) : ("-") })] }), _jsxs("div", { children: [_jsx("dt", { children: "Waste factor" }), _jsx("dd", { children: item.wasteFactorDefault ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Rounding rule" }), _jsx("dd", { children: item.roundingRule ?? "-" })] })] }), item.conversionNotes && _jsx("p", { className: "results-rerank-note", children: item.conversionNotes })] })), pricing.costingMode === "review" ? (_jsxs("p", { className: "basket-calc-text basket-calc-text--review", children: [_jsx("strong", { children: resolvingConversionInputs[item.lineId] === conversionInputKey(item) ? "Calculating order quantity…" : "Quantity needs confirmation." }), " ", reviewMessage(pricing)] })) : rawDiffersFromOrder && calcOpen[item.lineId] ? (_jsx("p", { className: "basket-calc-text", children: orderSummary(item, pricing) })) : null, _jsxs("div", { className: "basket-card-stats", children: [_jsxs("div", { className: "basket-stat", children: [_jsx("span", { className: "basket-stat-label", children: "Requested qty" }), _jsxs("div", { className: "basket-stat-value", children: [_jsxs("div", { className: "basket-qty-field", children: [_jsx("input", { className: "bom-qty-edit", type: "number", inputMode: "decimal", min: "0", placeholder: "-", value: requestedQtyValue, disabled: requestedLocked, onChange: (e) => setQtyDrafts((prev) => ({
|
|
251
278
|
...prev,
|
|
252
279
|
[item.lineId]: e.target.value,
|
|
253
280
|
})), onBlur: (e) => commitRequestedQty(item.lineId, e.target.value), onKeyDown: (e) => {
|
|
@@ -260,11 +287,11 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
260
287
|
if (e.key === "Enter")
|
|
261
288
|
e.target.blur();
|
|
262
289
|
}, "aria-label": `${item.description} order quantity` }), _jsxs("div", { className: "basket-qty-arrows", children: [_jsx("button", { type: "button", className: "basket-qty-arrow", onClick: () => stepSellUnits(item.lineId, parseFloat(sellUnitsDrafts[item.lineId] ?? String(pricing.requiredSellUnits ?? "0")) || 0, 1), "aria-label": `Increase ${item.description} order quantity`, children: _jsx(ChevronIcon, {}) }), _jsx("button", { type: "button", className: "basket-qty-arrow basket-qty-arrow--down", onClick: () => stepSellUnits(item.lineId, parseFloat(sellUnitsDrafts[item.lineId] ?? String(pricing.requiredSellUnits ?? "0")) || 0, -1), "aria-label": `Decrease ${item.description} order quantity`, children: _jsx(ChevronIcon, {}) })] })] }), _jsx("span", { className: "basket-metric-unit", children: renderDisplayUnit(pricing.sellUnit, parseFloat(sellUnitsDrafts[item.lineId] ?? String(pricing.requiredSellUnits ?? "0")) || 0) })] })] }), _jsxs("div", { className: "basket-stat basket-stat--total", children: [_jsx("span", { className: "basket-stat-label", children: "Total" }), _jsxs("div", { className: "basket-stat-value", children: [_jsx("strong", { children: pricing.lineTotal != null ? fmtCurrency(pricing.lineTotal) : "-" }), pricing.lineTotal != null && _jsx("span", { className: "basket-metric-unit", children: "ex. VAT" })] })] })] })] }, item.lineId));
|
|
263
|
-
}) }), totalPages > 1 && (_jsxs("div", { className: "results-pagination", children: [_jsxs("span", { className: "results-pagination-count", children: ["Showing ", (currentPage - 1) * PAGE_SIZE + 1, "\u2013", Math.min(currentPage * PAGE_SIZE, pricedItems.length), " of ", pricedItems.length] }), _jsxs("div", { className: "results-pagination-controls", children: [_jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => setPage(currentPage - 1), disabled: currentPage === 1, children: "Previous" }), _jsxs("span", { className: "results-pagination-page", children: ["Page ", currentPage, " of ", totalPages] }), _jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => setPage(currentPage + 1), disabled: currentPage === totalPages, children: "Next" })] })] }))] }), _jsxs("aside", { className: "basket-sidebar", children: [_jsx("div", { className: "basket-sidebar-title", children: qzaBasketLayout ? "Quote request summary" : "Order summary" }), _jsxs("div", { className: "basket-sidebar-row", children: [_jsx("span", { children: qzaBasketLayout ? "List price (ex. VAT)" : "Subtotal (ex. VAT)" }), _jsx("span", { children: fmtCurrency(subtotal) })] }), _jsxs("div", { className: "basket-sidebar-row", children: [_jsx("span", { children: qzaBasketLayout ? "VAT (20%)" : "VAT (20%)" }), _jsx("span", { children: fmtCurrency(vat) })] }), _jsxs("div", { className: "basket-sidebar-row basket-sidebar-row--total", children: [_jsx("span", { children: qzaBasketLayout ? "List price (inc. VAT)" : "Total" }), _jsx("span", { children: fmtCurrency(total) })] }), !qzaBasketLayout && (_jsx("div", { className: "basket-summary-waste", children: _jsxs("div", { className: "basket-waste-control", children: [_jsxs("label", { className: "basket-global-waste", children: [_jsx("input", { type: "checkbox", checked: wasteEnabled, onChange: (e) => setWasteEnabled(e.target.checked) }), _jsx("span", { children: "Add waste factor" })] }), _jsxs("label", { className: "basket-global-waste-percent", children: [_jsx("input", { type: "number", inputMode: "decimal", min: "0", step: "0.1", value: wastePercent, onChange: (e) => setWastePercent(e.target.value), disabled: !wasteEnabled, "aria-label": "Waste factor percent" }), _jsx("span", { children: "%" })] })] }) })), _jsx("label", { htmlFor: `${uid}-basket-reference`, className: "basket-sidebar-label", children: qzaBasketLayout ? "Enter email for your sharper quote" : "Your reference (optional)" }), _jsx("input", { id: `${uid}-basket-reference`, type: qzaBasketLayout ? "email" : "text", className: "basket-sidebar-input", value: reference, onChange: (e) => {
|
|
290
|
+
}) }), totalPages > 1 && (_jsxs("div", { className: "results-pagination", children: [_jsxs("span", { className: "results-pagination-count", children: ["Showing ", (currentPage - 1) * PAGE_SIZE + 1, "\u2013", Math.min(currentPage * PAGE_SIZE, pricedItems.length), " of ", pricedItems.length] }), _jsxs("div", { className: "results-pagination-controls", children: [_jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => setPage(currentPage - 1), disabled: currentPage === 1, children: "Previous" }), _jsxs("span", { className: "results-pagination-page", children: ["Page ", currentPage, " of ", totalPages] }), _jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => setPage(currentPage + 1), disabled: currentPage === totalPages, children: "Next" })] })] }))] }), _jsxs("aside", { className: "basket-sidebar", children: [_jsx("div", { className: "basket-sidebar-title", children: qzaBasketLayout ? "Quote request summary" : "Order summary" }), _jsxs("div", { className: "basket-sidebar-row", children: [_jsx("span", { children: qzaBasketLayout ? "List price (ex. VAT)" : "Subtotal (ex. VAT)" }), _jsx("span", { children: subtotal == null ? "To confirm" : fmtCurrency(subtotal) })] }), _jsxs("div", { className: "basket-sidebar-row", children: [_jsx("span", { children: qzaBasketLayout ? "VAT (20%)" : "VAT (20%)" }), _jsx("span", { children: vat == null ? "To confirm" : fmtCurrency(vat) })] }), _jsxs("div", { className: "basket-sidebar-row basket-sidebar-row--total", children: [_jsx("span", { children: qzaBasketLayout ? "List price (inc. VAT)" : "Total" }), _jsx("span", { children: total == null ? "To confirm" : fmtCurrency(total) })] }), !qzaBasketLayout && (_jsx("div", { className: "basket-summary-waste", children: _jsxs("div", { className: "basket-waste-control", children: [_jsxs("label", { className: "basket-global-waste", children: [_jsx("input", { type: "checkbox", checked: wasteEnabled, onChange: (e) => setWasteEnabled(e.target.checked) }), _jsx("span", { children: "Add waste factor" })] }), _jsxs("label", { className: "basket-global-waste-percent", children: [_jsx("input", { type: "number", inputMode: "decimal", min: "0", step: "0.1", value: wastePercent, onChange: (e) => setWastePercent(e.target.value), disabled: !wasteEnabled, "aria-label": "Waste factor percent" }), _jsx("span", { children: "%" })] })] }) })), _jsx("label", { htmlFor: `${uid}-basket-reference`, className: "basket-sidebar-label", children: qzaBasketLayout ? "Enter email for your sharper quote" : "Your reference (optional)" }), _jsx("input", { id: `${uid}-basket-reference`, type: qzaBasketLayout ? "email" : "text", className: "basket-sidebar-input", value: reference, onChange: (e) => {
|
|
264
291
|
setReference(e.target.value);
|
|
265
292
|
if (qzaBasketLayout)
|
|
266
293
|
setQuoteContactError("");
|
|
267
|
-
}, placeholder: qzaBasketLayout ? "you@example.com" : "PO number or job reference", "aria-invalid": qzaBasketLayout && quoteContactError === INVALID_EMAIL_MESSAGE, "aria-describedby": qzaBasketLayout && quoteContactError ? `${uid}-basket-reference-error` : undefined }), qzaBasketLayout && quoteContactError && (_jsx("p", { id: `${uid}-basket-reference-error`, className: "basket-quote-request-error", role: "alert", children: quoteContactError })), qzaBasketLayout ? (_jsx("button", { type: "button", className: "cta-btn", onClick: () => void submitQuoteRequest(), disabled: quoteRequestSending, "aria-busy": quoteRequestSending, children: _jsx("span", { children: quoteRequestSending ? "Sending…" : "Request our best quote" }) })) : (_jsx("button", { className: "cta-btn", onClick: () => {
|
|
294
|
+
}, placeholder: qzaBasketLayout ? "you@example.com" : "PO number or job reference", "aria-invalid": qzaBasketLayout && quoteContactError === INVALID_EMAIL_MESSAGE, "aria-describedby": qzaBasketLayout && quoteContactError ? `${uid}-basket-reference-error` : undefined }), qzaBasketLayout && quoteContactError && (_jsx("p", { id: `${uid}-basket-reference-error`, className: "basket-quote-request-error", role: "alert", children: quoteContactError })), qzaBasketLayout ? (_jsx("button", { type: "button", className: "cta-btn", onClick: () => void submitQuoteRequest(), disabled: quoteRequestSending || unpriced > 0 || resolvingCount > 0, "aria-busy": quoteRequestSending, children: _jsx("span", { children: quoteRequestSending ? "Sending…" : "Request our best quote" }) })) : (_jsx("button", { className: "cta-btn", onClick: () => {
|
|
268
295
|
track("downloaded", { type: "basket", items: items.length });
|
|
269
296
|
void exportBasketExcel(items);
|
|
270
297
|
}, "aria-label": internal ? "Download selection" : "Download basket", children: _jsx("span", { children: internal ? "Download Quote" : "Buy Now" }) })), _jsxs("p", { className: "basket-sidebar-disclaimer", children: [_jsx("span", { "aria-hidden": "true", children: "\u26A0\uFE0F" }), qzaBasketLayout
|
|
@@ -16,5 +16,6 @@ export declare function resolveBest(result: MatchResult | undefined, overriddenP
|
|
|
16
16
|
export declare function candidateFromCart(item: CartItem): MatchCandidate;
|
|
17
17
|
export declare function refreshedCartItem(refreshed: CartItem, existing: CartItem): CartItem;
|
|
18
18
|
export declare function displayLineLabel(item: LineItem, allItems: LineItem[]): string;
|
|
19
|
+
export declare function formatResultsTotal(totalValue: number | null, needsPricingCount: number): string | null;
|
|
19
20
|
export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart, onAddToCart, onRemoveFromCart, onGoToBasket, cartSeeded, onCartSeeded, reviewGateComplete, onReviewGateComplete, internal }: ResultsStageProps): import("react").JSX.Element;
|
|
20
21
|
export {};
|
|
@@ -153,6 +153,9 @@ export function displayLineLabel(item, allItems) {
|
|
|
153
153
|
const label = typeof item.customer_display_label === "string" ? item.customer_display_label.trim() : "";
|
|
154
154
|
return label || original || "(no description)";
|
|
155
155
|
}
|
|
156
|
+
export function formatResultsTotal(totalValue, needsPricingCount) {
|
|
157
|
+
return needsPricingCount > 0 || totalValue == null ? null : fmtCurrency(totalValue);
|
|
158
|
+
}
|
|
156
159
|
const LINK_GROUP_BUBBLE_STYLES = [
|
|
157
160
|
{ background: "#fff7ed", borderColor: "#fdba74", color: "#9a3412" },
|
|
158
161
|
{ background: "#eff6ff", borderColor: "#93c5fd", color: "#1d4ed8" },
|
|
@@ -379,7 +382,8 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
|
|
|
379
382
|
};
|
|
380
383
|
});
|
|
381
384
|
// Match BasketStage: catalogue waste defaults are opt-in in the basket UI.
|
|
382
|
-
|
|
385
|
+
const hasPendingReview = rows.some((row) => !row.unmatchable && !row.best && !skippedReviewIds.has(row.item.line_id));
|
|
386
|
+
let totalValue = hasPendingReview ? null : getCartSubtotal(Object.values(cart), { wasteFactorEnabled: false, wasteFactorOverride: 0 });
|
|
383
387
|
let needsPricingCount = 0;
|
|
384
388
|
for (const row of rows) {
|
|
385
389
|
if (row.unmatchable || row.decision?.include_in_quote !== true || !row.best)
|
|
@@ -390,6 +394,7 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
|
|
|
390
394
|
}
|
|
391
395
|
if (needsPricingCount > 0)
|
|
392
396
|
totalValue = null;
|
|
397
|
+
const totalLabel = formatResultsTotal(totalValue, needsPricingCount);
|
|
393
398
|
const hasBestAlternative = (row) => !row.unmatchable && !row.best && row.decision?.technical_classification !== "no_suitable_candidate" && recommendedCandidates(row.result).length > 0;
|
|
394
399
|
const previewCandidate = (row) => row.best ?? recommendedCandidates(row.result)[0];
|
|
395
400
|
const reviewRequiredRows = rows.filter((row) => !row.unmatchable && !row.best);
|
|
@@ -684,7 +689,7 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
|
|
|
684
689
|
})();
|
|
685
690
|
const activeReviewCartMatches = !!activeReviewRow?.best && cart[activeReviewRow.item.line_id]?.productId === activeReviewRow.best.product_id;
|
|
686
691
|
const activeReviewHasPendingUpdate = !!activeReviewRow?.best && !!cart[activeReviewRow.item.line_id] && !activeReviewCartMatches;
|
|
687
|
-
return (_jsxs("div", { className: "wizard-step entry-stage", children: [_jsxs("div", { className: `quick-order-card${internal ? "" : " quick-order-card--customer"}`, children: [_jsx("div", { className: `quick-order-hero${internal ? "" : " quick-order-hero--customer"}`, children: internal ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "quick-order-eyebrow", children: "Construction Services" }), _jsxs("div", { className: "quick-order-hero-title-row", children: [_jsx("h1", { children: "Your Results" }), _jsxs("span", { className: "summary-chip summary-chip--scope", children: [_jsx("strong", { children:
|
|
692
|
+
return (_jsxs("div", { className: "wizard-step entry-stage", children: [_jsxs("div", { className: `quick-order-card${internal ? "" : " quick-order-card--customer"}`, children: [_jsx("div", { className: `quick-order-hero${internal ? "" : " quick-order-hero--customer"}`, children: internal ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "quick-order-eyebrow", children: "Construction Services" }), _jsxs("div", { className: "quick-order-hero-title-row", children: [_jsx("h1", { children: "Your Results" }), _jsxs("span", { className: "summary-chip summary-chip--scope", children: [_jsx("strong", { children: totalLabel ?? "Review quantities" }), " Est. total"] })] }), _jsx("p", { children: "Each line item from your schedule matched against your catalogue, ranked by confidence." })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx("h1", { children: "Review your matched products" }), _jsxs("p", { children: ["Check you're happy with these items, then click ", qzaQuoteRequest ? "View Quote" : "View Basket", " to continue."] })] }), _jsxs("button", { type: "button", className: "results-total-action", onClick: onGoToBasket, "aria-label": qzaQuoteRequest ? "View quote" : "View basket", children: [_jsxs("span", { children: [_jsx("small", { children: qzaQuoteRequest ? "View Quote" : "View Basket" }), totalLabel != null && _jsx("strong", { children: totalLabel })] }), _jsx("span", { className: "arrow", children: _jsx(ForwardArrow, {}) })] })] })) }), _jsxs("div", { className: "quick-order-panel", style: { paddingTop: 16 }, children: [debugEnabled && (_jsx("div", { className: "results-summary", style: { marginBottom: 12 }, children: _jsx("button", { type: "button", className: "bom-toolbar-btn", "aria-pressed": showTimings, onClick: () => setShowTimings((value) => !value), children: showTimings ? "Hide timings" : "Show timings" }) })), debugEnabled && showTimings && _jsx(TimingBreakdown, { ...timings }), !qzaQuoteRequest && (_jsx("div", { className: "results-toolbar-row", children: !activeReviewRow && (_jsxs("div", { className: "results-summary", style: { marginBottom: 0 }, children: [needsPricingCount > 0 && (_jsxs("span", { className: "summary-chip summary-chip--neutral", children: [_jsx("strong", { children: needsPricingCount }), " need pricing"] })), _jsxs("div", { style: {
|
|
688
693
|
display: "flex",
|
|
689
694
|
gap: 8,
|
|
690
695
|
flexWrap: "wrap",
|