3 Commits
Author SHA1 Message Date
Christian Vidal WolfandClaude Fable 5 ea4592b695 fix(pricing): widen CategorizationCode and EMCO Compliant columns
Default widths were too narrow for the header labels, causing them to
overlap and become unreadable (140->200 and 110->150).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:05:37 +02:00
Christian Vidal WolfandClaude Fable 5 b42dd32cda 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>
2026-07-08 16:45:45 +02:00
Christian Vidal WolfandClaude Fable 5 0c152a4742 feat(pricing): add EMCO Compliant column next to CategorizationCode
Read-only Yes/No badge sourced from the matrix EmpCO_Compliant column
(detected dynamically by header; shows '-' until the new matrix layout
is live in Dropbox). Sortable, pinnable and resizable like the rest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:36:13 +02:00
2 changed files with 129 additions and 7 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 });
}
}
+75 -7
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');
@@ -69,7 +87,8 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
articleName: 220,
line: 100,
classification: 130,
productType: 140,
productType: 200,
empco: 150,
itemToLogistic: 160,
unitsOuter: 100,
outerW: 80,
@@ -244,6 +263,14 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
}, [resizingColumn, resizeStartX, resizeStartWidth]);
// ── Dynamic column detection ──────────────────────────────────────────────
// 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');
@@ -604,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 {
@@ -655,7 +694,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
});
return sorted;
}, [filteredRows, sortConfig]);
}, [filteredRows, sortConfig, empcoMap, COLUMNS]);
// ── Pagination ─────────────────────────────────────────────────────────
const paginatedRows = useMemo(() => {
@@ -761,6 +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: -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 },
@@ -791,7 +831,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
// Calculate sticky left position for each pinned column
const getStickyLeft = useMemo(() => {
const baseOrder = ['articleNo', 'articleName', 'line', 'classification', 'productType', 'itemToLogistic', 'unitsOuter', 'outerW', 'outerL', 'outerH', 'moq'];
const baseOrder = ['articleNo', 'articleName', 'line', 'classification', 'productType', 'empco', 'itemToLogistic', 'unitsOuter', 'outerW', 'outerL', 'outerH', 'moq'];
const dynamicOrder: string[] = [];
pricingEditableCols.forEach(col => dynamicOrder.push(`prc_${col.index}`));
containerCols.forEach(col => dynamicOrder.push(`con_${col.index}`));
@@ -816,7 +856,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
// Calculate z-index for sticky columns (so they stack correctly)
const getStickyRank = useMemo(() => {
const baseOrder = ['articleNo', 'articleName', 'line', 'classification', 'productType', 'itemToLogistic', 'unitsOuter', 'outerW', 'outerL', 'outerH', 'moq'];
const baseOrder = ['articleNo', 'articleName', 'line', 'classification', 'productType', 'empco', 'itemToLogistic', 'unitsOuter', 'outerW', 'outerL', 'outerH', 'moq'];
const dynamicOrder: string[] = [];
pricingEditableCols.forEach(col => dynamicOrder.push(`prc_${col.index}`));
containerCols.forEach(col => dynamicOrder.push(`con_${col.index}`));
@@ -1545,8 +1585,19 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'productType', columnWidths.productType); }} />
</th>
<th style={{
width: columnWidths.itemToLogistic,
<th style={{ width: columnWidths.empco, ...(isPinned('empco') ? { left: getStickyLeft('empco') ?? 0, zIndex: getHeaderStickyRank('empco') ?? 0 } : {}) }} className={cn(
"text-left px-3 py-3 text-xs font-semibold text-teal-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
isPinned('empco') && "sticky bg-slate-900"
)} onClick={() => handleSort('empco')}>
<span className="flex items-center gap-1">
{isPinned('empco') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
EMCO Compliant <SortIcon current={sortConfig.key === 'empco' ? sortConfig.direction : null} />
</span>
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'empco', columnWidths.empco); }} />
</th>
<th style={{
width: columnWidths.itemToLogistic,
...(isPinned('itemToLogistic')
? { left: getStickyLeft('itemToLogistic') ?? 0, zIndex: openFilter === 'logistic' ? 500 : (getHeaderStickyRank('itemToLogistic') ?? 0) }
: (openFilter === 'logistic' ? { zIndex: 500, position: 'relative' } : {}))
@@ -2111,6 +2162,23 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
)}
</td>
{/* 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 } : {}}>
{(() => {
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 (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>;
})()}
</td>
{/* ITEM TO LOGISTIC cell */}
<td className={cn("px-3 py-2 overflow-visible relative", isPinned('itemToLogistic') && "sticky bg-slate-800")} style={isPinned('itemToLogistic') ? { left: getStickyLeft('itemToLogistic') ?? 0, zIndex: getStickyRank('itemToLogistic') ?? 0 } : {}}>
{editingLogistic?.rowIndex === dataIndex ? (