Files
CrazeAnalytix/App.tsx
T

416 lines
17 KiB
TypeScript
Raw Normal View History

import React, { useState, useMemo, useEffect, useCallback } from 'react';
import FileUpload from './components/FileUpload';
import Dashboard from './components/Dashboard';
import DataGrid from './components/DataGrid';
import FilterBar from './components/FilterBar';
import AIChat from './components/AIChat';
import CrazeLogo from './components/CrazeLogo';
import TopMoversPage from './components/TopMoversPage'; // New import
import { SalesRecord, FilterState, AggregatedData } from './types';
import { processCSV, processExcel, filterData, aggregateData, getUniqueValues } from './services/dataProcessor';
import { queryGemini } from './services/geminiService';
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon } from './components/Icons';
import { loadSalesData, saveSalesData, clearSalesData } from './services/storage';
// New Refresh Icon
const RefreshIcon = ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-5 h-5"}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
);
// New Movers Icon
const MoversIcon = ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18 9 11.25l4.306 4.305a11.164 11.164 0 0 0 5.814-5.815L21.75 6m0 0-3.5-3.5m3.5 3.5-3.5 3.5" />
</svg>
);
// Hardcoded Permanent URL for Auto-Loading
// Using the original share link to leverage Dropbox's redirect for robust fetching.
const PERMANENT_DROPBOX_URL = "https://www.dropbox.com/scl/fi/b9zxn4z5i7sxwfakk5g5y/Amazon-Sell-Out-2023-2025.csv?rlkey=uoto6v0mm99py8nszy8ldtez8&dl=0";
const App: React.FC = () => {
const [rawData, setRawData] = useState<SalesRecord[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [view, setView] = useState<'dashboard' | 'table' | 'topMovers'>('dashboard'); // Added 'topMovers'
const [isChatOpen, setIsChatOpen] = useState(false);
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
// API Key State
const [apiKey, setApiKey] = useState<string>(() => localStorage.getItem('gemini_api_key') || '');
// Modal State
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
// Filters State
const [filters, setFilters] = useState<FilterState>({
customer: [],
year: [],
month: [],
line: [],
asin: [],
sku: [],
title: [],
});
// Handle API Key Change
const handleApiKeyChange = (key: string) => {
setApiKey(key);
if (key) {
localStorage.setItem('gemini_api_key', key);
} else {
localStorage.removeItem('gemini_api_key');
}
};
// Handle URL Fetch (Auto/Manual)
const handleUrlFetch = useCallback(async (url: string) => {
setSyncing(true);
try {
let directUrl = url;
// Create a direct download link for Dropbox if it's a share link.
if (url.includes('dropbox.com/') && !url.includes('dl.dropboxusercontent.com')) {
const urlObject = new URL(url);
urlObject.searchParams.set('dl', '1');
directUrl = urlObject.toString();
}
// Use a CORS proxy to bypass browser's same-origin policy restrictions.
const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(directUrl)}`;
const response = await fetch(proxyUrl);
if (!response.ok) throw new Error(`Failed to fetch CSV from URL: ${response.status} ${response.statusText}`);
const csvText = await response.text();
const data = await processCSV(csvText);
await saveSalesData(data);
initializeData(data);
setActiveUrl(url); // Store the original user-facing URL
const now = new Date().toISOString();
setLastUpdated(now);
localStorage.setItem('craze_last_updated', now);
localStorage.setItem('craze_csv_url', url);
setIsDataModalOpen(false); // Close modal on success
} catch (error) {
console.error("Failed to fetch/parse CSV from URL", error);
// Don't alert on auto-fetch to avoid spamming the user on startup if offline
// alert("Error syncing data. Please check the URL.");
throw error; // re-throw to be caught by caller
} finally {
setSyncing(false);
setLoading(false);
}
}, []);
const initializeData = (data: SalesRecord[]) => {
setRawData(data);
setFilters({
customer: [],
year: [],
month: [],
line: [],
asin: [],
sku: [],
title: [],
});
};
// 1. Initial Load from Cache (IndexedDB) or Auto-Fetch Permanent URL
useEffect(() => {
const initApp = async () => {
setLoading(true);
const currentStoredUrl = localStorage.getItem('craze_csv_url');
if (currentStoredUrl !== PERMANENT_DROPBOX_URL) {
localStorage.setItem('craze_csv_url', PERMANENT_DROPBOX_URL);
setActiveUrl(PERMANENT_DROPBOX_URL);
}
const { data, lastUpdated: date } = await loadSalesData();
if (data && data.length > 0) {
console.log("Loaded data from cache:", data.length, "rows");
initializeData(data);
setLastUpdated(date);
setLoading(false);
} else {
console.log("No cache found. Auto-fetching from Permanent URL...");
handleUrlFetch(PERMANENT_DROPBOX_URL).catch(e => {
console.error("Initial fetch failed.");
});
}
};
initApp();
}, [handleUrlFetch]);
// Handle uploaded file (Manual)
const handleFileUpload = async (file: File) => {
setSyncing(true);
try {
let data: SalesRecord[] = [];
const lowerName = file.name.toLowerCase();
if (lowerName.endsWith('.csv')) {
data = await processCSV(file);
} else if (lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')) {
data = await processExcel(file);
} else {
throw new Error("Unsupported file format");
}
await saveSalesData(data);
initializeData(data);
setLastUpdated(new Date().toISOString());
setIsDataModalOpen(false);
} catch (error) {
console.error("Failed to parse file", error);
alert("Error parsing file. Please check format (CSV or Excel).");
} finally {
setSyncing(false);
}
};
// 2. Schedule Auto-Refresh (Background)
useEffect(() => {
const checkAndRefresh = () => {
const now = new Date();
const today = now.toISOString().split('T')[0]; // YYYY-MM-DD
const lastRefreshDate = localStorage.getItem('craze_last_refresh_date');
// Refresh if it's after 7 AM and we haven't refreshed today
if (now.getHours() >= 7 && lastRefreshDate !== today) {
console.log("Triggering daily data refresh...");
handleUrlFetch(PERMANENT_DROPBOX_URL).then(() => {
localStorage.setItem('craze_last_refresh_date', today);
console.log("Daily refresh successful.");
}).catch(err => {
console.error("Daily refresh failed, will retry later.", err);
});
}
};
// Check immediately on load in case the user opens the app after 7 AM
checkAndRefresh();
// And then check periodically (e.g., every 15 minutes) in case app is left open across midnight
const interval = setInterval(checkAndRefresh, 15 * 60 * 1000);
return () => clearInterval(interval);
}, [handleUrlFetch]);
// Derive Data
const filteredData = useMemo(() => filterData(rawData, filters), [rawData, filters]);
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
// Derive Context Data (Product Line Context when drilling down)
const contextAggregatedData = useMemo(() => {
// 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;
if (!hasItemFilters || filteredData.length === 0) {
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: []
};
// 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'),
};
}, [rawData]);
const handleFilterChange = (key: keyof FilterState, value: string[]) => {
setFilters(prev => ({ ...prev, [key]: value }));
};
const handleAskGemini = async (text: string) => {
return await queryGemini(apiKey, 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();
setActiveUrl(null);
setRawData([]);
};
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>
{activeUrl && lastUpdated && (
<p className="text-[10px] text-emerald-400 mt-1 flex items-center gap-1 uppercase font-bold tracking-wider">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
Live Sync Active
</p>
)}
</div>
</div>
<div className="flex items-center gap-4">
{/* Main Action: Data Source Button */}
<button
onClick={() => setIsDataModalOpen(true)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold shadow-lg transition-all border
${activeUrl
? 'bg-slate-800 text-emerald-400 border-emerald-500/50 hover:bg-slate-700'
: 'bg-indigo-600 text-white border-transparent hover:bg-indigo-500'}`}
>
{syncing ? <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div> : <UploadIcon />}
<span className="hidden md:inline">{activeUrl ? 'Data Settings' : 'Connect Data'}</span>
</button>
{/* NEW REFRESH BUTTON */}
<button
onClick={() => activeUrl && handleUrlFetch(activeUrl)}
disabled={syncing}
title="Refresh Data"
className="p-3 rounded-lg bg-slate-800 border border-border text-slate-400 hover:text-white hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RefreshIcon className={`w-5 h-5 ${syncing ? 'animate-spin' : ''}`} />
</button>
{/* 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-4 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-4 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">Data Grid</span>
</button>
<button // New Top Movers Button
onClick={() => setView('topMovers')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all
${view === 'topMovers' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<MoversIcon className="w-5 h-5" /> <span className="hidden sm:inline">Top Movers</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 is now relevant for all views including Top Movers */}
{(view === 'dashboard' || view === 'table' || view === 'topMovers') && (
<FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} />
)}
<div className="mt-6">
{view === 'dashboard' ? (
<Dashboard
data={aggregatedData}
contextData={contextAggregatedData} // Pass context data
/>
) : view === 'table' ? (
<DataGrid data={filteredData} />
) : ( // New Top Movers View
<TopMoversPage
filteredData={filteredData} // Pass globally filtered data
/>
)}
</div>
</>
)}
</main>
{/* Chat Assistant - Now with API Key Props */}
<AIChat
onSendMessage={handleAskGemini}
isOpen={isChatOpen}
setIsOpen={setIsChatOpen}
apiKey={apiKey}
onApiKeyChange={handleApiKeyChange}
/>
{/* 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
onFileUpload={handleFileUpload}
onUrlSubmit={handleUrlFetch}
isLoading={syncing}
activeUrl={activeUrl}
onDisconnect={disconnectUrl}
lastUpdated={lastUpdated}
/>
</div>
</div>
</div>
)}
</div>
);
};
export default App;