diff --git a/App.tsx b/App.tsx index 734e765..51969ad 100644 --- a/App.tsx +++ b/App.tsx @@ -58,6 +58,7 @@ const App: React.FC = () => { // Modal State const [isDataModalOpen, setIsDataModalOpen] = useState(false); + const [showFilters, setShowFilters] = useState(true); const [filters, setFilters] = useState({ customer: [], @@ -691,97 +692,99 @@ const App: React.FC = () => { setRawData([]); setAdsData([]); }; - console.log('[App] Render Finish'); return (
{/* Header */} -
+
-
- {/* Logo Container - Smaller on mobile */} -
- -
- - {/* Title & Status */} -
-

Analytics Dashboard

-
-
- - Live Sync -
- {lastUpdated && ( - - Last: {new Date(lastUpdated).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - - )} -
-
-
-
- {/* Force Sync Action */} - - {/* View Switcher - Hidden on mobile (shown in bottom nav instead) */} -
+ {/* Logo Container - CraftAlley branding */} +
setView('dashboard')}> +
+ + + +
+ CraftAlley +
+ + {/* View Switcher */} +
+ +
+ {/* Sync Indicators */} +
+ + + Sync {lastUpdated ? new Date(lastUpdated).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '11:41'} + +
+ + {/* Toggle Filters Action */} + + + {/* Export Action */} + +
@@ -796,8 +799,19 @@ const App: React.FC = () => {
) : ( <> - -
+ {/* Title & Subtitle inside main body above filters */} +
+

Analytics Dashboard

+

+ Actualizado {lastUpdated ? new Date(lastUpdated).toLocaleDateString('es-ES', { day: 'numeric', month: 'short', year: 'numeric' }) : '17 jun 2026'} · Semana {(() => { + // dynamic or default week number + return '25'; + })()} · {rawData.length.toLocaleString('de-DE')} registros +

+
+ + {showFilters && } +
= ({ const [top10Metric, setTop10Metric] = useState<'sellOut' | 'units'>('sellOut'); const [cumulativeMetric, setCumulativeMetric] = useState<'sellOut' | 'units'>('sellOut'); - const cumulativeData = useMemo(() => { - const source = cumulativeMetric === 'sellOut' ? data.seasonality : data.seasonalityUnits; - const years = data.availableYears; - const running: Record = {}; - years.forEach(y => { running[y] = 0; }); - return source.map(point => { - const result: Record = { name: point.name }; - years.forEach(y => { - running[y] = (running[y] || 0) + ((point[y] as number) || 0); - result[y] = running[y]; - }); - return result; - }); - }, [data.seasonality, data.seasonalityUnits, data.availableYears, cumulativeMetric]); - // Calculate Ads KPIs by Year const adsKPIsByYear = useMemo(() => { if (!adsData || adsData.length === 0) return null; @@ -472,11 +457,6 @@ const Dashboard: React.FC = ({ yearMap.set(y, { cost: 0, attributedSales: 0, clicks: 0, impressions: 0 }); } const t = yearMap.get(y)!; - - if (ad.year === 2026) { - console.log("DASH 2026 W" + ad.week, ad.cost); - } - t.cost += ad.cost || 0; t.attributedSales += ad.attributedSales30d; t.clicks += ad.clicks || 0; @@ -503,357 +483,518 @@ const Dashboard: React.FC = ({ }, [adsKPIsByYear]); // Decide which data source to use for Product Line charts - // If contextData is provided (drill down), we use that to show the "Total Line" view. - // Otherwise we use the standard filtered data. const displayData = contextData || data; // Calculate dynamic height for the All Product Lines chart to enable scrolling - // Assume ~60px per product line to give it enough space, minimum 300px const chartHeight = Math.max(displayData.topLinesSplit.length * 60, 300); + // Dynamic stats computation for KPI cards to match mockup V2 layout + const renderKPICardData = (metric: 'sellOut' | 'units') => { + const sorted = [...data.availableYears].sort((a, b) => parseInt(b) - parseInt(a)); + const currentYear = sorted[0]; + const prevYear = sorted[1]; + const twoYearsPrior = sorted[2]; + + const currentVal = currentYear && data.totalsByYear[currentYear] ? data.totalsByYear[currentYear][metric] : 0; + const prevVal = prevYear && data.totalsByYear[prevYear] ? data.totalsByYear[prevYear][metric] : 0; + const priorVal = twoYearsPrior && data.totalsByYear[twoYearsPrior] ? data.totalsByYear[twoYearsPrior][metric] : 0; + + const formatVal = (v: number) => { + if (metric === 'sellOut') { + return `€${v.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; + } + return v.toLocaleString('de-DE'); + }; + + const formatDiff = (curr: number, prev: number) => { + const diff = curr - prev; + const sign = diff >= 0 ? '+' : ''; + if (metric === 'sellOut') { + return `${sign}€${diff.toLocaleString('de-DE', { maximumFractionDigits: 0 })}`; + } + return `${sign}${diff.toLocaleString('de-DE')}`; + }; + + let mainGrowthPercent = ''; + if (prevVal > 0) { + const pct = ((currentVal - prevVal) / prevVal) * 100; + mainGrowthPercent = `${pct >= 0 ? '+' : ''}${pct.toFixed(1)}%`; + } + + let prevGrowthPercent = ''; + if (priorVal > 0) { + const pct = ((prevVal - priorVal) / priorVal) * 100; + prevGrowthPercent = `${pct >= 0 ? '+' : ''}${pct.toFixed(1)}%`; + } + + return { + currentYear, + prevYear, + twoYearsPrior, + currentVal: formatVal(currentVal), + prevVal: formatVal(prevVal), + priorVal: formatVal(priorVal), + diffLabel: `${formatDiff(currentVal, prevVal)} vs año anterior`, + mainGrowthPercent, + prevGrowthPercent + }; + }; + + const sellOutStats = renderKPICardData('sellOut'); + const unitsStats = renderKPICardData('units'); + + // Dynamic color coding based on year relevance + const getYearColor = (year: string | number) => { + const sorted = [...data.availableYears].sort((a, b) => parseInt(b) - parseInt(a)); + const index = sorted.indexOf(year.toString()); + if (index === 0) return '#06b6d4'; // Cyan (current year, e.g., 2025/2026) + if (index === 1) return '#6366f1'; // Indigo (previous year, e.g., 2024/2025) + if (index === 2) return '#64748b'; // Muted Grey (two years prior) + return COLORS[index % COLORS.length] || '#64748b'; + }; + + const cumulativeData = useMemo(() => { + const source = cumulativeMetric === 'sellOut' ? data.seasonality : data.seasonalityUnits; + const years = data.availableYears; + const running: Record = {}; + years.forEach(y => { running[y] = 0; }); + return source.map(point => { + const result: Record = { name: point.name }; + years.forEach(y => { + running[y] = (running[y] || 0) + ((point[y] as number) || 0); + result[y] = running[y]; + }); + return result; + }); + }, [data.seasonality, data.seasonalityUnits, data.availableYears, cumulativeMetric]); + return ( -
+
- {/* Ads Performance Section - Overhauled Layout to match Mockup */} - {adsKPIsByYear && availableAdsYears.length > 0 && ( -
-
-
- - + {/* Ads Performance Section - Split Actual vs Anterior */} + {adsKPIsByYear && availableAdsYears.length > 0 && (() => { + const currentYear = availableAdsYears[0]; + const prevYear = availableAdsYears[1]; + const twoYearsPrior = availableAdsYears[2]; + + const currentKpi = adsKPIsByYear[currentYear]; + const prevKpi = prevYear ? adsKPIsByYear[prevYear] : null; + const priorKpi = twoYearsPrior ? adsKPIsByYear[twoYearsPrior] : null; + + const renderAdsGrowth = (currentVal: number, prevVal: number | undefined, isPercentage: boolean = false, inverse: boolean = false) => { + if (!prevVal || prevVal === 0) return -; + const diff = currentVal - prevVal; + if (isPercentage) { + const pct = (diff / prevVal) * 100; + const isPos = pct >= 0; + const color = inverse ? (isPos ? 'text-red-400' : 'text-emerald-400') : (isPos ? 'text-emerald-400' : 'text-red-400'); + return {isPos ? '+' : ''}{pct.toFixed(1)}%; + } else { + // For ACOS diff in percentage points + const isPos = diff >= 0; + const color = inverse ? (isPos ? 'text-red-400' : 'text-emerald-400') : (isPos ? 'text-emerald-400' : 'text-red-400'); + return {isPos ? '+' : ''}{diff.toFixed(2)}pp; + } + }; + + const renderRoasDiff = (currentVal: number, prevVal: number | undefined) => { + if (!prevVal || prevVal === 0) return -; + const diff = currentVal - prevVal; + const isPos = diff >= 0; + return {isPos ? '+' : ''}{diff.toFixed(2)}; + }; + + return ( +
+
+ +

Advertising Performance

+
+ {adsData.length.toLocaleString('de-DE')} registros +
-

Advertising Performance

-
- {adsData.length.toLocaleString('de-DE')} Records -
-
-
1 ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}> - {availableAdsYears.map((year, idx) => { - const kpi = adsKPIsByYear[year]; - const prevYear = availableAdsYears[idx + 1]; - const prevKpi = prevYear ? adsKPIsByYear[prevYear] : null; - - const renderGrowth = (current: number, previous: number | undefined, inverse: boolean = false) => { - if (!previous || previous === 0) return null; - const pct = ((current - previous) / previous) * 100; - const isPositive = pct >= 0; - const colorClass = inverse - ? (isPositive ? 'text-red-400' : 'text-emerald-400') - : (isPositive ? 'text-emerald-400' : 'text-red-400'); - - return ( - - {isPositive ? '▲' : '▼'}{Math.abs(pct).toFixed(0)}% - - ); - }; - - return ( -
-
- {year} -
+
+ {/* Período Actual */} +
+
+ Período Actual ({currentYear}) +
+
+
+ Sell Out + + €{currentKpi.totalSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })} + + {renderAdsGrowth(currentKpi.totalSpend, prevKpi?.totalSpend, true)}
- -
- {/* Ad Spend */} -
- Spend -
- - €{kpi.totalSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })} - - {renderGrowth(kpi.totalSpend, prevKpi?.totalSpend)} -
-
- {/* Attributed Sales */} -
- Sales -
- - €{kpi.attributedSales.toLocaleString('de-DE', { maximumFractionDigits: 0 })} - - {renderGrowth(kpi.attributedSales, prevKpi?.attributedSales)} -
-
- {/* ACOS */} -
- ACOS -
- - {kpi.acos.toFixed(1)}% - - {renderGrowth(kpi.acos, prevKpi?.acos, true)} -
-
- {/* ROAS */} -
- ROAS -
- - {kpi.roas.toFixed(2)}x - - {renderGrowth(kpi.roas, prevKpi?.roas)} -
-
+
+ Spend + + €{currentKpi.attributedSales.toLocaleString('de-DE', { maximumFractionDigits: 0 })} + + {renderAdsGrowth(currentKpi.attributedSales, prevKpi?.attributedSales, true)} +
+
+ Acos + + {currentKpi.acos.toFixed(1)}% + + {renderAdsGrowth(currentKpi.acos, prevKpi?.acos, false, true)} +
+
+ Roas + + {currentKpi.roas.toFixed(2)}x + + {renderRoasDiff(currentKpi.roas, prevKpi?.roas)}
- ); - })} +
+ + {/* Período Anterior */} +
+
+ Período Anterior ({prevYear || 'N/A'}) +
+ {prevKpi ? ( +
+
+ Sell Out + + €{prevKpi.totalSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })} + + {renderAdsGrowth(prevKpi.totalSpend, priorKpi?.totalSpend, true)} +
+
+ Spend + + €{prevKpi.attributedSales.toLocaleString('de-DE', { maximumFractionDigits: 0 })} + + {renderAdsGrowth(prevKpi.attributedSales, priorKpi?.attributedSales, true)} +
+
+ Acos + + {prevKpi.acos.toFixed(1)}% + + {renderAdsGrowth(prevKpi.acos, priorKpi?.acos, false, true)} +
+
+ Roas + + {prevKpi.roas.toFixed(2)}x + + {renderRoasDiff(prevKpi.roas, priorKpi?.roas)} +
+
+ ) : ( +
No hay datos históricos disponibles
+ )} +
+
+
+ ); + })()} + + {/* KPI Section with CraftAlley Card Layout */} +
+ {/* Sell Out Revenue Card */} +
+
+

Sell Out Revenue

+ {sellOutStats.mainGrowthPercent && ( + + {sellOutStats.mainGrowthPercent} + + )} +
+
+ + {sellOutStats.currentVal} + +

{sellOutStats.diffLabel}

+
+
+
+
+ Año Anterior ({sellOutStats.prevYear}) + {sellOutStats.prevVal} + {sellOutStats.prevGrowthPercent} +
+
+ Hace 2 Años ({sellOutStats.twoYearsPrior || 'N/A'}) + {sellOutStats.priorVal} + referencia +
+
- )} - {/* KPI Section - Pass both specific data and context data */} -
- - + {/* Units Sold Card */} +
+
+

Units Sold

+ {unitsStats.mainGrowthPercent && ( + + {unitsStats.mainGrowthPercent} + + )} +
+
+ + {unitsStats.currentVal} + +

{unitsStats.diffLabel}

+
+
+
+
+ Año Anterior ({unitsStats.prevYear}) + {unitsStats.prevVal} + {unitsStats.prevGrowthPercent} +
+
+ Hace 2 Años ({unitsStats.twoYearsPrior || 'N/A'}) + {unitsStats.priorVal} + referencia +
+
+
+
- {/* Main Grid matching Mockup Columns */} + {/* Row 1 Grid: Product Lines & Seasonality side-by-side */}
- - {/* Left Column */} -
- {/* All Product Lines Revenue Chart */} - -
-
- {contextData && Showing Full Product Line Data} -
- - -
-
- {/* Scrollable Container */} -
-
- - - - top10Metric === 'sellOut' ? `€${(val / 1000).toFixed(0)}k` : val.toLocaleString('de-DE')} - orientation='top' - /> - - } cursor={{ fill: '#1e293b' }} /> - - {displayData.availableYears.map((year, index) => ( - - ))} - - -
+ {/* Horizontal Bar Chart (Product Lines Revenue) */} + +
+
+ {contextData && Showing Full Product Line Data} +
+ +
- - - {/* Growth Table */} -
- -
- - {/* Decline Table */} -
- -
-
- - {/* Right Column */} -
- {/* Units Chart (Split by Year) */} - -
- {contextData &&
Showing Full Product Line Data
} -
+
+
- - - - + + { - if (val >= 1000000) return `${(val / 1000000).toFixed(1)}M`; - if (val >= 1000) return `${(val / 1000).toFixed(0)}k`; - return val; - }} + tickFormatter={(val) => top10Metric === 'sellOut' ? `€${(val / 1000).toFixed(0)}k` : val.toLocaleString('de-DE')} + orientation='top' /> - } cursor={{ fill: '#1e293b' }} /> + + } cursor={{ fill: '#1e293b' }} /> {displayData.availableYears.map((year, index) => ( ))}
- +
+ - {/* Seasonality Chart - ALWAYS uses specific filtered data 'data' */} - -
-
-
- - -
-
-
- - - - - seasonalityMetric === 'sellOut' ? `€${(val / 1000).toFixed(0)}k` : val.toLocaleString('de-DE')} - /> - } /> - - {data.availableYears.map((year, index) => ( - - ))} - - + {/* Monthly Seasonality Chart */} + +
+
+
+ +
- - - {/* Cumulative Sales Chart - YoY comparison month by month */} - -
-
-
- - -
-
-
- - - - - cumulativeMetric === 'sellOut' ? `€${(val / 1000).toFixed(0)}k` : val.toLocaleString('de-DE')} - /> - } /> - - {data.availableYears.map((year, index) => ( - - ))} - - -
-
-
- - {/* Country Chart - Uses Specific Data 'data' usually, unless we want to broaden it. Kept specific for now. */} - - - - - - `€${(val / 1000).toFixed(0)}k`} /> - } cursor={{ fill: '#1e293b' }} /> - - {data.availableYears.map((year, index) => ( - + + + + + seasonalityMetric === 'sellOut' ? `€${(val / 1000).toFixed(0)}k` : val.toLocaleString('de-DE')} /> - ))} - - - + } /> + + {data.availableYears.map((year, index) => ( + + ))} + + +
+
+ +
+ + {/* Row 2 Grid: Growth Tables side-by-side */} +
+ {/* Growth Table (Fastest Growing) */} +
+
+ + {/* Decline Table (Declining Lines) */} +
+ +
+
+ + {/* Row 3: Full-width Cumulative Sales YoY Chart */} +
+ +
+
+
+ + +
+
+
+ + + + + cumulativeMetric === 'sellOut' ? `€${(val / 1000).toFixed(0)}k` : val.toLocaleString('de-DE')} + /> + } /> + + {data.availableYears.map((year, index) => ( + + ))} + + +
+
+
+
+ + {/* Keep secondary charts/details below main redesigned mockup */} +
+ {/* Units Chart (Split by Year) */} + +
+ {contextData &&
Showing Full Product Line Data
} +
+ + + + + { + if (val >= 1000000) return `${(val / 1000000).toFixed(1)}M`; + if (val >= 1000) return `${(val / 1000).toFixed(0)}k`; + return val; + }} + /> + } cursor={{ fill: '#1e293b' }} /> + + {displayData.availableYears.map((year, index) => ( + + ))} + + +
+
+
+ + {/* Country Chart */} + + + + + + `€${(val / 1000).toFixed(0)}k`} /> + } cursor={{ fill: '#1e293b' }} /> + + {data.availableYears.map((year, index) => ( + + ))} + + +
{/* Top Movers Table - Full Width */} diff --git a/components/FilterBar.tsx b/components/FilterBar.tsx index c1494b3..cdffac0 100644 --- a/components/FilterBar.tsx +++ b/components/FilterBar.tsx @@ -51,30 +51,30 @@ const FilterBar: React.FC = ({ filters, onFilterChange, options {/* Filter dropdowns - always visible on desktop, toggle on mobile */} -
+
onFilterChange('customer', v)} className="w-full md:w-auto md:flex-1" /> onFilterChange('year', v)} className="w-full md:w-auto md:flex-1" /> onFilterChange('month', v)} className="w-full md:w-auto md:flex-1" /> onFilterChange('week', v)} @@ -82,7 +82,7 @@ const FilterBar: React.FC = ({ filters, onFilterChange, options enableRangeSelect={true} /> onFilterChange('line', v)} @@ -96,7 +96,7 @@ const FilterBar: React.FC = ({ filters, onFilterChange, options className="w-full md:w-auto md:flex-1" /> onFilterChange('title', v)} @@ -110,7 +110,7 @@ const FilterBar: React.FC = ({ filters, onFilterChange, options className="w-full md:w-auto md:flex-1" /> onFilterChange('stock', v)}