Files
Craze-Data-check/src/App.tsx
T
Christian Vidal WolfandClaude Sonnet 4.6 fdf878a4f8 feat: add Missing Class / Launch tab to Article Details
Adds a new 'Missing Class / Launch' tab in Article Details that surfaces
items where Classification is empty or Launch Date is blank/01-01-1900.
The tab shows Classification, Launch Date, and Ready to Order columns for
quick reference. Column indices for Launch Date and Ready to Order are
resolved dynamically from the Excel headers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:02:52 +02:00

627 lines
25 KiB
TypeScript

import React, { useState, useMemo, useEffect } from 'react';
import * as XLSX from 'xlsx';
import { AppState, ExcelRow, COLUMNS } from './types';
import { Sidebar } from './components/Sidebar';
import { TopBar } from './components/TopBar';
import { ProductDescriptions } from './components/ProductDescriptions';
import { MatrixView } from './components/MatrixView';
import { EditPanel } from './components/EditPanel';
import { getAllSyncedRows, saveRowToSupabase, resetAllPendingRows, saveHistoryEntry, deleteHistoryEntry } from './lib/supabase';
import { getStoredSession, signOut, type AuthSession } from './lib/auth';
import { LoginPage } from './components/LoginPage';
import { DimensionsView } from './components/DimensionsView';
import { PricingView } from './components/PricingView';
import { ArticleDetails } from './components/ArticleDetails';
import { HistoryView } from './components/HistoryView';
import { UndoToast } from './components/UndoToast';
import { PendingValidationView } from './components/PendingValidationView';
export default function App() {
const [session, setSession] = useState<AuthSession | null>(() => getStoredSession());
const [appState, setAppState] = useState<AppState>({
headers: [],
data: [],
fileName: '',
fileDate: null,
hasUnsavedChanges: false,
asinColumnIndex: null
});
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history'>('descriptions');
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
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 [isSavingAll, setIsSavingAll] = useState(false);
useEffect(() => {
console.log('[App] session changed:', session ? 'logged in' : 'logged out');
}, [session]);
const handleSignOut = () => {
signOut();
setSession(null);
};
useEffect(() => {
const loadDefaultData = async () => {
setIsLoadingDefault(true);
setDefaultLoadError(null);
try {
const isDev = import.meta.env.DEV;
let rows: any[][];
let allData: any[][];
let fileMeta = { rev: '', size: 0 };
if (isDev) {
const fileUrl = '/dropbox-file/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1';
console.log('Fetching Data-Matrix.xlsx from Dropbox...');
const response = await fetch(fileUrl);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const arrayBuffer = await response.arrayBuffer();
if (arrayBuffer.byteLength < 100) throw new Error('File too small');
const wb = XLSX.read(arrayBuffer, { type: 'array' });
const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname];
allData = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
rows = allData.slice(1);
fileMeta = { rev: 'dev', size: arrayBuffer.byteLength };
} else {
console.log('Fetching file info from Dropbox...');
const infoRes = await fetch('/api/dropbox-proxy?info=1');
if (infoRes.ok) {
fileMeta = await infoRes.json();
console.log('File meta:', fileMeta);
}
console.log('Fetching Data-Matrix.xlsx from proxy...');
const response = await fetch('/api/dropbox-proxy');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const arrayBuffer = await response.arrayBuffer();
if (arrayBuffer.byteLength < 100) throw new Error('File too small');
const wb = XLSX.read(arrayBuffer, { type: 'array' });
const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname];
allData = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
rows = allData.slice(1);
}
if (rows.length > 0) {
const headers = allData.slice(0, 1)[0];
console.log('Syncing Excel data to Supabase...');
const syncRes = await fetch('/api/dropbox-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows, fileMeta })
});
if (syncRes.ok) {
const syncResult = await syncRes.json();
console.log('Supabase sync result:', syncResult);
} else {
const errorText = await syncRes.text();
console.warn('Supabase sync failed:', syncRes.status, errorText);
}
console.log('Fetching synced data from Supabase...');
const syncedData = await getAllSyncedRows(session?.access_token);
const editableColumns = new Set([
COLUMNS.LONG_DE, COLUMNS.LONG_EN,
COLUMNS.SHORT_DE, COLUMNS.SHORT_EN,
COLUMNS.DETAILS_DE, COLUMNS.DETAILS_EN,
COLUMNS.INNER_L, COLUMNS.INNER_W, COLUMNS.INNER_H,
COLUMNS.OUTER_L, COLUMNS.OUTER_W, COLUMNS.OUTER_H,
COLUMNS.UNITS_OUTER, COLUMNS.MOQ,
COLUMNS.VERIFIED_DIMS
]);
headers.forEach((h: any, i: number) => {
const hl = String(h || '').toLowerCase();
if (hl.includes('srp') || hl.includes('uvp') || hl.includes('40') || hl.includes('price')) {
editableColumns.add(i);
}
});
const articleNoIdx = COLUMNS.ARTICLE_NO;
const processedRows = rows.map(row => {
const articleNo = String(row[articleNoIdx]);
const synced = syncedData[articleNo];
const finalRow = [...row];
if (synced) {
// Smart Merge: Only overlay fields we logically "own" via this app's editors
editableColumns.forEach(idx => {
if (synced.data[idx] !== undefined && synced.data[idx] !== null) {
finalRow[idx] = synced.data[idx];
}
});
if (synced.status === 'pending') {
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
}
}
return finalRow.map((val: any, idx: number) => {
if (val === undefined || val === null || val === '') return val;
const header = (headers[idx] || '').toLowerCase();
if ((header.includes('id') || header.includes('no') || header.includes('code') ||
header.includes('art.') || header.includes('barcode') || header.includes('article')) &&
!(header.includes('nw') || header.includes('gw') || header.includes('weight') || header.includes('kg'))) {
return val;
}
const formatKeywords = ['price', 'eur', 'cost', 'msrp', 'net', 'gross', 'netto', 'brutto', 'pp', 'pph', 'uvp', 'vpe', 'stk', 'nw', 'gw', 'weight', 'kg'];
const shouldFormat = formatKeywords.some(kw => header.includes(kw));
if (typeof val === 'number') {
return Number(val.toFixed(2));
}
if (typeof val === 'string') {
const normalized = val.trim().replace(',', '.');
const num = parseFloat(normalized);
if (!isNaN(num) && (shouldFormat || val.includes('.') || val.includes(','))) {
return num.toFixed(2);
}
}
return val;
});
});
const asinIdx = (headers as string[]).findIndex((h: string) =>
String(h).toLowerCase().trim() === 'asin'
);
setAppState({
headers: headers,
data: processedRows,
fileName: 'Data-Matrix.xlsx (Cloud Sync)',
fileDate: new Date(),
hasUnsavedChanges: false,
asinColumnIndex: asinIdx !== -1 ? asinIdx : null
});
setActiveModule('descriptions');
}
} catch (err) {
console.error('Failed to load from Dropbox:', err);
setDefaultLoadError(err instanceof Error ? err.message : 'Connection failed');
} finally {
setIsLoadingDefault(false);
}
};
if (session) loadDefaultData();
}, [session]);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (appState.hasUnsavedChanges) {
if (!window.confirm('You have unsaved changes. Are you sure you want to load a new file and discard them?')) {
e.target.value = '';
return;
}
}
const reader = new FileReader();
reader.onload = (evt) => {
const bstr = evt.target?.result;
const wb = XLSX.read(bstr, { type: 'binary' });
const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname];
const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
if (data.length > 0) {
const headers = data[0];
const rawRows = data.slice(1);
const processedRows = rawRows.map(row => {
return row.map((val, idx) => {
if (val === undefined || val === null || val === '') return val;
const header = (headers[idx] || '').toLowerCase();
if (header.includes('id') || header.includes('no') || header.includes('code') ||
header.includes('art.') || header.includes('barcode') || header.includes('article')) {
// But allow if it's a weight/measure column (e.g. Article NW (kg))
if (!(header.includes('nw') || header.includes('gw') || header.includes('weight') || header.includes('kg'))) {
return val;
}
}
// Handle date columns - Excel serial dates are numbers >= 25569 (Jan 1, 1970)
if (header.includes('date') || header.includes('launch') || header.includes('ready')) {
if (typeof val === 'number' && val >= 25569 && val <= 60000) {
const excelEpoch = new Date(1899, 11, 30);
const date = new Date(excelEpoch.getTime() + val * 86400000);
return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
return val;
}
const formatKeywords = ['price', 'eur', 'cost', 'msrp', 'net', 'gross', 'netto', 'brutto', 'pp', 'pph', 'uvp', 'vpe', 'stk', 'nw', 'gw', 'weight', 'kg'];
const shouldFormat = formatKeywords.some(kw => header.includes(kw));
if (typeof val === 'number') {
return Number(val.toFixed(2));
}
if (typeof val === 'string') {
const normalized = val.trim().replace(',', '.');
const num = parseFloat(normalized);
if (!isNaN(num) && (shouldFormat || val.includes('.') || val.includes(','))) {
return num.toFixed(2);
}
}
return val;
});
});
setAppState({
headers: headers,
data: processedRows,
fileName: file.name,
fileDate: new Date(),
hasUnsavedChanges: false,
asinColumnIndex: null
});
setActiveModule('descriptions');
}
};
reader.readAsBinaryString(file);
};
const handleExport = () => {
if (appState.data.length === 0) return;
const wsData = [
appState.headers,
...appState.data.map(row => {
const copy = [...row];
delete copy[COLUMNS.VERIFIED_DIMS];
return copy.slice(0, appState.headers.length);
})
];
const ws = XLSX.utils.aoa_to_sheet(wsData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Products');
const dateStr = new Date().toISOString().split('T')[0];
XLSX.writeFile(wb, `CRAZE_Products_Updated_${dateStr}.xlsx`);
// 3. Post-export: Reset pending statuses in Supabase
console.log('Resetting pending statuses in Supabase...');
resetAllPendingRows(session?.access_token).then(success => {
if (success) {
console.log('Successfully reset all pending statuses');
setRowStatuses({}); // Clear local statuses
}
});
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
};
const handleSaveRow = (rowIndex: number, updatedRow: ExcelRow) => {
const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]);
const originalData = appState.data[rowIndex]; // Capture before update
setAppState(prev => {
const newData = [...prev.data];
newData[rowIndex] = updatedRow;
return { ...prev, data: newData, hasUnsavedChanges: true };
});
setPendingRows(prev => ({
...prev,
[articleNo]: {
rowIndex,
// Keep the very first originalData if already pending (re-edit case)
originalData: prev[articleNo]?.originalData ?? originalData,
newData: updatedRow,
articleName: String(updatedRow[COLUMNS.ARTICLE_NAME] || articleNo),
}
}));
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
setEditingRowIndex(null);
};
const handleRevertRow = (articleNo: string) => {
const pending = pendingRows[articleNo];
if (!pending) return;
setAppState(prev => {
const newData = [...prev.data];
newData[pending.rowIndex] = pending.originalData;
const stillPending = Object.keys(pendingRows).length > 0;
return { ...prev, data: newData, hasUnsavedChanges: stillPending };
});
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
setRowStatuses(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
};
const handleSaveAll = async () => {
console.log('[handleSaveAll] Starting save, pendingRows:', pendingRows);
const entries = Object.entries(pendingRows) as [string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }][];
if (entries.length === 0) {
console.log('[handleSaveAll] No entries to save, returning');
return;
}
setIsSavingAll(true);
let failedArticles: string[] = [];
let jwtExpired = false;
const token = session?.access_token;
try {
for (const [articleNo, { newData, originalData, articleName }] of entries) {
console.log('[handleSaveAll] Saving article:', articleNo);
const result = await saveRowToSupabase(articleNo, newData, token);
console.log('[handleSaveAll] Save result for', articleNo, ':', result);
if (result.success) {
// Also save to history
await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown', token);
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
setPendingRows(prev => {
const n = { ...prev };
delete n[articleNo];
return n;
});
} else {
setRowStatuses(prev => ({ ...prev, [articleNo]: 'error' }));
failedArticles.push(`${articleNo} [${result.error || 'Unknown error'}]`);
if (result.error?.includes('401') || result.error?.includes('JWT expired')) {
jwtExpired = true;
}
}
}
console.log('[handleSaveAll] Finished loop. Failed:', failedArticles.length);
if (jwtExpired) {
alert("Tu sesión ha caducado (JWT expired). Por favor, haz clic en el botón de Cerrar Sesión (Sign out) arriba a la derecha y vuelve a iniciar sesión para guardar tus cambios.");
} else if (failedArticles.length > 0) {
alert(`Failed to save items:\n\n${failedArticles.join('\n')}\n\nPlease try again.`);
} else {
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
}
} catch (err: any) {
console.error('[handleSaveAll] Critical error:', err);
alert(`A critical error occurred while saving: ${err.message || err}\nCheck console for details.`);
} finally {
setIsSavingAll(false);
console.log('[handleSaveAll] isSavingAll set to false');
}
};
const captureState = (message: string) => {
setUndoHistory(prev => {
const newState = {
data: JSON.parse(JSON.stringify(appState.data)), // Deep copy
message
};
// Keep last 50 steps
const newHistory = [newState, ...prev].slice(0, 50);
return newHistory;
});
};
const handleUndo = () => {
if (undoHistory.length === 0) return;
const [lastAction, ...remainingHistory] = undoHistory;
setAppState(prev => ({
...prev,
data: lastAction.data,
hasUnsavedChanges: true
}));
setUndoHistory(remainingHistory);
};
const stats = useMemo(() => {
if (appState.data.length === 0) return null;
let missingDeLong = 0;
let missingEnLong = 0;
let missingDeShort = 0;
let missingEnShort = 0;
let fullyComplete = 0;
appState.data.forEach(row => {
const deLong = row[COLUMNS.LONG_DE];
const enLong = row[COLUMNS.LONG_EN];
const deShort = row[COLUMNS.SHORT_DE];
const enShort = row[COLUMNS.SHORT_EN];
if (!deLong) missingDeLong++;
if (!enLong) missingEnLong++;
if (!deShort) missingDeShort++;
if (!enShort) missingEnShort++;
if (deLong && enLong && deShort && enShort) fullyComplete++;
});
return {
total: appState.data.length,
missingDeLong,
missingEnLong,
missingDeShort,
missingEnShort,
fullyComplete
};
}, [appState.data]);
if (!session) {
return <LoginPage onLogin={() => {
const stored = getStoredSession();
console.log('[App] onLogin, stored session:', stored ? 'found' : 'null');
setSession(stored);
}} />;
}
return (
<div className="h-screen bg-[#040d1a] text-slate-200 flex flex-col font-sans overflow-hidden">
<TopBar
stats={stats}
onExport={handleExport}
hasData={appState.data.length > 0}
hasUnsavedChanges={appState.hasUnsavedChanges}
userEmail={session.user.email}
onSignOut={handleSignOut}
canUndo={undoHistory.length > 0}
onUndo={handleUndo}
undoMessage={undoHistory[0]?.message}
undoSteps={undoHistory.length}
pendingCount={Object.keys(pendingRows).length}
pendingChanges={Object.fromEntries(Object.entries(pendingRows).map(([k, v]) => [k, { articleName: (v as any).articleName }]))}
onSaveAll={handleSaveAll}
onRevertRow={handleRevertRow}
isSavingAll={isSavingAll}
/>
<div className="flex flex-1 overflow-hidden">
<Sidebar activeModule={activeModule} setActiveModule={setActiveModule} userEmail={session.user.email} />
<main className="flex-1 overflow-auto relative p-6 bg-[#041021]">
{isLoadingDefault ? (
<div className="flex flex-col items-center justify-center h-full text-slate-400">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p className="text-lg">Loading latest data...</p>
</div>
) : defaultLoadError && appState.data.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-red-400">
<p className="mb-4 text-lg">Failed to auto-load data: {defaultLoadError}</p>
<label className="cursor-pointer bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg shadow-lg transition-colors">
Load Excel File Manually
<input type="file" accept=".xlsx, .xls" className="hidden" onChange={handleFileUpload} />
</label>
</div>
) : appState.data.length === 0 && activeModule !== 'upload' ? (
<div className="flex flex-col items-center justify-center h-full text-slate-400">
<p className="mb-4 text-lg">No data loaded.</p>
<label className="cursor-pointer bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg shadow-lg transition-colors">
Load Excel File
<input type="file" accept=".xlsx, .xls" className="hidden" onChange={handleFileUpload} />
</label>
</div>
) : (
<>
{activeModule === 'descriptions' && (
<ProductDescriptions
data={appState.data}
headers={appState.headers}
asinColumnIndex={appState.asinColumnIndex}
onEdit={(index) => setEditingRowIndex(index)}
rowStatuses={rowStatuses}
/>
)}
{activeModule === 'matrix' && (
<MatrixView data={appState.data} headers={appState.headers} rowStatuses={rowStatuses} />
)}
{activeModule === 'dimensions' && (
<DimensionsView
data={appState.data}
headers={appState.headers}
onEdit={(index) => setEditingRowIndex(index)}
onSaveRow={handleSaveRow}
onCaptureState={captureState}
rowStatuses={rowStatuses}
onRevertRow={handleRevertRow}
/>
)}
{activeModule === 'pricing' && (
<PricingView
data={appState.data}
headers={appState.headers}
onSaveRow={handleSaveRow}
onCaptureState={captureState}
onEdit={(index) => setEditingRowIndex(index)}
rowStatuses={rowStatuses}
/>
)}
{activeModule === 'article_details' && (
<ArticleDetails
data={appState.data}
headers={appState.headers}
onEdit={(index) => setEditingRowIndex(index)}
rowStatuses={rowStatuses}
/>
)}
{activeModule === 'pending_validation' && (
<PendingValidationView
data={appState.data}
pendingRows={pendingRows}
rowStatuses={rowStatuses}
onRevertRow={handleRevertRow}
onEdit={(index) => setEditingRowIndex(index)}
/>
)}
{activeModule === 'history' && (
<HistoryView
headers={appState.headers}
data={appState.data}
sessionToken={session?.access_token}
onEdit={(index) => setEditingRowIndex(index)}
onRevert={async (articleNo, revertedData, historyId) => {
// Find the row in appState.data and update it
const rowIndex = appState.data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === articleNo);
if (rowIndex !== -1) {
setAppState(prev => {
const newData = [...prev.data];
newData[rowIndex] = revertedData;
return { ...prev, data: newData, hasUnsavedChanges: true };
});
// Mark as pending for sync
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
setPendingRows(prev => ({
...prev,
[articleNo]: {
rowIndex,
originalData: appState.data[rowIndex],
newData: revertedData,
articleName: String(revertedData[COLUMNS.ARTICLE_NAME] || articleNo),
}
}));
// Delete the history entry after revert
if (historyId) {
await deleteHistoryEntry(String(historyId), session?.access_token);
}
}
}}
/>
)}
</>
)}
</main>
</div>
<UndoToast
undoState={undoHistory[0] || null}
onUndo={handleUndo}
onClose={() => {
// Instead of clearing history, we can just hide the toast
// But since UndoToast is driven by undoHistory[0],
// we might want a way to "acknowledge" the current top of history
// For now, let's just not clear the history.
}}
/>
{editingRowIndex !== null && (
<EditPanel
row={appState.data[editingRowIndex]}
rowIndex={editingRowIndex}
onSave={handleSaveRow}
onClose={() => setEditingRowIndex(null)}
onCaptureState={captureState}
/>
)}
</div>
);
}