mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 11:05:23 +02:00
feat: replace Vendor tab with BSR tracker
This commit is contained in:
@@ -5,8 +5,8 @@ import Dashboard from './components/Dashboard';
|
||||
import FilterBar from './components/FilterBar';
|
||||
import AIChat from './components/AIChat';
|
||||
import CrazeLogo from './components/CrazeLogo';
|
||||
import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, processUKInventoryExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel, processVendorCSV } from './services/dataProcessor';
|
||||
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData, ActiveExperiment } from './types';
|
||||
import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, processUKInventoryExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel, processVendorCSV, processBSRExcel } from './services/dataProcessor';
|
||||
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData, ActiveExperiment, BSRRecord } from './types';
|
||||
import { queryGemini } from './services/geminiService';
|
||||
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons';
|
||||
import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage';
|
||||
@@ -60,6 +60,7 @@ const App: React.FC = () => {
|
||||
const [cachedForecastRecords, setCachedForecastRecords] = useState<ForecastRecord[]>([]);
|
||||
const [lastForecastFile, setLastForecastFile] = useState<string | null>(null);
|
||||
const [buyBoxLostMap, setBuyBoxLostMap] = useState<Map<string, { countries: string[]; reasons: Record<string, string> }>>(new Map());
|
||||
const [bsrData, setBsrData] = useState<BSRRecord[]>([]);
|
||||
|
||||
// Experiments state
|
||||
const [experimentMap, setExperimentMap] = useState<Map<string, ActiveExperiment[]>>(new Map());
|
||||
@@ -248,6 +249,21 @@ const App: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBSRFetch = useCallback(async () => {
|
||||
try {
|
||||
console.log('[App] Fetching BSR data from /api/fetch-bsr...');
|
||||
const response = await fetch('/api/fetch-bsr');
|
||||
if (!response.ok) throw new Error(`Failed to fetch BSR data: ${response.status}`);
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const data = await processBSRExcel(buffer);
|
||||
setBsrData(data);
|
||||
console.log('[App] Successfully loaded BSR data:', data.length, 'records');
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch/parse BSR data", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleExperimentsFetch = useCallback(async () => {
|
||||
try {
|
||||
const expMap = await getActiveExperiments(rawData);
|
||||
@@ -400,7 +416,8 @@ const App: React.FC = () => {
|
||||
handleStockFetch(),
|
||||
handleVendorStockFetch(),
|
||||
handleBuyBoxFetch(),
|
||||
handleExperimentsFetch()
|
||||
handleExperimentsFetch(),
|
||||
handleBSRFetch()
|
||||
]).catch(e => console.warn("Background fetch failed", e));
|
||||
|
||||
// 1f. Fetch Forecast data
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
try {
|
||||
const filePath = path.join(process.cwd(), 'BSR.xlsx');
|
||||
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'BSR data file not found' });
|
||||
}
|
||||
|
||||
const fileBuffer = await fs.readFile(filePath);
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="BSR.xlsx"');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
// Convert Buffer to ArrayBuffer before sending for fetch compatibility on client
|
||||
res.send(fileBuffer);
|
||||
} catch (error) {
|
||||
console.error('Error serving BSR Excel file:', error);
|
||||
res.status(500).json({ error: 'Failed to serve BSR file' });
|
||||
}
|
||||
}
|
||||
+165
-243
@@ -1,8 +1,7 @@
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import MultiSelectDropdown from './MultiSelectDropdown';
|
||||
import { fetchVendorData, fetchVendorFilterOptions, VendorDailyRow } from '../services/supabase';
|
||||
import { BSRRecord } from '../types';
|
||||
|
||||
const MARKET_COLORS: Record<string, string> = {
|
||||
DE: '#3b82f6',
|
||||
@@ -12,174 +11,121 @@ const MARKET_COLORS: Record<string, string> = {
|
||||
ES: '#f59e0b',
|
||||
};
|
||||
|
||||
interface WeeklyBSR {
|
||||
interface VendorDataViewProps {
|
||||
bsrData: BSRRecord[];
|
||||
}
|
||||
|
||||
interface WeeklyChartPoint {
|
||||
weekLabel: string;
|
||||
[key: string]: number | string | null; // e.g. DE_bsr, UK_bsr
|
||||
[key: string]: number | string | null;
|
||||
}
|
||||
|
||||
interface WeeklyRating {
|
||||
weekLabel: string;
|
||||
avg_rating: number | null;
|
||||
num_reviews: number | null;
|
||||
}
|
||||
|
||||
interface WeeklyBuyBox {
|
||||
weekLabel: string;
|
||||
[key: string]: number | string | null; // e.g. DE_amazon_pct
|
||||
}
|
||||
|
||||
// Get ISO week from date string
|
||||
function getISOWeek(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
|
||||
const week1 = new Date(d.getFullYear(), 0, 4);
|
||||
const weekNum = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7);
|
||||
return `${d.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function aggregateToWeekly(rows: VendorDailyRow[], markets: string[]) {
|
||||
// Group by week
|
||||
const byWeek = new Map<string, VendorDailyRow[]>();
|
||||
for (const row of rows) {
|
||||
const wk = getISOWeek(row.date);
|
||||
if (!byWeek.has(wk)) byWeek.set(wk, []);
|
||||
byWeek.get(wk)!.push(row);
|
||||
}
|
||||
|
||||
const sortedWeeks = [...byWeek.keys()].sort();
|
||||
|
||||
// BSR: min rank per week per market (best rank)
|
||||
const bsrData: WeeklyBSR[] = sortedWeeks.map(wk => {
|
||||
const weekRows = byWeek.get(wk)!;
|
||||
const point: WeeklyBSR = { weekLabel: wk.replace(/^\d{4}-/, '') };
|
||||
for (const m of markets) {
|
||||
const marketRows = weekRows.filter(r => r.market === m && r.bsr_detail_rank != null);
|
||||
if (marketRows.length > 0) {
|
||||
// Average BSR across ASINs for that market+week
|
||||
const avg = marketRows.reduce((s, r) => s + r.bsr_detail_rank!, 0) / marketRows.length;
|
||||
point[`${m}_bsr`] = Math.round(avg);
|
||||
} else {
|
||||
point[`${m}_bsr`] = null;
|
||||
}
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
// Ratings: average across all markets/ASINs per week
|
||||
const ratingData: WeeklyRating[] = sortedWeeks.map(wk => {
|
||||
const weekRows = byWeek.get(wk)!;
|
||||
const withRating = weekRows.filter(r => r.avg_rating != null);
|
||||
const withReviews = weekRows.filter(r => r.num_reviews != null);
|
||||
return {
|
||||
weekLabel: wk.replace(/^\d{4}-/, ''),
|
||||
avg_rating: withRating.length > 0
|
||||
? Math.round((withRating.reduce((s, r) => s + r.avg_rating!, 0) / withRating.length) * 10) / 10
|
||||
: null,
|
||||
num_reviews: withReviews.length > 0
|
||||
? Math.round(withReviews.reduce((s, r) => s + r.num_reviews!, 0) / withReviews.length)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
// Buy Box: % of rows where Amazon has buybox, per market per week
|
||||
const buyBoxData: WeeklyBuyBox[] = sortedWeeks.map(wk => {
|
||||
const weekRows = byWeek.get(wk)!;
|
||||
const point: WeeklyBuyBox = { weekLabel: wk.replace(/^\d{4}-/, '') };
|
||||
for (const m of markets) {
|
||||
const marketRows = weekRows.filter(r => r.market === m && r.amazon_has_buybox != null);
|
||||
if (marketRows.length > 0) {
|
||||
const amazonCount = marketRows.filter(r => r.amazon_has_buybox === true).length;
|
||||
point[`${m}_pct`] = Math.round((amazonCount / marketRows.length) * 100);
|
||||
} else {
|
||||
point[`${m}_pct`] = null;
|
||||
}
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
return { bsrData, ratingData, buyBoxData };
|
||||
}
|
||||
|
||||
const VendorDataView: React.FC = () => {
|
||||
const [vendorRows, setVendorRows] = useState<VendorDailyRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filter options
|
||||
const [availableMarkets, setAvailableMarkets] = useState<string[]>([]);
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
const [availableYears, setAvailableYears] = useState<number[]>([]);
|
||||
|
||||
// Selected filters
|
||||
const VendorDataView: React.FC<VendorDataViewProps> = ({ bsrData }) => {
|
||||
// Filters
|
||||
const [selectedMarkets, setSelectedMarkets] = useState<string[]>([]);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedYears, setSelectedYears] = useState<number[]>([]);
|
||||
const [selectedTopCats, setSelectedTopCats] = useState<string[]>([]);
|
||||
const [selectedDetailCats, setSelectedDetailCats] = useState<string[]>([]);
|
||||
const [asinSearch, setAsinSearch] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
|
||||
// Load filter options on mount
|
||||
useEffect(() => {
|
||||
const loadFilters = async () => {
|
||||
try {
|
||||
const opts = await fetchVendorFilterOptions();
|
||||
setAvailableMarkets(opts.markets);
|
||||
setAvailableTags(opts.tags);
|
||||
// Extract years from date range
|
||||
if (opts.dateRange) {
|
||||
const minYear = new Date(opts.dateRange.min).getFullYear();
|
||||
const maxYear = new Date(opts.dateRange.max).getFullYear();
|
||||
const years: number[] = [];
|
||||
for (let y = minYear; y <= maxYear; y++) years.push(y);
|
||||
setAvailableYears(years);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load vendor filter options', e);
|
||||
}
|
||||
};
|
||||
loadFilters();
|
||||
}, []);
|
||||
// Extract available filter options from the dataset
|
||||
const { availableMarkets, availableTopCats, availableDetailCats } = useMemo(() => {
|
||||
const markets = new Set<string>();
|
||||
const topCats = new Set<string>();
|
||||
const detailCats = new Set<string>();
|
||||
|
||||
// Fetch data when filters change
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchVendorData({
|
||||
markets: selectedMarkets.length > 0 ? selectedMarkets : undefined,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
asins: asinSearch.trim() ? asinSearch.split(',').map(a => a.trim()).filter(Boolean) : undefined,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
years: selectedYears.length > 0 ? selectedYears : undefined,
|
||||
bsrData.forEach(r => {
|
||||
if (r.market) markets.add(r.market);
|
||||
if (r.topLevelName) topCats.add(r.topLevelName);
|
||||
if (r.detailLevelName) detailCats.add(r.detailLevelName);
|
||||
});
|
||||
setVendorRows(data);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
return {
|
||||
availableMarkets: Array.from(markets).sort(),
|
||||
availableTopCats: Array.from(topCats).sort(),
|
||||
availableDetailCats: Array.from(detailCats).sort()
|
||||
};
|
||||
}, [bsrData]);
|
||||
|
||||
// Apply filters
|
||||
const filteredData = useMemo(() => {
|
||||
let result = bsrData;
|
||||
|
||||
if (selectedMarkets.length > 0) {
|
||||
result = result.filter(r => selectedMarkets.includes(r.market));
|
||||
}
|
||||
if (selectedTopCats.length > 0) {
|
||||
result = result.filter(r => r.topLevelName && selectedTopCats.includes(r.topLevelName));
|
||||
}
|
||||
if (selectedDetailCats.length > 0) {
|
||||
result = result.filter(r => r.detailLevelName && selectedDetailCats.includes(r.detailLevelName));
|
||||
}
|
||||
if (asinSearch.trim()) {
|
||||
const terms = asinSearch.toLowerCase().split(',').map(t => t.trim()).filter(Boolean);
|
||||
result = result.filter(r => terms.some(t => r.asin.toLowerCase().includes(t)));
|
||||
}
|
||||
}, [selectedMarkets, selectedTags, selectedYears, asinSearch, dateFrom, dateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
return result;
|
||||
}, [bsrData, selectedMarkets, selectedTopCats, selectedDetailCats, asinSearch]);
|
||||
|
||||
// Determine active markets from data
|
||||
// Determine active markets in filtered data for series generation
|
||||
const activeMarkets = useMemo(() => {
|
||||
const markets = [...new Set(vendorRows.map(r => r.market))].sort();
|
||||
return markets.length > 0 ? markets : availableMarkets;
|
||||
}, [vendorRows, availableMarkets]);
|
||||
const m = new Set(filteredData.map(r => r.market));
|
||||
return Array.from(m).sort();
|
||||
}, [filteredData]);
|
||||
|
||||
// Aggregate to weekly
|
||||
const { bsrData, ratingData, buyBoxData } = useMemo(
|
||||
() => aggregateToWeekly(vendorRows, activeMarkets),
|
||||
[vendorRows, activeMarkets]
|
||||
);
|
||||
// Aggregate Data for Charts
|
||||
const { topBsrChartData, detailBsrChartData, ratingChartData } = useMemo(() => {
|
||||
const byWeek = new Map<number, BSRRecord[]>();
|
||||
filteredData.forEach(r => {
|
||||
if (!byWeek.has(r.week)) byWeek.set(r.week, []);
|
||||
byWeek.get(r.week)!.push(r);
|
||||
});
|
||||
|
||||
// Empty state
|
||||
if (!loading && vendorRows.length === 0 && !error) {
|
||||
const sortedWeeks = Array.from(byWeek.keys()).sort((a, b) => a - b);
|
||||
|
||||
const topBsrChartData: WeeklyChartPoint[] = [];
|
||||
const detailBsrChartData: WeeklyChartPoint[] = [];
|
||||
const ratingChartData: WeeklyChartPoint[] = [];
|
||||
|
||||
sortedWeeks.forEach(week => {
|
||||
const row = byWeek.get(week)!;
|
||||
const weekLabel = `Week ${week}`;
|
||||
|
||||
const topPoint: WeeklyChartPoint = { weekLabel };
|
||||
const detailPoint: WeeklyChartPoint = { weekLabel };
|
||||
const ratingPoint: WeeklyChartPoint = { weekLabel };
|
||||
|
||||
activeMarkets.forEach(m => {
|
||||
const marketRows = row.filter(r => r.market === m);
|
||||
|
||||
// Top BSR
|
||||
const topBsrRows = marketRows.filter(r => r.topLevelBSR != null);
|
||||
topPoint[`${m}_bsr`] = topBsrRows.length > 0
|
||||
? Math.round(topBsrRows.reduce((sum, r) => sum + r.topLevelBSR!, 0) / topBsrRows.length)
|
||||
: null;
|
||||
|
||||
// Detail BSR
|
||||
const detailBsrRows = marketRows.filter(r => r.detailLevelBSR != null);
|
||||
detailPoint[`${m}_bsr`] = detailBsrRows.length > 0
|
||||
? Math.round(detailBsrRows.reduce((sum, r) => sum + r.detailLevelBSR!, 0) / detailBsrRows.length)
|
||||
: null;
|
||||
|
||||
// Rating
|
||||
const ratingRows = marketRows.filter(r => r.avgRating != null);
|
||||
ratingPoint[`${m}_rating`] = ratingRows.length > 0
|
||||
? Math.round((ratingRows.reduce((sum, r) => sum + r.avgRating!, 0) / ratingRows.length) * 10) / 10
|
||||
: null;
|
||||
});
|
||||
|
||||
topBsrChartData.push(topPoint);
|
||||
detailBsrChartData.push(detailPoint);
|
||||
ratingChartData.push(ratingPoint);
|
||||
});
|
||||
|
||||
return { topBsrChartData, detailBsrChartData, ratingChartData };
|
||||
}, [filteredData, activeMarkets]);
|
||||
|
||||
if (bsrData.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] gap-4 text-center px-4">
|
||||
<div className="p-4 bg-emerald-500/10 rounded-full text-emerald-400">
|
||||
@@ -187,10 +133,9 @@ const VendorDataView: React.FC = () => {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-200">No Vendor Data Yet</h2>
|
||||
<h2 className="text-xl font-bold text-slate-200">No BSR Data Yet</h2>
|
||||
<p className="text-sm text-slate-400 max-w-md">
|
||||
Upload a Vendor Central daily CSV to see BSR trends, ratings, and Buy Box data.
|
||||
Use the data source settings (top-right) to upload.
|
||||
Ensure BSR.xlsx is present and loading to see Top Level BSR, Detail Level BSR, and Average Rating trends.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -207,30 +152,16 @@ const VendorDataView: React.FC = () => {
|
||||
onChange={setSelectedMarkets}
|
||||
/>
|
||||
<MultiSelectDropdown
|
||||
label="Product Line"
|
||||
options={availableTags}
|
||||
selected={selectedTags}
|
||||
onChange={setSelectedTags}
|
||||
label="Top Level Category"
|
||||
options={availableTopCats}
|
||||
selected={selectedTopCats}
|
||||
onChange={setSelectedTopCats}
|
||||
/>
|
||||
<MultiSelectDropdown
|
||||
label="Year"
|
||||
options={availableYears.map(String)}
|
||||
selected={selectedYears.map(String)}
|
||||
onChange={(vals) => setSelectedYears(vals.map(v => parseInt(v)))}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="From date"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="To date"
|
||||
label="Detail Level Category"
|
||||
options={availableDetailCats}
|
||||
selected={selectedDetailCats}
|
||||
onChange={setSelectedDetailCats}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -239,14 +170,12 @@ const VendorDataView: React.FC = () => {
|
||||
onChange={e => setAsinSearch(e.target.value)}
|
||||
className="bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 w-64"
|
||||
/>
|
||||
{(selectedMarkets.length > 0 || selectedTags.length > 0 || selectedYears.length > 0 || dateFrom || dateTo || asinSearch) && (
|
||||
{(selectedMarkets.length > 0 || selectedTopCats.length > 0 || selectedDetailCats.length > 0 || asinSearch) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedMarkets([]);
|
||||
setSelectedTags([]);
|
||||
setSelectedYears([]);
|
||||
setDateFrom('');
|
||||
setDateTo('');
|
||||
setSelectedTopCats([]);
|
||||
setSelectedDetailCats([]);
|
||||
setAsinSearch('');
|
||||
}}
|
||||
className="text-xs text-slate-400 hover:text-white underline"
|
||||
@@ -254,22 +183,15 @@ const VendorDataView: React.FC = () => {
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||
<div className="w-4 h-4 border-2 border-slate-600 border-t-indigo-400 rounded-full animate-spin"></div>
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{error && <span className="text-red-400 text-sm">Error: {error}</span>}
|
||||
<span className="text-slate-500 text-xs ml-auto">{vendorRows.length.toLocaleString()} daily records</span>
|
||||
<span className="text-slate-500 text-xs ml-auto">{filteredData.length.toLocaleString()} records filtered</span>
|
||||
</div>
|
||||
|
||||
{/* BSR Trend Chart */}
|
||||
{/* Top Level BSR Trend Chart */}
|
||||
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-4">
|
||||
<h3 className="text-lg font-bold text-slate-200 mb-4">BSR Detail Category Rank (Weekly Avg)</h3>
|
||||
<h3 className="text-lg font-bold text-slate-200 mb-4">Top Level BSR Trend (Weekly Avg)</h3>
|
||||
<p className="text-xs text-slate-500 mb-3">Lower rank = better position. Averaged across filtered ASINs per market.</p>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={bsrData}>
|
||||
<LineChart data={topBsrChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
|
||||
<YAxis reversed tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
@@ -294,13 +216,42 @@ const VendorDataView: React.FC = () => {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Ratings & Reviews Chart */}
|
||||
{/* Detail Level BSR Trend Chart */}
|
||||
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-4">
|
||||
<h3 className="text-lg font-bold text-slate-200 mb-4">Average Rating & Reviews (Weekly)</h3>
|
||||
<p className="text-xs text-slate-500 mb-3">Averaged across all filtered ASINs and markets.</p>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={ratingData}>
|
||||
<h3 className="text-lg font-bold text-slate-200 mb-4">Detail Level BSR Trend (Weekly Avg)</h3>
|
||||
<p className="text-xs text-slate-500 mb-3">Lower rank = better position. Averaged across filtered ASINs per market.</p>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={detailBsrChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
|
||||
<YAxis reversed tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
labelStyle={{ color: '#e2e8f0' }}
|
||||
/>
|
||||
<Legend />
|
||||
{activeMarkets.map(m => (
|
||||
<Line
|
||||
key={m}
|
||||
type="monotone"
|
||||
dataKey={`${m}_bsr`}
|
||||
name={`${m} BSR`}
|
||||
stroke={MARKET_COLORS[m] || '#6b7280'}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Average Rating Chart */}
|
||||
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-4">
|
||||
<h3 className="text-lg font-bold text-slate-200 mb-4">Average Rating (Weekly Avg)</h3>
|
||||
<p className="text-xs text-slate-500 mb-3">Averaged across filtered ASINs per market.</p>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={ratingChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
|
||||
<YAxis domain={[0, 5]} tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
@@ -308,49 +259,20 @@ const VendorDataView: React.FC = () => {
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
labelStyle={{ color: '#e2e8f0' }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="avg_rating" name="Avg Rating" stroke="#f59e0b" strokeWidth={2} dot={false} connectNulls />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={ratingData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
labelStyle={{ color: '#e2e8f0' }}
|
||||
/>
|
||||
<Bar dataKey="num_reviews" name="Avg Reviews" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buy Box Chart */}
|
||||
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-4">
|
||||
<h3 className="text-lg font-bold text-slate-200 mb-4">Amazon Buy Box Ownership (Weekly %)</h3>
|
||||
<p className="text-xs text-slate-500 mb-3">Percentage of daily records where Amazon holds the Buy Box, per market.</p>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={buyBoxData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: '#94a3b8', fontSize: 11 }} tickFormatter={(v) => `${v}%`} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
labelStyle={{ color: '#e2e8f0' }}
|
||||
formatter={(value: number) => `${value}%`}
|
||||
/>
|
||||
<Legend />
|
||||
{activeMarkets.map(m => (
|
||||
<Bar
|
||||
<Line
|
||||
key={m}
|
||||
dataKey={`${m}_pct`}
|
||||
name={`${m} Amazon BB %`}
|
||||
fill={MARKET_COLORS[m] || '#6b7280'}
|
||||
radius={[4, 4, 0, 0]}
|
||||
type="monotone"
|
||||
dataKey={`${m}_rating`}
|
||||
name={`${m} Rating`}
|
||||
stroke={MARKET_COLORS[m] || '#6b7280'}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { VendorStockBadge } from './VendorStockBadge';
|
||||
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
|
||||
import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons';
|
||||
import { ExcelFilter } from './ExcelFilter';
|
||||
import ExperimentTracker from './ExperimentTracker';
|
||||
import { ExperimentBadge } from './ExperimentBadge';
|
||||
import { ActiveExperiment } from '../types';
|
||||
|
||||
@@ -800,9 +799,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:gap-4 animate-fade-in px-1 md:px-0">
|
||||
{/* Experiment Tracker */}
|
||||
<ExperimentTracker rows={rows} weeks={weeks} primaryMetric={primaryMetric} customerFilters={customerFilters} />
|
||||
|
||||
{/* Toolbar: Search & Pagination */}
|
||||
<div className="flex flex-col lg:flex-row justify-between items-center gap-3 md:gap-4 bg-slate-900 border border-white/10 p-2 md:p-4 rounded-xl shadow-lg">
|
||||
<div className="flex flex-wrap items-center gap-4 w-full lg:w-auto">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint, ForecastRecord, MonthlyForecastPoint, ProductForecastData, VendorCSVRow, VendorDailyRow } from '../types';
|
||||
import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint, ForecastRecord, MonthlyForecastPoint, ProductForecastData, VendorCSVRow, VendorDailyRow, BSRRecord } from '../types';
|
||||
import * as XLSX from 'xlsx';
|
||||
import Papa from 'papaparse';
|
||||
|
||||
@@ -657,6 +657,60 @@ export const processTrafficExcel = async (fileOrBuffer: File | ArrayBuffer): Pro
|
||||
}
|
||||
};
|
||||
|
||||
// --- BSR DATA PARSING ---
|
||||
|
||||
export const processBSRExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<BSRRecord[]> => {
|
||||
try {
|
||||
const arrayBuffer = fileOrBuffer instanceof File
|
||||
? await fileOrBuffer.arrayBuffer()
|
||||
: fileOrBuffer;
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
const allData: BSRRecord[] = [];
|
||||
|
||||
// Typically BSR is in the first sheet
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const jsonData: any[] = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
|
||||
|
||||
console.log(`Processing BSR sheet "${sheetName}": ${jsonData.length} rows`);
|
||||
|
||||
for (let i = 0; i < jsonData.length; i++) {
|
||||
const row = jsonData[i];
|
||||
|
||||
const weekRaw = getColumnValue(row, ['week', 'woche', 'semana']);
|
||||
const marketRaw = getColumnValue(row, ['market', 'country', 'marketplace']);
|
||||
const asinRaw = getColumnValue(row, ['asin']);
|
||||
const topLevelBSRRaw = getColumnValue(row, ['Mean Weekly Top Level BSR']);
|
||||
const topLevelNameRaw = getColumnValue(row, ['Top Level Category Name']);
|
||||
const detailLevelBSRRaw = getColumnValue(row, ['Mean Weekly Detail Level BSR']);
|
||||
const detailLevelNameRaw = getColumnValue(row, ['Detail Level Category Name']);
|
||||
const avgRatingRaw = getColumnValue(row, ['Mean Weekly Average Rating']);
|
||||
|
||||
if (!weekRaw || !marketRaw || !asinRaw) continue;
|
||||
|
||||
const week = parseInt(String(weekRaw).match(/\\d+/)?.[0] || '0', 10);
|
||||
if (isNaN(week) || week === 0) continue;
|
||||
|
||||
allData.push({
|
||||
week,
|
||||
market: String(marketRaw).trim(),
|
||||
asin: String(asinRaw).trim(),
|
||||
topLevelBSR: parseIntSafe(String(topLevelBSRRaw)),
|
||||
topLevelName: topLevelNameRaw ? String(topLevelNameRaw).trim() : null,
|
||||
detailLevelBSR: parseIntSafe(String(detailLevelBSRRaw)),
|
||||
detailLevelName: detailLevelNameRaw ? String(detailLevelNameRaw).trim() : null,
|
||||
avgRating: parseFloat(String(avgRatingRaw)) || null,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Total BSR records loaded: ${allData.length}`);
|
||||
return allData;
|
||||
} catch (error) {
|
||||
console.error("Error processing BSR Excel:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// --- DATA MERGING ---
|
||||
|
||||
export const mergeSalesAndAdsData = (
|
||||
|
||||
@@ -280,6 +280,17 @@ export interface VendorCSVRow {
|
||||
'Glance Views': string;
|
||||
}
|
||||
|
||||
export interface BSRRecord {
|
||||
week: number;
|
||||
market: string;
|
||||
asin: string;
|
||||
topLevelBSR: number | null;
|
||||
topLevelName: string | null;
|
||||
detailLevelBSR: number | null;
|
||||
detailLevelName: string | null;
|
||||
avgRating: number | null;
|
||||
}
|
||||
|
||||
// Experiment Tracking Types
|
||||
export type ExperimentType = 'pricing' | 'advertising' | 'content' | 'promotion' | 'seo';
|
||||
export type ExperimentStatus = 'planned' | 'active' | 'completed' | 'paused';
|
||||
|
||||
Reference in New Issue
Block a user