mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 15:45:24 +02:00
feat: automatic ads data loading and attributed sales line in chart
This commit is contained in:
@@ -9,10 +9,10 @@ import FilterBar from './components/FilterBar';
|
|||||||
import AIChat from './components/AIChat';
|
import AIChat from './components/AIChat';
|
||||||
import CrazeLogo from './components/CrazeLogo';
|
import CrazeLogo from './components/CrazeLogo';
|
||||||
import { SalesRecord, FilterState, AggregatedData, AdsRecord } from './types'; // Imported AdsRecord
|
import { SalesRecord, FilterState, AggregatedData, AdsRecord } from './types'; // Imported AdsRecord
|
||||||
import { processCSV, filterData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, mergeSalesAndAdsData } from './services/dataProcessor'; // Imported new processors
|
import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, mergeSalesAndAdsData } from './services/dataProcessor';
|
||||||
import { queryGemini } from './services/geminiService';
|
import { queryGemini } from './services/geminiService';
|
||||||
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon } from './components/Icons';
|
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon } from './components/Icons';
|
||||||
import { loadSalesData, saveSalesData, clearSalesData } from './services/storage';
|
import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage';
|
||||||
|
|
||||||
// New Refresh Icon
|
// New Refresh Icon
|
||||||
const RefreshIcon = ({ className }: { className?: string }) => (
|
const RefreshIcon = ({ className }: { className?: string }) => (
|
||||||
@@ -24,6 +24,7 @@ const RefreshIcon = ({ className }: { className?: string }) => (
|
|||||||
// Hardcoded Permanent URL for Auto-Loading
|
// Hardcoded Permanent URL for Auto-Loading
|
||||||
// Using the original share link to leverage Dropbox's redirect for robust fetching.
|
// 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&st=pzn1zkrg&dl=0";
|
const PERMANENT_DROPBOX_URL = "https://www.dropbox.com/scl/fi/b9zxn4z5i7sxwfakk5g5y/Amazon-Sell-Out-2023-2025.csv?rlkey=uoto6v0mm99py8nszy8ldtez8&st=pzn1zkrg&dl=0";
|
||||||
|
const PERMANENT_ADS_URL = "https://www.dropbox.com/scl/fi/wng8tep7awhvzd65amwad/Ads-Weekly.xlsx?rlkey=kcmoq8dxgibsb2eb8zz47xvyo&st=z02m4w3g&dl=0";
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [rawData, setRawData] = useState<SalesRecord[]>([]);
|
const [rawData, setRawData] = useState<SalesRecord[]>([]);
|
||||||
@@ -84,6 +85,29 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleAdsFetch = useCallback(async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
console.log('[App] Fetching ads from /api/fetch-ads...');
|
||||||
|
const response = await fetch('/api/fetch-ads');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch Ads: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
const data = await processAdsExcel(buffer);
|
||||||
|
|
||||||
|
await saveAdsData(data);
|
||||||
|
setAdsData(data);
|
||||||
|
console.log('[App] Successfully loaded', data.length, 'ads records');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch/parse Ads", error);
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const initializeData = (data: SalesRecord[]) => {
|
const initializeData = (data: SalesRecord[]) => {
|
||||||
setRawData(data);
|
setRawData(data);
|
||||||
setFilters({
|
setFilters({
|
||||||
@@ -134,6 +158,16 @@ const App: React.FC = () => {
|
|||||||
console.error("Initial fetch failed.");
|
console.error("Initial fetch failed.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1b. Load Ads from cache or auto-fetch
|
||||||
|
const { data: cachedAds, lastUpdated: adsLastUpdated } = await loadAdsData();
|
||||||
|
if (cachedAds && cachedAds.length > 0) {
|
||||||
|
console.log("Loaded ads from cache:", cachedAds.length, "records");
|
||||||
|
setAdsData(cachedAds);
|
||||||
|
} else {
|
||||||
|
console.log("Fetching fresh Ads from URL...");
|
||||||
|
handleAdsFetch();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
initApp();
|
initApp();
|
||||||
}, [handleDataFetch]);
|
}, [handleDataFetch]);
|
||||||
@@ -147,6 +181,8 @@ const App: React.FC = () => {
|
|||||||
initializeData(data);
|
initializeData(data);
|
||||||
setLastUpdated(new Date().toISOString());
|
setLastUpdated(new Date().toISOString());
|
||||||
setIsDataModalOpen(false);
|
setIsDataModalOpen(false);
|
||||||
|
// Also trigger ads fetch if not loaded to maintain sync
|
||||||
|
if (adsData.length === 0) handleAdsFetch();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to parse CSV", error);
|
console.error("Failed to parse CSV", error);
|
||||||
alert("Error parsing CSV. Please check the format.");
|
alert("Error parsing CSV. Please check the format.");
|
||||||
@@ -162,6 +198,7 @@ const App: React.FC = () => {
|
|||||||
const isExcel = file.name.endsWith('.xlsx') || file.name.endsWith('.xls');
|
const isExcel = file.name.endsWith('.xlsx') || file.name.endsWith('.xls');
|
||||||
const data = isExcel ? await processAdsExcel(file) : await processAdsCSV(file);
|
const data = isExcel ? await processAdsExcel(file) : await processAdsCSV(file);
|
||||||
setAdsData(data);
|
setAdsData(data);
|
||||||
|
await saveAdsData(data);
|
||||||
console.log("Ads loaded:", data.length, "records from", file.name);
|
console.log("Ads loaded:", data.length, "records from", file.name);
|
||||||
setIsDataModalOpen(false);
|
setIsDataModalOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -185,6 +222,8 @@ const App: React.FC = () => {
|
|||||||
handleDataFetch().then(() => {
|
handleDataFetch().then(() => {
|
||||||
localStorage.setItem('craze_last_refresh_date', today);
|
localStorage.setItem('craze_last_refresh_date', today);
|
||||||
console.log("Daily refresh successful.");
|
console.log("Daily refresh successful.");
|
||||||
|
// Also refresh ads
|
||||||
|
handleAdsFetch();
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error("Daily refresh failed, will retry later.", err);
|
console.error("Daily refresh failed, will retry later.", err);
|
||||||
});
|
});
|
||||||
@@ -203,6 +242,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
// Derive Data
|
// Derive Data
|
||||||
const filteredData = useMemo(() => filterData(rawData, filters), [rawData, filters]);
|
const filteredData = useMemo(() => filterData(rawData, filters), [rawData, filters]);
|
||||||
|
const filteredAdsData = useMemo(() => filterAdsData(adsData, filters), [adsData, filters]);
|
||||||
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
|
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
|
||||||
|
|
||||||
// Combine Sales & Ads Data dynamically based on current filters
|
// Combine Sales & Ads Data dynamically based on current filters
|
||||||
@@ -264,8 +304,10 @@ const App: React.FC = () => {
|
|||||||
// Allow disconnecting to clear data, but the app will likely re-connect on next reload due to "Permanent" requirement
|
// Allow disconnecting to clear data, but the app will likely re-connect on next reload due to "Permanent" requirement
|
||||||
localStorage.removeItem('craze_csv_url');
|
localStorage.removeItem('craze_csv_url');
|
||||||
await clearSalesData();
|
await clearSalesData();
|
||||||
|
await clearAdsData();
|
||||||
setActiveUrl(null);
|
setActiveUrl(null);
|
||||||
setRawData([]);
|
setRawData([]);
|
||||||
|
setAdsData([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -308,7 +350,10 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
{/* NEW REFRESH BUTTON */}
|
{/* NEW REFRESH BUTTON */}
|
||||||
<button
|
<button
|
||||||
onClick={handleDataFetch}
|
onClick={() => {
|
||||||
|
handleDataFetch();
|
||||||
|
handleAdsFetch();
|
||||||
|
}}
|
||||||
disabled={syncing}
|
disabled={syncing}
|
||||||
title="Refresh Data"
|
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"
|
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"
|
||||||
@@ -361,10 +406,10 @@ const App: React.FC = () => {
|
|||||||
<Dashboard
|
<Dashboard
|
||||||
data={aggregatedData}
|
data={aggregatedData}
|
||||||
contextData={contextAggregatedData}
|
contextData={contextAggregatedData}
|
||||||
adsData={adsData}
|
adsData={filteredAdsData}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view === 'table' && <DataGrid data={filteredData} hasCustomerFilter={filters.customer.length > 0} adsData={adsData} />}
|
{view === 'table' && <DataGrid data={filteredData} hasCustomerFilter={filters.customer.length > 0} adsData={filteredAdsData} />}
|
||||||
{view === 'movers' && <TopMovers data={filteredData} />}
|
{view === 'movers' && <TopMovers data={filteredData} />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
||||||
|
|
||||||
|
const ADS_DROPBOX_URL = "https://www.dropbox.com/scl/fi/wng8tep7awhvzd65amwad/Ads-Weekly.xlsx?rlkey=kcmoq8dxgibsb2eb8zz47xvyo&st=z02m4w3g&dl=1";
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
// CORS headers
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.status(200).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[fetch-ads] Fetching Ads from Dropbox...');
|
||||||
|
const response = await fetch(ADS_DROPBOX_URL);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Dropbox responded with ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
console.log('[fetch-ads] Successfully fetched Ads Excel, size:', buffer.byteLength);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.status(200).send(Buffer.from(buffer));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[fetch-ads] Error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
-2
@@ -4,7 +4,7 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { SalesRecord, PivotRow, AdsRecord } from '../types';
|
import { SalesRecord, PivotRow, AdsRecord } from '../types';
|
||||||
import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor';
|
import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor';
|
||||||
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon } from './Icons';
|
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons';
|
||||||
|
|
||||||
interface DataGridProps {
|
interface DataGridProps {
|
||||||
data: SalesRecord[];
|
data: SalesRecord[];
|
||||||
@@ -232,6 +232,7 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
|
|||||||
const [showChart, setShowChart] = useState(true);
|
const [showChart, setShowChart] = useState(true);
|
||||||
const [visibleMetrics, setVisibleMetrics] = useState<('sellOut' | 'units')[]>(['sellOut', 'units']);
|
const [visibleMetrics, setVisibleMetrics] = useState<('sellOut' | 'units')[]>(['sellOut', 'units']);
|
||||||
const [showAdsMetrics, setShowAdsMetrics] = useState(true);
|
const [showAdsMetrics, setShowAdsMetrics] = useState(true);
|
||||||
|
const [showAttributedSales, setShowAttributedSales] = useState(false);
|
||||||
|
|
||||||
// Calculate Ads Summary for the Grid
|
// Calculate Ads Summary for the Grid
|
||||||
const adsSummary = useMemo(() => {
|
const adsSummary = useMemo(() => {
|
||||||
@@ -270,13 +271,60 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
|
|||||||
[selectedDimensions]);
|
[selectedDimensions]);
|
||||||
|
|
||||||
// Transform flat data into Pivot structure
|
// Transform flat data into Pivot structure
|
||||||
const { rows: pivotRows, years } = useMemo(() => {
|
const { rows: basePivotRows, years } = useMemo(() => {
|
||||||
// Apply Pan-EU grouping when no customer filter is applied
|
// Apply Pan-EU grouping when no customer filter is applied
|
||||||
const processedData = applyPanEUGrouping(data, hasCustomerFilter);
|
const processedData = applyPanEUGrouping(data, hasCustomerFilter);
|
||||||
|
|
||||||
return pivotSalesData(processedData, effectiveDimensions);
|
return pivotSalesData(processedData, effectiveDimensions);
|
||||||
}, [data, effectiveDimensions, hasCustomerFilter]);
|
}, [data, effectiveDimensions, hasCustomerFilter]);
|
||||||
|
|
||||||
|
// Enrich pivot rows with ads data aggregated by ASIN
|
||||||
|
const pivotRows = useMemo(() => {
|
||||||
|
if (!adsData || adsData.length === 0) return basePivotRows;
|
||||||
|
|
||||||
|
// Aggregate ads by ASIN + Year
|
||||||
|
const adsAggMap = new Map<string, Map<string, { adSpend: number; attributedSales: number }>>();
|
||||||
|
|
||||||
|
adsData.forEach(ad => {
|
||||||
|
const asinKey = ad.asin.toUpperCase();
|
||||||
|
const yearKey = ad.year.toString();
|
||||||
|
|
||||||
|
if (!adsAggMap.has(asinKey)) {
|
||||||
|
adsAggMap.set(asinKey, new Map());
|
||||||
|
}
|
||||||
|
const yearMap = adsAggMap.get(asinKey)!;
|
||||||
|
|
||||||
|
if (!yearMap.has(yearKey)) {
|
||||||
|
yearMap.set(yearKey, { adSpend: 0, attributedSales: 0 });
|
||||||
|
}
|
||||||
|
const yearData = yearMap.get(yearKey)!;
|
||||||
|
yearData.adSpend += ad.cost;
|
||||||
|
yearData.attributedSales += ad.attributedSales30d;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enrich each pivot row with ads data
|
||||||
|
return basePivotRows.map(row => {
|
||||||
|
const asinKey = row.asin.toUpperCase();
|
||||||
|
const yearMap = adsAggMap.get(asinKey);
|
||||||
|
|
||||||
|
if (!yearMap) return row;
|
||||||
|
|
||||||
|
const adsByYear: Record<string, { adSpend: number; attributedSales: number; acos: number; tacos: number }> = {};
|
||||||
|
|
||||||
|
yearMap.forEach((adsYearData, yearKey) => {
|
||||||
|
const salesForYear = row.totalsByYear[yearKey]?.sellOut || 0;
|
||||||
|
adsByYear[yearKey] = {
|
||||||
|
adSpend: adsYearData.adSpend,
|
||||||
|
attributedSales: adsYearData.attributedSales,
|
||||||
|
acos: adsYearData.attributedSales > 0 ? (adsYearData.adSpend / adsYearData.attributedSales) * 100 : 0,
|
||||||
|
tacos: salesForYear > 0 ? (adsYearData.adSpend / salesForYear) * 100 : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...row, adsByYear };
|
||||||
|
});
|
||||||
|
}, [basePivotRows, adsData]);
|
||||||
|
|
||||||
// Data for the time series chart, supporting single and multi-year comparison
|
// Data for the time series chart, supporting single and multi-year comparison
|
||||||
const { chartData, uniqueYears, isComparisonView, chartTitle } = useMemo(() => {
|
const { chartData, uniqueYears, isComparisonView, chartTitle } = useMemo(() => {
|
||||||
const yearsInView = Array.from(new Set(data.map(d => d.year.toString()))).sort((a: string, b: string) => parseInt(b) - parseInt(a));
|
const yearsInView = Array.from(new Set(data.map(d => d.year.toString()))).sort((a: string, b: string) => parseInt(b) - parseInt(a));
|
||||||
@@ -298,7 +346,9 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
|
|||||||
}
|
}
|
||||||
const weekData = adsMap.get(ad.week)!;
|
const weekData = adsMap.get(ad.week)!;
|
||||||
const adSpendKey = `${ad.year}_adSpend`;
|
const adSpendKey = `${ad.year}_adSpend`;
|
||||||
|
const attrSalesKey = `${ad.year}_attributedSales`;
|
||||||
weekData[adSpendKey] = (weekData[adSpendKey] || 0) + ad.cost;
|
weekData[adSpendKey] = (weekData[adSpendKey] || 0) + ad.cost;
|
||||||
|
weekData[attrSalesKey] = (weekData[attrSalesKey] || 0) + ad.attributedSales30d;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -420,6 +470,17 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle sorting by Ads Metrics (adSpend_2025, tacos_2025)
|
||||||
|
else if ((sortConfig.key as string).includes('_') && !((sortConfig.key as string).startsWith('total_') || (sortConfig.key as string).startsWith('growth_'))) {
|
||||||
|
const parts = (sortConfig.key as string).split('_');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const metric = parts[0] as 'adSpend' | 'tacos';
|
||||||
|
const year = parts[1];
|
||||||
|
valA = a.adsByYear?.[year]?.[metric] || 0;
|
||||||
|
valB = b.adsByYear?.[year]?.[metric] || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
|
if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||||
if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
|
if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
@@ -539,6 +600,18 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
|
|||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
dot={false}
|
dot={false}
|
||||||
/>
|
/>
|
||||||
|
),
|
||||||
|
// Attributed Sales line
|
||||||
|
showAttributedSales && adsSummary && (
|
||||||
|
<Line
|
||||||
|
key={`${year}_attrSales`}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`${year}_attributedSales`}
|
||||||
|
name={`Attr. Sales ${year}`}
|
||||||
|
stroke="#fbbf24"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
) : (
|
) : (
|
||||||
@@ -639,6 +712,17 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
|
|||||||
<span className="hidden sm:inline">{showAdsMetrics ? 'Hide Ads' : 'Show Ads'}</span>
|
<span className="hidden sm:inline">{showAdsMetrics ? 'Hide Ads' : 'Show Ads'}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Attributed Sales Toggle - Only show when ads data is loaded and ads metrics are shown */}
|
||||||
|
{adsSummary && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAttributedSales(!showAttributedSales)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${showAttributedSales ? 'bg-amber-600/20 text-amber-400 border-amber-500/50' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'}`}
|
||||||
|
>
|
||||||
|
<TrendingIcon className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">{showAttributedSales ? 'Hide Attr. Sales' : 'Show Attr. Sales'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 w-full lg:w-auto justify-between lg:justify-end">
|
<div className="flex items-center gap-3 w-full lg:w-auto justify-between lg:justify-end">
|
||||||
@@ -906,6 +990,28 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
|
|||||||
</th>
|
</th>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ads Columns - Only show when ads data exists and toggle is on */}
|
||||||
|
{showAdsMetrics && adsSummary && (
|
||||||
|
<>
|
||||||
|
<th
|
||||||
|
className="px-3 py-3 border-b border-fuchsia-500/30 text-right bg-fuchsia-950/30 min-w-[90px] cursor-pointer hover:bg-fuchsia-900/40 transition-colors"
|
||||||
|
onClick={() => requestSort(`adSpend_${year}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end text-fuchsia-400 text-[10px] uppercase">
|
||||||
|
Ad Spend {year} {getSortIcon(`adSpend_${year}`)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-3 py-3 border-b border-fuchsia-500/30 text-right bg-fuchsia-950/30 min-w-[70px] cursor-pointer hover:bg-fuchsia-900/40 transition-colors"
|
||||||
|
onClick={() => requestSort(`tacos_${year}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end text-fuchsia-400 text-[10px] uppercase">
|
||||||
|
TACOS {year} {getSortIcon(`tacos_${year}`)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -974,6 +1080,24 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
|
|||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ads Data Cells */}
|
||||||
|
{showAdsMetrics && adsSummary && (() => {
|
||||||
|
const adsYearData = row.adsByYear?.[year];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td className="px-3 py-3 text-right text-fuchsia-400 font-medium bg-fuchsia-950/10">
|
||||||
|
{adsYearData ? `€${adsYearData.adSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 py-3 text-right font-bold text-xs bg-fuchsia-950/10 ${adsYearData
|
||||||
|
? (adsYearData.tacos <= 10 ? 'text-emerald-400' : adsYearData.tacos <= 20 ? 'text-amber-400' : 'text-red-400')
|
||||||
|
: 'text-slate-600'
|
||||||
|
}`}>
|
||||||
|
{adsYearData ? `${adsYearData.tacos.toFixed(1)}%` : '-'}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -352,9 +352,11 @@ export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const processAdsExcel = async (file: File): Promise<AdsRecord[]> => {
|
export const processAdsExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<AdsRecord[]> => {
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = fileOrBuffer instanceof File
|
||||||
|
? await fileOrBuffer.arrayBuffer()
|
||||||
|
: fileOrBuffer;
|
||||||
const workbook = XLSX.read(arrayBuffer);
|
const workbook = XLSX.read(arrayBuffer);
|
||||||
const allData: AdsRecord[] = [];
|
const allData: AdsRecord[] = [];
|
||||||
|
|
||||||
@@ -542,6 +544,29 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor
|
|||||||
|
|
||||||
// --- EXISTING HELPERS ---
|
// --- EXISTING HELPERS ---
|
||||||
|
|
||||||
|
// Filter Ads Data by Country, Year, Week, and ASIN
|
||||||
|
export const filterAdsData = (adsData: AdsRecord[], filters: FilterState): AdsRecord[] => {
|
||||||
|
return adsData.filter(ad => {
|
||||||
|
// Country/Customer match (ads use 'country', sales use 'customer')
|
||||||
|
const countryMatch = filters.customer.length === 0 ||
|
||||||
|
filters.customer.some(c => c.toUpperCase() === ad.country.toUpperCase());
|
||||||
|
|
||||||
|
// Year match
|
||||||
|
const yearMatch = filters.year.length === 0 ||
|
||||||
|
filters.year.includes(ad.year.toString());
|
||||||
|
|
||||||
|
// Week match (filters use "W1", "W2" format)
|
||||||
|
const weekStr = `W${ad.week}`;
|
||||||
|
const weekMatch = filters.week.length === 0 || filters.week.includes(weekStr);
|
||||||
|
|
||||||
|
// ASIN match
|
||||||
|
const asinMatch = filters.asin.length === 0 ||
|
||||||
|
filters.asin.some(a => a.toUpperCase() === ad.asin.toUpperCase());
|
||||||
|
|
||||||
|
return countryMatch && yearMatch && weekMatch && asinMatch;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const filterData = (data: SalesRecord[], filters: FilterState): SalesRecord[] => {
|
export const filterData = (data: SalesRecord[], filters: FilterState): SalesRecord[] => {
|
||||||
return data.filter(item => {
|
return data.filter(item => {
|
||||||
// 1. Month Logic: Handle "Apr-23" matching "Apr" filter
|
// 1. Month Logic: Handle "Apr-23" matching "Apr" filter
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { SalesRecord } from '../types';
|
|||||||
|
|
||||||
const DB_NAME = 'CrazeAnalytixDB';
|
const DB_NAME = 'CrazeAnalytixDB';
|
||||||
const STORE_NAME = 'salesData';
|
const STORE_NAME = 'salesData';
|
||||||
|
const ADS_STORE_NAME = 'adsData';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 1;
|
||||||
// Increment this when data processing logic changes (e.g., field mapping changes)
|
// Increment this when data processing logic changes (e.g., field mapping changes)
|
||||||
// This forces cache invalidation and re-processing of CSV data
|
// This forces cache invalidation and re-processing of CSV data
|
||||||
@@ -17,6 +18,9 @@ const initDB = (): Promise<IDBDatabase> => {
|
|||||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
db.createObjectStore(STORE_NAME);
|
db.createObjectStore(STORE_NAME);
|
||||||
}
|
}
|
||||||
|
if (!db.objectStoreNames.contains(ADS_STORE_NAME)) {
|
||||||
|
db.createObjectStore(ADS_STORE_NAME);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => resolve(request.result);
|
||||||
@@ -99,3 +103,63 @@ export const clearSalesData = async (): Promise<void> => {
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- ADS STORAGE ---
|
||||||
|
|
||||||
|
export const saveAdsData = async (data: any[]): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const db = await initDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(ADS_STORE_NAME, 'readwrite');
|
||||||
|
const store = transaction.objectStore(ADS_STORE_NAME);
|
||||||
|
|
||||||
|
store.put(data, 'currentData');
|
||||||
|
store.put(new Date().toISOString(), 'lastUpdated');
|
||||||
|
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving ads to IndexedDB:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadAdsData = async (): Promise<{ data: any[]; lastUpdated: string | null }> => {
|
||||||
|
try {
|
||||||
|
const db = await initDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(ADS_STORE_NAME, 'readonly');
|
||||||
|
const store = transaction.objectStore(ADS_STORE_NAME);
|
||||||
|
|
||||||
|
const dataReq = store.get('currentData');
|
||||||
|
const dateReq = store.get('lastUpdated');
|
||||||
|
|
||||||
|
transaction.oncomplete = () => {
|
||||||
|
resolve({
|
||||||
|
data: dataReq.result || [],
|
||||||
|
lastUpdated: dateReq.result || null
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading ads from IndexedDB:", error);
|
||||||
|
return { data: [], lastUpdated: null };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAdsData = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const db = await initDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(ADS_STORE_NAME, 'readwrite');
|
||||||
|
const store = transaction.objectStore(ADS_STORE_NAME);
|
||||||
|
store.clear();
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ export interface PivotRow {
|
|||||||
// Dynamic buckets
|
// Dynamic buckets
|
||||||
totalsByYear: Record<string, YearlyData>;
|
totalsByYear: Record<string, YearlyData>;
|
||||||
months: MonthlyPivot[]; // Always 12 elements
|
months: MonthlyPivot[]; // Always 12 elements
|
||||||
|
|
||||||
|
// Ads data (optional, aggregated by ASIN)
|
||||||
|
adsByYear?: Record<string, {
|
||||||
|
adSpend: number;
|
||||||
|
attributedSales: number;
|
||||||
|
acos: number;
|
||||||
|
tacos: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeSeriesData {
|
export interface TimeSeriesData {
|
||||||
|
|||||||
Reference in New Issue
Block a user