mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 17:05:23 +02:00
- Add year filter with multi-select dropdown - Add date range pickers (from/to) - Fix tags filter using ilike for partial matching - Add clear filters button - Update supabase service with proper year filtering Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
361 lines
15 KiB
TypeScript
361 lines
15 KiB
TypeScript
|
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
|
import MultiSelectDropdown from './MultiSelectDropdown';
|
|
import { fetchVendorData, fetchVendorFilterOptions, VendorDailyRow } from '../services/supabase';
|
|
|
|
const MARKET_COLORS: Record<string, string> = {
|
|
DE: '#3b82f6',
|
|
UK: '#ef4444',
|
|
IT: '#22c55e',
|
|
FR: '#a855f7',
|
|
ES: '#f59e0b',
|
|
};
|
|
|
|
interface WeeklyBSR {
|
|
weekLabel: string;
|
|
[key: string]: number | string | null; // e.g. DE_bsr, UK_bsr
|
|
}
|
|
|
|
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 [selectedMarkets, setSelectedMarkets] = useState<string[]>([]);
|
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
const [selectedYears, setSelectedYears] = useState<number[]>([]);
|
|
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();
|
|
}, []);
|
|
|
|
// 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,
|
|
});
|
|
setVendorRows(data);
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [selectedMarkets, selectedTags, selectedYears, asinSearch, dateFrom, dateTo]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
// Determine active markets from data
|
|
const activeMarkets = useMemo(() => {
|
|
const markets = [...new Set(vendorRows.map(r => r.market))].sort();
|
|
return markets.length > 0 ? markets : availableMarkets;
|
|
}, [vendorRows, availableMarkets]);
|
|
|
|
// Aggregate to weekly
|
|
const { bsrData, ratingData, buyBoxData } = useMemo(
|
|
() => aggregateToWeekly(vendorRows, activeMarkets),
|
|
[vendorRows, activeMarkets]
|
|
);
|
|
|
|
// Empty state
|
|
if (!loading && vendorRows.length === 0 && !error) {
|
|
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">
|
|
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
<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>
|
|
<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.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 p-4 pb-24 md:pb-4">
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap gap-3 items-center">
|
|
<MultiSelectDropdown
|
|
label="Market"
|
|
options={availableMarkets}
|
|
selected={selectedMarkets}
|
|
onChange={setSelectedMarkets}
|
|
/>
|
|
<MultiSelectDropdown
|
|
label="Product Line"
|
|
options={availableTags}
|
|
selected={selectedTags}
|
|
onChange={setSelectedTags}
|
|
/>
|
|
<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"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Search ASINs (comma-separated)"
|
|
value={asinSearch}
|
|
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) && (
|
|
<button
|
|
onClick={() => {
|
|
setSelectedMarkets([]);
|
|
setSelectedTags([]);
|
|
setSelectedYears([]);
|
|
setDateFrom('');
|
|
setDateTo('');
|
|
setAsinSearch('');
|
|
}}
|
|
className="text-xs text-slate-400 hover:text-white underline"
|
|
>
|
|
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>
|
|
</div>
|
|
|
|
{/* 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>
|
|
<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}>
|
|
<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>
|
|
|
|
{/* Ratings & Reviews 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}>
|
|
<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 }} />
|
|
<Tooltip
|
|
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
|
|
key={m}
|
|
dataKey={`${m}_pct`}
|
|
name={`${m} Amazon BB %`}
|
|
fill={MARKET_COLORS[m] || '#6b7280'}
|
|
radius={[4, 4, 0, 0]}
|
|
/>
|
|
))}
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default VendorDataView;
|