mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 12:25:23 +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) {
|
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: PricingViewProps) {
|
||||||
const COLUMNS = useColumns();
|
const COLUMNS = useColumns();
|
||||||
const [filterMode, setFilterMode] = usePersistentState<FilterMode>('pricing-filterMode', 'all_errors');
|
const [filterMode, setFilterMode] = usePersistentState<FilterMode>('pricing-filterMode', 'all_errors');
|
||||||
@@ -245,7 +263,15 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
}, [resizingColumn, resizeStartX, resizeStartWidth]);
|
}, [resizingColumn, resizeStartX, resizeStartWidth]);
|
||||||
|
|
||||||
// ── Dynamic column detection ──────────────────────────────────────────────
|
// ── 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');
|
const uvpIdx = findCol(headers, 'uvp');
|
||||||
|
|
||||||
// All SRP columns, sorted: INT first, UK second, then alphabetically
|
// 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');
|
const units40fIdx = findCol(headers, '40f');
|
||||||
|
|
||||||
// EmpCO_Compliant column from the matrix file (empty until the new layout is live)
|
return { uvpIdx, srpCols: srp, containerCols: container, nwIdx, gwIdx, unitsOuterIdx, units40fIdx };
|
||||||
const empcoIdx = findCol(headers, 'empco');
|
|
||||||
|
|
||||||
return { uvpIdx, srpCols: srp, containerCols: container, nwIdx, gwIdx, unitsOuterIdx, units40fIdx, empcoIdx };
|
|
||||||
}, [headers, COLUMNS]);
|
}, [headers, COLUMNS]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -608,6 +631,18 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
if (sortConfig.key === null || sortConfig.direction === null) return filteredRows;
|
if (sortConfig.key === null || sortConfig.direction === null) return filteredRows;
|
||||||
|
|
||||||
const { key, direction } = sortConfig;
|
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;
|
let colIndex: number;
|
||||||
|
|
||||||
if (typeof key === 'number') {
|
if (typeof key === 'number') {
|
||||||
@@ -620,7 +655,6 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
case 'line': colIndex = COLUMNS.LINE; break;
|
case 'line': colIndex = COLUMNS.LINE; break;
|
||||||
case 'classification': colIndex = COLUMNS.CLASSIFICATION; break;
|
case 'classification': colIndex = COLUMNS.CLASSIFICATION; break;
|
||||||
case 'productType': colIndex = COLUMNS.CATEGORIZATION_CODE; break;
|
case 'productType': colIndex = COLUMNS.CATEGORIZATION_CODE; break;
|
||||||
case 'empco': colIndex = empcoIdx; break;
|
|
||||||
case 'unitsOuter': colIndex = unitsOuterIdx; break;
|
case 'unitsOuter': colIndex = unitsOuterIdx; break;
|
||||||
case 'outerW': colIndex = COLUMNS.OUTER_W; break;
|
case 'outerW': colIndex = COLUMNS.OUTER_W; break;
|
||||||
case 'outerL': colIndex = COLUMNS.OUTER_L; break;
|
case 'outerL': colIndex = COLUMNS.OUTER_L; break;
|
||||||
@@ -660,7 +694,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
});
|
});
|
||||||
|
|
||||||
return sorted;
|
return sorted;
|
||||||
}, [filteredRows, sortConfig]);
|
}, [filteredRows, sortConfig, empcoMap, COLUMNS]);
|
||||||
|
|
||||||
// ── Pagination ─────────────────────────────────────────────────────────
|
// ── Pagination ─────────────────────────────────────────────────────────
|
||||||
const paginatedRows = useMemo(() => {
|
const paginatedRows = useMemo(() => {
|
||||||
@@ -766,7 +800,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
{ key: 'line', label: 'Line', index: COLUMNS.LINE },
|
{ key: 'line', label: 'Line', index: COLUMNS.LINE },
|
||||||
{ key: 'classification', label: 'Classification', index: COLUMNS.CLASSIFICATION },
|
{ key: 'classification', label: 'Classification', index: COLUMNS.CLASSIFICATION },
|
||||||
{ key: 'productType', label: 'CategorizationCode', index: COLUMNS.CATEGORIZATION_CODE },
|
{ 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: 'itemToLogistic', label: 'Item to Logistic', index: COLUMNS.ITEM_TO_LOGISTIC },
|
||||||
{ key: 'unitsOuter', label: 'Units/Outer', index: unitsOuterIdx },
|
{ key: 'unitsOuter', label: 'Units/Outer', index: unitsOuterIdx },
|
||||||
{ key: 'outerW', label: 'Outer W', index: COLUMNS.OUTER_W },
|
{ 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 });
|
cols.push({ key: `con_${col.index}`, label: col.name, index: col.index });
|
||||||
});
|
});
|
||||||
return cols;
|
return cols;
|
||||||
}, [COLUMNS, unitsOuterIdx, empcoIdx, pricingEditableCols, containerCols]);
|
}, [COLUMNS, unitsOuterIdx, pricingEditableCols, containerCols]);
|
||||||
|
|
||||||
const togglePinnedColumn = (key: string) => {
|
const togglePinnedColumn = (key: string) => {
|
||||||
setPinnedColumns(prev => {
|
setPinnedColumns(prev => {
|
||||||
@@ -2128,14 +2162,17 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
)}
|
)}
|
||||||
</td>
|
</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 } : {}}>
|
<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 (empcoMap === null) {
|
||||||
if (raw === 'true' || raw === 'yes') {
|
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>;
|
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="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>;
|
return <span className="text-xs text-slate-500">-</span>;
|
||||||
|
|||||||
Reference in New Issue
Block a user