mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 14:05:24 +02:00
feat: implement period-over-period comparison in Ads tab
- AdsPerformance.tsx: Automatically activate comparison view when 2 periods are selected - AdsPerformance.tsx: Add growth percentages and trend indicators to table and summary - AdsPerformance.tsx: Implement chronological sorting for previous vs current periods - App.tsx: Pass filters to AdsPerformance component
This commit is contained in:
@@ -493,7 +493,7 @@ const App: React.FC = () => {
|
|||||||
{view === 'table' && <DataGrid data={combinedAdsData} hasCustomerFilter={filters.customer.length > 0} adsData={filteredAdsData} />}
|
{view === 'table' && <DataGrid data={combinedAdsData} hasCustomerFilter={filters.customer.length > 0} adsData={filteredAdsData} />}
|
||||||
{view === 'weekly' && <WeeklyGrid data={combinedAdsData} top50Ranking={top50Ranking2025} />}
|
{view === 'weekly' && <WeeklyGrid data={combinedAdsData} top50Ranking={top50Ranking2025} />}
|
||||||
{view === 'movers' && <TopMovers data={filteredData} />}
|
{view === 'movers' && <TopMovers data={filteredData} />}
|
||||||
{view === 'ads' && <AdsPerformance data={combinedAdsData} />}
|
{view === 'ads' && <AdsPerformance data={combinedAdsData} filters={filters} />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+169
-84
@@ -1,65 +1,110 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { CombinedKPIs } from '../types';
|
import { CombinedKPIs, FilterState } from '../types';
|
||||||
|
|
||||||
interface AdsPerformanceProps {
|
interface AdsPerformanceProps {
|
||||||
data: CombinedKPIs[];
|
data: CombinedKPIs[];
|
||||||
|
filters: FilterState;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
|
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
|
||||||
|
|
||||||
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data }) => {
|
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters }) => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({
|
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({
|
||||||
key: 'cost',
|
key: 'cost',
|
||||||
direction: 'desc'
|
direction: 'desc'
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. Aggregate data by ASIN
|
// Detect comparison mode: exactly 2 weeks, 2 months, or 2 years
|
||||||
|
const comparisonConfig = useMemo(() => {
|
||||||
|
let periods: string[] = [];
|
||||||
|
let type: 'week' | 'month' | 'year' | null = null;
|
||||||
|
|
||||||
|
if (filters.week.length === 2) {
|
||||||
|
periods = [...filters.week].sort((a, b) => {
|
||||||
|
const wA = parseInt(a.replace('W', ''));
|
||||||
|
const wB = parseInt(b.replace('W', ''));
|
||||||
|
return wA - wB;
|
||||||
|
});
|
||||||
|
type = 'week';
|
||||||
|
} else if (filters.month.length === 2) {
|
||||||
|
// Simple sort for abbreviated months might be tricky without year,
|
||||||
|
// but usually users pick consecutive ones.
|
||||||
|
periods = [...filters.month];
|
||||||
|
type = 'month';
|
||||||
|
} else if (filters.year.length === 2) {
|
||||||
|
periods = [...filters.year].sort();
|
||||||
|
type = 'year';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type && periods.length === 2) {
|
||||||
|
return { type, prev: periods[0], curr: periods[1] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
// 1. Aggregate and Compare data by ASIN
|
||||||
const aggregatedByAsin = useMemo(() => {
|
const aggregatedByAsin = useMemo(() => {
|
||||||
const map = new Map<string, CombinedKPIs>();
|
const map = new Map<string, { curr: CombinedKPIs; prev?: CombinedKPIs }>();
|
||||||
|
|
||||||
data.forEach(item => {
|
data.forEach(item => {
|
||||||
const asin = item.asin.trim().toUpperCase();
|
const asin = item.asin.trim().toUpperCase();
|
||||||
|
const periodVal = comparisonConfig?.type === 'week' ? `W${item.week}` :
|
||||||
|
comparisonConfig?.type === 'month' ? item.month :
|
||||||
|
comparisonConfig?.type === 'year' ? item.year.toString() : null;
|
||||||
|
|
||||||
|
const isCurrent = !comparisonConfig || periodVal === comparisonConfig.curr;
|
||||||
|
const isPrevious = comparisonConfig && periodVal === comparisonConfig.prev;
|
||||||
|
|
||||||
if (!map.has(asin)) {
|
if (!map.has(asin)) {
|
||||||
map.set(asin, { ...item });
|
// Initialize with empty record if it's new
|
||||||
|
const defaultRec: CombinedKPIs = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 };
|
||||||
|
map.set(asin, {
|
||||||
|
curr: isCurrent ? { ...item } : { ...defaultRec },
|
||||||
|
prev: isPrevious ? { ...item } : undefined
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const existing = map.get(asin)!;
|
const entry = map.get(asin)!;
|
||||||
existing.salesTotal += item.salesTotal;
|
const target = isCurrent ? entry.curr : (isPrevious ? (entry.prev || (entry.prev = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 })) : null);
|
||||||
existing.unitsTotal += item.unitsTotal;
|
|
||||||
existing.salesAds += item.salesAds;
|
if (target) {
|
||||||
existing.unitsAds += item.unitsAds;
|
target.salesTotal += item.salesTotal;
|
||||||
existing.cost += item.cost;
|
target.unitsTotal += item.unitsTotal;
|
||||||
existing.clicks += item.clicks;
|
target.salesAds += item.salesAds;
|
||||||
existing.impressions += item.impressions;
|
target.unitsAds += item.unitsAds;
|
||||||
existing.conversions += item.conversions;
|
target.cost += item.cost;
|
||||||
existing.salesOrganic += item.salesOrganic;
|
target.clicks += item.clicks;
|
||||||
|
target.impressions += item.impressions;
|
||||||
|
target.conversions += item.conversions;
|
||||||
|
target.salesOrganic += item.salesOrganic;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(map.values()).map(item => {
|
return Array.from(map.values()).map(({ curr, prev }) => {
|
||||||
// Recalculate derived metrics for the aggregate
|
const calcMetrics = (item: CombinedKPIs) => ({
|
||||||
const salesTotal = item.salesTotal;
|
...item,
|
||||||
const adSales = item.salesAds;
|
acos: item.salesAds > 0 ? (item.cost / item.salesAds) * 100 : 0,
|
||||||
const adCost = item.cost;
|
tacos: item.salesTotal > 0 ? (item.cost / item.salesTotal) * 100 : 0,
|
||||||
const adClicks = item.clicks;
|
roas: item.cost > 0 ? item.salesAds / item.cost : 0,
|
||||||
const adImpressions = item.impressions;
|
ctr: item.impressions > 0 ? (item.clicks / item.impressions) * 100 : 0,
|
||||||
const adConversions = item.conversions;
|
cpc: item.clicks > 0 ? item.cost / item.clicks : 0,
|
||||||
|
cvrUnits: item.clicks > 0 ? (item.conversions / item.clicks) * 100 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currFinal = calcMetrics(curr);
|
||||||
|
const prevFinal = prev ? calcMetrics(prev) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...currFinal,
|
||||||
acos: adSales > 0 ? (adCost / adSales) * 100 : 0,
|
prevData: prevFinal
|
||||||
tacos: salesTotal > 0 ? (adCost / salesTotal) * 100 : 0,
|
|
||||||
roas: adCost > 0 ? adSales / adCost : 0,
|
|
||||||
ctr: adImpressions > 0 ? (adClicks / adImpressions) * 100 : 0,
|
|
||||||
cpc: adClicks > 0 ? adCost / adClicks : 0,
|
|
||||||
cvrUnits: adClicks > 0 ? (adConversions / adClicks) * 100 : 0,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data, comparisonConfig]);
|
||||||
|
|
||||||
// 2. Global Totals for Summary Header
|
// 2. Global Totals
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
return aggregatedByAsin.reduce((acc, curr) => ({
|
const sum = (items: any[]) => items.reduce((acc, curr) => ({
|
||||||
salesTotal: acc.salesTotal + curr.salesTotal,
|
salesTotal: acc.salesTotal + curr.salesTotal,
|
||||||
salesAds: acc.salesAds + curr.salesAds,
|
salesAds: acc.salesAds + curr.salesAds,
|
||||||
salesOrganic: acc.salesOrganic + curr.salesOrganic,
|
salesOrganic: acc.salesOrganic + curr.salesOrganic,
|
||||||
@@ -67,25 +112,25 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data }) => {
|
|||||||
impressions: acc.impressions + curr.impressions,
|
impressions: acc.impressions + curr.impressions,
|
||||||
clicks: acc.clicks + curr.clicks,
|
clicks: acc.clicks + curr.clicks,
|
||||||
conversions: acc.conversions + curr.conversions,
|
conversions: acc.conversions + curr.conversions,
|
||||||
}), {
|
}), { salesTotal: 0, salesAds: 0, salesOrganic: 0, cost: 0, impressions: 0, clicks: 0, conversions: 0 });
|
||||||
salesTotal: 0,
|
|
||||||
salesAds: 0,
|
|
||||||
salesOrganic: 0,
|
|
||||||
cost: 0,
|
|
||||||
impressions: 0,
|
|
||||||
clicks: 0,
|
|
||||||
conversions: 0,
|
|
||||||
});
|
|
||||||
}, [aggregatedByAsin]);
|
|
||||||
|
|
||||||
const globalMetrics = {
|
const currTotals = sum(aggregatedByAsin);
|
||||||
acos: totals.salesAds > 0 ? (totals.cost / totals.salesAds) * 100 : 0,
|
const prevTotals = comparisonConfig ? sum(aggregatedByAsin.map(a => a.prevData).filter(Boolean)) : null;
|
||||||
tacos: totals.salesTotal > 0 ? (totals.cost / totals.salesTotal) * 100 : 0,
|
|
||||||
roas: totals.cost > 0 ? totals.salesAds / totals.cost : 0,
|
return { curr: currTotals, prev: prevTotals };
|
||||||
ctr: totals.impressions > 0 ? (totals.clicks / totals.impressions) * 100 : 0,
|
}, [aggregatedByAsin, comparisonConfig]);
|
||||||
cpc: totals.clicks > 0 ? totals.cost / totals.clicks : 0,
|
|
||||||
cvr: totals.clicks > 0 ? (totals.conversions / totals.clicks) * 100 : 0,
|
const calculateDerived = (t: any) => ({
|
||||||
};
|
acos: t.salesAds > 0 ? (t.cost / t.salesAds) * 100 : 0,
|
||||||
|
tacos: t.salesTotal > 0 ? (t.cost / t.salesTotal) * 100 : 0,
|
||||||
|
roas: t.cost > 0 ? t.salesAds / t.cost : 0,
|
||||||
|
ctr: t.impressions > 0 ? (t.clicks / t.impressions) * 100 : 0,
|
||||||
|
cpc: t.clicks > 0 ? t.cost / t.clicks : 0,
|
||||||
|
cvr: t.clicks > 0 ? (t.conversions / t.clicks) * 100 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currMetrics = calculateDerived(totals.curr);
|
||||||
|
const prevMetrics = totals.prev ? calculateDerived(totals.prev) : null;
|
||||||
|
|
||||||
// 3. Filter and Sort
|
// 3. Filter and Sort
|
||||||
const filteredAndSorted = useMemo(() => {
|
const filteredAndSorted = useMemo(() => {
|
||||||
@@ -128,26 +173,29 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data }) => {
|
|||||||
<h1 className="text-2xl font-bold text-white mb-1">Performance Overview</h1>
|
<h1 className="text-2xl font-bold text-white mb-1">Performance Overview</h1>
|
||||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
|
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||||
<span>{aggregatedByAsin.length} records processed • All • All Years</span>
|
<span>
|
||||||
|
{aggregatedByAsin.length} products •
|
||||||
|
{comparisonConfig ? ` Comparison: ${comparisonConfig.prev} vs ${comparisonConfig.curr}` : ' Direct Overview'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Grid */}
|
{/* Summary Grid */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 xl:grid-cols-13 gap-4 bg-[#141820] p-4 rounded-xl border border-white/5 shadow-xl">
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 xl:grid-cols-13 gap-4 bg-[#141820] p-4 rounded-xl border border-white/5 shadow-xl">
|
||||||
<SummaryCard label="TOTAL SALES" value={formatCurrency(totals.salesTotal)} color="text-emerald-400" />
|
<SummaryCard label="TOTAL SALES" value={formatCurrency(totals.curr.salesTotal)} prevValue={totals.prev?.salesTotal} color="text-emerald-400" />
|
||||||
<SummaryCard label="ADS SALES" value={formatCurrency(totals.salesAds)} color="text-orange-400" />
|
<SummaryCard label="ADS SALES" value={formatCurrency(totals.curr.salesAds)} prevValue={totals.prev?.salesAds} color="text-orange-400" />
|
||||||
<SummaryCard label="ORGANIC SALES" value={formatCurrency(totals.salesOrganic)} color="text-blue-400" />
|
<SummaryCard label="ORGANIC SALES" value={formatCurrency(totals.curr.salesOrganic)} prevValue={totals.prev?.salesOrganic} color="text-blue-400" />
|
||||||
<SummaryCard label="AD SPEND" value={formatCurrency(totals.cost)} color="text-indigo-400" />
|
<SummaryCard label="AD SPEND" value={formatCurrency(totals.curr.cost)} prevValue={totals.prev?.cost} color="text-indigo-400" />
|
||||||
<SummaryCard label="IMPR." value={formatNumber(totals.impressions)} />
|
<SummaryCard label="IMPR." value={formatNumber(totals.curr.impressions)} prevValue={totals.prev?.impressions} />
|
||||||
<SummaryCard label="CLICKS" value={formatNumber(totals.clicks)} />
|
<SummaryCard label="CLICKS" value={formatNumber(totals.curr.clicks)} prevValue={totals.prev?.clicks} />
|
||||||
<SummaryCard label="ADS ORDERS" value={formatNumber(totals.conversions)} />
|
<SummaryCard label="ADS ORDERS" value={formatNumber(totals.curr.conversions)} prevValue={totals.prev?.conversions} />
|
||||||
<SummaryCard label="CPC" value={`$${globalMetrics.cpc.toFixed(2)}`} />
|
<SummaryCard label="CPC" value={`$${currMetrics.cpc.toFixed(2)}`} prevValue={prevMetrics?.cpc} inverse />
|
||||||
<SummaryCard label="CTR" value={formatPercent(globalMetrics.ctr)} />
|
<SummaryCard label="CTR" value={formatPercent(currMetrics.ctr)} prevValue={prevMetrics?.ctr} />
|
||||||
<SummaryCard label="CVR" value={formatPercent(globalMetrics.cvr)} />
|
<SummaryCard label="CVR" value={formatPercent(currMetrics.cvr)} prevValue={prevMetrics?.cvr} />
|
||||||
<SummaryCard label="ACOS" value={formatPercent(globalMetrics.acos)} />
|
<SummaryCard label="ACOS" value={formatPercent(currMetrics.acos)} prevValue={prevMetrics?.acos} inverse />
|
||||||
<SummaryCard label="ROAS" value={globalMetrics.roas.toFixed(2)} color="text-blue-300" />
|
<SummaryCard label="ROAS" value={currMetrics.roas.toFixed(2)} prevValue={prevMetrics?.roas} color="text-blue-300" />
|
||||||
<SummaryCard label="TACOS" value={formatPercent(globalMetrics.tacos)} />
|
<SummaryCard label="TACOS" value={formatPercent(currMetrics.tacos)} prevValue={prevMetrics?.tacos} inverse />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table Section */}
|
{/* Table Section */}
|
||||||
@@ -155,7 +203,9 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data }) => {
|
|||||||
<div className="p-4 border-b border-white/5 flex justify-between items-center bg-[#1A1F29]">
|
<div className="p-4 border-b border-white/5 flex justify-between items-center bg-[#1A1F29]">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-bold text-white uppercase tracking-wider">Product Inventory</h2>
|
<h2 className="text-sm font-bold text-white uppercase tracking-wider">Product Inventory</h2>
|
||||||
<p className="text-[10px] text-slate-500 mt-0.5">Displaying total accumulated data for selected filters.</p>
|
<p className="text-[10px] text-slate-500 mt-0.5">
|
||||||
|
{comparisonConfig ? `Comparing ${comparisonConfig.curr} vs ${comparisonConfig.prev}` : 'Displaying total data for selected filters.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
@@ -189,7 +239,7 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data }) => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/[0.03]">
|
<tbody className="divide-y divide-white/[0.03]">
|
||||||
{filteredAndSorted.map((row) => (
|
{filteredAndSorted.map((row: any) => (
|
||||||
<tr key={row.id} className="hover:bg-white/[0.02] transition-colors group">
|
<tr key={row.id} className="hover:bg-white/[0.02] transition-colors group">
|
||||||
<td className="p-4 py-3">
|
<td className="p-4 py-3">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
@@ -198,19 +248,19 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data }) => {
|
|||||||
<span className="text-[10px] text-slate-500 truncate max-w-[220px] leading-tight" title={row.title}>{row.title}</span>
|
<span className="text-[10px] text-slate-500 truncate max-w-[220px] leading-tight" title={row.title}>{row.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 text-right font-bold text-white">{formatNumber(row.salesTotal)}</td>
|
<TableCell value={row.salesTotal} prevValue={row.prevData?.salesTotal} format="currency" />
|
||||||
<td className="p-4 text-right font-bold text-white">{formatNumber(row.salesAds)}</td>
|
<TableCell value={row.salesAds} prevValue={row.prevData?.salesAds} format="currency" />
|
||||||
<td className="p-4 text-right font-bold text-white">{formatNumber(row.salesOrganic)}</td>
|
<TableCell value={row.salesOrganic} prevValue={row.prevData?.salesOrganic} format="currency" />
|
||||||
<td className="p-4 text-right font-bold text-white">{formatNumber(row.cost)}</td>
|
<TableCell value={row.cost} prevValue={row.prevData?.cost} format="currency" />
|
||||||
<td className="p-4 text-right text-slate-400">{formatNumber(row.impressions)}</td>
|
<TableCell value={row.impressions} prevValue={row.prevData?.impressions} format="number" />
|
||||||
<td className="p-4 text-right text-slate-400">{formatNumber(row.clicks)}</td>
|
<TableCell value={row.clicks} prevValue={row.prevData?.clicks} format="number" />
|
||||||
<td className="p-4 text-right text-slate-400">{formatNumber(row.conversions)}</td>
|
<TableCell value={row.conversions} prevValue={row.prevData?.conversions} format="number" />
|
||||||
<td className="p-4 text-right text-slate-400">${row.cpc.toFixed(2)}</td>
|
<TableCell value={row.cpc} prevValue={row.prevData?.cpc} format="number" prefix="$" inverse />
|
||||||
<td className="p-4 text-right text-slate-400">{row.ctr.toFixed(2)}%</td>
|
<TableCell value={row.ctr} prevValue={row.prevData?.ctr} format="percent" />
|
||||||
<td className="p-4 text-right text-slate-400">{row.cvrUnits.toFixed(2)}%</td>
|
<TableCell value={row.cvrUnits} prevValue={row.prevData?.cvrUnits} format="percent" />
|
||||||
<td className="p-4 text-right text-slate-400">{row.acos.toFixed(2)}%</td>
|
<TableCell value={row.acos} prevValue={row.prevData?.acos} format="percent" inverse />
|
||||||
<td className="p-4 text-right font-bold text-blue-300">{row.roas.toFixed(2)}</td>
|
<TableCell value={row.roas} prevValue={row.prevData?.roas} format="number" highlight />
|
||||||
<td className="p-4 text-right text-slate-400">{row.tacos.toFixed(2)}%</td>
|
<TableCell value={row.tacos} prevValue={row.prevData?.tacos} format="percent" inverse />
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -221,12 +271,47 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SummaryCard = ({ label, value, color = "text-white" }: { label: string; value: string; color?: string }) => (
|
const SummaryCard = ({ label, value, prevValue, color = "text-white", inverse = false }: { label: string; value: string; prevValue?: number; color?: string; inverse?: boolean }) => {
|
||||||
|
const growth = prevValue !== undefined && prevValue > 0
|
||||||
|
? ((parseFloat(value.replace(/[^0-9.-]+/g, "")) - prevValue) / prevValue) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="flex flex-col items-center justify-center p-2 border-r last:border-r-0 border-white/5 w-full min-w-[80px]">
|
<div className="flex flex-col items-center justify-center p-2 border-r last:border-r-0 border-white/5 w-full min-w-[80px]">
|
||||||
<span className={`${color} text-[10px] font-black tracking-widest mb-1 text-center leading-tight`}>{label}</span>
|
<span className={`${color} text-[10px] font-black tracking-widest mb-1 text-center leading-tight uppercase`}>{label}</span>
|
||||||
<span className="text-sm font-bold text-white">{value}</span>
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-sm font-bold text-white transition-all">{value}</span>
|
||||||
|
{growth !== null && (
|
||||||
|
<span className={`text-[9px] font-bold mt-0.5 ${(growth > 0 ? !inverse : inverse) ? 'text-emerald-400' : 'text-rose-500'}`}>
|
||||||
|
{growth > 0 ? '↑' : '↓'} {Math.abs(growth).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TableCell = ({ value, prevValue, format, prefix = "", inverse = false, highlight = false }: { value: number; prevValue?: number; format: 'currency' | 'number' | 'percent'; prefix?: string; inverse?: boolean; highlight?: boolean }) => {
|
||||||
|
const growth = prevValue !== undefined && prevValue > 0 ? ((value - prevValue) / prevValue) * 100 : null;
|
||||||
|
|
||||||
|
let displayValue = "";
|
||||||
|
if (format === 'currency') displayValue = new Intl.NumberFormat('en-US').format(value);
|
||||||
|
else if (format === 'number') displayValue = prefix + value.toFixed(format === 'number' && prefix === "$" ? 2 : 0);
|
||||||
|
else if (format === 'percent') displayValue = value.toFixed(2) + "%";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td className="p-4 text-right">
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className={`font-bold ${highlight ? 'text-blue-300' : 'text-white'}`}>{displayValue}</span>
|
||||||
|
{growth !== null && (
|
||||||
|
<span className={`text-[9px] font-medium ${(growth > 0 ? !inverse : inverse) ? 'text-emerald-400' : 'text-rose-400'} opacity-80`}>
|
||||||
|
{growth > 0 ? '+' : ''}{growth.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const SortableHeader = ({ label, sortKey, activeSort, onSort }: {
|
const SortableHeader = ({ label, sortKey, activeSort, onSort }: {
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user