mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 11:45:24 +02:00
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:
co-authored by
Claude Fable 5
parent
0c152a4742
commit
b42dd32cda
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user