@metron-labs/quote-react 0.1.46 → 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/components/estimators/DeckingEstimator.js +5 -4
- package/dist/components/estimators/EstimatorResults.d.ts +2 -1
- package/dist/components/estimators/EstimatorResults.js +1 -1
- package/dist/components/estimators/EstimatorReviewStage.d.ts +2 -1
- package/dist/components/estimators/InsulationEstimator.js +9 -5
- package/dist/components/estimators/SkirtingEstimator.js +2 -2
- package/dist/components/estimators/useFixedBasket.d.ts +1 -0
- package/dist/components/estimators/useFixedBasket.js +3 -3
- 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/lib/estimators/fixedProducts.d.ts +3 -0
- package/dist/lib/estimators/fixedProducts.js +41 -25
- package/dist/lib/estimators/resolveProducts.d.ts +14 -4
- package/dist/lib/estimators/resolveProducts.js +34 -8
- 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
|
@@ -13,6 +13,8 @@ interface CartApi {
|
|
|
13
13
|
/** Show the basket. Each app decides what that means for its own navigation. */
|
|
14
14
|
openBasket: () => void;
|
|
15
15
|
}
|
|
16
|
+
/** The inputs that can change a conversion, including the selected parent of a dependent line. */
|
|
17
|
+
export declare function conversionInputKey(item: Pick<CartItem, "productId" | "qty" | "unit" | "linkedContext">): string;
|
|
16
18
|
export declare function refreshLinkedDependents(cart: Record<string, CartItem>, anchorItem: CartItem): Record<string, CartItem>;
|
|
17
19
|
/**
|
|
18
20
|
* One basket per app. BoQApp and EstimatingToolsApp each mount their own provider, so a
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
|
4
|
+
/** The inputs that can change a conversion, including the selected parent of a dependent line. */
|
|
5
|
+
export function conversionInputKey(item) {
|
|
6
|
+
return JSON.stringify([item.productId, item.qty, item.unit, item.linkedContext ?? []]);
|
|
7
|
+
}
|
|
4
8
|
export function refreshLinkedDependents(cart, anchorItem) {
|
|
5
9
|
const next = { ...cart, [anchorItem.lineId]: anchorItem };
|
|
6
10
|
for (const [lineId, dependent] of Object.entries(next)) {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { EstimatorToolId } from "../../components/estimators/registry";
|
|
2
2
|
import type { FixedBasketLine } from "../../components/estimators/useFixedBasket";
|
|
3
3
|
import type { EstimatorRequirement } from "./types";
|
|
4
|
+
/** The catalogue search for this line's fixed product, or null when the active client has none. */
|
|
5
|
+
export declare function fixedProductSearch(tool: EstimatorToolId, requirement: EstimatorRequirement): string | null;
|
|
6
|
+
/** Basket lines when every line has a fixed product, so the calculator can skip the matching step. */
|
|
4
7
|
export declare function fixedBasketLines(tool: EstimatorToolId, requirements: EstimatorRequirement[]): FixedBasketLine[] | null;
|
|
@@ -1,32 +1,48 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { getQuoteConfig } from "../config";
|
|
2
|
+
// The exact product each calculator line orders, per client. Each value is the catalogue search
|
|
3
|
+
// that finds it: the ID, plus a few words where the ID alone is too short to surface it (search
|
|
4
|
+
// matches IDs as substrings, so "11" alone hits hundreds of products). A client or line with no
|
|
5
|
+
// entry falls back to catalogue search.
|
|
6
|
+
//
|
|
7
|
+
// ponytail: hand-kept table in the package, for one client. It belongs in the backend, per client
|
|
8
|
+
// code - when it moves, only fixedProductSearch changes (to read it from the API); every caller
|
|
9
|
+
// goes through that.
|
|
10
|
+
const FIXED_PRODUCTS = {
|
|
11
|
+
// FORT
|
|
12
|
+
"NZ4Y-ZMSQ": {
|
|
13
|
+
"brick-block": { "brick-common": "105499", "brick-engineering-red": "1654", "brick-engineering-blue": "1723", block: "501", mortar: "646" },
|
|
14
|
+
// Primed MDF, 4.4m lengths. Ogee and torus come in 119mm skirting / 69mm architrave; the other
|
|
15
|
+
// profiles only in 94mm and 144mm, so the nearer usable width is taken. FORT has no ovolo.
|
|
16
|
+
skirting: { "skirting-chamfered": "1525", "skirting-ogee": "135 primed mdf ogee", "skirting-pencil-round": "892", "skirting-torus": "1941" },
|
|
17
|
+
architrave: { "architrave-ogee": "134 primed mdf ogee", "architrave-ovolo": "1531", "architrave-pencil-round": "1896", "architrave-square-edge": "118 primed mdf square", "architrave-torus": "1531" },
|
|
18
|
+
// Board keys come from the board type; see fixedProductSearch.
|
|
19
|
+
plasterboard: { standard: "1647", moisture: "11 moisture plasterboard", fire: "14 fire plasterboard", sound: "1769", screws: "131160", tape: "1766", adhesive: "108439" },
|
|
20
|
+
insulation: { "insulation-floor": "152187", "insulation-wall": "147", "insulation-roof": "442", "insulation-loft": "152180" },
|
|
21
|
+
timber: { studs: "1213", joists: "208", battens: "1729" },
|
|
10
22
|
fencing: {
|
|
11
|
-
"panels-boundary-0.9": "
|
|
12
|
-
"panels-skyline-
|
|
13
|
-
"concrete-intermediate-
|
|
14
|
-
"timber-post-6": "
|
|
23
|
+
"panels-boundary-0.9": "66 lap fence panel", "panels-boundary-1.2": "1450", "panels-boundary-1.5": "1449", "panels-boundary-1.8": "1448",
|
|
24
|
+
"panels-skyline-1.8": "1475",
|
|
25
|
+
"concrete-intermediate-8": "40197", "concrete-end-8": "40198",
|
|
26
|
+
"timber-post-6": "141411", "timber-post-8": "1709", postcrete: "12142", gate: "1984", "gravel-boards": "40200",
|
|
15
27
|
},
|
|
16
|
-
decking: { "boards-
|
|
17
|
-
paving: { "surface-council": "
|
|
18
|
-
}
|
|
19
|
-
if (tool === "plasterboard") {
|
|
20
|
-
return { standard: "2001035", moisture: "2002039", fire: "2002087", sound: "2002044" }[requirement.label.split(" ")[0].toLowerCase()] ?? products[tool]?.[key] ?? null;
|
|
21
|
-
}
|
|
22
|
-
return products[tool]?.[key] ?? null;
|
|
28
|
+
decking: { "boards-summit": "24322", joists: "208", screws: "131079" },
|
|
29
|
+
paving: { "surface-council": "23995", "surface-raj": "12369", "surface-brindle": "304", "surface-charcoal": "296", "surface-pea": "431", "sub-base": "569", bedding: "563" },
|
|
30
|
+
},
|
|
23
31
|
};
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
/** The catalogue search for this line's fixed product, or null when the active client has none. */
|
|
33
|
+
export function fixedProductSearch(tool, requirement) {
|
|
34
|
+
const table = FIXED_PRODUCTS[getQuoteConfig().client ?? ""]?.[tool];
|
|
35
|
+
if (!table)
|
|
26
36
|
return null;
|
|
37
|
+
// The plasterboard line is keyed "board" whatever the board; its label starts with the type.
|
|
38
|
+
const key = tool === "plasterboard" && requirement.key === "board" ? requirement.label.split(" ")[0].toLowerCase() : requirement.key;
|
|
39
|
+
return table[key] ?? null;
|
|
40
|
+
}
|
|
41
|
+
/** Basket lines when every line has a fixed product, so the calculator can skip the matching step. */
|
|
42
|
+
export function fixedBasketLines(tool, requirements) {
|
|
27
43
|
const lines = requirements.map((requirement) => {
|
|
28
|
-
const
|
|
29
|
-
return
|
|
44
|
+
const search = fixedProductSearch(tool, requirement);
|
|
45
|
+
return search ? { key: requirement.key, productId: search.split(" ")[0], search, qty: requirement.qty, unit: requirement.unit } : null;
|
|
30
46
|
});
|
|
31
|
-
return lines.every(Boolean) ? lines : null;
|
|
47
|
+
return lines.length > 0 && lines.every(Boolean) ? lines : null;
|
|
32
48
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MatchCandidate } from "../../util/types";
|
|
2
|
+
import type { EstimatorToolId } from "../../components/estimators/registry";
|
|
2
3
|
import type { EstimatorRequirement } from "./types";
|
|
3
4
|
export interface ResolvedRequirement {
|
|
4
5
|
requirement: EstimatorRequirement;
|
|
@@ -11,11 +12,20 @@ export interface CatalogueStatus {
|
|
|
11
12
|
/** With an account code, prices come back net of that trade account's discounts. */
|
|
12
13
|
export declare function searchCatalogue(query: string, category?: string, limit?: number, account?: string): Promise<MatchCandidate[]>;
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* The unit to ask the basket for. A bag/box/roll/pack count is already a count of the product's
|
|
16
|
+
* sell units - the estimator did that sum - so it is asked in the product's own unit. A catalogue
|
|
17
|
+
* that sells the same bag "each" would otherwise refuse the line and leave it unpriced. Only a
|
|
18
|
+
* product sold one item at a time qualifies: 3 bags must never become 3 tonnes or 3 x 1000.
|
|
17
19
|
*/
|
|
18
|
-
export declare function
|
|
20
|
+
export declare function estimateRequestUnit(unit: string, candidate: MatchCandidate | null | undefined): string;
|
|
21
|
+
/**
|
|
22
|
+
* Looks up every requirement in one go - the one place both the customer calculators and
|
|
23
|
+
* Metron's internal tool turn calculator lines into products. With the calculator's `tool`, a
|
|
24
|
+
* line the client has a fixed product for gets that product first, ahead of the search results.
|
|
25
|
+
* A requirement that matches nothing comes back with an empty candidate list rather than being
|
|
26
|
+
* dropped, so the results step can say so instead of quietly shortening the materials list.
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveRequirements(requirements: EstimatorRequirement[], account?: string, tool?: EstimatorToolId): Promise<ResolvedRequirement[]>;
|
|
19
29
|
/**
|
|
20
30
|
* Whether this client has a catalogue at all. Merchants without one still get working
|
|
21
31
|
* calculators - they just don't get the basket step.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { apiHeaders, readJson } from "../api/boqClient";
|
|
2
|
-
import { apiUrl } from "../config";
|
|
2
|
+
import { apiUrl, getQuoteConfig } from "../config";
|
|
3
|
+
import { fixedProductSearch } from "./fixedProducts";
|
|
3
4
|
// Estimator queries repeat constantly - the same "skirting board" search fires on every
|
|
4
5
|
// recalculation - so results are memoised for the tab's lifetime. Nothing here is
|
|
5
6
|
// user-specific beyond the client the headers already scope it to.
|
|
@@ -24,8 +25,9 @@ function toCandidate(item) {
|
|
|
24
25
|
}
|
|
25
26
|
/** With an account code, prices come back net of that trade account's discounts. */
|
|
26
27
|
export async function searchCatalogue(query, category, limit = 12, account) {
|
|
27
|
-
// The account
|
|
28
|
-
|
|
28
|
+
// The client and account are part of the key: the same search is a different catalogue per
|
|
29
|
+
// client, and a different price per account.
|
|
30
|
+
const cacheKey = `${getQuoteConfig().client ?? ""}||${query}||${category ?? ""}||${limit}||${account ?? ""}`;
|
|
29
31
|
const cached = cache.get(cacheKey);
|
|
30
32
|
if (cached)
|
|
31
33
|
return cached;
|
|
@@ -42,15 +44,39 @@ export async function searchCatalogue(query, category, limit = 12, account) {
|
|
|
42
44
|
cache.set(cacheKey, candidates);
|
|
43
45
|
return candidates;
|
|
44
46
|
}
|
|
47
|
+
const PACK_UNITS = new Set(["bag", "box", "roll", "pack"]);
|
|
48
|
+
const SINGLE_ITEM_UNITS = new Set(["each", "bag", "box", "roll", "pack", "tub"]);
|
|
45
49
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
50
|
+
* The unit to ask the basket for. A bag/box/roll/pack count is already a count of the product's
|
|
51
|
+
* sell units - the estimator did that sum - so it is asked in the product's own unit. A catalogue
|
|
52
|
+
* that sells the same bag "each" would otherwise refuse the line and leave it unpriced. Only a
|
|
53
|
+
* product sold one item at a time qualifies: 3 bags must never become 3 tonnes or 3 x 1000.
|
|
49
54
|
*/
|
|
50
|
-
export
|
|
55
|
+
export function estimateRequestUnit(unit, candidate) {
|
|
56
|
+
const uoi = candidate?.uoi;
|
|
57
|
+
const singleItem = uoi != null && SINGLE_ITEM_UNITS.has(uoi.toLowerCase()) && (candidate?.uoi_count ?? 1) === 1;
|
|
58
|
+
return PACK_UNITS.has(unit) && singleItem ? uoi : unit;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Looks up every requirement in one go - the one place both the customer calculators and
|
|
62
|
+
* Metron's internal tool turn calculator lines into products. With the calculator's `tool`, a
|
|
63
|
+
* line the client has a fixed product for gets that product first, ahead of the search results.
|
|
64
|
+
* A requirement that matches nothing comes back with an empty candidate list rather than being
|
|
65
|
+
* dropped, so the results step can say so instead of quietly shortening the materials list.
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveRequirements(requirements, account, tool) {
|
|
51
68
|
const results = await Promise.all(requirements.map(async (requirement) => {
|
|
52
69
|
try {
|
|
53
|
-
|
|
70
|
+
const fixedSearch = tool ? fixedProductSearch(tool, requirement) : null;
|
|
71
|
+
const [fixed, found] = await Promise.all([
|
|
72
|
+
// A failed fixed lookup leaves the line on search results rather than emptying it.
|
|
73
|
+
fixedSearch
|
|
74
|
+
? searchCatalogue(fixedSearch, undefined, undefined, account).then((items) => items.find((item) => item.product_id === fixedSearch.split(" ")[0]), () => undefined)
|
|
75
|
+
: undefined,
|
|
76
|
+
searchCatalogue(requirement.query, requirement.category, undefined, account),
|
|
77
|
+
]);
|
|
78
|
+
const candidates = fixed ? [fixed, ...found.filter((item) => item.product_id !== fixed.product_id)] : found;
|
|
79
|
+
return { requirement: { ...requirement, unit: estimateRequestUnit(requirement.unit, candidates[0]) }, candidates };
|
|
54
80
|
}
|
|
55
81
|
catch {
|
|
56
82
|
return { requirement, candidates: [] };
|
package/dist/styles.css
CHANGED
|
@@ -5813,12 +5813,11 @@
|
|
|
5813
5813
|
}
|
|
5814
5814
|
|
|
5815
5815
|
:where(.app-wrapper) .results-total-action small {
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
font-size: 11px;
|
|
5816
|
+
color: var(--text);
|
|
5817
|
+
font-size: 15px;
|
|
5819
5818
|
font-weight: 600;
|
|
5820
|
-
letter-spacing: 0
|
|
5821
|
-
text-transform:
|
|
5819
|
+
letter-spacing: 0;
|
|
5820
|
+
text-transform: none;
|
|
5822
5821
|
}
|
|
5823
5822
|
|
|
5824
5823
|
:where(.app-wrapper) .results-total-action strong {
|
package/dist/util/pricing.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface PricingBreakdown {
|
|
|
11
11
|
calculation: string;
|
|
12
12
|
costingMode: "direct" | "derived" | "review";
|
|
13
13
|
}
|
|
14
|
-
export declare function getCartSubtotal(items: CartItem[], options?: Parameters<typeof getPricingBreakdown>[1]): number;
|
|
14
|
+
export declare function getCartSubtotal(items: CartItem[], options?: Parameters<typeof getPricingBreakdown>[1]): number | null;
|
|
15
15
|
export declare function getPricingBreakdown(item: CartItem, options?: {
|
|
16
16
|
wasteFactorEnabled?: boolean;
|
|
17
17
|
wasteFactorOverride?: number | null;
|
package/dist/util/pricing.js
CHANGED
|
@@ -112,7 +112,8 @@ const coverageFromDescription = (item, requestedUnit, sellUnit) => {
|
|
|
112
112
|
return Number.isFinite(coverage) && coverage > 0 ? coverage : null;
|
|
113
113
|
};
|
|
114
114
|
export function getCartSubtotal(items, options) {
|
|
115
|
-
|
|
115
|
+
const totals = items.map((item) => getPricingBreakdown(item, options).lineTotal);
|
|
116
|
+
return totals.some((total) => total == null) ? null : totals.reduce((sum, total) => sum + (total ?? 0), 0);
|
|
116
117
|
}
|
|
117
118
|
export function getPricingBreakdown(item, options) {
|
|
118
119
|
const requestedAmount = item.qty;
|
|
@@ -137,6 +138,8 @@ export function getPricingBreakdown(item, options) {
|
|
|
137
138
|
calculation: overriddenSellUnits != null ? `${reason} Manually set to ${overriddenSellUnits} ${sellUnit}.` : reason,
|
|
138
139
|
costingMode: "review",
|
|
139
140
|
});
|
|
141
|
+
if (!Number.isFinite(requestedAmount) || requestedAmount <= 0)
|
|
142
|
+
return review("Quantity needs confirmation.");
|
|
140
143
|
if (!supportsRequestedUnit(item, requestedUnit)) {
|
|
141
144
|
return review("Requested unit is not supported by this catalogue conversion.");
|
|
142
145
|
}
|