@metron-labs/quote-react 0.1.42 → 0.1.44
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 +37 -5
- package/dist/app/stages/BasketStage.d.ts +3 -1
- package/dist/app/stages/BasketStage.js +13 -7
- package/dist/app/stages/EntryStage.js +3 -0
- package/dist/app/stages/ScheduleResultsStage.js +5 -0
- package/dist/embed/quote.js +38 -33
- package/dist/lib/boq/inputLimits.d.ts +2 -0
- package/dist/lib/boq/inputLimits.js +2 -0
- package/dist/lib/config.js +2 -1
- package/dist/lib/useEscapeKey.d.ts +2 -0
- package/dist/lib/useEscapeKey.js +20 -0
- package/dist/styles.css +25 -20
- package/dist/util/pricing.js +3 -2
- package/package.json +1 -1
package/dist/app/QuickQuote.js
CHANGED
|
@@ -17,7 +17,7 @@ const SAVED_QUOTE_KEY = "metron-quick-quote";
|
|
|
17
17
|
export default function QuickQuote(props) {
|
|
18
18
|
return (_jsx(CartProvider, { children: _jsx(QuickQuoteInner, { ...props }) }));
|
|
19
19
|
}
|
|
20
|
-
function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = false
|
|
20
|
+
function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = false }) {
|
|
21
21
|
const [stage, setStage] = useState("entry");
|
|
22
22
|
const [entryMode, setEntryMode] = useState(null);
|
|
23
23
|
const [error, setError] = useState("");
|
|
@@ -52,7 +52,27 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
52
52
|
void fetch(apiUrl("/api/catalogue/resolve-conversions"), {
|
|
53
53
|
method: "POST",
|
|
54
54
|
headers: { "Content-Type": "application/json", ...clientHeaders() },
|
|
55
|
-
body: JSON.stringify({
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
lines: unresolved.map((item) => ({
|
|
57
|
+
line_id: item.lineId,
|
|
58
|
+
product_id: item.productId,
|
|
59
|
+
requested_description: item.requestedDescription ?? item.description,
|
|
60
|
+
requested_qty: item.qty,
|
|
61
|
+
requested_uom: item.unit || null,
|
|
62
|
+
selected_description: item.description,
|
|
63
|
+
selected_uoi: item.uoi,
|
|
64
|
+
selected_uoi_count: item.uoiCount,
|
|
65
|
+
linked_context: item.linkedContext ?? [],
|
|
66
|
+
selected_conversion: {
|
|
67
|
+
sales_uom_normalised: item.salesUomNormalised,
|
|
68
|
+
conversion_type: item.conversionType,
|
|
69
|
+
sales_unit_size: item.salesUnitSize,
|
|
70
|
+
sales_unit_size_uom: item.salesUnitSizeUom,
|
|
71
|
+
coverage_qty: item.coverageQty,
|
|
72
|
+
coverage_uom: item.coverageUom,
|
|
73
|
+
},
|
|
74
|
+
})),
|
|
75
|
+
}),
|
|
56
76
|
})
|
|
57
77
|
.then((response) => (response.ok ? readJson(response) : null))
|
|
58
78
|
.then((payload) => {
|
|
@@ -146,8 +166,11 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
146
166
|
const hash = window.location.hash;
|
|
147
167
|
if (hash === "#quote-request")
|
|
148
168
|
setStage("basket");
|
|
149
|
-
else if (hash === "#matched-products" && scheduleResult)
|
|
169
|
+
else if (hash === "#matched-products" && scheduleResult) {
|
|
170
|
+
// Opening an entry tab after browser-back clears the mode; results only render in schedule mode.
|
|
171
|
+
setEntryMode("schedule");
|
|
150
172
|
setStage("results");
|
|
173
|
+
}
|
|
151
174
|
else
|
|
152
175
|
setStage("entry");
|
|
153
176
|
// Turbo's popstate listener (registered before ours, so it can't be pre-empted) stamps its
|
|
@@ -236,6 +259,10 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
236
259
|
const lines = job.result.lineItems.length;
|
|
237
260
|
const matched = job.result.results.filter((result) => result.candidates.length > 0).length;
|
|
238
261
|
track("results_shown", { lines, matched, unmatched: lines - matched });
|
|
262
|
+
// A new schedule is a new quote. Browser-back to the entry screen only swaps the
|
|
263
|
+
// screen, so the previous quote's basket is still here - drop it.
|
|
264
|
+
clearCart();
|
|
265
|
+
setCartSeeded(false);
|
|
239
266
|
setScheduleReviewGateComplete(false);
|
|
240
267
|
setScheduleResult(job.result);
|
|
241
268
|
writeQuoteHistory("results");
|
|
@@ -250,7 +277,7 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
250
277
|
schedulePollCancelRef.current?.();
|
|
251
278
|
schedulePollCancelRef.current = null;
|
|
252
279
|
}
|
|
253
|
-
}, [writeQuoteHistory, track]);
|
|
280
|
+
}, [writeQuoteHistory, track, clearCart]);
|
|
254
281
|
// Entry-page "paste your line items" shortcut: skip the upload step entirely,
|
|
255
282
|
// wrap the pasted text as a .txt — verbatim, so the copy archived in blob storage reads
|
|
256
283
|
// exactly as pasted — and feed it straight into the same match pipeline.
|
|
@@ -267,5 +294,10 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
267
294
|
setEntryMode("schedule");
|
|
268
295
|
await runScheduleMatch(file);
|
|
269
296
|
}, [runScheduleMatch]);
|
|
270
|
-
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" &&
|
|
297
|
+
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 }), 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: () => {
|
|
298
|
+
setScheduleResult(null);
|
|
299
|
+
setScheduleReviewGateComplete(false);
|
|
300
|
+
setCartSeeded(false);
|
|
301
|
+
clearCart();
|
|
302
|
+
}, internal: internal }))] }) }) }));
|
|
271
303
|
}
|
|
@@ -8,7 +8,9 @@ interface BasketStageProps {
|
|
|
8
8
|
onBack: () => void;
|
|
9
9
|
/** Clears the basket and returns to the start, from the quote-request confirmation page. */
|
|
10
10
|
onStartNewQuote: () => void;
|
|
11
|
+
/** Called once the quote request is sent; the confirmation stays up whatever it clears. */
|
|
12
|
+
onQuoteRequested?: () => void;
|
|
11
13
|
internal?: boolean;
|
|
12
14
|
}
|
|
13
|
-
export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onResolveConversions, onRemove, onBack, onStartNewQuote, internal }: BasketStageProps): import("react").JSX.Element;
|
|
15
|
+
export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onResolveConversions, onRemove, onBack, onStartNewQuote, onQuoteRequested, internal }: BasketStageProps): import("react").JSX.Element;
|
|
14
16
|
export {};
|
|
@@ -9,6 +9,7 @@ import { getCartSubtotal, getPricingBreakdown } from "../../util/pricing";
|
|
|
9
9
|
import { exportBasketExcel } from "../../util/exportScheduleExcel";
|
|
10
10
|
import { renderDisplayUnit, unitDisplayText } from "../components/UnitDisplay";
|
|
11
11
|
import { clientHeaders, getBoqClientId, readJson } from "../../lib/api/boqClient";
|
|
12
|
+
import { MAX_ORDER_QTY } from "../../lib/boq/inputLimits";
|
|
12
13
|
// ponytail: flat UK VAT rate, matching fmtCurrency's GBP-only assumption elsewhere in this app.
|
|
13
14
|
const VAT_RATE = 0.2;
|
|
14
15
|
const PAGE_SIZE = 10;
|
|
@@ -36,7 +37,7 @@ function TrashIcon() {
|
|
|
36
37
|
function ChevronIcon() {
|
|
37
38
|
return (_jsx("svg", { width: "8", height: "5", viewBox: "0 0 8 5", fill: "none", children: _jsx("path", { d: "M1 4L4 1L7 4", stroke: "currentColor", strokeWidth: "1.4", strokeLinecap: "round", strokeLinejoin: "round" }) }));
|
|
38
39
|
}
|
|
39
|
-
export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onResolveConversions, onRemove, onBack, onStartNewQuote, internal = false }) {
|
|
40
|
+
export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onResolveConversions, onRemove, onBack, onStartNewQuote, onQuoteRequested, internal = false }) {
|
|
40
41
|
// Both widgets can render a basket on the same page, so element ids are made unique.
|
|
41
42
|
const uid = useId();
|
|
42
43
|
const qzaBasketLayout = !internal && getBoqClientId() === "QZA6-MRA4";
|
|
@@ -128,11 +129,11 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
128
129
|
function commitRequestedQty(lineId, raw) {
|
|
129
130
|
const n = parseFloat(raw);
|
|
130
131
|
if (!isNaN(n) && n >= 0)
|
|
131
|
-
onUpdateQty(lineId, n);
|
|
132
|
+
onUpdateQty(lineId, Math.min(n, MAX_ORDER_QTY));
|
|
132
133
|
clearQtyDraft(lineId);
|
|
133
134
|
}
|
|
134
135
|
function stepRequestedQty(lineId, current, delta) {
|
|
135
|
-
onUpdateQty(lineId, Math.max(0, current + delta));
|
|
136
|
+
onUpdateQty(lineId, Math.min(MAX_ORDER_QTY, Math.max(0, current + delta)));
|
|
136
137
|
clearQtyDraft(lineId);
|
|
137
138
|
}
|
|
138
139
|
function clearSellUnitsDraft(lineId) {
|
|
@@ -145,11 +146,11 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
145
146
|
function commitSellUnits(lineId, raw) {
|
|
146
147
|
const n = parseFloat(raw);
|
|
147
148
|
if (!isNaN(n) && n >= 0)
|
|
148
|
-
onUpdateSellUnits(lineId, n);
|
|
149
|
+
onUpdateSellUnits(lineId, Math.min(n, MAX_ORDER_QTY));
|
|
149
150
|
clearSellUnitsDraft(lineId);
|
|
150
151
|
}
|
|
151
152
|
function stepSellUnits(lineId, current, delta) {
|
|
152
|
-
onUpdateSellUnits(lineId, Math.max(0, current + delta));
|
|
153
|
+
onUpdateSellUnits(lineId, Math.min(MAX_ORDER_QTY, Math.max(0, current + delta)));
|
|
153
154
|
clearSellUnitsDraft(lineId);
|
|
154
155
|
}
|
|
155
156
|
// Sends the basket to the merchant as a quote request (POST /v1/quote-requests). Success only
|
|
@@ -195,6 +196,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
195
196
|
}
|
|
196
197
|
track("quote_requested", { items: pricedItems.length, total: Math.round(total * 100) / 100 });
|
|
197
198
|
setQuoteRequestSent(true);
|
|
199
|
+
onQuoteRequested?.();
|
|
198
200
|
window.scrollTo(0, 0);
|
|
199
201
|
}
|
|
200
202
|
catch {
|
|
@@ -228,11 +230,15 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
228
230
|
if (quoteRequestSent) {
|
|
229
231
|
return (_jsx("div", { className: "wizard-step entry-stage", children: _jsxs("div", { className: "quote-request-sent", role: "status", children: [_jsx("div", { className: "quote-request-sent-icon", "aria-hidden": "true", children: _jsx("svg", { width: "40", height: "40", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "5 12.5 10 17.5 19 7" }) }) }), _jsx("h1", { children: "Quote requested" }), _jsxs("p", { children: ["You're all done. We'll email your sharper price to ", _jsx("strong", { children: reference.trim() }), ", usually within one working day."] }), _jsx("button", { type: "button", className: "cta-btn", onClick: onStartNewQuote, children: _jsx("span", { children: "Start a new quote" }) }), _jsx("p", { className: "quote-request-sent-note", children: "Nothing is charged until you approve the quote." })] }) }));
|
|
230
232
|
}
|
|
233
|
+
// A customer's empty basket gets the same full-page treatment as the confirmation above.
|
|
234
|
+
if (!internal && items.length === 0) {
|
|
235
|
+
return (_jsx("div", { className: "wizard-step entry-stage", children: _jsxs("div", { className: "quote-request-sent", children: [_jsx("div", { className: "quote-request-sent-icon", "aria-hidden": "true", children: _jsxs("svg", { width: "40", height: "40", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M3 9h18l-2 11H5z" }), _jsx("path", { d: "M8 9l4-6 4 6" })] }) }), _jsx("h1", { children: qzaBasketLayout ? "Your quote request is empty" : "Your basket is empty" }), _jsx("p", { children: qzaBasketLayout ? "Start again with your list to build your quote." : "Start again with your list to fill your basket." }), _jsx("button", { type: "button", className: "cta-btn", onClick: onStartNewQuote, children: _jsx("span", { children: "Back to start" }) })] }) }));
|
|
236
|
+
}
|
|
231
237
|
return (_jsx("div", { className: `wizard-step entry-stage${qzaBasketLayout ? " basket-layout--qza6-mra4" : ""}`, children: _jsxs("div", { className: `quick-order-card${internal ? "" : " quick-order-card--customer"}`, children: [_jsxs("div", { className: `quick-order-hero${internal ? "" : " quick-order-hero--customer"}`, children: [internal && _jsx("div", { className: "quick-order-eyebrow", children: "Construction Services" }), _jsxs("div", { className: "basket-hero-copy", children: [qzaBasketLayout && (_jsxs("button", { type: "button", className: "cta-btn cta-btn-secondary basket-hero-back", onClick: onBack, children: [_jsx(BackArrow, {}), _jsx("span", { children: "Back to results" })] })), _jsxs("div", { className: "quick-order-hero-title-row", children: [_jsx("h1", { children: internal ? "Your Selection" : qzaBasketLayout ? "Request a quote" : "Your Basket" }), internal && (_jsxs("span", { className: "summary-chip summary-chip--scope", children: [_jsx("strong", { children: items.length }), " ", items.length === 1 ? "item" : "items"] }))] }), _jsx("p", { children: internal
|
|
232
238
|
? "Review the items you've added before exporting them for review."
|
|
233
239
|
: qzaBasketLayout
|
|
234
240
|
? "Check your items & quantities below, and we'll do our best to beat list prices."
|
|
235
|
-
: "Review your quantities and order summary before you buy." })] })] }), _jsx("div", { className: "quick-order-panel", style: { paddingTop: 16 }, children: items.length === 0 ? (_jsxs("div", { className: "quick-order-history-empty", children: [_jsx("div", { className: "basket-empty-icon", "aria-hidden": "true", children:
|
|
241
|
+
: "Review your quantities and order summary before you buy." })] })] }), _jsx("div", { className: "quick-order-panel", style: { paddingTop: 16 }, children: items.length === 0 ? (_jsxs("div", { className: "quick-order-history-empty", children: [_jsx("div", { className: "basket-empty-icon", "aria-hidden": "true", children: "\uD83D\uDCCB" }), _jsx("p", { children: "Your selection is empty." }), _jsx("p", { className: "basket-empty-hint", children: "Go back to your results and add quoted items." }), _jsx("div", { className: "btn-row", style: { marginTop: 20, justifyContent: "center" }, children: _jsx("button", { className: "cta-btn", style: { width: "auto", maxWidth: 280 }, onClick: onBack, "aria-label": "Back to results", children: _jsx("span", { children: "Back to Results" }) }) })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "basket-layout", children: [_jsxs("div", { className: "basket-main", children: [_jsx("div", { className: "basket-list", "aria-label": internal ? "Selection items" : qzaBasketLayout ? "Quote request items" : "Basket items", children: pagedItems.map(({ item, pricing }) => {
|
|
236
242
|
const requestedQtyValue = qtyDrafts[item.lineId] ?? String(pricing.requestedAmount);
|
|
237
243
|
const requestedLocked = pricing.requestedUnit === pricing.sellUnit || pricing.costingMode === "review";
|
|
238
244
|
const rawDiffersFromOrder = pricing.requiredSellUnits == null || pricing.requestedAmount !== pricing.requiredSellUnits || pricing.requestedUnit !== pricing.sellUnit;
|
|
@@ -252,7 +258,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
252
258
|
if (e.key === "Enter")
|
|
253
259
|
e.target.blur();
|
|
254
260
|
}, "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));
|
|
255
|
-
}) }), 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.
|
|
261
|
+
}) }), 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) => {
|
|
256
262
|
setReference(e.target.value);
|
|
257
263
|
if (qzaBasketLayout)
|
|
258
264
|
setQuoteContactError("");
|
|
@@ -8,6 +8,7 @@ import CloseIcon from "../../components/CloseIcon";
|
|
|
8
8
|
import { clientHeaders, readJson } from "../../lib/api/boqClient";
|
|
9
9
|
import { FILE_EXTENSIONS, isPhotoFile, shrinkPhoto, validateLines, validateUploadFile } from "../../lib/boq/inputLimits";
|
|
10
10
|
import { ProcessingContent } from "./ProcessingStage";
|
|
11
|
+
import { useEscapeKey } from "../../lib/useEscapeKey";
|
|
11
12
|
const UPLOAD_ACCEPT = [...FILE_EXTENSIONS.schedule, ...FILE_EXTENSIONS.photo].join(",");
|
|
12
13
|
const STEPS = {
|
|
13
14
|
paste: [
|
|
@@ -53,6 +54,8 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
|
|
|
53
54
|
setActiveTab(null);
|
|
54
55
|
onEntryModeChange(null);
|
|
55
56
|
};
|
|
57
|
+
// Like the close button, but not mid-match, where the modal is showing progress.
|
|
58
|
+
useEscapeKey(Boolean(activeTab) && !matching, closeWorkflow);
|
|
56
59
|
const applyUploadFile = (files) => {
|
|
57
60
|
if (!files?.length)
|
|
58
61
|
return;
|
|
@@ -12,6 +12,7 @@ import { productImageSrc } from "../../lib/productImage";
|
|
|
12
12
|
import { candidateToCartItem } from "../../lib/boq/toCartItem";
|
|
13
13
|
import { apiHeaders, getBoqClientId, readJson } from "../../lib/api/boqClient";
|
|
14
14
|
import { exportScheduleExcel } from "../../util/exportScheduleExcel";
|
|
15
|
+
import { useEscapeKey } from "../../lib/useEscapeKey";
|
|
15
16
|
import { getCartSubtotal, getPricingBreakdown } from "../../util/pricing";
|
|
16
17
|
const NO_SELECTION = "__none__";
|
|
17
18
|
const PAGE_SIZE = 24;
|
|
@@ -486,6 +487,10 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
|
|
|
486
487
|
setSelectedReviewId(null);
|
|
487
488
|
setReviewFlowActive(false);
|
|
488
489
|
};
|
|
490
|
+
// Escape does what each dialog's own close or cancel does. The review gate has neither - it's
|
|
491
|
+
// a required step - so Escape leaves it open.
|
|
492
|
+
useEscapeKey(confirmStartOver, () => setConfirmStartOver(false));
|
|
493
|
+
useEscapeKey(Boolean(activeReviewRow) && !confirmStartOver, () => closeReview());
|
|
489
494
|
const completeReviewAndReturnToGate = (lineId) => {
|
|
490
495
|
setReviewedReviewIds((ids) => new Set(ids).add(lineId));
|
|
491
496
|
if (pendingReviewRows.some((row) => row.item.line_id !== lineId)) {
|