@metron-labs/quote-react 0.1.43 → 0.1.45
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 +42 -7
- package/dist/app/stages/BasketStage.js +11 -6
- package/dist/app/stages/EntryStage.d.ts +13 -1
- package/dist/app/stages/EntryStage.js +89 -5
- package/dist/embed/quote.js +19 -12
- 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/styles.css +7 -0
- package/dist/util/pricing.js +3 -2
- package/package.json +1 -1
package/dist/app/QuickQuote.js
CHANGED
|
@@ -6,6 +6,7 @@ import { TrackContext, useTracker } from "../lib/analytics";
|
|
|
6
6
|
import { submitSchedule, pollScheduleJob } from "../lib/api/fetchSchedule";
|
|
7
7
|
import { pollJob } from "../lib/api/pollJob";
|
|
8
8
|
import EntryStage from "./stages/EntryStage";
|
|
9
|
+
import { ENTRY_DRAFT_KEY } from "./stages/EntryStage";
|
|
9
10
|
import ScheduleResultsStage from "./stages/ScheduleResultsStage";
|
|
10
11
|
import BasketStage from "./stages/BasketStage";
|
|
11
12
|
import { CartProvider, useCart } from "../lib/cart/CartContext";
|
|
@@ -22,11 +23,22 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
22
23
|
const [entryMode, setEntryMode] = useState(null);
|
|
23
24
|
const [error, setError] = useState("");
|
|
24
25
|
const schedulePollCancelRef = useRef(null);
|
|
26
|
+
const matchRunRef = useRef(0);
|
|
25
27
|
const [processingStep, setProcessingStep] = useState(0);
|
|
26
28
|
const [scheduleResult, setScheduleResult] = useState(null);
|
|
27
29
|
const [scheduleReviewGateComplete, setScheduleReviewGateComplete] = useState(false);
|
|
28
30
|
const { cart, addToCart, addManyToCart, removeFromCart, updateCartQty, updateCartSellUnits, updateCartConversions, clearCart } = useCart();
|
|
29
31
|
const [cartSeeded, setCartSeeded] = useState(false);
|
|
32
|
+
// The file is memory only - a refresh loses it (a File can't go in sessionStorage). The tab
|
|
33
|
+
// and text survive one: EntryStage keeps them under ENTRY_DRAFT_KEY.
|
|
34
|
+
const [entryDraft, setEntryDraft] = useState(null);
|
|
35
|
+
const clearEntryDraft = () => {
|
|
36
|
+
setEntryDraft(null);
|
|
37
|
+
try {
|
|
38
|
+
window.sessionStorage.removeItem(ENTRY_DRAFT_KEY);
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
};
|
|
30
42
|
const resolvedConversionInputs = useRef(new Map());
|
|
31
43
|
const rootRef = useRef(null);
|
|
32
44
|
const track = useTracker("quick-quote", rootRef);
|
|
@@ -166,8 +178,11 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
166
178
|
const hash = window.location.hash;
|
|
167
179
|
if (hash === "#quote-request")
|
|
168
180
|
setStage("basket");
|
|
169
|
-
else if (hash === "#matched-products" && scheduleResult)
|
|
181
|
+
else if (hash === "#matched-products" && scheduleResult) {
|
|
182
|
+
// Opening an entry tab after browser-back clears the mode; results only render in schedule mode.
|
|
183
|
+
setEntryMode("schedule");
|
|
170
184
|
setStage("results");
|
|
185
|
+
}
|
|
171
186
|
else
|
|
172
187
|
setStage("entry");
|
|
173
188
|
// Turbo's popstate listener (registered before ours, so it can't be pre-empted) stamps its
|
|
@@ -181,8 +196,8 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
181
196
|
return;
|
|
182
197
|
// Our own steps always get our state back, even if a stamp slipped through earlier -
|
|
183
198
|
// otherwise it sticks and every later visit to that step is a Turbo page visit.
|
|
184
|
-
if (hash === "#quote-request" || hash === "#matched-products") {
|
|
185
|
-
window.history.replaceState({ boqQuoteStage: hash === "#quote-request" ? "basket" : "results" }, "", href);
|
|
199
|
+
if (hash === "#quote-request" || hash === "#matched-products" || hash === "#quote-entry") {
|
|
200
|
+
window.history.replaceState({ boqQuoteStage: hash === "#quote-request" ? "basket" : hash === "#quote-entry" ? "entry" : "results" }, "", href);
|
|
186
201
|
return;
|
|
187
202
|
}
|
|
188
203
|
if (!original?.turbo && window.history.state?.turbo)
|
|
@@ -230,12 +245,14 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
230
245
|
setScheduleReviewGateComplete(false);
|
|
231
246
|
clearCart();
|
|
232
247
|
setCartSeeded(false);
|
|
248
|
+
clearEntryDraft();
|
|
233
249
|
};
|
|
234
250
|
// Schedule-upload path: match a schedule file's line items against the
|
|
235
251
|
// catalogue on the backend. Takes the file directly, so
|
|
236
252
|
// callers that have only just obtained it — e.g. the entry-page paste box —
|
|
237
253
|
// don't hit a stale-closure read.
|
|
238
254
|
const runScheduleMatch = useCallback(async (file, providedLines, archiveSource = true) => {
|
|
255
|
+
const run = ++matchRunRef.current;
|
|
239
256
|
setError("");
|
|
240
257
|
setProcessingStep(0);
|
|
241
258
|
let step = 0;
|
|
@@ -245,6 +262,8 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
245
262
|
};
|
|
246
263
|
try {
|
|
247
264
|
const { jobId } = await submitSchedule(file, providedLines, archiveSource);
|
|
265
|
+
if (run !== matchRunRef.current)
|
|
266
|
+
return;
|
|
248
267
|
// Poll rather than hold one request open: the pipeline can take several minutes,
|
|
249
268
|
// well past Azure Static Web Apps' 45s API timeout, but each individual poll is fast.
|
|
250
269
|
const { promise, cancel } = pollJob(() => pollScheduleJob(jobId), 5000, advanceFakeStep, "schedule");
|
|
@@ -256,21 +275,36 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
256
275
|
const lines = job.result.lineItems.length;
|
|
257
276
|
const matched = job.result.results.filter((result) => result.candidates.length > 0).length;
|
|
258
277
|
track("results_shown", { lines, matched, unmatched: lines - matched });
|
|
278
|
+
// A new schedule is a new quote. Browser-back to the entry screen only swaps the
|
|
279
|
+
// screen, so the previous quote's basket is still here - drop it.
|
|
280
|
+
clearCart();
|
|
281
|
+
setCartSeeded(false);
|
|
259
282
|
setScheduleReviewGateComplete(false);
|
|
260
283
|
setScheduleResult(job.result);
|
|
261
284
|
writeQuoteHistory("results");
|
|
262
285
|
setStage("results");
|
|
263
286
|
}
|
|
264
287
|
catch (err) {
|
|
288
|
+
if (run !== matchRunRef.current)
|
|
289
|
+
return;
|
|
265
290
|
track("error", { stage: "matching" });
|
|
266
291
|
setError(err?.message || "Failed to process schedule");
|
|
267
292
|
setStage("entry");
|
|
268
293
|
}
|
|
269
294
|
finally {
|
|
270
|
-
|
|
271
|
-
|
|
295
|
+
if (run === matchRunRef.current) {
|
|
296
|
+
schedulePollCancelRef.current?.();
|
|
297
|
+
schedulePollCancelRef.current = null;
|
|
298
|
+
}
|
|
272
299
|
}
|
|
273
|
-
}, [writeQuoteHistory, track]);
|
|
300
|
+
}, [writeQuoteHistory, track, clearCart]);
|
|
301
|
+
// Browser back out of the dialog mid-match: drop the match, or its results would turn up
|
|
302
|
+
// later on a screen the user has already left. The job still finishes server-side, unread.
|
|
303
|
+
const cancelMatch = useCallback(() => {
|
|
304
|
+
matchRunRef.current++;
|
|
305
|
+
schedulePollCancelRef.current?.();
|
|
306
|
+
schedulePollCancelRef.current = null;
|
|
307
|
+
}, []);
|
|
274
308
|
// Entry-page "paste your line items" shortcut: skip the upload step entirely,
|
|
275
309
|
// wrap the pasted text as a .txt — verbatim, so the copy archived in blob storage reads
|
|
276
310
|
// exactly as pasted — and feed it straight into the same match pipeline.
|
|
@@ -287,10 +321,11 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
287
321
|
setEntryMode("schedule");
|
|
288
322
|
await runScheduleMatch(file);
|
|
289
323
|
}, [runScheduleMatch]);
|
|
290
|
-
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: () => {
|
|
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: () => {
|
|
291
325
|
setScheduleResult(null);
|
|
292
326
|
setScheduleReviewGateComplete(false);
|
|
293
327
|
setCartSeeded(false);
|
|
294
328
|
clearCart();
|
|
329
|
+
clearEntryDraft();
|
|
295
330
|
}, internal: internal }))] }) }) }));
|
|
296
331
|
}
|
|
@@ -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;
|
|
@@ -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
|
|
@@ -229,11 +230,15 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
229
230
|
if (quoteRequestSent) {
|
|
230
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." })] }) }));
|
|
231
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
|
+
}
|
|
232
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
|
|
233
238
|
? "Review the items you've added before exporting them for review."
|
|
234
239
|
: qzaBasketLayout
|
|
235
240
|
? "Check your items & quantities below, and we'll do our best to beat list prices."
|
|
236
|
-
: "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 }) => {
|
|
237
242
|
const requestedQtyValue = qtyDrafts[item.lineId] ?? String(pricing.requestedAmount);
|
|
238
243
|
const requestedLocked = pricing.requestedUnit === pricing.sellUnit || pricing.costingMode === "review";
|
|
239
244
|
const rawDiffersFromOrder = pricing.requiredSellUnits == null || pricing.requestedAmount !== pricing.requiredSellUnits || pricing.requestedUnit !== pricing.sellUnit;
|
|
@@ -253,7 +258,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
253
258
|
if (e.key === "Enter")
|
|
254
259
|
e.target.blur();
|
|
255
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));
|
|
256
|
-
}) }), 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) => {
|
|
257
262
|
setReference(e.target.value);
|
|
258
263
|
if (qzaBasketLayout)
|
|
259
264
|
setQuoteContactError("");
|
|
@@ -3,6 +3,18 @@ interface EntryStageProps {
|
|
|
3
3
|
onPasteSchedule: (text: string, providedLines?: PhotoExtractedLine[], archiveSource?: boolean) => Promise<void> | void;
|
|
4
4
|
onUploadSchedule: (file: File) => Promise<void> | void;
|
|
5
5
|
onEntryModeChange: (mode: EntryMode) => void;
|
|
6
|
+
/** The last submitted input, so stepping back from the matches reopens the dialog with it. */
|
|
7
|
+
draft?: EntryDraft | null;
|
|
8
|
+
onSubmitDraft?: (draft: EntryDraft) => void;
|
|
9
|
+
/** Browser back out of the dialog mid-match - drop the match in flight. */
|
|
10
|
+
onCancelMatch?: () => void;
|
|
6
11
|
}
|
|
7
|
-
|
|
12
|
+
type QuickOrderTab = "paste" | "upload";
|
|
13
|
+
export interface EntryDraft {
|
|
14
|
+
tab: QuickOrderTab;
|
|
15
|
+
text: string;
|
|
16
|
+
file: File | null;
|
|
17
|
+
}
|
|
18
|
+
export declare const ENTRY_DRAFT_KEY = "metron-quick-quote-entry";
|
|
19
|
+
export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryModeChange, draft, onSubmitDraft, onCancelMatch }: EntryStageProps): import("react").JSX.Element;
|
|
8
20
|
export {};
|
|
@@ -22,16 +22,21 @@ const STEPS = {
|
|
|
22
22
|
{ title: "Review Quote", desc: "Review the matched materials for your quote." },
|
|
23
23
|
],
|
|
24
24
|
};
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// The open dialog is its own history step, so browser back closes it rather than leaving the page.
|
|
26
|
+
const DIALOG_HASH = "#quote-entry";
|
|
27
|
+
export const ENTRY_DRAFT_KEY = "metron-quick-quote-entry";
|
|
28
|
+
export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryModeChange, draft, onSubmitDraft, onCancelMatch }) {
|
|
29
|
+
const [activeTab, setActiveTab] = useState(() => (draft && typeof window !== "undefined" && window.location.hash === DIALOG_HASH ? draft.tab : null));
|
|
27
30
|
const track = useTrack();
|
|
28
|
-
const [pastedText, setPastedText] = useState("");
|
|
29
|
-
const [uploadFile, setUploadFile] = useState(null);
|
|
31
|
+
const [pastedText, setPastedText] = useState(draft?.text ?? "");
|
|
32
|
+
const [uploadFile, setUploadFile] = useState(draft?.file ?? null);
|
|
30
33
|
const [uploadError, setUploadError] = useState("");
|
|
31
34
|
const [matching, setMatching] = useState(false);
|
|
32
35
|
const [pageDragOver, setPageDragOver] = useState(false);
|
|
33
36
|
const [photoPreviewUrl, setPhotoPreviewUrl] = useState(null);
|
|
34
37
|
const dragDepth = useRef(0);
|
|
38
|
+
// Bumped on each submit and on cancel; a match that finds it moved on was cancelled.
|
|
39
|
+
const matchRun = useRef(0);
|
|
35
40
|
const fileInputRef = useRef(null);
|
|
36
41
|
const lineCount = pastedText.split("\n").filter((line) => line.trim()).length;
|
|
37
42
|
const pasteLineProblem = validateLines(pastedText);
|
|
@@ -45,14 +50,77 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
|
|
|
45
50
|
setPhotoPreviewUrl(url);
|
|
46
51
|
return () => URL.revokeObjectURL(url);
|
|
47
52
|
}, [uploadFile]);
|
|
53
|
+
// Back closes the dialog, forward reopens it on the tab it was showing.
|
|
54
|
+
const lastTab = useRef(activeTab ?? draft?.tab ?? null);
|
|
55
|
+
if (activeTab)
|
|
56
|
+
lastTab.current = activeTab;
|
|
57
|
+
// A refresh on the dialog's step reopens it with its tab and text; the file can't come back.
|
|
58
|
+
// After mount, not in the initial state, so a server-rendered host doesn't hit a hydration mismatch.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (draft || window.location.hash !== DIALOG_HASH)
|
|
61
|
+
return;
|
|
62
|
+
let saved = null;
|
|
63
|
+
try {
|
|
64
|
+
saved = JSON.parse(window.sessionStorage.getItem(ENTRY_DRAFT_KEY) ?? "null");
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
if (!saved?.tab) {
|
|
68
|
+
// Nothing to reopen (a copied link in a new tab, or storage cleared): drop the hash, or
|
|
69
|
+
// the dialog would reuse this step and closing it would go back off the page.
|
|
70
|
+
const url = new URL(window.location.href);
|
|
71
|
+
url.hash = "";
|
|
72
|
+
window.history.replaceState(window.history.state, "", url);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
lastTab.current = saved.tab;
|
|
76
|
+
setActiveTab(saved.tab);
|
|
77
|
+
setPastedText(saved.text ?? "");
|
|
78
|
+
}, []);
|
|
79
|
+
// Only while the dialog is open: the closed screen's empty state would overwrite the
|
|
80
|
+
// saved draft before the effect above reads it on a StrictMode remount.
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!activeTab)
|
|
83
|
+
return;
|
|
84
|
+
try {
|
|
85
|
+
window.sessionStorage.setItem(ENTRY_DRAFT_KEY, JSON.stringify({ tab: activeTab, text: pastedText }));
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
}, [activeTab, pastedText]);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const onPopState = () => {
|
|
91
|
+
if (window.location.hash === DIALOG_HASH)
|
|
92
|
+
return setActiveTab(lastTab.current);
|
|
93
|
+
setActiveTab(null);
|
|
94
|
+
// Backing out of the dialog cancels a match in progress (a no-op when there isn't one);
|
|
95
|
+
// forward reopens the dialog with the input, ready to submit again.
|
|
96
|
+
matchRun.current++;
|
|
97
|
+
setMatching(false);
|
|
98
|
+
onCancelMatch?.();
|
|
99
|
+
};
|
|
100
|
+
window.addEventListener("popstate", onPopState);
|
|
101
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
102
|
+
}, []);
|
|
48
103
|
const openWorkflow = (tab) => {
|
|
49
104
|
track("method_selected", { method: tab });
|
|
50
105
|
setActiveTab(tab);
|
|
51
106
|
onEntryModeChange(null);
|
|
107
|
+
const hash = window.location.hash;
|
|
108
|
+
if (hash === DIALOG_HASH)
|
|
109
|
+
return;
|
|
110
|
+
const url = new URL(window.location.href);
|
|
111
|
+
url.hash = DIALOG_HASH;
|
|
112
|
+
// Our own state only, as in QuickQuote's writeQuoteHistory. A results/basket hash left
|
|
113
|
+
// behind by "start over" is reused, or back would land on that stale step.
|
|
114
|
+
const stale = hash === "#matched-products" || hash === "#quote-request";
|
|
115
|
+
window.history[stale ? "replaceState" : "pushState"]({ boqQuoteStage: "entry" }, "", url);
|
|
52
116
|
};
|
|
53
117
|
const closeWorkflow = () => {
|
|
54
118
|
setActiveTab(null);
|
|
55
119
|
onEntryModeChange(null);
|
|
120
|
+
// Drop the dialog's step too. Closing hides the dialog straight away, so a second
|
|
121
|
+
// Escape/click can't step back again past the page.
|
|
122
|
+
if (window.location.hash === DIALOG_HASH)
|
|
123
|
+
window.history.back();
|
|
56
124
|
};
|
|
57
125
|
// Like the close button, but not mid-match, where the modal is showing progress.
|
|
58
126
|
useEscapeKey(Boolean(activeTab) && !matching, closeWorkflow);
|
|
@@ -70,15 +138,26 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
|
|
|
70
138
|
setUploadFile(file);
|
|
71
139
|
};
|
|
72
140
|
const startMatching = async (run) => {
|
|
141
|
+
// Keep only what was submitted: stepping back to an image's dialog shouldn't still
|
|
142
|
+
// hold an earlier pasted list, or the other way round.
|
|
143
|
+
if (activeTab === "paste")
|
|
144
|
+
setUploadFile(null);
|
|
145
|
+
if (activeTab === "upload")
|
|
146
|
+
setPastedText("");
|
|
147
|
+
if (activeTab)
|
|
148
|
+
onSubmitDraft?.({ tab: activeTab, text: activeTab === "paste" ? pastedText : "", file: activeTab === "upload" ? uploadFile : null });
|
|
149
|
+
const id = ++matchRun.current;
|
|
73
150
|
setMatching(true);
|
|
74
151
|
try {
|
|
75
152
|
await run();
|
|
76
153
|
}
|
|
77
154
|
finally {
|
|
78
|
-
|
|
155
|
+
if (id === matchRun.current)
|
|
156
|
+
setMatching(false);
|
|
79
157
|
}
|
|
80
158
|
};
|
|
81
159
|
const matchPhoto = async (file) => {
|
|
160
|
+
const id = matchRun.current;
|
|
82
161
|
try {
|
|
83
162
|
const photo = await shrinkPhoto(file);
|
|
84
163
|
const problem = validateUploadFile(photo);
|
|
@@ -96,9 +175,14 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
|
|
|
96
175
|
const lines = Array.isArray(payload?.lines)
|
|
97
176
|
? payload.lines.filter((line) => Boolean(line && typeof line === "object" && typeof line.text === "string"))
|
|
98
177
|
: undefined;
|
|
178
|
+
// Cancelled while the photo was being read - don't start matching it.
|
|
179
|
+
if (id !== matchRun.current)
|
|
180
|
+
return;
|
|
99
181
|
await onPasteSchedule(text, lines, false);
|
|
100
182
|
}
|
|
101
183
|
catch (error) {
|
|
184
|
+
if (id !== matchRun.current)
|
|
185
|
+
return;
|
|
102
186
|
track("error", { stage: "photo" });
|
|
103
187
|
setUploadError(error?.message || "Failed to extract line items from image");
|
|
104
188
|
}
|