mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 15:35:22 +02:00
feat: enhance Forecast 2026 view with dynamic periods, top 50 badges and improved product details
This commit is contained in:
@@ -538,7 +538,7 @@ const App: React.FC = () => {
|
|||||||
{view === 'weekly' && <WeeklyGrid data={combinedAdsData} top50Ranking={top50Ranking2025} onDrillDown={handleSkuDrillDown} />}
|
{view === 'weekly' && <WeeklyGrid data={combinedAdsData} top50Ranking={top50Ranking2025} onDrillDown={handleSkuDrillDown} />}
|
||||||
{view === 'movers' && <TopMovers data={filteredData} />}
|
{view === 'movers' && <TopMovers data={filteredData} />}
|
||||||
{view === 'ads' && <AdsPerformance data={combinedAdsData} filters={filters} top50Ranking={top50Ranking2025} />}
|
{view === 'ads' && <AdsPerformance data={combinedAdsData} filters={filters} top50Ranking={top50Ranking2025} />}
|
||||||
{view === 'forecast' && <ForecastView data={forecastData} />}
|
{view === 'forecast' && <ForecastView data={forecastData} filters={filters} top50Ranking={top50Ranking2025} />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+107
-32
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { ProductForecastData } from '../types';
|
import { ProductForecastData, FilterState } from '../types';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
LineChart, Line, Legend, ComposedChart, Area
|
LineChart, Line, Legend, ComposedChart, Area
|
||||||
@@ -8,17 +8,60 @@ import {
|
|||||||
|
|
||||||
interface ForecastViewProps {
|
interface ForecastViewProps {
|
||||||
data: ProductForecastData[];
|
data: ProductForecastData[];
|
||||||
|
filters: FilterState;
|
||||||
|
top50Ranking?: {
|
||||||
|
eu: Map<string, number>;
|
||||||
|
uk: Map<string, number>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9'];
|
const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
const ForecastView: React.FC<ForecastViewProps> = ({ data }) => {
|
// Top 50 Badge Component (reused logic)
|
||||||
|
const Top50Badge: React.FC<{ rank: number; label: string; theme?: 'amber' | 'indigo' | 'blue' }> = ({ rank, label, theme = 'amber' }) => {
|
||||||
|
const themeClasses = {
|
||||||
|
amber: 'bg-amber-500/10 text-amber-500 border-amber-500/20',
|
||||||
|
indigo: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/20',
|
||||||
|
blue: 'bg-blue-500/10 text-blue-400 border-blue-500/20'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-1 px-1.5 py-0.5 rounded border text-[9px] font-black uppercase tracking-tighter shadow-sm ${themeClasses[theme]}`}>
|
||||||
|
<span className="opacity-70">{label}</span>
|
||||||
|
<span className="text-sm">#{rank}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking }) => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// Determine the "Current Month" based on available 2026 data
|
||||||
|
const lastDataMonthIdx = useMemo(() => {
|
||||||
|
let maxIdx = 0; // Default Jan
|
||||||
|
data.forEach(p => {
|
||||||
|
p.monthlyData.forEach((md, idx) => {
|
||||||
|
if (md.actualUnits > 0 && idx > maxIdx) {
|
||||||
|
maxIdx = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return maxIdx;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Active months for "Monthly Forecast" calculation
|
||||||
|
const activeMonths = useMemo(() => {
|
||||||
|
if (filters.month && filters.month.length > 0) {
|
||||||
|
// Map e.g. "Apr-24" or just "Apr" to the short name
|
||||||
|
return filters.month.map(m => m.split('-')[0]);
|
||||||
|
}
|
||||||
|
// If no filter, use Jan to lastDataMonth
|
||||||
|
return MONTH_ORDER.slice(0, lastDataMonthIdx + 1);
|
||||||
|
}, [filters.month, lastDataMonthIdx]);
|
||||||
|
|
||||||
// Global Aggregate Data
|
// Global Aggregate Data
|
||||||
const globalMonthlyData = useMemo(() => {
|
const globalMonthlyData = useMemo(() => {
|
||||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
return MONTH_ORDER.map(m => {
|
||||||
return months.map(m => {
|
|
||||||
let forecast = 0;
|
let forecast = 0;
|
||||||
let actual = 0;
|
let actual = 0;
|
||||||
data.forEach(p => {
|
data.forEach(p => {
|
||||||
@@ -145,50 +188,82 @@ const ForecastView: React.FC<ForecastViewProps> = ({ data }) => {
|
|||||||
<thead className="bg-slate-950 text-slate-500 uppercase text-[10px] font-black tracking-widest border-b border-border">
|
<thead className="bg-slate-950 text-slate-500 uppercase text-[10px] font-black tracking-widest border-b border-border">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4">Product Info</th>
|
<th className="px-6 py-4">Product Info</th>
|
||||||
<th className="px-6 py-4 text-right">Annual Forecast</th>
|
<th className="px-6 py-4 text-right">Forecast (Period)</th>
|
||||||
<th className="px-6 py-4 text-right">Actual 2026</th>
|
<th className="px-6 py-4 text-right">Actual Units Sales 2026</th>
|
||||||
<th className="px-6 py-4 text-right">Fulfillment %</th>
|
<th className="px-6 py-4 text-right">Fc 26 Achievement</th>
|
||||||
<th className="px-6 py-4">Monthly Status (YTD)</th>
|
<th className="px-6 py-4 text-center">YTD Achievement</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800">
|
<tbody className="divide-y divide-slate-800">
|
||||||
{sortedProducts.map((p) => {
|
{sortedProducts.map((p) => {
|
||||||
|
const asin = p.asin.toUpperCase();
|
||||||
|
|
||||||
|
// Top 50 Badges logic
|
||||||
|
const ranks = [];
|
||||||
|
if (top50Ranking) {
|
||||||
|
const rankEU = top50Ranking.eu.get(asin);
|
||||||
|
const rankUK = top50Ranking.uk.get(asin);
|
||||||
|
if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' as const });
|
||||||
|
if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' as const });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monthly Forecast Calculation
|
||||||
|
const filteredForecast = p.monthlyData
|
||||||
|
.filter(md => activeMonths.includes(md.month))
|
||||||
|
.reduce((acc, md) => acc + md.forecastUnits, 0);
|
||||||
|
|
||||||
|
// Actual Sales for 2026 (Selected Period)
|
||||||
|
const actualPeriod = p.monthlyData
|
||||||
|
.filter(md => activeMonths.includes(md.month))
|
||||||
|
.reduce((acc, md) => acc + md.actualUnits, 0);
|
||||||
|
|
||||||
|
// Total annual actual for achievement percentage
|
||||||
const actualTotal = p.monthlyData.reduce((acc, md) => acc + md.actualUnits, 0);
|
const actualTotal = p.monthlyData.reduce((acc, md) => acc + md.actualUnits, 0);
|
||||||
const fulfillment = p.annualForecast > 0 ? (actualTotal / p.annualForecast) * 100 : 0;
|
const totalAchievement = p.annualForecast > 0 ? (actualTotal / p.annualForecast) * 100 : 0;
|
||||||
|
|
||||||
|
// YTD Achievement (Achievement of the period)
|
||||||
|
const periodAchievement = filteredForecast > 0 ? (actualPeriod / filteredForecast) * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={p.asin} className="hover:bg-indigo-500/5 transition-colors group">
|
<tr key={p.asin} className="hover:bg-indigo-500/5 transition-colors group">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-bold text-slate-200 group-hover:text-indigo-400 transition-colors">{p.asin}</span>
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[10px] text-slate-500 truncate max-w-xs">{p.title}</span>
|
{ranks.map((r, i) => (
|
||||||
|
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
|
||||||
|
))}
|
||||||
|
<span className="font-black text-xs text-indigo-400 uppercase tracking-tighter">{p.sku}</span>
|
||||||
|
<span className="text-[10px] font-bold text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded border border-white/5">{p.asin}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-white/70 truncate w-[240px] leading-tight" title={p.title}>{p.title}</span>
|
||||||
|
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{p.line}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right font-mono font-bold text-slate-400">
|
<td className="px-6 py-4 text-right font-mono font-bold text-slate-400">
|
||||||
{p.annualForecast.toLocaleString('de-DE')}
|
{filteredForecast.toLocaleString('de-DE')}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right font-mono font-bold text-emerald-400">
|
<td className="px-6 py-4 text-right font-mono font-bold text-emerald-400">
|
||||||
{actualTotal.toLocaleString('de-DE')}
|
{actualPeriod.toLocaleString('de-DE')}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<span className={`px-2 py-1 rounded text-xs font-black ${fulfillment >= 50 ? 'bg-emerald-500/10 text-emerald-400' : fulfillment >= 20 ? 'bg-indigo-500/10 text-indigo-400' : 'bg-slate-800 text-slate-500'}`}>
|
<div className="flex flex-col items-end gap-1">
|
||||||
{fulfillment.toFixed(1)}%
|
<span className={`px-2 py-1 rounded text-xs font-black ${totalAchievement >= 50 ? 'bg-emerald-500/10 text-emerald-400' : totalAchievement >= 10 ? 'bg-indigo-500/10 text-indigo-400' : 'bg-slate-800 text-slate-500'}`}>
|
||||||
</span>
|
{totalAchievement.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-slate-500 font-bold uppercase">of Annual {p.annualForecast.toLocaleString('de-DE')}</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 min-w-[240px]">
|
<td className="px-6 py-4">
|
||||||
<div className="flex gap-1 h-3 items-end">
|
<div className="flex flex-col items-center gap-2">
|
||||||
{p.monthlyData.map((md, idx) => {
|
<div className="w-32 h-2 bg-slate-800 rounded-full overflow-hidden border border-white/5">
|
||||||
const isMet = md.actualUnits >= md.forecastUnits && md.forecastUnits > 0;
|
<div
|
||||||
const height = md.forecastUnits > 0 ? (Math.min(1.5, md.actualUnits / md.forecastUnits) * 100) : 0;
|
className={`h-full transition-all duration-1000 ${periodAchievement >= 100 ? 'bg-emerald-500' : periodAchievement >= 50 ? 'bg-indigo-500' : 'bg-amber-500'}`}
|
||||||
return (
|
style={{ width: `${Math.min(100, periodAchievement)}%` }}
|
||||||
<div
|
></div>
|
||||||
key={md.month}
|
</div>
|
||||||
className={`flex-1 rounded-t-sm transition-all ${isMet ? 'bg-emerald-500' : md.actualUnits > 0 ? 'bg-indigo-500' : 'bg-slate-800'}`}
|
<span className={`text-[11px] font-black ${periodAchievement >= 100 ? 'text-emerald-400' : periodAchievement >= 50 ? 'text-indigo-400' : 'text-amber-400'}`}>
|
||||||
style={{ height: `${Math.max(10, height)}%` }}
|
{periodAchievement.toFixed(1)}%
|
||||||
title={`${md.month}: ${md.actualUnits} / ${md.forecastUnits}`}
|
</span>
|
||||||
></div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1584,6 +1584,7 @@ export const calculateForecastViewData = (
|
|||||||
asin: identifier,
|
asin: identifier,
|
||||||
sku: meta?.sku || identifier, // Fallback to ASIN if SKU not found
|
sku: meta?.sku || identifier, // Fallback to ASIN if SKU not found
|
||||||
title: meta?.title || identifier,
|
title: meta?.title || identifier,
|
||||||
|
line: meta?.line || "",
|
||||||
annualForecast: fc.annualForecast,
|
annualForecast: fc.annualForecast,
|
||||||
monthlyData
|
monthlyData
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export interface ProductForecastData {
|
|||||||
asin: string;
|
asin: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
line: string;
|
||||||
annualForecast: number;
|
annualForecast: number;
|
||||||
monthlyData: MonthlyForecastPoint[];
|
monthlyData: MonthlyForecastPoint[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user