@metron-labs/quote-react 0.1.73 → 0.1.75

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.
@@ -3,7 +3,7 @@ import { useEffect, useId, useRef, useState } from "react";
3
3
  import { apiUrl } from "../../util/config";
4
4
  import { useTrack } from "../../util/analytics";
5
5
  import { fmtCurrency } from "../../util/formatters";
6
- import { getCartSubtotal, getPricingBreakdown } from "../../util/pricing";
6
+ import { getCartSubtotal, getPricingBreakdown, needsQuantityConfirmation } from "../../util/pricing";
7
7
  import { exportBasketExcel } from "../../util/exportScheduleExcel";
8
8
  import { renderDisplayUnit, unitDisplayText } from "../../components/unit/UnitDisplay";
9
9
  import { clientHeaders, getBoqClientId, readJson } from "../../util/api/boqClient";
@@ -16,12 +16,17 @@ import ProductImage from "../../util/ProductImage";
16
16
  const VAT_RATE = 0.2; //Flat UK VAT rate, matching fmtCurrency's GBP-only assumption elsewhere in this app.
17
17
  const PAGE_SIZE = 10;
18
18
  const INVALID_EMAIL_MESSAGE = "Enter a valid email address so we can send your quote.";
19
- const orderSummary = (item, pricing) => {
19
+ export const orderSummary = (item, pricing) => {
20
20
  const requested = `${pricing.requestedAmount} ${unitDisplayText(pricing.requestedUnit, pricing.requestedAmount)}`;
21
21
  const order = `${pricing.requiredSellUnits} ${unitDisplayText(pricing.sellUnit, pricing.requiredSellUnits ?? 0)}`;
22
22
  const conversion = item.unitConversions?.find((entry) => entry.requestUom === pricing.requestedUnit);
23
23
  if (conversion && conversion.quantityPerSaleUnit < 1) {
24
- return `${requested} requested → order ${order} (${Math.round(1 / conversion.quantityPerSaleUnit)} ${unitDisplayText(pricing.sellUnit, 2)} per ${unitDisplayText(pricing.requestedUnit, 1)})`;
24
+ // A whole number reads per requested unit ("2 bulk bags per m3"); otherwise per sell unit ("0.75 l per unit", not "1 units per l").
25
+ const perRequested = Math.round(1 / conversion.quantityPerSaleUnit);
26
+ const ratio = Math.abs(perRequested * conversion.quantityPerSaleUnit - 1) < 1e-9
27
+ ? `${perRequested} ${unitDisplayText(pricing.sellUnit, perRequested)} per ${unitDisplayText(pricing.requestedUnit, 1)}`
28
+ : `${conversion.quantityPerSaleUnit} ${unitDisplayText(pricing.requestedUnit, conversion.quantityPerSaleUnit)} per ${unitDisplayText(pricing.sellUnit, 1)}`;
29
+ return `${requested} requested → order ${order} (${ratio})`;
25
30
  }
26
31
  const bulkBagKg = pricing.sellUnit === "bulk bag" ? item.description.match(/(\d+(?:\.\d+)?)\s*kg\b/i) : null;
27
32
  if (bulkBagKg) {
@@ -268,12 +273,13 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
268
273
  ? "Check your items and quantities below, and we'll do our best to beat list prices."
269
274
  : "Review your quantities and order summary, then send us your basket." })] })] }), _jsx("div", { className: "quick-order-panel", style: { paddingTop: 16 }, children: waitingForConversions ? (_jsxs("div", { className: "basket-resolving", role: "status", "aria-label": "Calculating order quantities", children: [_jsx("span", { className: "basket-resolving-wheel", "aria-hidden": "true" }), _jsx("span", { children: "Calculating your quantities\u2026" })] })) : 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 }) => {
270
275
  const requestedQtyValue = qtyDrafts[item.lineId] ?? String(pricing.requestedAmount);
271
- const requestedLocked = pricing.requestedUnit === pricing.sellUnit || pricing.costingMode === "review";
276
+ const needsConfirmation = needsQuantityConfirmation(item, pricing);
277
+ const requestedLocked = pricing.requestedUnit === pricing.sellUnit || needsConfirmation;
272
278
  const rawDiffersFromOrder = pricing.requiredSellUnits == null || pricing.requestedAmount !== pricing.requiredSellUnits || pricing.requestedUnit !== pricing.sellUnit;
273
279
  return (_jsxs("article", { className: "basket-card", children: [_jsxs("div", { className: "basket-card-top", children: [_jsx(ProductImage, { className: "basket-card-thumb", description: item.description, photoUrl: item.pdpPhotoUrl, 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) => ({
274
280
  ...prev,
275
281
  [item.lineId]: !prev[item.lineId],
276
- })), "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) => ({
282
+ })), "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 })] })), needsConfirmation ? (_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." }), " ", item.conversionNotes?.trim() || 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
283
  ...prev,
278
284
  [item.lineId]: e.target.value,
279
285
  })), onBlur: (e) => commitRequestedQty(item.lineId, e.target.value), onKeyDown: (e) => {
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import UnitLabel from "./UnitLabel";
3
3
  function pluralizeUnit(unit, count) {
4
- if (count === 1 || /^m\d*$/i.test(unit))
4
+ // Unit symbols never take a plural: "800 kg", not "800 kgs".
5
+ if (count === 1 || /^(m\d*|mm|cm|ml|kg|g)$/i.test(unit))
5
6
  return unit;
6
7
  if (/(s|x|z|ch|sh)$/i.test(unit))
7
8
  return `${unit}es`;
@@ -13,7 +14,7 @@ export function unitDisplayText(unit, count) {
13
14
  const normalized = (unit ?? "").trim();
14
15
  if (!normalized || normalized === "-")
15
16
  return "";
16
- const word = /^each$/i.test(normalized) ? "unit" : normalized;
17
+ const word = /^each$/i.test(normalized) ? "unit" : /^l$/i.test(normalized) ? "litre" : normalized;
17
18
  return count != null ? pluralizeUnit(word, count) : word;
18
19
  }
19
20
  export function renderDisplayUnit(unit, count) {