fix: commit all BC sync files missing from repo

This commit is contained in:
Christian Vidal Wolf
2026-05-15 11:00:21 +02:00
parent 85794b4b52
commit 143f655f3c
14 changed files with 1701 additions and 49 deletions
+282 -25
View File
@@ -21,7 +21,7 @@ import { UndoToast } from './components/UndoToast';
import { PendingValidationView } from './components/PendingValidationView';
import { MissingDataView } from './components/MissingDataView';
import { CosmeticItemsView } from './components/CosmeticItemsView';
import { downloadBusinessCentralItemsExcel, updateCpnpNoInBC } from './services/businessCentral';
import { downloadBusinessCentralItemsExcel, previewBusinessCentralSync, applyBusinessCentralSync, isPreviewTokenMismatchError } from './services/businessCentral';
const FORCED_ZERO_STOCK_SKUS = new Set([
'11631VC', '1237VC', '1238VC', '1652VC', '1653VC', '1684VC', '1688VC', '1717VC',
'1718VC', '180VC', '1832VC', '2025VC', '2027VC', '2180VC', '2181VC', '2210VC',
@@ -50,6 +50,36 @@ const FORCED_ZERO_STOCK_SKUS = new Set([
'6471VC', '5658VCI', '2915244CLM', '30512912CLM', '2861262CLM', '41683MDRG', '41891MDRG'
]);
const BC_SYNC_QUEUE_STORAGE_KEY = 'craze_bc_sync_queue';
type BcSyncStatus = 'queued' | 'previewed' | 'syncing' | 'synced' | 'failed';
interface BcSyncQueueEntry {
rowIndex: number;
originalData: ExcelRow;
newData: ExcelRow;
articleName: string;
selected: boolean;
status: BcSyncStatus;
previewToken?: string;
error?: string;
source: 'save_all' | 'cosmetic_items' | 'manual';
}
type BcSyncQueue = Record<string, BcSyncQueueEntry>;
function readStoredBcSyncQueue(): BcSyncQueue {
try {
const raw = localStorage.getItem(BC_SYNC_QUEUE_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as BcSyncQueue;
} catch {
return {};
}
}
export default function App() {
const [session, setSession] = useState<AuthSession | null>(() => getStoredSession());
@@ -68,7 +98,9 @@ export default function App() {
const [defaultLoadError, setDefaultLoadError] = useState<string | null>(null);
const [rowStatuses, setRowStatuses] = useState<Record<string, string>>({});
const [pendingRows, setPendingRows] = useState<Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }>>({});
const [bcSyncQueue, setBcSyncQueue] = useState<BcSyncQueue>(() => readStoredBcSyncQueue());
const [isSavingAll, setIsSavingAll] = useState(false);
const [isSyncingBC, setIsSyncingBC] = useState(false);
const [isDownloadingBCExcel, setIsDownloadingBCExcel] = useState(false);
const [refreshTrigger, setRefreshTrigger] = useState(0);
@@ -76,6 +108,14 @@ export default function App() {
console.log('[App] session changed:', session ? 'logged in' : 'logged out');
}, [session]);
useEffect(() => {
try {
localStorage.setItem(BC_SYNC_QUEUE_STORAGE_KEY, JSON.stringify(bcSyncQueue));
} catch {
// Ignore storage quota or serialization errors.
}
}, [bcSyncQueue]);
const handleSignOut = () => {
signOut();
setSession(null);
@@ -231,18 +271,31 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
finalRow.push(null);
}
// Merge logic: Prioritize internal control columns from syncedData or historyData
// Use synced.data only if it has internal cols (>= 100), otherwise use hist
const syncedHasInternalCols = synced?.data && (synced.data.length > 100 || Object.values(COLUMNS).some(idx => idx >= 100 && synced.data[idx] !== undefined && synced.data[idx] !== null));
const sourceForInternal = syncedHasInternalCols ? synced!.data : (hist || synced?.data);
if (sourceForInternal) {
// 1. ALWAYS restore Internal Control Columns (indices >= 100)
// Merge logic:
// - Use the Dropbox row as the base for master data and prices.
// - Restore internal control columns from history when available.
// This is critical because some synced rows still have empty internal
// slots even when the change history already contains the corrected value.
const mergedInternalSource = hist || synced?.data;
if (mergedInternalSource) {
// 1. Restore internal control columns (indices >= 100)
// These are the "TYPE", "Item to Logistic", "Checking", etc.
// We use hardcoded indices from COLUMNS to ensure they stay at the end.
// We use hardcoded indices from COLUMNS to keep them stable.
Object.values(COLUMNS).forEach(idx => {
if (idx >= 100 && sourceForInternal[idx] !== undefined && sourceForInternal[idx] !== null) {
finalRow[idx] = sourceForInternal[idx];
if (idx < 100) return;
const historyValue = hist?.[idx];
const syncedValue = synced?.data?.[idx];
const value =
historyValue !== undefined && historyValue !== null && historyValue !== ''
? historyValue
: syncedValue !== undefined && syncedValue !== null && syncedValue !== ''
? syncedValue
: undefined;
if (value !== undefined) {
finalRow[idx] = value;
}
});
@@ -250,8 +303,9 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
// This protects against the "Column Shift" bug where old saved indices might be wrong.
if (synced?.status === 'pending' || synced?.status === 'edited') {
editableColumns.forEach(idx => {
if (idx < 100 && sourceForInternal[idx] !== undefined && sourceForInternal[idx] !== null) {
finalRow[idx] = sourceForInternal[idx];
const value = synced?.data?.[idx];
if (idx < 100 && value !== undefined && value !== null) {
finalRow[idx] = value;
}
});
}
@@ -529,6 +583,12 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
});
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
setRowStatuses(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
setBcSyncQueue(prev => {
if (!prev[articleNo]) return prev;
const n = { ...prev };
delete n[articleNo];
return n;
});
};
const handleSaveAll = async () => {
@@ -544,28 +604,32 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
let sessionIssue = false;
try {
await Promise.all(entries.map(async ([articleNo, { newData, originalData, articleName }]) => {
await Promise.all(entries.map(async ([articleNo, { rowIndex, newData, originalData, articleName }]) => {
console.log('[handleSaveAll] Saving article:', articleNo);
const result = await saveRowToSupabase(articleNo, newData);
console.log('[handleSaveAll] Save result for', articleNo, ':', result);
if (result.success) {
const cpnpValue = String(newData[COLUMNS.CPNP_NO] ?? '').trim();
const cpnpChanged = cpnpValue !== String(originalData[COLUMNS.CPNP_NO] ?? '').trim();
const [histRes, bcResult] = await Promise.all([
saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown'),
cpnpValue && cpnpChanged ? updateCpnpNoInBC(articleNo, cpnpValue) : Promise.resolve({ success: true }),
]);
const histRes = await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown');
if (!histRes.success) {
console.warn(`[handleSaveAll] History save failed for ${articleNo}:`, histRes.error);
}
if (!bcResult.success) {
console.warn(`[handleSaveAll] BC sync failed for ${articleNo}:`, (bcResult as any).error);
}
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
setBcSyncQueue(prev => ({
...prev,
[articleNo]: {
rowIndex,
originalData,
newData,
articleName,
selected: true,
status: 'queued',
source: 'save_all',
},
}));
setRowStatuses(prev => ({ ...prev, [articleNo]: 'bc_pending' }));
setPendingRows(prev => {
const n = { ...prev };
delete n[articleNo];
@@ -598,6 +662,189 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
}
};
const handleQueueBcSync = (articleNo: string, rowIndex: number, originalData: ExcelRow, newData: ExcelRow, articleName: string) => {
setBcSyncQueue(prev => ({
...prev,
[articleNo]: {
rowIndex,
originalData,
newData,
articleName,
selected: true,
status: 'queued',
source: 'cosmetic_items',
},
}));
setRowStatuses(prev => ({ ...prev, [articleNo]: 'bc_pending' }));
};
const handleToggleBcQueueSelection = (articleNo: string) => {
setBcSyncQueue(prev => {
const item = prev[articleNo];
if (!item) return prev;
return {
...prev,
[articleNo]: {
...item,
selected: !item.selected,
},
};
});
};
const handleSelectAllBcQueue = (selected: boolean) => {
setBcSyncQueue(prev => {
const next: BcSyncQueue = { ...prev };
(Object.keys(next) as string[]).forEach(articleNo => {
const item = next[articleNo];
next[articleNo] = { ...item, selected };
});
return next;
});
};
const bcQueueEntries = useMemo<Array<[string, BcSyncQueueEntry]>>(
() => Object.entries(bcSyncQueue) as Array<[string, BcSyncQueueEntry]>,
[bcSyncQueue]
);
const selectedBcQueueEntries = useMemo<Array<[string, BcSyncQueueEntry]>>(
() => bcQueueEntries.filter(([, item]) => item.selected),
[bcQueueEntries]
);
useEffect(() => {
if (appState.data.length === 0) return;
const validArticles = new Set(appState.data.map(row => String(row[COLUMNS.ARTICLE_NO] ?? '').trim()).filter(Boolean));
setBcSyncQueue(prev => {
let changed = false;
const next: BcSyncQueue = {};
for (const [articleNo, item] of Object.entries(prev) as Array<[string, BcSyncQueueEntry]>) {
if (!validArticles.has(articleNo)) {
changed = true;
continue;
}
next[articleNo] = item;
}
return changed ? next : prev;
});
}, [appState.data]);
const handlePreviewSelectedBcSync = async () => {
if (selectedBcQueueEntries.length === 0) return;
setIsSyncingBC(true);
try {
const results = await Promise.all(selectedBcQueueEntries.map(async ([articleNo, item]) => {
const preview = await previewBusinessCentralSync(appState.headers, item.newData);
return [articleNo, preview] as const;
}));
setBcSyncQueue(prev => {
const next: BcSyncQueue = { ...prev };
results.forEach(([articleNo, preview]) => {
const item = next[articleNo];
if (!item || !preview.success) {
if (item) {
next[articleNo] = {
...item,
status: 'failed',
error: preview.error || 'Preview failed',
};
}
return;
}
next[articleNo] = {
...item,
status: 'previewed',
previewToken: preview.previewToken,
error: undefined,
};
});
return next;
});
} finally {
setIsSyncingBC(false);
}
};
const handleSyncSelectedBcSync = async () => {
if (selectedBcQueueEntries.length === 0) return;
setIsSyncingBC(true);
try {
const selectedArticles = new Set(selectedBcQueueEntries.map(([articleNo]) => articleNo));
setBcSyncQueue(prev => {
const next: BcSyncQueue = { ...prev };
(Object.entries(next) as Array<[string, BcSyncQueueEntry]>).forEach(([articleNo, item]) => {
if (selectedArticles.has(articleNo)) {
next[articleNo] = { ...item, status: 'syncing', error: undefined };
}
});
return next;
});
for (const [articleNo, item] of selectedBcQueueEntries) {
const result = await applyBusinessCentralSync(appState.headers, item.newData, item.previewToken);
if (!result.success) {
if (isPreviewTokenMismatchError(result.error)) {
const refreshedPreview = await previewBusinessCentralSync(appState.headers, item.newData);
if (refreshedPreview.success) {
setBcSyncQueue(prev => ({
...prev,
[articleNo]: {
...prev[articleNo],
status: 'previewed',
previewToken: refreshedPreview.previewToken,
error: undefined,
},
}));
const retryResult = await applyBusinessCentralSync(appState.headers, item.newData, refreshedPreview.previewToken);
if (retryResult.success) {
setBcSyncQueue(prev => {
const next = { ...prev };
delete next[articleNo];
return next;
});
setRowStatuses(prev => ({ ...prev, [articleNo]: 'synced' }));
continue;
}
setBcSyncQueue(prev => ({
...prev,
[articleNo]: {
...prev[articleNo],
status: 'failed',
error: retryResult.error || 'BC sync failed',
},
}));
continue;
}
}
setBcSyncQueue(prev => ({
...prev,
[articleNo]: {
...prev[articleNo],
status: 'failed',
error: result.error || 'BC sync failed',
},
}));
continue;
}
setBcSyncQueue(prev => {
const next = { ...prev };
delete next[articleNo];
return next;
});
setRowStatuses(prev => ({ ...prev, [articleNo]: 'synced' }));
}
} finally {
setIsSyncingBC(false);
}
};
const handleDownloadBCExcel = async () => {
setIsDownloadingBCExcel(true);
try {
@@ -616,6 +863,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
data: JSON.parse(JSON.stringify(appState.data)), // Deep copy
rowStatuses: { ...rowStatuses },
pendingRows: { ...pendingRows },
bcSyncQueue: JSON.parse(JSON.stringify(bcSyncQueue)) as BcSyncQueue,
message
};
// Keep last 50 steps
@@ -639,6 +887,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
// Restore statuses and pending state
if (lastAction.rowStatuses) setRowStatuses(lastAction.rowStatuses);
if (lastAction.pendingRows) setPendingRows(lastAction.pendingRows);
if (lastAction.bcSyncQueue) setBcSyncQueue(lastAction.bcSyncQueue);
setUndoHistory(remainingHistory);
};
@@ -709,6 +958,13 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
onSaveAll={handleSaveAll}
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 }]))}
onToggleBcQueueSelection={handleToggleBcQueueSelection}
onSelectAllBcQueue={handleSelectAllBcQueue}
onPreviewSelectedBcSync={handlePreviewSelectedBcSync}
onSyncSelectedBcSync={handleSyncSelectedBcSync}
isSyncingBC={isSyncingBC}
isDownloadingBCExcel={isDownloadingBCExcel}
isMaximized={isMaximized}
onToggleMaximize={() => setIsMaximized(true)}
@@ -823,6 +1079,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
onSaveRow={handleSaveRow}
onCaptureState={captureState}
rowStatuses={rowStatuses}
onQueueBcSync={handleQueueBcSync}
/>
)}
{activeModule === 'history' && (