import React, { useState, useMemo, useEffect } from 'react'; import * as XLSX from 'xlsx'; import { X } from 'lucide-react'; import { cn } from './lib/utils'; import { AppState, ExcelRow, resolveColumnIndices, COLUMNS } from './types'; import { ColumnsProvider } from './contexts/ColumnsContext'; 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'; import { MissingDataView } from './components/MissingDataView'; export default function App() { const [session, setSession] = useState(() => getStoredSession()); const [appState, setAppState] = useState({ headers: [], data: [], fileName: '', fileDate: null, hasUnsavedChanges: false, asinColumnIndex: null }); const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data'>('descriptions'); const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]); const [editingRowIndex, setEditingRowIndex] = useState(null); const [isLoadingDefault, setIsLoadingDefault] = useState(true); const [defaultLoadError, setDefaultLoadError] = useState(null); const [rowStatuses, setRowStatuses] = useState>({}); const [pendingRows, setPendingRows] = useState>({}); const [isSavingAll, setIsSavingAll] = useState(false); const [refreshTrigger, setRefreshTrigger] = useState(0); useEffect(() => { console.log('[App] session changed:', session ? 'logged in' : 'logged out'); }, [session]); const handleSignOut = () => { signOut(); setSession(null); }; // Sync session state when localStorage is updated (e.g. by token refresh) useEffect(() => { const handleStorage = (e: StorageEvent) => { if (e.key === 'craze_auth_session') { setSession(getStoredSession()); } }; window.addEventListener('storage', handleStorage); return () => window.removeEventListener('storage', handleStorage); }, []); 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 cacheBuster = `t_${Date.now()}`; const fileUrl = `/dropbox-file/scl/fi/usa8me7ywgylrij2bt6hj/Data-Matrix.xlsx?rlkey=tsec8csrhye54u1fdvk15ped1&dl=1&${cacheBuster}=${Date.now()}`; console.log('Fetching Data-Matrix.xlsx from Dropbox (hard refresh)...'); const response = await fetch(fileUrl, { cache: 'no-store' }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('text/html')) { throw new Error('Dropbox returned an HTML page instead of the Excel file. This usually means the sharing link has expired or requires manual interaction. Please generate a new "Copy Link" from Dropbox.'); } 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(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 (hard refresh)...'); const response = await fetch('/api/dropbox-proxy', { cache: 'no-store' }); 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(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(); // 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 // 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.ITEM_TO_LOGISTIC]: 'Item to Logistic', [COLUMNS.ANNA_CHECK]: 'Anna Check', [COLUMNS.ANNA_NOTE]: 'Anna Note', }; Object.entries(VIRTUAL_COLS).forEach(([idxStr, name]) => { const idx = Number(idxStr); if (extendedHeaders[idx] === undefined) { extendedHeaders[idx] = name; } }); // Fill any sparse holes with '' to avoid undefined.includes() errors for (let i = 0; i < extendedHeaders.length; i++) { if (extendedHeaders[i] === undefined) extendedHeaders[i] = ''; } const resolvedCols = resolveColumnIndices(extendedHeaders); const editableColumns = new Set([ resolvedCols.CLASSIFICATION, resolvedCols.LONG_DE, resolvedCols.LONG_EN, resolvedCols.SHORT_DE, resolvedCols.SHORT_EN, resolvedCols.DETAILS_DE, resolvedCols.DETAILS_EN, resolvedCols.INNER_L, resolvedCols.INNER_W, resolvedCols.INNER_H, resolvedCols.OUTER_L, resolvedCols.OUTER_W, resolvedCols.OUTER_H, resolvedCols.UNITS_OUTER, resolvedCols.MOQ, resolvedCols.VERIFIED_DIMS, resolvedCols.VALIDATED_CHECK, resolvedCols.VALIDATED_NOTE, resolvedCols.PRODUCT_TYPE, resolvedCols.ITEM_TO_LOGISTIC ]); 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 = resolvedCols.ARTICLE_NO; const processedRows = rows.map(row => { const articleNo = String(row[articleNoIdx]); const synced = syncedData[articleNo]; // Start with fresh Dropbox values (prices from Excel) let finalRow = [...row]; // Load manual edits: status 'edited' = saved edits, 'pending' = unsaved edits if (synced && (synced.status === 'edited' || synced.status === 'pending')) { // Compare saved row vs fresh Excel row: wherever Supabase differs from Excel, // that means the user edited that column — apply it regardless of editableColumns // index resolution, which can vary across sessions. const savedLen = Array.isArray(synced.data) ? synced.data.length : 0; for (let idx = 0; idx < savedLen; idx++) { const savedVal = synced.data[idx]; const excelVal = row[idx]; const savedStr = savedVal == null ? '' : String(savedVal); const excelStr = excelVal == null ? '' : String(excelVal); if (savedStr !== excelStr) { // Value differs from Excel → user edited it, apply the saved value finalRow[idx] = savedVal; } } setRowStatuses(prev => ({ ...prev, [articleNo]: synced.status })); } return finalRow.map((val: any, idx: number) => { if (val === undefined || val === null || val === '') return val; const header = (extendedHeaders[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 = (extendedHeaders as string[]).findIndex((h: string) => String(h).toLowerCase().trim() === 'asin' ); setAppState({ headers: extendedHeaders, 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, refreshTrigger]); const handleFileUpload = (e: React.ChangeEvent) => { 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(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.toISOString().split('T')[0]; // Returns YYYY-MM-DD } 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 (pending → edited to preserve on reload) console.log('Resetting pending statuses in Supabase...'); resetAllPendingRows().then(success => { if (success) { console.log('Successfully reset all pending statuses'); // Only clear 'pending' statuses locally; keep 'edited'/'saved' so they remain visible setRowStatuses((prev: Record) => { const next: Record = {}; for (const [id, status] of Object.entries(prev)) { if (status !== 'pending') next[id] = status as string; } return next; }); } }); setAppState(prev => ({ ...prev, hasUnsavedChanges: false })); }; const handleSaveRow = (rowIndex: number, updatedRow: ExcelRow) => { const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]); const currentPending = pendingRows[articleNo]; const originalData = currentPending?.originalData ?? appState.data[rowIndex]; const isRowDifferent = (rowA: any[], rowB: any[]) => { if (!rowA || !rowB) return rowA !== rowB; const length = Math.max(rowA.length, rowB.length); for (let i = 0; i < length; i++) { const a = rowA[i]; const b = rowB[i]; if (a === b) continue; const normA = (a === null || a === undefined || a === '' || a === false) ? null : a; const normB = (b === null || b === undefined || b === '' || b === false) ? null : b; if (normA === normB) continue; if (String(a) === String(b)) continue; return true; } return false; }; const isActuallyModified = isRowDifferent(updatedRow, originalData); if (isActuallyModified) { setAppState(prev => { const newData = [...prev.data]; newData[rowIndex] = updatedRow; return { ...prev, data: newData, hasUnsavedChanges: true }; }); setPendingRows(prev => ({ ...prev, [articleNo]: { rowIndex, originalData, newData: updatedRow, articleName: String(updatedRow[COLUMNS.ARTICLE_NAME] || articleNo), } })); setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' })); } else { // Changed back to original state - remove from pending setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; }); setRowStatuses(prev => { const n = { ...prev }; delete n[articleNo]; return n; }); setAppState(prev => { const newData = [...prev.data]; newData[rowIndex] = updatedRow; // Use a more reliable way to check if there are still other pending rows const otherPendingCount = Object.keys(pendingRows).filter(k => k !== articleNo).length; const stillHasChanges = otherPendingCount > 0; return { ...prev, data: newData, hasUnsavedChanges: stillHasChanges }; }); } 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 sessionIssue = false; try { for (const [articleNo, { newData, originalData, articleName }] of entries) { console.log('[handleSaveAll] Saving article:', articleNo); const result = await saveRowToSupabase(articleNo, newData); 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'); 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('expired') || result.error?.includes('Session expired')) { sessionIssue = true; } } } console.log('[handleSaveAll] Finished loop. Failed:', failedArticles.length); if (sessionIssue) { alert("Tu sesión ha caducado definitivamente. Por favor, cierra sesión e inicia sesión de nuevo para continuar. No refresques la página para no perder tus cambios pendientes en pantalla."); } 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 rowStatuses: { ...rowStatuses }, pendingRows: { ...pendingRows }, 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; // Restore data setAppState(prev => ({ ...prev, data: lastAction.data, hasUnsavedChanges: Object.keys(lastAction.pendingRows || {}).length > 0 })); // Restore statuses and pending state if (lastAction.rowStatuses) setRowStatuses(lastAction.rowStatuses); if (lastAction.pendingRows) setPendingRows(lastAction.pendingRows); 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]); const [isMaximized, setIsMaximized] = useState(false); if (!session) { return { const stored = getStoredSession(); console.log('[App] onLogin, stored session:', stored ? 'found' : 'null'); setSession(stored); }} />; } return (
{!isMaximized && ( { setRefreshTrigger(t => t + 1); }} 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} isMaximized={isMaximized} onToggleMaximize={() => setIsMaximized(true)} /> )}
{!isMaximized && ( )}
{isMaximized && ( )} {isLoadingDefault ? (

Loading latest data...

) : defaultLoadError && appState.data.length === 0 ? (

Failed to auto-load data: {defaultLoadError}

) : appState.data.length === 0 && activeModule !== 'upload' ? (

No data loaded.

) : ( <> {activeModule === 'descriptions' && ( setEditingRowIndex(index)} rowStatuses={rowStatuses} /> )} {activeModule === 'matrix' && ( )} {activeModule === 'dimensions' && ( setEditingRowIndex(index)} onSaveRow={handleSaveRow} onCaptureState={captureState} rowStatuses={rowStatuses} onRevertRow={handleRevertRow} /> )} {activeModule === 'pricing' && ( setEditingRowIndex(index)} rowStatuses={rowStatuses} /> )} {activeModule === 'article_details' && ( setEditingRowIndex(index)} rowStatuses={rowStatuses} /> )} {activeModule === 'pending_validation' && ( setEditingRowIndex(index)} /> )} {activeModule === 'missing_data' && ( )} {activeModule === 'history' && ( 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)); } } }} /> )} )}
{ // 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 && ( setEditingRowIndex(null)} onCaptureState={captureState} /> )}
); }