Files
Craze-Data-check/src/App.tsx
T

388 lines
14 KiB
TypeScript
Raw Normal View History

2026-03-27 12:03:57 +01:00
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';
2026-03-27 12:23:35 +01:00
import { getAllSyncedRows, saveRowToSupabase } from './lib/supabase';
import { getStoredSession, signOut, type AuthSession } from './lib/auth';
import { LoginPage } from './components/LoginPage';
import { DimensionsView } from './components/DimensionsView';
import { UndoToast } from './components/UndoToast';
export default function App() {
const [session, setSession] = useState<AuthSession | null>(() => getStoredSession());
const handleSignOut = () => {
signOut();
setSession(null);
};
if (!session) {
return <LoginPage onLogin={() => setSession(getStoredSession())} />;
}
const [appState, setAppState] = useState<AppState>({
headers: [],
data: [],
fileName: '',
fileDate: null,
hasUnsavedChanges: false
});
const [activeModule, setActiveModule] = useState<'descriptions' | 'matrix' | 'dimensions'>('descriptions');
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
2026-03-27 12:03:57 +01:00
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
const [defaultLoadError, setDefaultLoadError] = useState<string | null>(null);
useEffect(() => {
const loadDefaultData = async () => {
// In dev: Vite proxy handles /dropbox-file (see vite.config.ts)
// In prod: Vercel serverless function at /api/dropbox-proxy handles it
const fileUrl = import.meta.env.DEV
? '/dropbox-file/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1'
: '/api/dropbox-proxy';
setIsLoadingDefault(true);
setDefaultLoadError(null);
try {
console.log('Fetching Data-Matrix.xlsx via Vite proxy...');
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
2026-03-27 12:03:57 +01:00
}
const arrayBuffer = await response.arrayBuffer();
if (arrayBuffer.byteLength < 100) {
throw new Error('File too small — possibly empty or error response');
}
const wb = XLSX.read(arrayBuffer, { type: 'array' });
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 rawHeaders = data[0];
const rawRows = data.slice(1);
console.log('Applying Supabase overrides...');
const syncedData = await getAllSyncedRows();
const articleNoIdx = COLUMNS.ARTICLE_NO;
const processedRows = rawRows.map(row => {
const articleNo = String(row[articleNoIdx]);
const finalRow = syncedData[articleNo] || row;
// Format numeric/price fields to 2 decimal places
return finalRow.map((val, idx) => {
if (val === undefined || val === null || val === '') return val;
const header = (rawHeaders[idx] || '').toLowerCase();
// Skip Article No, Barcodes, and other code-like fields
// But allow if it's a weight/measure column (e.g. Article NW (kg))
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;
});
});
setAppState({
headers: rawHeaders,
data: processedRows,
fileName: 'Data-Matrix.xlsx (Cloud Sync)',
fileDate: new Date(),
hasUnsavedChanges: false
});
setActiveModule('descriptions');
}
} catch (err) {
console.error('Failed to load from Dropbox:', err);
setDefaultLoadError(err instanceof Error ? err.message : 'Connection failed');
} finally {
setIsLoadingDefault(false);
}
2026-03-27 12:03:57 +01:00
};
loadDefaultData();
}, []);
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 rawHeaders = 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 = (rawHeaders[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: rawHeaders,
data: processedRows,
fileName: file.name,
fileDate: new Date(),
hasUnsavedChanges: false
});
setActiveModule('descriptions');
}
};
reader.readAsBinaryString(file);
};
const handleExport = () => {
if (appState.data.length === 0) return;
const wsData = [appState.headers, ...appState.data];
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`);
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
};
2026-03-27 12:23:35 +01:00
const handleSaveRow = async (rowIndex: number, updatedRow: ExcelRow) => {
// 1. Update UI state
setAppState(prev => {
const newData = [...prev.data];
newData[rowIndex] = updatedRow;
return {
...prev,
data: newData,
hasUnsavedChanges: true
};
});
setEditingRowIndex(null);
2026-03-27 12:23:35 +01:00
// 2. Persist to Supabase
const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]);
console.log(`Saving article ${articleNo} to Supabase...`);
const success = await saveRowToSupabase(articleNo, updatedRow);
if (success) {
console.log(`Successfully saved ${articleNo}`);
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
} else {
console.error(`Failed to save ${articleNo} to Supabase`);
alert("Error saving to database. Local changes will be lost on refresh if not saved.");
}
};
const captureState = (message: string) => {
setUndoHistory(prev => {
const newState = {
data: JSON.parse(JSON.stringify(appState.data)), // Deep copy
message
};
// Keep only last 5 steps
const newHistory = [newState, ...prev].slice(0, 5);
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]);
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}
2026-03-29 17:37:58 +02:00
onUndo={handleUndo}
undoMessage={undoHistory[0]?.message}
undoSteps={undoHistory.length}
/>
<div className="flex flex-1 overflow-hidden">
<Sidebar activeModule={activeModule} setActiveModule={setActiveModule} />
<main className="flex-1 overflow-auto relative p-6 bg-[#041021]">
2026-03-27 12:03:57 +01:00
{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}
onEdit={(index) => setEditingRowIndex(index)}
/>
)}
{activeModule === 'matrix' && (
<MatrixView data={appState.data} headers={appState.headers} />
)}
2026-03-29 17:10:27 +02:00
{activeModule === 'dimensions' && (
<DimensionsView
data={appState.data}
headers={appState.headers}
onEdit={(index) => setEditingRowIndex(index)}
onSaveRow={handleSaveRow}
onCaptureState={captureState}
/>
)}
</>
)}
</main>
</div>
<UndoToast
undoState={undoHistory[0] || null}
onUndo={handleUndo}
onClose={() => setUndoHistory([])}
/>
{editingRowIndex !== null && (
<EditPanel
row={appState.data[editingRowIndex]}
rowIndex={editingRowIndex}
onSave={handleSaveRow}
onClose={() => setEditingRowIndex(null)}
onCaptureState={captureState}
/>
)}
</div>
);
}