@metron-labs/quote-react 0.1.28 → 0.1.29

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.
@@ -5,7 +5,7 @@ import { useTrack } from "../../lib/analytics";
5
5
  import BackArrow from "../../components/BackArrow";
6
6
  import { fmtCurrency } from "../../lib/formatters";
7
7
  import { productImageSrc } from "../../lib/productImage";
8
- import { getPricingBreakdown } from "../../util/pricing";
8
+ 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";
@@ -73,7 +73,8 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
73
73
  product_id: item.productId,
74
74
  requested_description: item.requestedDescription ?? item.description,
75
75
  requested_qty: item.qty,
76
- requested_uom: item.unit,
76
+ requested_uom: item.unit || null,
77
+ linked_context: item.linkedContext ?? [],
77
78
  selected_description: item.description,
78
79
  selected_uoi: item.uoi,
79
80
  selected_uoi_count: item.uoiCount,
@@ -95,6 +96,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
95
96
  const conversion = resolved?.conversion;
96
97
  if (!conversion || typeof resolved?.line_id !== "string")
97
98
  continue;
99
+ const current = cart[resolved.line_id];
98
100
  updates[resolved.line_id] = {
99
101
  conversionType: conversion.conversion_type,
100
102
  requestUomsSupported: conversion.request_uoms_supported,
@@ -106,12 +108,15 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
106
108
  roundingRule: conversion.rounding_rule,
107
109
  conversionConfidence: conversion.conversion_confidence,
108
110
  conversionNotes: conversion.conversion_notes,
111
+ ...(current?.qty === 0 && Number.isFinite(conversion.suggested_order_qty) && conversion.suggested_order_qty > 0
112
+ ? { qty: conversion.suggested_order_qty, unit: conversion.suggested_order_uom || current.unit }
113
+ : {}),
109
114
  };
110
115
  }
111
116
  if (Object.keys(updates).length > 0)
112
117
  onResolveConversions(updates);
113
118
  })
114
- .catch(() => { });
119
+ .catch((error) => console.error("Catalogue conversion resolution failed", error));
115
120
  }, [cart, onResolveConversions]);
116
121
  function clearQtyDraft(lineId) {
117
122
  setQtyDrafts((prev) => {
@@ -206,7 +211,10 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
206
211
  wasteFactorOverride: Math.max(0, Number.parseFloat(wastePercent) || 0) / 100,
207
212
  }),
208
213
  }));
209
- const subtotal = pricedItems.reduce((sum, entry) => sum + (entry.pricing.lineTotal ?? 0), 0);
214
+ const subtotal = getCartSubtotal(items, {
215
+ wasteFactorEnabled: wasteEnabled,
216
+ wasteFactorOverride: Math.max(0, Number.parseFloat(wastePercent) || 0) / 100,
217
+ });
210
218
  const vat = subtotal * VAT_RATE;
211
219
  const total = subtotal + vat;
212
220
  // Once per visit to the basket, with the figures it opened with.
@@ -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 { getCartSubtotal, getPricingBreakdown } from "../../util/pricing";
15
16
  const NO_SELECTION = "__none__";
16
17
  const PAGE_SIZE = 24;
17
18
  const SORT_OPTIONS = [
@@ -147,7 +148,9 @@ function fmtTextList(values) {
147
148
  }
148
149
  export function displayLineLabel(item, allItems) {
149
150
  void allItems;
150
- return item.customer_display_label?.trim() || item.description_original || "(no description)";
151
+ const original = typeof item.description_original === "string" ? item.description_original.trim() : "";
152
+ const label = typeof item.customer_display_label === "string" ? item.customer_display_label.trim() : "";
153
+ return (label && original.includes(label) ? label : original) || "(no description)";
151
154
  }
152
155
  const LINK_GROUP_BUBBLE_STYLES = [
153
156
  { background: "#fff7ed", borderColor: "#fdba74", color: "#9a3412" },
@@ -379,16 +382,18 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
379
382
  band: bandFor(best?.scores?.overall),
380
383
  };
381
384
  });
382
- let totalValue = 0;
385
+ // Match BasketStage: catalogue waste defaults are opt-in in the basket UI.
386
+ let totalValue = 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)
386
390
  continue;
387
- if (row.best.price != null)
388
- totalValue += row.best.price * row.qty;
389
- else
391
+ const cartItem = cart[row.item.line_id];
392
+ if (!cartItem || getPricingBreakdown(cartItem).lineTotal == null)
390
393
  needsPricingCount += 1;
391
394
  }
395
+ if (needsPricingCount > 0)
396
+ totalValue = null;
392
397
  const hasBestAlternative = (row) => !row.unmatchable && !row.best && row.decision?.technical_classification !== "no_suitable_candidate" && recommendedCandidates(row.result).length > 0;
393
398
  const previewCandidate = (row) => row.best ?? recommendedCandidates(row.result)[0];
394
399
  const reviewRequiredRows = rows.filter((row) => !row.unmatchable && !row.best);
@@ -556,6 +561,12 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
556
561
  description: row.item.description_original,
557
562
  unit: row.item.unit,
558
563
  qty: row.qty,
564
+ linkedContext: row.item.linked_group_role === "dependent" && row.item.linked_group_id
565
+ ? lineItems
566
+ .filter((line) => line.linked_group_id === row.item.linked_group_id && line.linked_group_role === "anchor")
567
+ .slice(0, 1)
568
+ .map((anchor) => ({ description: anchor.description_original, qty: anchor.qty, unit: anchor.unit, role: "anchor" }))
569
+ : undefined,
559
570
  });
560
571
  }
561
572
  // All matched items, including review-required ones, land in the basket automatically
@@ -571,16 +582,14 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
571
582
  onCartSeeded();
572
583
  // eslint-disable-next-line react-hooks/exhaustive-deps
573
584
  }, [cartSeeded]);
574
- // Keep already-added basket rows aligned to the latest matched candidate metadata. This
575
- // repairs stale cart rows after pricing/conversion schema changes without clobbering any
576
- // quantity edits the user made in the basket.
585
+ // The cart is authoritative once a row has been added. Rebuilding an existing row from
586
+ // scheduleResult can overwrite contextual quantities resolved in the basket when the
587
+ // user navigates back here. Only seed rows that are not already present.
577
588
  useEffect(() => {
578
589
  for (const row of rows) {
579
590
  const existing = cart[row.item.line_id];
580
- if (!existing)
581
- continue;
582
- const refreshed = toCartItem(row);
583
- onAddToCart(refreshedCartItem(refreshed, existing));
591
+ if (!existing && !row.unmatchable && row.best)
592
+ onAddToCart(toCartItem(row));
584
593
  }
585
594
  // eslint-disable-next-line react-hooks/exhaustive-deps
586
595
  }, [scheduleResult]);
@@ -664,7 +673,7 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
664
673
  })();
665
674
  const activeReviewCartMatches = !!activeReviewRow?.best && cart[activeReviewRow.item.line_id]?.productId === activeReviewRow.best.product_id;
666
675
  const activeReviewHasPendingUpdate = !!activeReviewRow?.best && !!cart[activeReviewRow.item.line_id] && !activeReviewCartMatches;
667
- 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: fmtCurrency(totalValue) }), " 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" }), _jsx("strong", { children: fmtCurrency(totalValue) })] }), _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: {
676
+ 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: totalValue == null ? "Calculating…" : fmtCurrency(totalValue) }), " 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" }), _jsx("strong", { children: totalValue == null ? "Calculating…" : fmtCurrency(totalValue) })] }), _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: {
668
677
  display: "flex",
669
678
  gap: 8,
670
679
  flexWrap: "wrap",