mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 15:35:22 +02:00
Fix syntax error in App.tsx: Restore missing useMemo declaration
This commit is contained in:
@@ -497,335 +497,336 @@ const App: React.FC = () => {
|
|||||||
// Determine which dataset to use for velocity calculation based on top50Mode
|
// Determine which dataset to use for velocity calculation based on top50Mode
|
||||||
|
|
||||||
// Combine Sales & Ads Data dynamically based on current filters
|
// Combine Sales & Ads Data dynamically based on current filters
|
||||||
return mergeSalesAndAdsData(filteredData, filteredAdsData, globalAsinMetadata, trafficData, velocityMap);
|
const combinedAdsData = useMemo(() => {
|
||||||
}, [filteredData, filteredAdsData, globalAsinMetadata, trafficData, velocityMap]);
|
return mergeSalesAndAdsData(filteredData, filteredAdsData, globalAsinMetadata, trafficData, velocityMap);
|
||||||
|
}, [filteredData, filteredAdsData, globalAsinMetadata, trafficData, velocityMap]);
|
||||||
|
|
||||||
// Derived Data for Grid (YTD Filtered - Isolated)
|
// Derived Data for Grid (YTD Filtered - Isolated)
|
||||||
// 1. Identify "Current Year" max week
|
// 1. Identify "Current Year" max week
|
||||||
const gridContext = useMemo(() => {
|
const gridContext = useMemo(() => {
|
||||||
if (rawData.length === 0) return { minWeek: 1, maxWeek: 53, currentYear: new Date().getFullYear() };
|
if (rawData.length === 0) return { minWeek: 1, maxWeek: 53, currentYear: new Date().getFullYear() };
|
||||||
|
|
||||||
const currentYear = Math.max(...rawData.map(r => r.year));
|
const currentYear = Math.max(...rawData.map(r => r.year));
|
||||||
const currentYearData = rawData.filter(r => r.year === currentYear);
|
const currentYearData = rawData.filter(r => r.year === currentYear);
|
||||||
const maxWeek = Math.max(...currentYearData.map(r => r.week).filter(Boolean));
|
const maxWeek = Math.max(...currentYearData.map(r => r.week).filter(Boolean));
|
||||||
|
|
||||||
return { minWeek: 1, maxWeek: maxWeek || 53, currentYear };
|
return { minWeek: 1, maxWeek: maxWeek || 53, currentYear };
|
||||||
}, [rawData]);
|
}, [rawData]);
|
||||||
|
|
||||||
const ytdGridData = useMemo(() => {
|
const ytdGridData = useMemo(() => {
|
||||||
// Filter combinedAdsData to only include weeks <= maxWeek
|
// Filter combinedAdsData to only include weeks <= maxWeek
|
||||||
// This ensures we compare "Like for Like" periods across years
|
// This ensures we compare "Like for Like" periods across years
|
||||||
// We do strictly LESS THAN OR EQUAL to maxWeek.
|
// We do strictly LESS THAN OR EQUAL to maxWeek.
|
||||||
if (!combinedAdsData) return [];
|
if (!combinedAdsData) return [];
|
||||||
return combinedAdsData.filter(item => {
|
return combinedAdsData.filter(item => {
|
||||||
// CombinedKPIs has 'week' property
|
// CombinedKPIs has 'week' property
|
||||||
if (!item.week) return true; // Keep non-weekly items if any (shouldn't be)
|
if (!item.week) return true; // Keep non-weekly items if any (shouldn't be)
|
||||||
return item.week <= gridContext.maxWeek;
|
return item.week <= gridContext.maxWeek;
|
||||||
});
|
});
|
||||||
}, [combinedAdsData, gridContext.maxWeek]);
|
}, [combinedAdsData, gridContext.maxWeek]);
|
||||||
|
|
||||||
// Derived Data for Views
|
// Derived Data for Views
|
||||||
const years = useMemo(() => getUniqueValues(rawData, 'year').sort().reverse(), [rawData]);
|
const years = useMemo(() => getUniqueValues(rawData, 'year').sort().reverse(), [rawData]);
|
||||||
|
|
||||||
// Derive Context Data (Product Line Context when drilling down)
|
// Derive Context Data (Product Line Context when drilling down)
|
||||||
const contextAggregatedData = useMemo(() => {
|
const contextAggregatedData = useMemo(() => {
|
||||||
// Check if we are filtering by specific items (SKU, ASIN, Title)
|
// Check if we are filtering by specific items (SKU, ASIN, Title)
|
||||||
const hasItemFilters = filters.sku.length > 0 || filters.asin.length > 0 || filters.title.length > 0;
|
const hasItemFilters = filters.sku.length > 0 || filters.asin.length > 0 || filters.title.length > 0;
|
||||||
|
|
||||||
if (!hasItemFilters || filteredData.length === 0) {
|
if (!hasItemFilters || filteredData.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Identify the Product Lines associated with the currently filtered items
|
|
||||||
const activeLines = Array.from(new Set(filteredData.map(r => r.line)));
|
|
||||||
|
|
||||||
// 2. Create a "broad" filter: Keep Year/Customer/Month, but CLEAR Item filters, and restrict to these Lines
|
|
||||||
const contextFilters: FilterState = {
|
|
||||||
...filters,
|
|
||||||
line: activeLines, // Force these lines
|
|
||||||
sku: [], // Clear specific item filters
|
|
||||||
asin: [],
|
|
||||||
title: [],
|
|
||||||
stock: []
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. Process this broader dataset
|
|
||||||
const broadData = filterData(rawData, contextFilters);
|
|
||||||
return aggregateData(broadData);
|
|
||||||
|
|
||||||
}, [rawData, filters, filteredData]);
|
|
||||||
|
|
||||||
|
|
||||||
// Derive Options for Filter Dropdowns
|
|
||||||
const filterOptions = useMemo(() => {
|
|
||||||
return {
|
|
||||||
customer: getUniqueValues(rawData, 'customer'),
|
|
||||||
year: getUniqueValues(rawData, 'year'),
|
|
||||||
month: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
|
||||||
line: getUniqueValues(rawData, 'line'),
|
|
||||||
asin: getUniqueValues(rawData, 'asin'),
|
|
||||||
sku: getUniqueValues(rawData, 'sku'),
|
|
||||||
title: getUniqueValues(rawData, 'title'),
|
|
||||||
week: Array.from(new Set(rawData.map(r => r.week).filter(w => w !== undefined))).sort((a, b) => (a as number) - (b as number)).map(w => `W${w}`),
|
|
||||||
stock: [
|
|
||||||
'Out of Stock (0)',
|
|
||||||
'In Stock (>0)',
|
|
||||||
'Low Stock (<10)',
|
|
||||||
...Array.from(new Set(Array.from(stockMap.values()).map(String))).sort((a, b) => parseFloat(a) - parseFloat(b))
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}, [rawData, stockMap]);
|
|
||||||
|
|
||||||
const handleFilterChange = (key: keyof FilterState, value: string[]) => {
|
|
||||||
setFilters(prev => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAskGemini = async (text: string) => {
|
|
||||||
return await queryGemini(text, aggregatedData, filteredData.length);
|
|
||||||
};
|
|
||||||
|
|
||||||
const disconnectUrl = async () => {
|
|
||||||
// Allow disconnecting to clear data, but the app will likely re-connect on next reload due to "Permanent" requirement
|
|
||||||
localStorage.removeItem('craze_csv_url');
|
|
||||||
await clearSalesData();
|
|
||||||
await clearAdsData();
|
|
||||||
setActiveUrl(null);
|
|
||||||
setRawData([]);
|
|
||||||
setAdsData([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex flex-col bg-background text-slate-200">
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-slate-950/90 backdrop-blur border-b border-border py-4 px-6 relative z-40 shadow-2xl">
|
|
||||||
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
|
||||||
<div className="flex items-center gap-8">
|
|
||||||
{/* Logo Container (Horizontal Box) - Persistent User Image */}
|
|
||||||
<div className="h-16 w-64 relative flex-shrink-0">
|
|
||||||
<CrazeLogo />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title & Status */}
|
|
||||||
<div className="hidden lg:block border-l border-slate-700 pl-6">
|
|
||||||
<h1 className="text-xl font-bold tracking-tight text-white text-shadow-sm">Analytics Dashboard</h1>
|
|
||||||
<div className="flex items-center gap-3 mt-1.5">
|
|
||||||
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-emerald-500/10 border border-emerald-500/20 rounded-full">
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full bg-emerald-400 ${syncing ? 'animate-ping' : ''}`}></span>
|
|
||||||
<span className="text-[9px] text-emerald-400 font-black uppercase tracking-wider">Live Sync</span>
|
|
||||||
</div>
|
|
||||||
{lastUpdated && (
|
|
||||||
<span className="text-[9px] text-slate-500 font-bold uppercase tracking-wider">
|
|
||||||
Last: {new Date(lastUpdated).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
{/* Force Sync Action */}
|
|
||||||
<button
|
|
||||||
onClick={() => { handleDataFetch(); handleAdsFetch(); handleTrafficFetch(); handleVendorStockFetch(); }}
|
|
||||||
disabled={syncing}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-black uppercase tracking-widest transition-all
|
|
||||||
${syncing
|
|
||||||
? 'bg-indigo-500/20 text-indigo-400 border border-indigo-500/30'
|
|
||||||
: 'bg-slate-900 text-slate-300 border border-border hover:border-indigo-500/50 hover:text-white shadow-lg active:scale-95'}`}
|
|
||||||
>
|
|
||||||
<RefreshIcon className={`w-4 h-4 ${syncing ? 'animate-spin' : ''}`} />
|
|
||||||
<span>{syncing ? 'Syncing...' : 'Sync Now'}</span>
|
|
||||||
</button>
|
|
||||||
{/* View Switcher is the priority now */}
|
|
||||||
{/* View Switcher */}
|
|
||||||
<div className="flex bg-slate-900 rounded-lg p-1 border border-border">
|
|
||||||
<button
|
|
||||||
onClick={() => setView('dashboard')}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
|
||||||
${view === 'dashboard' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
|
||||||
>
|
|
||||||
<ChartIcon /> <span className="hidden sm:inline">Dashboard</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView('table')}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
|
||||||
${view === 'table' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
|
||||||
>
|
|
||||||
<TableIcon /> <span className="hidden sm:inline">Grid</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView('weekly')}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
|
||||||
${view === 'weekly' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
|
||||||
>
|
|
||||||
<TrendingIcon /> <span className="hidden sm:inline">Weekly Sales</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView('movers')}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
|
||||||
${view === 'movers' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
|
||||||
>
|
|
||||||
<TrendingIcon /> <span className="hidden sm:inline">Movers</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView('ads')}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
|
||||||
${view === 'ads' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
|
||||||
>
|
|
||||||
<MegaphoneIcon /> <span className="hidden sm:inline">Ads</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView('forecast')}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
|
||||||
${view === 'forecast' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
|
||||||
>
|
|
||||||
<ChartIcon /> <span className="hidden sm:inline">Fc 26</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="flex-1 relative">
|
|
||||||
{loading ? (
|
|
||||||
// Initial loading spinner
|
|
||||||
<div className="flex flex-col items-center justify-center h-[80vh] gap-4">
|
|
||||||
<div className="w-16 h-16 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
<h2 className="text-xl font-bold text-slate-300">Loading Dashboard...</h2>
|
|
||||||
<p className="text-sm text-slate-500">Syncing with Dropbox...</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} />
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className={view === 'dashboard' ? '' : 'hidden'}>
|
|
||||||
<Dashboard data={aggregatedData} filterState={filters} adsData={filteredAdsData} stockMap={stockMap} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
|
||||||
<div className={view === 'table' ? '' : 'hidden'}>
|
|
||||||
<DataGrid
|
|
||||||
data={ytdGridData}
|
|
||||||
hasCustomerFilter={filters.customer.length > 0}
|
|
||||||
adsData={filteredAdsData}
|
|
||||||
stockMap={stockMap}
|
|
||||||
vendorStockMap={vendorStockMap}
|
|
||||||
top50Ranking={top50Ranking2025}
|
|
||||||
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
|
||||||
velocityMap={velocityMap}
|
|
||||||
buyBoxLostMap={buyBoxLostMap}
|
|
||||||
defaultSort={{ key: `total_sellOut_${gridContext.currentYear}`, direction: 'desc' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
|
||||||
<div className={view === 'weekly' ? '' : 'hidden'}>
|
|
||||||
<WeeklyGrid
|
|
||||||
data={combinedAdsData}
|
|
||||||
onDrillDown={handleSkuDrillDown}
|
|
||||||
stockMap={stockMap}
|
|
||||||
vendorStockMap={vendorStockMap}
|
|
||||||
stockFilter={filters.stock}
|
|
||||||
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
|
||||||
vendorStockFilter={filters.vendorStock}
|
|
||||||
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
|
|
||||||
wocFilter={filters.woc}
|
|
||||||
onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))}
|
|
||||||
customerFilters={filters.customer}
|
|
||||||
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
|
||||||
top50Ranking={top50Ranking2025}
|
|
||||||
velocityMap={velocityMap}
|
|
||||||
buyBoxLostMap={buyBoxLostMap}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
|
||||||
<div className={view === 'movers' ? '' : 'hidden'}>
|
|
||||||
<TopMovers data={filteredData} stockMap={stockMap} />
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
|
||||||
<div className={view === 'ads' ? '' : 'hidden'}>
|
|
||||||
<AdsPerformance
|
|
||||||
data={combinedAdsData}
|
|
||||||
filters={filters}
|
|
||||||
top50Ranking={top50Ranking2025}
|
|
||||||
stockMap={stockMap}
|
|
||||||
vendorStockMap={vendorStockMap}
|
|
||||||
stockFilter={filters.stock}
|
|
||||||
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
|
||||||
vendorStockFilter={filters.vendorStock}
|
|
||||||
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
|
|
||||||
wocFilter={filters.woc}
|
|
||||||
onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))}
|
|
||||||
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
|
||||||
buyBoxLostMap={buyBoxLostMap}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
|
||||||
<div className={view === 'forecast' ? '' : 'hidden'}>
|
|
||||||
<ForecastView
|
|
||||||
data={forecastData}
|
|
||||||
filters={filters}
|
|
||||||
top50Ranking={top50Ranking2025}
|
|
||||||
stockMap={stockMap}
|
|
||||||
vendorStockMap={vendorStockMap}
|
|
||||||
stockFilter={filters.stock}
|
|
||||||
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
|
||||||
vendorStockFilter={filters.vendorStock}
|
|
||||||
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
|
|
||||||
wocFilter={filters.woc}
|
|
||||||
onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))}
|
|
||||||
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
|
||||||
buyBoxLostMap={buyBoxLostMap}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Chat Assistant */}
|
|
||||||
<AIChat onSendMessage={handleAskGemini} isOpen={isChatOpen} setIsOpen={setIsChatOpen} />
|
|
||||||
|
|
||||||
{/* DATA MODAL */}
|
|
||||||
{
|
|
||||||
isDataModalOpen && (
|
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm animate-fade-in p-4">
|
|
||||||
<div className="bg-slate-950 border border-border rounded-2xl shadow-2xl w-full max-w-lg relative overflow-hidden">
|
|
||||||
<div className="bg-slate-900 px-6 py-4 border-b border-border flex justify-between items-center">
|
|
||||||
<h2 className="text-lg font-bold text-white">Data Source Settings</h2>
|
|
||||||
<button onClick={() => setIsDataModalOpen(false)} className="text-slate-400 hover:text-white transition-colors">
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
<FileUpload
|
|
||||||
onSalesUpload={handleSalesUpload}
|
|
||||||
onAdsUpload={handleAdsUpload}
|
|
||||||
onTrafficUpload={handleTrafficUpload}
|
|
||||||
onUrlSubmit={handleDataFetch}
|
|
||||||
isLoading={syncing}
|
|
||||||
activeUrl={activeUrl}
|
|
||||||
onDisconnect={disconnectUrl}
|
|
||||||
lastUpdated={lastUpdated}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</div >
|
// 1. Identify the Product Lines associated with the currently filtered items
|
||||||
);
|
const activeLines = Array.from(new Set(filteredData.map(r => r.line)));
|
||||||
|
|
||||||
|
// 2. Create a "broad" filter: Keep Year/Customer/Month, but CLEAR Item filters, and restrict to these Lines
|
||||||
|
const contextFilters: FilterState = {
|
||||||
|
...filters,
|
||||||
|
line: activeLines, // Force these lines
|
||||||
|
sku: [], // Clear specific item filters
|
||||||
|
asin: [],
|
||||||
|
title: [],
|
||||||
|
stock: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Process this broader dataset
|
||||||
|
const broadData = filterData(rawData, contextFilters);
|
||||||
|
return aggregateData(broadData);
|
||||||
|
|
||||||
|
}, [rawData, filters, filteredData]);
|
||||||
|
|
||||||
|
|
||||||
|
// Derive Options for Filter Dropdowns
|
||||||
|
const filterOptions = useMemo(() => {
|
||||||
|
return {
|
||||||
|
customer: getUniqueValues(rawData, 'customer'),
|
||||||
|
year: getUniqueValues(rawData, 'year'),
|
||||||
|
month: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
||||||
|
line: getUniqueValues(rawData, 'line'),
|
||||||
|
asin: getUniqueValues(rawData, 'asin'),
|
||||||
|
sku: getUniqueValues(rawData, 'sku'),
|
||||||
|
title: getUniqueValues(rawData, 'title'),
|
||||||
|
week: Array.from(new Set(rawData.map(r => r.week).filter(w => w !== undefined))).sort((a, b) => (a as number) - (b as number)).map(w => `W${w}`),
|
||||||
|
stock: [
|
||||||
|
'Out of Stock (0)',
|
||||||
|
'In Stock (>0)',
|
||||||
|
'Low Stock (<10)',
|
||||||
|
...Array.from(new Set(Array.from(stockMap.values()).map(String))).sort((a, b) => parseFloat(a) - parseFloat(b))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}, [rawData, stockMap]);
|
||||||
|
|
||||||
|
const handleFilterChange = (key: keyof FilterState, value: string[]) => {
|
||||||
|
setFilters(prev => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAskGemini = async (text: string) => {
|
||||||
|
return await queryGemini(text, aggregatedData, filteredData.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnectUrl = async () => {
|
||||||
|
// Allow disconnecting to clear data, but the app will likely re-connect on next reload due to "Permanent" requirement
|
||||||
|
localStorage.removeItem('craze_csv_url');
|
||||||
|
await clearSalesData();
|
||||||
|
await clearAdsData();
|
||||||
|
setActiveUrl(null);
|
||||||
|
setRawData([]);
|
||||||
|
setAdsData([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-background text-slate-200">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-slate-950/90 backdrop-blur border-b border-border py-4 px-6 relative z-40 shadow-2xl">
|
||||||
|
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
{/* Logo Container (Horizontal Box) - Persistent User Image */}
|
||||||
|
<div className="h-16 w-64 relative flex-shrink-0">
|
||||||
|
<CrazeLogo />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & Status */}
|
||||||
|
<div className="hidden lg:block border-l border-slate-700 pl-6">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-white text-shadow-sm">Analytics Dashboard</h1>
|
||||||
|
<div className="flex items-center gap-3 mt-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-emerald-500/10 border border-emerald-500/20 rounded-full">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full bg-emerald-400 ${syncing ? 'animate-ping' : ''}`}></span>
|
||||||
|
<span className="text-[9px] text-emerald-400 font-black uppercase tracking-wider">Live Sync</span>
|
||||||
|
</div>
|
||||||
|
{lastUpdated && (
|
||||||
|
<span className="text-[9px] text-slate-500 font-bold uppercase tracking-wider">
|
||||||
|
Last: {new Date(lastUpdated).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
{/* Force Sync Action */}
|
||||||
|
<button
|
||||||
|
onClick={() => { handleDataFetch(); handleAdsFetch(); handleTrafficFetch(); handleVendorStockFetch(); }}
|
||||||
|
disabled={syncing}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-black uppercase tracking-widest transition-all
|
||||||
|
${syncing
|
||||||
|
? 'bg-indigo-500/20 text-indigo-400 border border-indigo-500/30'
|
||||||
|
: 'bg-slate-900 text-slate-300 border border-border hover:border-indigo-500/50 hover:text-white shadow-lg active:scale-95'}`}
|
||||||
|
>
|
||||||
|
<RefreshIcon className={`w-4 h-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||||
|
<span>{syncing ? 'Syncing...' : 'Sync Now'}</span>
|
||||||
|
</button>
|
||||||
|
{/* View Switcher is the priority now */}
|
||||||
|
{/* View Switcher */}
|
||||||
|
<div className="flex bg-slate-900 rounded-lg p-1 border border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setView('dashboard')}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
||||||
|
${view === 'dashboard' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
||||||
|
>
|
||||||
|
<ChartIcon /> <span className="hidden sm:inline">Dashboard</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('table')}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
||||||
|
${view === 'table' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
||||||
|
>
|
||||||
|
<TableIcon /> <span className="hidden sm:inline">Grid</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('weekly')}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
||||||
|
${view === 'weekly' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
||||||
|
>
|
||||||
|
<TrendingIcon /> <span className="hidden sm:inline">Weekly Sales</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('movers')}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
||||||
|
${view === 'movers' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
||||||
|
>
|
||||||
|
<TrendingIcon /> <span className="hidden sm:inline">Movers</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('ads')}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
||||||
|
${view === 'ads' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
||||||
|
>
|
||||||
|
<MegaphoneIcon /> <span className="hidden sm:inline">Ads</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('forecast')}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
||||||
|
${view === 'forecast' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
||||||
|
>
|
||||||
|
<ChartIcon /> <span className="hidden sm:inline">Fc 26</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 relative">
|
||||||
|
{loading ? (
|
||||||
|
// Initial loading spinner
|
||||||
|
<div className="flex flex-col items-center justify-center h-[80vh] gap-4">
|
||||||
|
<div className="w-16 h-16 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-300">Loading Dashboard...</h2>
|
||||||
|
<p className="text-sm text-slate-500">Syncing with Dropbox...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} />
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className={view === 'dashboard' ? '' : 'hidden'}>
|
||||||
|
<Dashboard data={aggregatedData} filterState={filters} adsData={filteredAdsData} stockMap={stockMap} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<div className={view === 'table' ? '' : 'hidden'}>
|
||||||
|
<DataGrid
|
||||||
|
data={ytdGridData}
|
||||||
|
hasCustomerFilter={filters.customer.length > 0}
|
||||||
|
adsData={filteredAdsData}
|
||||||
|
stockMap={stockMap}
|
||||||
|
vendorStockMap={vendorStockMap}
|
||||||
|
top50Ranking={top50Ranking2025}
|
||||||
|
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
||||||
|
velocityMap={velocityMap}
|
||||||
|
buyBoxLostMap={buyBoxLostMap}
|
||||||
|
defaultSort={{ key: `total_sellOut_${gridContext.currentYear}`, direction: 'desc' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<div className={view === 'weekly' ? '' : 'hidden'}>
|
||||||
|
<WeeklyGrid
|
||||||
|
data={combinedAdsData}
|
||||||
|
onDrillDown={handleSkuDrillDown}
|
||||||
|
stockMap={stockMap}
|
||||||
|
vendorStockMap={vendorStockMap}
|
||||||
|
stockFilter={filters.stock}
|
||||||
|
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
||||||
|
vendorStockFilter={filters.vendorStock}
|
||||||
|
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
|
||||||
|
wocFilter={filters.woc}
|
||||||
|
onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))}
|
||||||
|
customerFilters={filters.customer}
|
||||||
|
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
||||||
|
top50Ranking={top50Ranking2025}
|
||||||
|
velocityMap={velocityMap}
|
||||||
|
buyBoxLostMap={buyBoxLostMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<div className={view === 'movers' ? '' : 'hidden'}>
|
||||||
|
<TopMovers data={filteredData} stockMap={stockMap} />
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<div className={view === 'ads' ? '' : 'hidden'}>
|
||||||
|
<AdsPerformance
|
||||||
|
data={combinedAdsData}
|
||||||
|
filters={filters}
|
||||||
|
top50Ranking={top50Ranking2025}
|
||||||
|
stockMap={stockMap}
|
||||||
|
vendorStockMap={vendorStockMap}
|
||||||
|
stockFilter={filters.stock}
|
||||||
|
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
||||||
|
vendorStockFilter={filters.vendorStock}
|
||||||
|
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
|
||||||
|
wocFilter={filters.woc}
|
||||||
|
onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))}
|
||||||
|
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
||||||
|
buyBoxLostMap={buyBoxLostMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<div className={view === 'forecast' ? '' : 'hidden'}>
|
||||||
|
<ForecastView
|
||||||
|
data={forecastData}
|
||||||
|
filters={filters}
|
||||||
|
top50Ranking={top50Ranking2025}
|
||||||
|
stockMap={stockMap}
|
||||||
|
vendorStockMap={vendorStockMap}
|
||||||
|
stockFilter={filters.stock}
|
||||||
|
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
||||||
|
vendorStockFilter={filters.vendorStock}
|
||||||
|
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
|
||||||
|
wocFilter={filters.woc}
|
||||||
|
onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))}
|
||||||
|
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
||||||
|
buyBoxLostMap={buyBoxLostMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Chat Assistant */}
|
||||||
|
<AIChat onSendMessage={handleAskGemini} isOpen={isChatOpen} setIsOpen={setIsChatOpen} />
|
||||||
|
|
||||||
|
{/* DATA MODAL */}
|
||||||
|
{
|
||||||
|
isDataModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm animate-fade-in p-4">
|
||||||
|
<div className="bg-slate-950 border border-border rounded-2xl shadow-2xl w-full max-w-lg relative overflow-hidden">
|
||||||
|
<div className="bg-slate-900 px-6 py-4 border-b border-border flex justify-between items-center">
|
||||||
|
<h2 className="text-lg font-bold text-white">Data Source Settings</h2>
|
||||||
|
<button onClick={() => setIsDataModalOpen(false)} className="text-slate-400 hover:text-white transition-colors">
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<FileUpload
|
||||||
|
onSalesUpload={handleSalesUpload}
|
||||||
|
onAdsUpload={handleAdsUpload}
|
||||||
|
onTrafficUpload={handleTrafficUpload}
|
||||||
|
onUrlSubmit={handleDataFetch}
|
||||||
|
isLoading={syncing}
|
||||||
|
activeUrl={activeUrl}
|
||||||
|
onDisconnect={disconnectUrl}
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
</div >
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
Reference in New Issue
Block a user