From b42dd32cdad9da2e26631e2fe29795ce86795ac6 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Wed, 8 Jul 2026 16:45:45 +0200 Subject: [PATCH] feat(pricing): source EMCO Compliant live from Business Central EMCO Compliant is maintained manually in BC, so the pricing column now fetches it from the new /api/bc-empco endpoint (items API, field empCOCompliant) on every app load instead of reading the Dropbox matrix. One fetch per page load, cached across tab switches; sorting uses the live map. Co-Authored-By: Claude Fable 5 --- api/bc-empco.js | 54 ++++++++++++++++++++++++++++ src/components/PricingView.tsx | 65 ++++++++++++++++++++++++++-------- 2 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 api/bc-empco.js diff --git a/api/bc-empco.js b/api/bc-empco.js new file mode 100644 index 0000000..bc81326 --- /dev/null +++ b/api/bc-empco.js @@ -0,0 +1,54 @@ +import { applyCors, isAllowedOrigin } from './_cors.js'; +import { getBcConfig, getBCToken, getItemsUrl } from '../bc-runtime.js'; + +function setCors(req, res) { + applyCors(req, res, 'GET, OPTIONS'); +} + +export default async function handler(req, res) { + setCors(req, res); + + if (req.method === 'OPTIONS') return res.status(204).end(); + + const origin = req.headers.origin; + if (origin && !isAllowedOrigin(origin)) { + return res.status(403).json({ error: 'Forbidden' }); + } + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const config = getBcConfig(); + const token = await getBCToken(config); + const itemsUrl = getItemsUrl(config); + + // Note: $select suppresses @odata.nextLink on this BC API, so page manually with $skip. + const TOP = 1000; + const values = {}; + for (let skip = 0; ; skip += TOP) { + const response = await fetch(`${itemsUrl}?$select=no,empCOCompliant&$top=${TOP}&$skip=${skip}`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + }); + if (!response.ok) { + const txt = await response.text(); + throw new Error(`BC GET items failed (${response.status}): ${txt}`); + } + const json = await response.json(); + const page = json.value || []; + for (const item of page) { + if (item.no !== undefined && item.no !== null) { + values[String(item.no)] = Boolean(item.empCOCompliant); + } + } + if (page.length < TOP) break; + } + + res.setHeader('Cache-Control', 's-maxage=300, stale-while-revalidate=600'); + return res.json({ success: true, count: Object.keys(values).length, values }); + } catch (err) { + console.error('[bc-empco] error:', err.message); + return res.status(500).json({ success: false, error: err.message }); + } +} diff --git a/src/components/PricingView.tsx b/src/components/PricingView.tsx index 673ddbf..c911c93 100644 --- a/src/components/PricingView.tsx +++ b/src/components/PricingView.tsx @@ -57,6 +57,24 @@ function findCol(headers: string[], ...keywords: string[]): number { ); } +// EMCO Compliant is maintained manually in Business Central, so it is fetched +// live from BC (via /api/bc-empco) instead of the Dropbox matrix file. +// Module-level cache: one fetch per page load, shared across tab switches. +let empcoFetchPromise: Promise> | null = null; +function fetchEmpcoMap(): Promise> { + if (!empcoFetchPromise) { + empcoFetchPromise = fetch('/api/bc-empco') + .then(res => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`)))) + .then(json => (json && json.values && typeof json.values === 'object' ? json.values : {})) + .catch(err => { + console.warn('[bc-empco] fetch failed:', err); + empcoFetchPromise = null; // allow retry on next mount + return {}; + }); + } + return empcoFetchPromise; +} + export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: PricingViewProps) { const COLUMNS = useColumns(); const [filterMode, setFilterMode] = usePersistentState('pricing-filterMode', 'all_errors'); @@ -245,7 +263,15 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, }, [resizingColumn, resizeStartX, resizeStartWidth]); // ── Dynamic column detection ────────────────────────────────────────────── - const { uvpIdx, srpCols, containerCols, nwIdx, gwIdx, unitsOuterIdx, units40fIdx, empcoIdx } = useMemo(() => { + // EMCO Compliant values live from Business Central (null = still loading) + const [empcoMap, setEmpcoMap] = useState | null>(null); + useEffect(() => { + let alive = true; + fetchEmpcoMap().then(map => { if (alive) setEmpcoMap(map); }); + return () => { alive = false; }; + }, []); + + const { uvpIdx, srpCols, containerCols, nwIdx, gwIdx, unitsOuterIdx, units40fIdx } = useMemo(() => { const uvpIdx = findCol(headers, 'uvp'); // All SRP columns, sorted: INT first, UK second, then alphabetically @@ -274,10 +300,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, const units40fIdx = findCol(headers, '40f'); - // EmpCO_Compliant column from the matrix file (empty until the new layout is live) - const empcoIdx = findCol(headers, 'empco'); - - return { uvpIdx, srpCols: srp, containerCols: container, nwIdx, gwIdx, unitsOuterIdx, units40fIdx, empcoIdx }; + return { uvpIdx, srpCols: srp, containerCols: container, nwIdx, gwIdx, unitsOuterIdx, units40fIdx }; }, [headers, COLUMNS]); useEffect(() => { @@ -608,8 +631,20 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, if (sortConfig.key === null || sortConfig.direction === null) return filteredRows; const { key, direction } = sortConfig; + + // EMCO Compliant sorts by the live BC map (not a row column) + if (key === 'empco') { + const rank = (r: ExcelRow) => { + const v = empcoMap?.[String(r[COLUMNS.ARTICLE_NO] ?? '')]; + return v === true ? 2 : v === false ? 1 : 0; + }; + return [...filteredRows].sort((a, b) => + direction === 'asc' ? rank(a.row) - rank(b.row) : rank(b.row) - rank(a.row) + ); + } + let colIndex: number; - + if (typeof key === 'number') { colIndex = key; } else { @@ -620,7 +655,6 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, case 'line': colIndex = COLUMNS.LINE; break; case 'classification': colIndex = COLUMNS.CLASSIFICATION; break; case 'productType': colIndex = COLUMNS.CATEGORIZATION_CODE; break; - case 'empco': colIndex = empcoIdx; break; case 'unitsOuter': colIndex = unitsOuterIdx; break; case 'outerW': colIndex = COLUMNS.OUTER_W; break; case 'outerL': colIndex = COLUMNS.OUTER_L; break; @@ -660,7 +694,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, }); return sorted; - }, [filteredRows, sortConfig]); + }, [filteredRows, sortConfig, empcoMap, COLUMNS]); // ── Pagination ───────────────────────────────────────────────────────── const paginatedRows = useMemo(() => { @@ -766,7 +800,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, { key: 'line', label: 'Line', index: COLUMNS.LINE }, { key: 'classification', label: 'Classification', index: COLUMNS.CLASSIFICATION }, { key: 'productType', label: 'CategorizationCode', index: COLUMNS.CATEGORIZATION_CODE }, - { key: 'empco', label: 'EMCO Compliant', index: empcoIdx }, + { key: 'empco', label: 'EMCO Compliant', index: -1 }, { key: 'itemToLogistic', label: 'Item to Logistic', index: COLUMNS.ITEM_TO_LOGISTIC }, { key: 'unitsOuter', label: 'Units/Outer', index: unitsOuterIdx }, { key: 'outerW', label: 'Outer W', index: COLUMNS.OUTER_W }, @@ -781,7 +815,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, cols.push({ key: `con_${col.index}`, label: col.name, index: col.index }); }); return cols; - }, [COLUMNS, unitsOuterIdx, empcoIdx, pricingEditableCols, containerCols]); + }, [COLUMNS, unitsOuterIdx, pricingEditableCols, containerCols]); const togglePinnedColumn = (key: string) => { setPinnedColumns(prev => { @@ -2128,14 +2162,17 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, )} - {/* EMCO Compliant cell (read-only, from matrix file) */} + {/* EMCO Compliant cell (read-only, live from Business Central) */} {(() => { - const raw = empcoIdx >= 0 ? String(row[empcoIdx] ?? '').trim().toLowerCase() : ''; - if (raw === 'true' || raw === 'yes') { + if (empcoMap === null) { + return ; + } + const value = empcoMap[String(row[COLUMNS.ARTICLE_NO] ?? '')]; + if (value === true) { return Yes; } - if (raw === 'false' || raw === 'no') { + if (value === false) { return No; } return -;