From 633f523fc867d8759d40e94cc7bc85646aea5233 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Thu, 2 Jul 2026 15:49:52 +0200 Subject: [PATCH] feat(bc): sync categorization code --- bc-sync-runtime.js | 23 +++++++++ src/App.tsx | 69 ++++++++++++++++++++++++-- src/components/EditPanel.tsx | 8 +-- src/components/PricingView.tsx | 26 +++++----- src/components/TopBar.tsx | 5 +- src/services/businessCentralMapping.ts | 24 ++++++++- src/types.ts | 16 +++++- 7 files changed, 146 insertions(+), 25 deletions(-) diff --git a/bc-sync-runtime.js b/bc-sync-runtime.js index cb6fcbb..28142b9 100644 --- a/bc-sync-runtime.js +++ b/bc-sync-runtime.js @@ -8,6 +8,17 @@ function findHeaderIndex(headers, patterns) { ); } +function findCategorizationCodeIndex(headers) { + 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) { if (value === null || value === undefined || value === '') return null; @@ -60,6 +71,7 @@ export function buildBusinessCentralMappingPreview(headers, row) { 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 = (() => { @@ -76,6 +88,9 @@ export function buildBusinessCentralMappingPreview(headers, row) { const articleNo = String(getValue(row, articleNoIdx) ?? ''); + const categorizationCode = getValue(row, categorizationCodeIdx); + const hasCategorizationCode = String(categorizationCode ?? '').trim() !== ''; + const itemsPayload = { no: getValue(row, articleNoIdx), articleDetailsEnglish: getValue(row, articleDetailsEnIdx), @@ -88,6 +103,10 @@ export function buildBusinessCentralMappingPreview(headers, row) { cpnpNo: getValue(row, cpnpIdx), }; + if (hasCategorizationCode) { + itemsPayload.categorizationCode = String(categorizationCode).trim(); + } + const itemUnitsOfMeasurePayload = { itemNo: getValue(row, articleNoIdx), code: 'OUTER', @@ -118,6 +137,9 @@ export function buildBusinessCentralMappingPreview(headers, row) { 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), @@ -239,6 +261,7 @@ function getItemsSelect() { 'shortDescriptionInGerman', 'shortDescriptionInEnglish', 'cpnpNo', + 'categorizationCode', ].join(','); } diff --git a/src/App.tsx b/src/App.tsx index 081a0e2..cf148d0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = { [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(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} diff --git a/src/components/EditPanel.tsx b/src/components/EditPanel.tsx index f32b448..fa347f2 100644 --- a/src/components/EditPanel.tsx +++ b/src/components/EditPanel.tsx @@ -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)[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
- setFormData(p => ({...p, productType: val}))} /> + setFormData(p => ({...p, productType: val}))} /> setFormData(p => ({...p, itemToLogistic: val}))} />
diff --git a/src/components/PricingView.tsx b/src/components/PricingView.tsx index 403da9f..c49e8ab 100644 --- a/src/components/PricingView.tsx +++ b/src/components/PricingView.tsx @@ -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')}> {isPinned('productType') && } - Type + CategorizationCode )} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 4af459c..22b8d37 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -18,7 +18,7 @@ interface TopBarProps { onRevertRow: (articleNo: string) => void; isSavingAll: boolean; bcQueueCount: number; - bcQueueEntries: Record; + bcQueueEntries: Record; onToggleBcQueueSelection: (articleNo: string) => void; onSelectAllBcQueue: (selected: boolean) => void; onPreviewSelectedBcSync: () => Promise; @@ -274,6 +274,9 @@ export function TopBar({ stats, activeModule, onRefresh, userEmail, onSignOut, c

{articleNo}

{entry.articleName}

+

+ CategorizationCode: {entry.categorizationCode || 'empty - skipped'} +

{formatQueueStatus(entry.status)} {entry.error ? ` · ${entry.error}` : ''} diff --git a/src/services/businessCentralMapping.ts b/src/services/businessCentralMapping.ts index 3205d0f..79ec324 100644 --- a/src/services/businessCentralMapping.ts +++ b/src/services/businessCentralMapping.ts @@ -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 = { 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), diff --git a/src/types.ts b/src/types.ts index dc9b399..5c282ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = { 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; -} \ No newline at end of file +}