mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 11:45:24 +02:00
1290 lines
50 KiB
TypeScript
1290 lines
50 KiB
TypeScript
import React, { useState, useMemo, useEffect } from 'react';
|
|
import * as XLSX from 'xlsx';
|
|
import { X } from 'lucide-react';
|
|
import defaultWorkbookUrl from '../data (3).xlsx?url';
|
|
import { cn } from './lib/utils';
|
|
import { AppState, ExcelRow, resolveColumnIndices, COLUMNS } from './types';
|
|
import { ColumnsProvider } from './contexts/ColumnsContext';
|
|
import { FilterProvider } from './contexts/FilterContext';
|
|
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, getHistoryDataForMerge } 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';
|
|
import { CosmeticItemsView } from './components/CosmeticItemsView';
|
|
import { ControlDashboardView } from './components/ControlDashboardView';
|
|
import { UserManagementView } from './components/UserManagementView';
|
|
import { downloadBusinessCentralItemsExcel, previewBusinessCentralSync, applyBusinessCentralSync, isPreviewTokenMismatchError } from './services/businessCentral';
|
|
import { ControlTabId, type DashboardDrilldownRequest } from './lib/controlDashboard';
|
|
|
|
const FORCED_ZERO_STOCK_SKUS = new Set([
|
|
'11631VC', '1237VC', '1238VC', '1652VC', '1653VC', '1684VC', '1688VC', '1717VC',
|
|
'1718VC', '180VC', '1832VC', '2025VC', '2027VC', '2180VC', '2181VC', '2210VC',
|
|
'2260VC', '2355VC', '2356VC', '2360VC', '2400VC', '2401VC', '2402VC', '2405VC',
|
|
'2406VC', '2414VC', '2415VC', '2425VC', '2447VC', '2450VC', '2510VC', '2517VC',
|
|
'2522VC', '2611VC', '2648VC', '2660VC', '2665VC', '2857VC', '2858VC', '2990VC',
|
|
'3011VC', '3012VC', '3013VC', '3015VC', '3018VC', '3019VC', '3020VC', '3021VC',
|
|
'3027VC', '3030VC', '3035VC', '3038VC', '3041VC', '3043VC', '3044VC', '3045VC',
|
|
'3046VC', '3048VC', '3052VC', '3053VC', '3054VC', '3055VC', '3062VC', '3063VC',
|
|
'3064VC', '3069VC', '3070VC', '3071VC', '3072VC', '3074VC', '3089VC', '3116VC',
|
|
'3117VC', '3118VC', '3119VC', '3120VC', '3121VC', '3123VC', '3125VC', '3126VC',
|
|
'3127VC', '3130VC', '3133VC', '3134VC', '3135VC', '3200VC', '3201VC', '3230VC',
|
|
'3311VC', '3314VC', '3315VC', '3354VC', '3372VC', '3380VC', '3391VC', '3392VC',
|
|
'3423VC', '3432VC', '3437VC', '3484VC', '3487VC', '3555VC', '3557VC', '3558VC',
|
|
'3559VC', '3712VC', '3824VC', '4107VC', '4108VC', '4110VC', '4115VC', '4125VC',
|
|
'4130VC', '4134VC', '4140VC', '4155VC', '4158VC', '4165VC', '4231VC', '4232VC',
|
|
'4255VC', '4259VC', '4261VC', '4263VC', '4264VC', '4265VC', '4285VC', '4288VC',
|
|
'4330VC', '4361VC', '4442VC', '4445VC', '4462VC', '4528VC', '4531VC', '4534VC',
|
|
'4558VC', '4657VC', '4721VC', '4722VC', '4727VC', '4735VC', '4737VC', '4745VC',
|
|
'4752VC', '4758VC', '4765VC', '4785VC', '4790VC', '4801VC', '4815VC', '4874VC',
|
|
'4882VC', '5032VC', '5113VC', '5382VC', '5383VC', '5440VC', '5442VC', '5456VC',
|
|
'5540VC', '5571VC', '5572VC', '5574VC', '5575VC', '5588VC', '5592VC', '5685VC',
|
|
'5712VC', '5721VC', '5807VC', '5823VC', '5825VC', '5835VC', '5852VC', '5871VC',
|
|
'5872VC', '5873VC', '5874VC', '5882VC', '5883VC', '5885VC', '5987VC', '5991VC',
|
|
'5995VC', '6001VC', '6007VC', '6040VC', '6151VC', '6252VC', '6255VC', '6270VC',
|
|
'6471VC', '5658VCI', '2915244CLM', '30512912CLM', '2861262CLM', '41683MDRG', '41891MDRG'
|
|
]);
|
|
|
|
const BC_SYNC_QUEUE_STORAGE_KEY = 'craze_bc_sync_queue';
|
|
const LEGACY_CPNP_INDEX = 77;
|
|
|
|
type BcSyncStatus = 'queued' | 'previewed' | 'preview_only' | 'syncing' | 'synced' | 'failed';
|
|
|
|
interface BcSyncQueueEntry {
|
|
rowIndex: number;
|
|
originalData: ExcelRow;
|
|
newData: ExcelRow;
|
|
articleName: string;
|
|
selected: boolean;
|
|
status: BcSyncStatus;
|
|
previewToken?: string;
|
|
error?: string;
|
|
warning?: 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 {};
|
|
}
|
|
}
|
|
|
|
async function loadWorkbookFromUrl(url: string) {
|
|
const response = await fetch(url, { 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];
|
|
const allData = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
|
|
const rows = allData.slice(1);
|
|
const headers = allData.slice(0, 1)[0] || [];
|
|
|
|
return {
|
|
headers,
|
|
rows,
|
|
fileMeta: { rev: url, size: arrayBuffer.byteLength },
|
|
};
|
|
}
|
|
|
|
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<ControlTabId>('control_dashboard');
|
|
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 [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);
|
|
const [dashboardDrilldown, setDashboardDrilldown] = useState<DashboardDrilldownRequest | null>(null);
|
|
|
|
useEffect(() => {
|
|
console.log('[App] session changed:', session ? 'logged in' : 'logged out');
|
|
}, [session]);
|
|
|
|
// Background user validation status check
|
|
useEffect(() => {
|
|
if (!session) return;
|
|
|
|
let isMounted = true;
|
|
|
|
const checkUserStatus = async () => {
|
|
try {
|
|
const res = await fetch('/api/users-admin', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${session.access_token}`
|
|
},
|
|
body: JSON.stringify({ action: 'check-status' })
|
|
});
|
|
|
|
if (!isMounted) return;
|
|
|
|
if (!res.ok) {
|
|
console.warn('[App] Validation check failed, logging out.');
|
|
handleSignOut();
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
if (!data.validated) {
|
|
alert('Tu usuario ya no está autorizado o está pendiente de validación.');
|
|
handleSignOut();
|
|
}
|
|
} catch (err) {
|
|
console.error('[App] Background validation check error:', err);
|
|
}
|
|
};
|
|
|
|
// Check immediately on mount/session change
|
|
checkUserStatus();
|
|
|
|
// Check periodically every 5 minutes
|
|
const interval = setInterval(checkUserStatus, 5 * 60 * 1000);
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
clearInterval(interval);
|
|
};
|
|
}, [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);
|
|
};
|
|
|
|
// 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)...');
|
|
try {
|
|
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.');
|
|
}
|
|
|
|
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 };
|
|
} catch (devErr) {
|
|
console.warn('Dropbox dev load failed, using bundled workbook fallback:', devErr);
|
|
const fallback = await loadWorkbookFromUrl(defaultWorkbookUrl);
|
|
allData = [fallback.headers, ...fallback.rows];
|
|
rows = fallback.rows;
|
|
fileMeta = fallback.fileMeta;
|
|
}
|
|
} 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)...');
|
|
try {
|
|
const response = await fetch('/api/dropbox-proxy', { 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.');
|
|
}
|
|
|
|
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);
|
|
} catch (prodErr) {
|
|
console.warn('Dropbox prod load failed, using bundled workbook fallback:', prodErr);
|
|
const fallback = await loadWorkbookFromUrl(defaultWorkbookUrl);
|
|
allData = [fallback.headers, ...fallback.rows];
|
|
rows = fallback.rows;
|
|
fileMeta = fallback.fileMeta;
|
|
}
|
|
}
|
|
|
|
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, headers, 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();
|
|
console.log('Fetching history data from Supabase for merge...');
|
|
const historyData = await getHistoryDataForMerge();
|
|
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
|
|
// by older saved rows — ensure headers covers them so the merge and render work.
|
|
const extendedHeaders = [...headers];
|
|
const VIRTUAL_COLS: Record<number, string> = {
|
|
[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',
|
|
[COLUMNS.CPNP_NO]: 'CPNP No.',
|
|
};
|
|
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 articleNoIdx = resolvedCols.ARTICLE_NO;
|
|
const processedRows = rows.map(row => {
|
|
const articleNo = String(row[articleNoIdx]);
|
|
const synced = syncedData[articleNo];
|
|
const hist = historyData[articleNo];
|
|
|
|
// Start with fresh Dropbox values (prices from Excel)
|
|
let finalRow = [...row];
|
|
|
|
// Pad row to match extendedHeaders length so internal cols (index >= 100) are readable
|
|
while (finalRow.length < extendedHeaders.length) {
|
|
finalRow.push(null);
|
|
}
|
|
|
|
const syncedHasInternalCols = Boolean(
|
|
synced?.data && (
|
|
synced.data.length > 100 ||
|
|
Object.values(COLUMNS).some(idx =>
|
|
idx >= 100 &&
|
|
synced.data?.[idx] !== undefined &&
|
|
synced.data?.[idx] !== null &&
|
|
synced.data?.[idx] !== ''
|
|
)
|
|
)
|
|
);
|
|
const sourceForInternal = syncedHasInternalCols ? synced!.data : (hist || synced?.data);
|
|
|
|
if (sourceForInternal) {
|
|
Object.values(COLUMNS).forEach(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;
|
|
}
|
|
});
|
|
|
|
// Older cosmetic edits stored CPNP in index 77 before the dedicated
|
|
// internal CPNP column was stabilized at index 107.
|
|
const legacyCpnp =
|
|
hist?.[COLUMNS.CPNP_NO] ??
|
|
synced?.data?.[COLUMNS.CPNP_NO] ??
|
|
hist?.[LEGACY_CPNP_INDEX] ??
|
|
synced?.data?.[LEGACY_CPNP_INDEX];
|
|
if (legacyCpnp !== undefined && legacyCpnp !== null && String(legacyCpnp).trim() !== '') {
|
|
finalRow[COLUMNS.CPNP_NO] = legacyCpnp;
|
|
}
|
|
|
|
// Update row status in UI if it's not the default 'excel'
|
|
if (synced?.status && synced.status !== 'excel') {
|
|
setRowStatuses(prev => ({ ...prev, [articleNo]: synced.status! }));
|
|
}
|
|
}
|
|
|
|
// Force stock 0 for specific SKUs (User request)
|
|
if (FORCED_ZERO_STOCK_SKUS.has(articleNo)) {
|
|
finalRow[COLUMNS.ITEM_AVAILABLE] = 0;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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;
|
|
});
|
|
});
|
|
|
|
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('control_dashboard');
|
|
}
|
|
} 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<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 resolvedCols = resolveColumnIndices(headers);
|
|
const processedRows = rawRows.map(row => {
|
|
const finalRow = 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;
|
|
});
|
|
|
|
// Force stock 0 for specific SKUs (User request)
|
|
const articleNo = String(finalRow[resolvedCols.ARTICLE_NO] || '');
|
|
if (FORCED_ZERO_STOCK_SKUS.has(articleNo)) {
|
|
finalRow[resolvedCols.ITEM_AVAILABLE] = 0;
|
|
}
|
|
return finalRow;
|
|
});
|
|
|
|
setAppState({
|
|
headers: headers,
|
|
data: processedRows,
|
|
fileName: file.name,
|
|
fileDate: new Date(),
|
|
hasUnsavedChanges: false,
|
|
asinColumnIndex: null
|
|
});
|
|
setActiveModule('control_dashboard');
|
|
}
|
|
};
|
|
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<string, string>) => {
|
|
const next: Record<string, string> = {};
|
|
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; });
|
|
setBcSyncQueue(prev => {
|
|
if (!prev[articleNo]) return 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 {
|
|
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 histRes = await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown');
|
|
|
|
if (!histRes.success) {
|
|
console.warn(`[handleSaveAll] History save failed for ${articleNo}:`, histRes.error);
|
|
}
|
|
|
|
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];
|
|
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 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 handleDiscardSelectedBcQueue = () => {
|
|
const selectedCount = (Object.values(bcSyncQueue) as BcSyncQueueEntry[]).filter(e => e.selected).length;
|
|
if (selectedCount === 0) return;
|
|
if (!window.confirm(`Discard ${selectedCount} pending BC sync(s)? They will be removed from the queue.`)) return;
|
|
setBcSyncQueue(prev => {
|
|
const next: BcSyncQueue = { ...prev };
|
|
(Object.keys(next) as string[]).forEach(articleNo => {
|
|
if (next[articleNo].selected) delete next[articleNo];
|
|
});
|
|
return next;
|
|
});
|
|
};
|
|
|
|
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',
|
|
warning: undefined,
|
|
};
|
|
}
|
|
return;
|
|
}
|
|
|
|
next[articleNo] = {
|
|
...item,
|
|
status: 'previewed',
|
|
previewToken: preview.previewToken,
|
|
error: undefined,
|
|
selected: item.selected,
|
|
warning: 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 preview = await previewBusinessCentralSync(appState.headers, item.newData);
|
|
if (!preview.success) {
|
|
setBcSyncQueue(prev => ({
|
|
...prev,
|
|
[articleNo]: {
|
|
...prev[articleNo],
|
|
status: 'failed',
|
|
error: preview.error || 'Preview failed',
|
|
warning: undefined,
|
|
},
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
const result = await applyBusinessCentralSync(appState.headers, item.newData, preview.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,
|
|
warning: 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',
|
|
warning: retryResult.warning,
|
|
},
|
|
}));
|
|
continue;
|
|
}
|
|
}
|
|
setBcSyncQueue(prev => ({
|
|
...prev,
|
|
[articleNo]: {
|
|
...prev[articleNo],
|
|
status: 'failed',
|
|
error: result.error || 'BC sync failed',
|
|
warning: result.warning,
|
|
},
|
|
}));
|
|
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 {
|
|
const result = await downloadBusinessCentralItemsExcel();
|
|
if (!result.success) {
|
|
alert(`Failed to download Business Central Excel:\n\n${result.error || 'Unknown error'}`);
|
|
}
|
|
} finally {
|
|
setIsDownloadingBCExcel(false);
|
|
}
|
|
};
|
|
|
|
const captureState = (message: string) => {
|
|
setUndoHistory(prev => {
|
|
const newState = {
|
|
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
|
|
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);
|
|
if (lastAction.bcSyncQueue) setBcSyncQueue(lastAction.bcSyncQueue);
|
|
|
|
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 <LoginPage onLogin={() => {
|
|
const stored = getStoredSession();
|
|
console.log('[App] onLogin, stored session:', stored ? 'found' : 'null');
|
|
setSession(stored);
|
|
}} />;
|
|
}
|
|
|
|
return (
|
|
<ColumnsProvider headers={appState.headers}>
|
|
<FilterProvider>
|
|
<div className="h-screen bg-[#040d1a] text-slate-200 flex flex-col font-sans overflow-hidden">
|
|
{!isMaximized && (
|
|
<TopBar
|
|
stats={stats}
|
|
activeModule={activeModule}
|
|
onExport={handleExport}
|
|
onDownloadBCExcel={handleDownloadBCExcel}
|
|
onRefresh={() => { 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}
|
|
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 }]))}
|
|
onToggleBcQueueSelection={handleToggleBcQueueSelection}
|
|
onSelectAllBcQueue={handleSelectAllBcQueue}
|
|
onPreviewSelectedBcSync={handlePreviewSelectedBcSync}
|
|
onSyncSelectedBcSync={handleSyncSelectedBcSync}
|
|
onDiscardSelectedBcQueue={handleDiscardSelectedBcQueue}
|
|
isSyncingBC={isSyncingBC}
|
|
isDownloadingBCExcel={isDownloadingBCExcel}
|
|
isMaximized={isMaximized}
|
|
onToggleMaximize={() => setIsMaximized(true)}
|
|
/>
|
|
)}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{!isMaximized && (
|
|
<Sidebar activeModule={activeModule} setActiveModule={setActiveModule} userEmail={session.user.email} />
|
|
)}
|
|
<main className={cn(
|
|
"flex-1 overflow-auto relative bg-[#041021] transition-all duration-300",
|
|
isMaximized ? "p-0" : "p-6"
|
|
)}>
|
|
{isMaximized && (
|
|
<button
|
|
onClick={() => setIsMaximized(false)}
|
|
className="fixed top-4 right-4 z-[100] p-3 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-full shadow-2xl border border-slate-700 transition-all active:scale-95 group"
|
|
title="Exit Fullscreen"
|
|
>
|
|
<X className="w-6 h-6" />
|
|
<span className="absolute right-full mr-2 top-1/2 -translate-y-1/2 px-2 py-1 bg-slate-900 text-xs text-white rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
|
|
Exit Fullscreen
|
|
</span>
|
|
</button>
|
|
)}
|
|
{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}
|
|
dashboardDrilldown={dashboardDrilldown}
|
|
onDashboardDrilldownApplied={() => setDashboardDrilldown(null)}
|
|
/>
|
|
)}
|
|
{activeModule === 'control_dashboard' && (
|
|
<ControlDashboardView
|
|
headers={appState.headers}
|
|
data={appState.data}
|
|
pendingRows={pendingRows}
|
|
rowStatuses={rowStatuses}
|
|
activeModule={activeModule}
|
|
onDrillDown={(request) => {
|
|
setDashboardDrilldown(request);
|
|
setActiveModule(request.tabId);
|
|
}}
|
|
/>
|
|
)}
|
|
{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}
|
|
dashboardDrilldown={dashboardDrilldown}
|
|
onDashboardDrilldownApplied={() => setDashboardDrilldown(null)}
|
|
/>
|
|
)}
|
|
{activeModule === 'article_details' && (
|
|
<ArticleDetails
|
|
data={appState.data}
|
|
onEdit={(index) => setEditingRowIndex(index)}
|
|
rowStatuses={rowStatuses}
|
|
dashboardDrilldown={dashboardDrilldown}
|
|
onDashboardDrilldownApplied={() => setDashboardDrilldown(null)}
|
|
/>
|
|
)}
|
|
{activeModule === 'pending_validation' && (
|
|
<PendingValidationView
|
|
data={appState.data}
|
|
pendingRows={pendingRows}
|
|
rowStatuses={rowStatuses}
|
|
onRevertRow={handleRevertRow}
|
|
onEdit={(index) => setEditingRowIndex(index)}
|
|
/>
|
|
)}
|
|
{activeModule === 'missing_data' && (
|
|
<MissingDataView
|
|
data={appState.data}
|
|
headers={appState.headers}
|
|
onSaveRow={handleSaveRow}
|
|
onCaptureState={captureState}
|
|
/>
|
|
)}
|
|
{activeModule === 'cosmetic_items' && (
|
|
<CosmeticItemsView
|
|
data={appState.data}
|
|
headers={appState.headers}
|
|
onSaveRow={handleSaveRow}
|
|
onCaptureState={captureState}
|
|
rowStatuses={rowStatuses}
|
|
onQueueBcSync={handleQueueBcSync}
|
|
dashboardDrilldown={dashboardDrilldown}
|
|
onDashboardDrilldownApplied={() => setDashboardDrilldown(null)}
|
|
/>
|
|
)}
|
|
{activeModule === 'history' && (
|
|
<HistoryView
|
|
headers={appState.headers}
|
|
data={appState.data}
|
|
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));
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
{activeModule === 'user_management' && session && (
|
|
<UserManagementView session={session} />
|
|
)}
|
|
</>
|
|
)}
|
|
</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>
|
|
</FilterProvider>
|
|
</ColumnsProvider>
|
|
);
|
|
}
|