feat(bc): sync categorization code

This commit is contained in:
Christian Vidal Wolf
2026-07-02 15:49:52 +02:00
parent 9e19a8cace
commit 633f523fc8
7 changed files with 146 additions and 25 deletions
+64 -5
View File
@@ -59,6 +59,16 @@ const LEGACY_CPNP_INDEX = 77;
const DROPBOX_PROXY_URL = '/api/dropbox-proxy';
const DROPBOX_FILE_URL = '/dropbox-file/scl/fi/usa8me7ywgylrij2bt6hj/Data-Matrix.xlsx?rlkey=tsec8csrhye54u1fdvk15ped1&st=qbxxs4cn&dl=0';
function normalizeImportedHeaders(headers: any[]): string[] {
const hasCategorizationCode = headers.some(header =>
String(header ?? '').toLowerCase().trim().replace(/[\s_-]+/g, '') === 'categorizationcode'
);
return headers.map(header => {
const label = String(header ?? '');
return !hasCategorizationCode && label.trim().toUpperCase() === 'TYPE' ? 'CategorizationCode' : label;
});
}
type BcSyncStatus = 'queued' | 'previewed' | 'preview_only' | 'syncing' | 'synced' | 'failed';
interface BcSyncQueueEntry {
@@ -249,7 +259,7 @@ export default function App() {
}
if (rows.length > 0) {
const headers = allData.slice(0, 1)[0];
const headers = normalizeImportedHeaders(allData.slice(0, 1)[0]);
console.log('Syncing Excel data to Supabase...');
const syncRes = await fetch('/api/dropbox-sync', {
@@ -273,14 +283,14 @@ export default function App() {
console.log('Supabase synced rows:', Object.keys(syncedData).length, '| History rows:', Object.keys(historyData).length);
// Extend headers with virtual columns if the Excel is shorter than the saved data.
// COLUMNS hardcoded indices (PRODUCT_TYPE=103, ITEM_TO_LOGISTIC=104, etc.) are used
// COLUMNS hardcoded indices (CATEGORIZATION_CODE=103, ITEM_TO_LOGISTIC=104, etc.) are used
// by older saved rows — ensure headers covers them so the merge and render work.
const extendedHeaders = [...headers];
const VIRTUAL_COLS: Record<number, string> = {
[COLUMNS.VERIFIED_DIMS]: 'Verified Dims',
[COLUMNS.VALIDATED_CHECK]: 'Validated',
[COLUMNS.VALIDATED_NOTE]: 'Note',
[COLUMNS.PRODUCT_TYPE]: 'TYPE',
[COLUMNS.CATEGORIZATION_CODE]: 'CategorizationCode',
[COLUMNS.ITEM_TO_LOGISTIC]: 'Item to Logistic',
[COLUMNS.ANNA_CHECK]: 'Anna Check',
[COLUMNS.ANNA_NOTE]: 'Anna Note',
@@ -448,7 +458,7 @@ export default function App() {
const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
if (data.length > 0) {
const headers = data[0];
const headers = normalizeImportedHeaders(data[0]);
const rawRows = data.slice(1);
const resolvedCols = resolveColumnIndices(headers);
@@ -733,6 +743,18 @@ export default function App() {
[bcQueueEntries]
);
const bcQueueDisplayEntries = useMemo(() => {
const queueColumns = resolveColumnIndices(appState.headers);
return Object.fromEntries((Object.entries(bcSyncQueue) as Array<[string, BcSyncQueueEntry]>).map(([articleNo, entry]) => [articleNo, {
articleName: entry.articleName,
selected: entry.selected,
status: entry.status,
error: entry.error,
warning: entry.warning,
categorizationCode: String(entry.newData[queueColumns.CATEGORIZATION_CODE] ?? '').trim(),
}]));
}, [bcSyncQueue, appState.headers]);
useEffect(() => {
if (appState.data.length === 0) return;
const validArticles = new Set(appState.data.map(row => String(row[COLUMNS.ARTICLE_NO] ?? '').trim()).filter(Boolean));
@@ -794,12 +816,26 @@ export default function App() {
const handleSyncSelectedBcSync = async () => {
if (bcQueueEntries.length === 0) return;
setIsSyncingBC(true);
const summary = {
synced: [] as string[],
failed: [] as string[],
skippedEmptyCategorization: [] as string[],
};
try {
const syncColumns = resolveColumnIndices(appState.headers);
const entriesToSync = [...bcQueueEntries].sort((a, b) => {
const rowDelta = a[1].rowIndex - b[1].rowIndex;
if (rowDelta !== 0) return rowDelta;
return a[0].localeCompare(b[0]);
});
const plannedCategorizationUpdates = entriesToSync
.map(([articleNo, item]) => ({
articleNo,
categorizationCode: String(item.newData[syncColumns.CATEGORIZATION_CODE] ?? '').trim(),
}))
.filter(item => item.categorizationCode);
console.table(plannedCategorizationUpdates);
setBcSyncQueue(prev => {
const next: BcSyncQueue = { ...prev };
@@ -812,6 +848,14 @@ export default function App() {
});
for (const [articleNo, item] of entriesToSync) {
const categorizationCode = String(item.newData[syncColumns.CATEGORIZATION_CODE] ?? '').trim();
if (!categorizationCode) {
console.info(`[BC sync] ${articleNo}: CategorizationCode is empty, so categorizationCode will not be sent.`);
summary.skippedEmptyCategorization.push(articleNo);
} else {
console.info(`[BC sync] ${articleNo}: will send categorizationCode=${categorizationCode}`);
}
const preview = await previewBusinessCentralSync(appState.headers, item.newData);
if (!preview.success) {
setBcSyncQueue(prev => ({
@@ -823,6 +867,7 @@ export default function App() {
warning: undefined,
},
}));
summary.failed.push(`${articleNo}: ${preview.error || 'Preview failed'}`);
continue;
}
@@ -850,6 +895,7 @@ export default function App() {
return next;
});
setRowStatuses(prev => ({ ...prev, [articleNo]: 'synced' }));
summary.synced.push(articleNo);
continue;
}
@@ -862,6 +908,7 @@ export default function App() {
warning: retryResult.warning,
},
}));
summary.failed.push(`${articleNo}: ${retryResult.error || 'BC sync failed'}`);
continue;
}
}
@@ -874,6 +921,7 @@ export default function App() {
warning: result.warning,
},
}));
summary.failed.push(`${articleNo}: ${result.error || 'BC sync failed'}`);
continue;
}
@@ -883,9 +931,20 @@ export default function App() {
return next;
});
setRowStatuses(prev => ({ ...prev, [articleNo]: 'synced' }));
summary.synced.push(articleNo);
}
} finally {
setIsSyncingBC(false);
const lines = [
`Business Central sync finished.`,
`Synced: ${summary.synced.length}`,
`Failed: ${summary.failed.length}`,
`Empty CategorizationCode skipped: ${summary.skippedEmptyCategorization.length}`,
];
if (summary.failed.length > 0) {
lines.push('', 'Failures:', ...summary.failed.slice(0, 10));
}
alert(lines.join('\n'));
}
};
@@ -987,7 +1046,7 @@ export default function App() {
onRevertRow={handleRevertRow}
isSavingAll={isSavingAll}
bcQueueCount={Object.keys(bcSyncQueue).length}
bcQueueEntries={Object.fromEntries((Object.entries(bcSyncQueue) as Array<[string, BcSyncQueueEntry]>).map(([k, v]) => [k, { articleName: v.articleName, selected: v.selected, status: v.status, error: v.error, warning: v.warning }]))}
bcQueueEntries={bcQueueDisplayEntries}
onToggleBcQueueSelection={handleToggleBcQueueSelection}
onSelectAllBcQueue={handleSelectAllBcQueue}
onPreviewSelectedBcSync={handlePreviewSelectedBcSync}
+4 -4
View File
@@ -185,7 +185,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
outerH: row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? String(row[COLUMNS.OUTER_H]) : '',
unitsOuter: row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? String(row[COLUMNS.UNITS_OUTER]) : '',
moq: row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? String(row[COLUMNS.MOQ]) : '',
productType: row[COLUMNS.PRODUCT_TYPE] !== undefined && row[COLUMNS.PRODUCT_TYPE] !== null ? String(row[COLUMNS.PRODUCT_TYPE]) : '',
productType: row[COLUMNS.CATEGORIZATION_CODE] !== undefined && row[COLUMNS.CATEGORIZATION_CODE] !== null ? String(row[COLUMNS.CATEGORIZATION_CODE]) : '',
itemToLogistic: row[COLUMNS.ITEM_TO_LOGISTIC] !== undefined && row[COLUMNS.ITEM_TO_LOGISTIC] !== null ? String(row[COLUMNS.ITEM_TO_LOGISTIC]) : '',
});
@@ -222,7 +222,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
moq: COLUMNS.MOQ,
detailsDe: COLUMNS.DETAILS_DE,
detailsEn: COLUMNS.DETAILS_EN,
productType: COLUMNS.PRODUCT_TYPE,
productType: COLUMNS.CATEGORIZATION_CODE,
itemToLogistic: COLUMNS.ITEM_TO_LOGISTIC,
};
const colIndex = (colMap as Record<string, number>)[field as string];
@@ -360,7 +360,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
newRow[COLUMNS.MOQ] = formData.moq;
newRow[COLUMNS.DETAILS_DE] = formData.detailsDe;
newRow[COLUMNS.DETAILS_EN] = formData.detailsEn;
newRow[COLUMNS.PRODUCT_TYPE] = formData.productType;
newRow[COLUMNS.CATEGORIZATION_CODE] = formData.productType;
newRow[COLUMNS.ITEM_TO_LOGISTIC] = formData.itemToLogistic;
onSave(rowIndex, newRow);
};
@@ -450,7 +450,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
</div>
<div className="grid grid-cols-2 gap-3 pt-2 border-t border-slate-700/30">
<DimensionInput label="Type" value={formData.productType} field="productType" isModified={isModified('productType')} onChange={(val) => setFormData(p => ({...p, productType: val}))} />
<DimensionInput label="CategorizationCode" value={formData.productType} field="productType" isModified={isModified('productType')} onChange={(val) => setFormData(p => ({...p, productType: val}))} />
<DimensionInput label="Item to Logistic" value={formData.itemToLogistic} field="itemToLogistic" isModified={isModified('itemToLogistic')} onChange={(val) => setFormData(p => ({...p, itemToLogistic: val}))} />
</div>
</div>
+13 -13
View File
@@ -405,7 +405,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
[data]);
const uniqueProductTypes = useMemo(() =>
Array.from(new Set(data.map(r => String(r[COLUMNS.PRODUCT_TYPE] || '')).filter(Boolean))).sort(),
Array.from(new Set(data.map(r => String(r[COLUMNS.CATEGORIZATION_CODE] || '')).filter(Boolean))).sort(),
[data]);
const uniqueUnitsOuter = useMemo(() =>
@@ -518,7 +518,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
result = result.filter(r => (classificationFilter || []).includes(String(r.row[COLUMNS.CLASSIFICATION] || '')));
}
if ((productTypeFilter || []).length > 0) {
result = result.filter(r => (productTypeFilter || []).includes(String(r.row[COLUMNS.PRODUCT_TYPE] || '')));
result = result.filter(r => (productTypeFilter || []).includes(String(r.row[COLUMNS.CATEGORIZATION_CODE] || '')));
}
if ((unitsOuterFilter || []).length > 0) {
result = result.filter(r => (unitsOuterFilter || []).includes(String(r.row[unitsOuterIdx] || '')));
@@ -615,7 +615,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
case 'articleName': colIndex = COLUMNS.ARTICLE_NAME; break;
case 'line': colIndex = COLUMNS.LINE; break;
case 'classification': colIndex = COLUMNS.CLASSIFICATION; break;
case 'productType': colIndex = COLUMNS.PRODUCT_TYPE; break;
case 'productType': colIndex = COLUMNS.CATEGORIZATION_CODE; break;
case 'unitsOuter': colIndex = unitsOuterIdx; break;
case 'outerW': colIndex = COLUMNS.OUTER_W; break;
case 'outerL': colIndex = COLUMNS.OUTER_L; break;
@@ -704,9 +704,9 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
if (e.key === 'Escape') cancelEdit();
};
// ── All unique product types (for autocomplete) ───────────────────────────
// ── All unique categorization codes (for autocomplete) ────────────────────
const allProductTypes = useMemo(() =>
Array.from(new Set(data.map(r => String(r[COLUMNS.PRODUCT_TYPE] || '')).filter(Boolean))).sort()
Array.from(new Set(data.map(r => String(r[COLUMNS.CATEGORIZATION_CODE] || '')).filter(Boolean))).sort()
, [data]);
const startEditType = (rowIndex: number, currentValue: string) => {
@@ -720,8 +720,8 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
setTypeSuggestions([]);
const original = data[rowIndex];
const newRow = [...original];
newRow[COLUMNS.PRODUCT_TYPE] = value;
onCaptureState(`Updated type for ${original[COLUMNS.ARTICLE_NO]}`);
newRow[COLUMNS.CATEGORIZATION_CODE] = value;
onCaptureState(`Updated CategorizationCode for ${original[COLUMNS.ARTICLE_NO]}`);
await onSaveRow(rowIndex, newRow);
}, [data, onSaveRow, onCaptureState]);
@@ -760,7 +760,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
{ key: 'articleName', label: 'Article Name', index: COLUMNS.ARTICLE_NAME },
{ key: 'line', label: 'Line', index: COLUMNS.LINE },
{ key: 'classification', label: 'Classification', index: COLUMNS.CLASSIFICATION },
{ key: 'productType', label: 'Type', index: COLUMNS.PRODUCT_TYPE },
{ key: 'productType', label: 'CategorizationCode', index: COLUMNS.CATEGORIZATION_CODE },
{ 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 },
@@ -1521,7 +1521,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
)} onClick={() => handleSort('productType')}>
<span className="flex items-center gap-1">
{isPinned('productType') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
Type <SortIcon current={sortConfig.key === 'productType' ? sortConfig.direction : null} />
CategorizationCode <SortIcon current={sortConfig.key === 'productType' ? sortConfig.direction : null} />
<button
id="filter-trigger-type"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setOpenFilter(openFilter === 'productType' ? null : 'productType'); }}
@@ -2065,7 +2065,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
</span>
</td>
{/* TYPE cell */}
{/* CategorizationCode cell */}
<td className={cn("px-3 py-2 overflow-visible relative", isPinned('productType') && "sticky bg-slate-800")} style={isPinned('productType') ? { left: getStickyLeft('productType') ?? 0, zIndex: getStickyRank('productType') ?? 0 } : {}}>
{editingType?.rowIndex === dataIndex ? (
<div className="relative">
@@ -2097,15 +2097,15 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
</div>
) : (
<button
onClick={() => startEditType(dataIndex, String(row[COLUMNS.PRODUCT_TYPE] || ''))}
onClick={() => startEditType(dataIndex, String(row[COLUMNS.CATEGORIZATION_CODE] || ''))}
className={cn(
'group flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors hover:bg-slate-700/60 w-full overflow-hidden',
row[COLUMNS.PRODUCT_TYPE]
row[COLUMNS.CATEGORIZATION_CODE]
? 'text-purple-300 border border-transparent hover:border-slate-600'
: 'text-slate-500 border border-dashed border-slate-600 hover:border-purple-500/50'
)}
>
<span className="truncate">{row[COLUMNS.PRODUCT_TYPE] || 'Add type…'}</span>
<span className="truncate">{row[COLUMNS.CATEGORIZATION_CODE] || 'Add CategorizationCode...'}</span>
<Edit2 className="w-2.5 h-2.5 opacity-0 group-hover:opacity-50 shrink-0" />
</button>
)}
+4 -1
View File
@@ -18,7 +18,7 @@ interface TopBarProps {
onRevertRow: (articleNo: string) => void;
isSavingAll: boolean;
bcQueueCount: number;
bcQueueEntries: Record<string, { articleName: string; selected: boolean; status: string; error?: string; warning?: string }>;
bcQueueEntries: Record<string, { articleName: string; selected: boolean; status: string; error?: string; warning?: string; categorizationCode?: string }>;
onToggleBcQueueSelection: (articleNo: string) => void;
onSelectAllBcQueue: (selected: boolean) => void;
onPreviewSelectedBcSync: () => Promise<void>;
@@ -274,6 +274,9 @@ export function TopBar({ stats, activeModule, onRefresh, userEmail, onSignOut, c
<div className="flex-1 min-w-0">
<p className="text-xs font-mono text-slate-400">{articleNo}</p>
<p className="text-sm text-white truncate">{entry.articleName}</p>
<p className="text-[10px] text-cyan-300 truncate">
CategorizationCode: {entry.categorizationCode || 'empty - skipped'}
</p>
<p className="text-[10px] text-slate-500 uppercase">
{formatQueueStatus(entry.status)}
{entry.error ? ` · ${entry.error}` : ''}
+23 -1
View File
@@ -24,6 +24,17 @@ function findHeaderIndex(headers: string[], patterns: string[]): number {
);
}
function findCategorizationCodeIndex(headers: string[]): number {
const normalized = headers.map(h => String(h || '').toLowerCase().trim());
const categorizationIdx = normalized.findIndex(header => header.replace(/[\s_-]+/g, '') === 'categorizationcode');
if (categorizationIdx >= 0) return categorizationIdx;
const typeIdx = normalized.findIndex(header => header === 'type');
if (typeIdx >= 0) return typeIdx;
return normalized.findIndex(header => header.includes('product') && header.includes('type'));
}
function formatDateForBc(value: any): string | null {
if (value === null || value === undefined || value === '') return null;
@@ -81,6 +92,7 @@ export function buildBusinessCentralMappingPreview(headers: string[], row: Excel
const shortDeIdx = findHeaderIndex(headers, ['short', 'description', 'german']);
const shortEnIdx = findHeaderIndex(headers, ['short', 'description', 'english']);
const cpnpIdx = findHeaderIndex(headers, ['cpnp']);
const categorizationCodeIdx = findCategorizationCodeIndex(headers);
const unitsOuterIdx = findHeaderIndex(headers, ['units', 'outer']);
const units40HqIdx = (() => {
@@ -94,7 +106,10 @@ export function buildBusinessCentralMappingPreview(headers: string[], row: Excel
const articleNo = String(getValue(row, articleNoIdx) ?? '');
const itemsPayload = {
const categorizationCode = getValue(row, categorizationCodeIdx);
const hasCategorizationCode = String(categorizationCode ?? '').trim() !== '';
const itemsPayload: Record<string, any> = {
no: getValue(row, articleNoIdx),
articleDetailsEnglish: getValue(row, articleDetailsEnIdx),
articleDetailsGerman: getValue(row, articleDetailsDeIdx),
@@ -106,6 +121,10 @@ export function buildBusinessCentralMappingPreview(headers: string[], row: Excel
cpnpNo: getValue(row, cpnpIdx),
};
if (hasCategorizationCode) {
itemsPayload.categorizationCode = String(categorizationCode).trim();
}
const itemUnitsOfMeasurePayload = {
itemNo: getValue(row, articleNoIdx),
code: 'OUTER',
@@ -136,6 +155,9 @@ export function buildBusinessCentralMappingPreview(headers: string[], row: Excel
makeFieldPreview('Short Description - German', shortDeIdx, 'shortDescriptionInGerman', itemsPayload.shortDescriptionInGerman),
makeFieldPreview('Short Description - English', shortEnIdx, 'shortDescriptionInEnglish', itemsPayload.shortDescriptionInEnglish),
makeFieldPreview('CPNP', cpnpIdx, 'cpnpNo', itemsPayload.cpnpNo),
...(hasCategorizationCode
? [makeFieldPreview('CategorizationCode', categorizationCodeIdx, 'categorizationCode', itemsPayload.categorizationCode)]
: []),
],
itemUnitsFields: [
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnitsOfMeasurePayload.itemNo),
+15 -1
View File
@@ -40,6 +40,7 @@ export const COLUMNS = {
VERIFIED_DIMS: 100,
VALIDATED_CHECK: 101,
VALIDATED_NOTE: 102,
CATEGORIZATION_CODE: 103,
PRODUCT_TYPE: 103,
ITEM_TO_LOGISTIC: 104,
ANNA_CHECK: 105,
@@ -78,6 +79,7 @@ export const COLUMN_PATTERNS: Record<keyof typeof COLUMNS, string[]> = {
VERIFIED_DIMS: ['verified', 'dims'],
VALIDATED_CHECK: ['validated', 'check'],
VALIDATED_NOTE: ['validated', 'note'],
CATEGORIZATION_CODE: ['categorization', 'code'],
PRODUCT_TYPE: ['product', 'type'],
ITEM_TO_LOGISTIC: ['item', 'logistic'],
ANNA_CHECK: ['anna', 'check'],
@@ -101,5 +103,17 @@ export function resolveColumnIndices(headers: string[]): typeof COLUMNS {
}
});
let categorizationIdx = h.findIndex(headerText => headerText.replace(/[\s_-]+/g, '') === 'categorizationcode');
if (categorizationIdx < 0) {
categorizationIdx = h.findIndex(headerText => headerText.trim() === 'type');
}
if (categorizationIdx >= 0) {
resolved.CATEGORIZATION_CODE = categorizationIdx;
resolved.PRODUCT_TYPE = categorizationIdx;
} else {
resolved.CATEGORIZATION_CODE = resolved.PRODUCT_TYPE;
}
return resolved;
}
}