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 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-07-08 16:45:45 +02:00
co-authored by Claude Fable 5
parent 0c152a4742
commit b42dd32cda
2 changed files with 105 additions and 14 deletions
+54
View File
@@ -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 });
}
}
+51 -14
View File
@@ -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<Record<string, boolean>> | null = null;
function fetchEmpcoMap(): Promise<Record<string, boolean>> {
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<FilterMode>('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<Record<string, boolean> | 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,
)}
</td>
{/* EMCO Compliant cell (read-only, from matrix file) */}
{/* EMCO Compliant cell (read-only, live from Business Central) */}
<td className={cn("px-3 py-2 relative", isPinned('empco') && "sticky bg-slate-800")} style={isPinned('empco') ? { left: getStickyLeft('empco') ?? 0, zIndex: getStickyRank('empco') ?? 0 } : {}}>
{(() => {
const raw = empcoIdx >= 0 ? String(row[empcoIdx] ?? '').trim().toLowerCase() : '';
if (raw === 'true' || raw === 'yes') {
if (empcoMap === null) {
return <span className="text-xs text-slate-600 animate-pulse"></span>;
}
const value = empcoMap[String(row[COLUMNS.ARTICLE_NO] ?? '')];
if (value === true) {
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-teal-500/10 text-teal-300 border border-teal-500/30">Yes</span>;
}
if (raw === 'false' || raw === 'no') {
if (value === false) {
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-500/10 text-red-300 border border-red-500/30">No</span>;
}
return <span className="text-xs text-slate-500">-</span>;