Refine dashboard and RFV visuals
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s

This commit is contained in:
Cauê Faleiros
2026-06-25 10:38:31 -03:00
parent a6858fe2fc
commit eb77dd3ba2
6 changed files with 489 additions and 236 deletions

View File

@@ -3,10 +3,10 @@ import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue } from
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters'; import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
const COLORS = [ const COLORS = [
'#10b981', '#3b82f6', '#8b5cf6', '#f43f5e', '#f97316', '#25C2FF', '#18D6B5', '#A06BFF', '#FF6B8A', '#FFC247',
'#06b6d4', '#ec4899', '#eab308', '#6366f1', '#14b8a6', '#70E083', '#6EA5FF', '#FF6EC7', '#FF8A63', '#22C7E6',
'#6ee7b7', '#93c5fd', '#c4b5fd', '#fda4af', '#fdba74', '#B8E84D', '#FFA24A', '#B992FF', '#38E0C4', '#FFD166',
'#67e8f9', '#f9a8d4', '#fde047', '#a5b4fc', '#5eead4' '#FF7A9B', '#82B8FF', '#52DFA0', '#D28BFF', '#F7B23B'
]; ];
const globalColorMap: Record<string, string> = {}; const globalColorMap: Record<string, string> = {};

View File

@@ -62,12 +62,12 @@ const Layout = () => {
]; ];
return ( return (
<div className="flex h-screen bg-dark-bg text-dark-text overflow-hidden"> <div className="app-shell flex h-screen text-dark-text overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
<aside className={`bg-dark-sidebar border-r border-dark-border flex flex-col transition-all duration-300 ${isSidebarCollapsed ? 'w-20' : 'w-64'}`}> <aside className={`bg-dark-sidebar border-r border-dark-border flex flex-col transition-all duration-300 ${isSidebarCollapsed ? 'w-20' : 'w-64'}`}>
<div className={`h-20 px-6 border-b border-dark-border flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}> <div className={`h-20 px-6 border-b border-dark-border flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`p-1.5 bg-brand-primary/20 rounded-lg text-brand-primary`}> <div className="rounded-lg border border-brand-primary/25 bg-brand-primary/15 p-1.5 text-brand-primary shadow-sm shadow-brand-primary/10">
<BarChart3 className="w-6 h-6" /> <BarChart3 className="w-6 h-6" />
</div> </div>
{!isSidebarCollapsed && <span className="text-xl font-bold text-dark-text">Nexstar</span>} {!isSidebarCollapsed && <span className="text-xl font-bold text-dark-text">Nexstar</span>}
@@ -105,8 +105,8 @@ const Layout = () => {
isSidebarCollapsed ? 'justify-center' : 'space-x-3' isSidebarCollapsed ? 'justify-center' : 'space-x-3'
} ${ } ${
isActive isActive
? 'bg-brand-primary/10 text-brand-primary font-semibold shadow-md shadow-brand-primary/5' ? 'bg-brand-primary/12 text-brand-primary font-semibold shadow-md shadow-brand-primary/10 ring-1 ring-brand-primary/15'
: 'text-dark-muted hover:bg-dark-card hover:text-dark-text' : 'text-dark-muted hover:bg-dark-input/70 hover:text-dark-text'
}`} }`}
title={isSidebarCollapsed ? item.name : undefined} title={isSidebarCollapsed ? item.name : undefined}
> >
@@ -138,7 +138,7 @@ const Layout = () => {
</header> </header>
{/* Content Area */} {/* Content Area */}
<div className="flex-1 overflow-y-auto p-8 relative"> <div className="app-content flex-1 overflow-y-auto p-8 relative">
<Outlet context={{ dateRange, setDateRange, ordersData: emptyOrdersData, isDataLoading: false, refreshInterval, setRefreshInterval }} /> <Outlet context={{ dateRange, setDateRange, ordersData: emptyOrdersData, isDataLoading: false, refreshInterval, setRefreshInterval }} />
</div> </div>
</main> </main>

View File

@@ -165,14 +165,20 @@ export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: Ca
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`; const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => { return getCachedAnalytics(path, async () => {
try { try {
const response = await authFetch(path); const response = await authFetch(path, options?.force ? { cache: 'no-store' } : {});
if (!response.ok) return null; if (!response.ok) return null;
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Fetch dashboard analytics failed', error); console.error('Fetch dashboard analytics failed', error);
return null; return null;
} }
}, options); }, {
...options,
shouldCache: (data) => {
const metrics = data as DashboardAnalytics | null;
return Boolean(metrics && Array.isArray(metrics.revenueBySeller) && Array.isArray(metrics.ordersBySeller));
}
});
}; };
export const fetchProductAnalytics = async (dateRange: DateRange): Promise<ProductAnalyticsItem[]> => { export const fetchProductAnalytics = async (dateRange: DateRange): Promise<ProductAnalyticsItem[]> => {

View File

@@ -1,22 +1,26 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--color-brand-primary: #9ECAE1; --color-brand-primary: #b8c7d1;
--color-dark-bg: #0a0a0a; --color-dark-bg: #070707;
--color-dark-card: #141414; --color-dark-card: #141414;
--color-dark-header: #141414; --color-dark-header: #101010;
--color-dark-sidebar: #141414; --color-dark-sidebar: #111111;
--color-dark-border: #222222; --color-dark-border: #282828;
--color-dark-input: #1a1a1a; --color-dark-input: #1c1c1c;
--color-dark-text: #ededed; --color-dark-text: #f0f0ee;
--color-dark-muted: #888888; --color-dark-muted: #929292;
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
} }
@layer base { @layer base {
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
@apply bg-dark-bg text-dark-text; background:
radial-gradient(circle at 18% -8%, rgba(255, 255, 255, 0.035), transparent 30rem),
radial-gradient(circle at 88% 4%, rgba(184, 199, 209, 0.025), transparent 34rem),
#070707;
@apply text-dark-text;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
@@ -29,3 +33,19 @@
border-radius: 3px; border-radius: 3px;
} }
} }
@layer components {
.app-shell {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.018), rgba(255, 255, 255, 0) 24rem),
radial-gradient(circle at 28% -18%, rgba(255, 255, 255, 0.035), transparent 34rem),
radial-gradient(circle at 88% 8%, rgba(184, 199, 209, 0.024), transparent 38rem),
#070707;
}
.app-content {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.012), rgba(255, 255, 255, 0) 16rem),
transparent;
}
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useOutletContext, useNavigate } from 'react-router-dom'; import { useOutletContext, useNavigate } from 'react-router-dom';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LabelList } from 'recharts';
import { DollarSign, Loader2, ShoppingCart, TrendingUp } from 'lucide-react'; import { DollarSign, Loader2, ShoppingCart, TrendingUp } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import type { DashboardAnalytics, OrderData, DateRange } from '../types'; import type { DashboardAnalytics, OrderData, DateRange } from '../types';
@@ -11,6 +11,43 @@ const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
}; };
const truncateLabel = (value: string, maxLength = 22) => {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength - 1)}...`;
};
const SELLER_COLORS = [
'#25C2FF',
'#52DFA0',
'#A06BFF',
'#FF8A63',
'#FFC247',
'#FF7A9B',
'#18D6B5',
'#FFA24A',
'#82B8FF',
'#B8E84D'
];
const BAR_FILL_OPACITY = 0.5;
const BAR_STROKE_OPACITY = 0.85;
const PIE_FILL_OPACITY = 0.68;
const getBarGlowStyle = (color: string) => ({
filter: `drop-shadow(0 0 3px ${color}40)`
});
const getPieGlowStyle = (color: string) => ({
filter: `drop-shadow(0 0 8px ${color}80)`,
cursor: 'pointer'
});
const kpiIconStyle = (color: string) => ({
backgroundColor: `${color}14`,
borderColor: `${color}26`,
color
});
type ChartTooltipPayload = { type ChartTooltipPayload = {
value: number; value: number;
name?: string; name?: string;
@@ -84,6 +121,69 @@ const Dashboard = () => {
return buildDashboardMetrics(ordersData, dateRange); return buildDashboardMetrics(ordersData, dateRange);
}, [dateRange, ordersData, serverMetrics]); }, [dateRange, ordersData, serverMetrics]);
const sellerColorMap = useMemo(() => {
const colorMap = new Map<string, string>();
[...revenueBySeller, ...ordersBySeller].forEach((seller) => {
const key = seller.id || seller.name;
if (!colorMap.has(key)) {
colorMap.set(key, SELLER_COLORS[colorMap.size % SELLER_COLORS.length]);
}
});
return colorMap;
}, [ordersBySeller, revenueBySeller]);
const revenueBySellerChartData = useMemo(() => {
return revenueBySeller.map((seller) => ({
...seller,
fill: sellerColorMap.get(seller.id || seller.name) || seller.fill
}));
}, [revenueBySeller, sellerColorMap]);
const sellerPerformanceRows = useMemo(() => {
const rowsBySeller = new Map<string, { id: string; name: string; revenue: number; orders: number; fill: string }>();
revenueBySeller.forEach((seller) => {
const key = seller.id || seller.name;
rowsBySeller.set(seller.id || seller.name, {
id: key,
name: seller.name,
revenue: seller.value,
orders: 0,
fill: sellerColorMap.get(key) || seller.fill
});
});
ordersBySeller.forEach((seller) => {
const key = seller.id || seller.name;
const existing = rowsBySeller.get(key);
rowsBySeller.set(key, {
id: key,
name: existing?.name || seller.name,
revenue: existing?.revenue || 0,
orders: seller.value,
fill: existing?.fill || sellerColorMap.get(key) || seller.fill
});
});
return [...rowsBySeller.values()]
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 10);
}, [ordersBySeller, revenueBySeller, sellerColorMap]);
const ticketBySellerChartData = useMemo(() => {
return sellerPerformanceRows
.filter((seller) => seller.orders > 0)
.map((seller) => ({
id: seller.id,
name: seller.name,
value: seller.revenue / seller.orders,
fill: seller.fill
}))
.sort((a, b) => b.value - a.value);
}, [sellerPerformanceRows]);
const handleManualRefresh = () => { const handleManualRefresh = () => {
void loadDashboardMetrics(dateRange, { force: true }); void loadDashboardMetrics(dateRange, { force: true });
}; };
@@ -118,8 +218,8 @@ const Dashboard = () => {
<p className="text-dark-muted text-sm font-medium mb-1">Receita Total</p> <p className="text-dark-muted text-sm font-medium mb-1">Receita Total</p>
<h3 className="text-3xl font-bold text-dark-text">{formatCurrency(totalRevenue)}</h3> <h3 className="text-3xl font-bold text-dark-text">{formatCurrency(totalRevenue)}</h3>
</div> </div>
<div className="p-3 bg-emerald-500/10 rounded-xl"> <div className="rounded-xl border p-3" style={kpiIconStyle('#52DFA0')}>
<DollarSign className="w-6 h-6 text-emerald-500" /> <DollarSign className="w-6 h-6" />
</div> </div>
</div> </div>
</div> </div>
@@ -130,8 +230,8 @@ const Dashboard = () => {
<p className="text-dark-muted text-sm font-medium mb-1">Total de Produtos Vendidos</p> <p className="text-dark-muted text-sm font-medium mb-1">Total de Produtos Vendidos</p>
<h3 className="text-3xl font-bold text-dark-text">{totalOrders}</h3> <h3 className="text-3xl font-bold text-dark-text">{totalOrders}</h3>
</div> </div>
<div className="p-3 bg-blue-500/10 rounded-xl"> <div className="rounded-xl border p-3" style={kpiIconStyle('#82B8FF')}>
<ShoppingCart className="w-6 h-6 text-blue-500" /> <ShoppingCart className="w-6 h-6" />
</div> </div>
</div> </div>
</div> </div>
@@ -142,8 +242,8 @@ const Dashboard = () => {
<p className="text-dark-muted text-sm font-medium mb-1">Ticket Médio (Por Item)</p> <p className="text-dark-muted text-sm font-medium mb-1">Ticket Médio (Por Item)</p>
<h3 className="text-3xl font-bold text-dark-text">{formatCurrency(averageOrderValue)}</h3> <h3 className="text-3xl font-bold text-dark-text">{formatCurrency(averageOrderValue)}</h3>
</div> </div>
<div className="p-3 bg-purple-500/10 rounded-xl"> <div className="rounded-xl border p-3" style={kpiIconStyle('#B992FF')}>
<TrendingUp className="w-6 h-6 text-purple-500" /> <TrendingUp className="w-6 h-6" />
</div> </div>
</div> </div>
</div> </div>
@@ -152,18 +252,36 @@ const Dashboard = () => {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm flex flex-col"> <div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm flex flex-col">
<h3 className="text-lg font-bold mb-6 text-dark-text">Receita por Vendedor</h3> <h3 className="text-lg font-bold mb-6 text-dark-text">Receita por Vendedor</h3>
<div className="h-72 w-full flex items-center justify-center"> <div className="h-80 w-full flex items-center justify-center">
{revenueBySeller.length ? ( {revenueBySellerChartData.length ? (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={revenueBySeller} margin={{ top: 5, right: 24, left: 18, bottom: 5 }}> <BarChart data={revenueBySellerChartData} layout="vertical" margin={{ top: 5, right: 88, left: 8, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="#222222" horizontal={false} />
<XAxis dataKey="name" stroke="#888888" fontSize={10} tickLine={false} axisLine={false} tick={false} /> <XAxis type="number" stroke="#888888" fontSize={11} tickLine={false} axisLine={false} tickFormatter={(value) => `${Number(value) / 1000}k`} />
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} /> <YAxis
dataKey="name"
type="category"
width={150}
stroke="#888888"
fontSize={11}
tickLine={false}
axisLine={false}
tickFormatter={(value) => truncateLabel(String(value))}
/>
<Tooltip content={<CustomTooltip isCurrency valueLabel="Receita:" />} cursor={{ fill: '#222222' }} /> <Tooltip content={<CustomTooltip isCurrency valueLabel="Receita:" />} cursor={{ fill: '#222222' }} />
<Bar dataKey="value" radius={[4, 4, 0, 0]}> <Bar dataKey="value" radius={[0, 4, 4, 0]} isAnimationActive={false}>
{revenueBySeller.map((entry) => ( {revenueBySellerChartData.map((entry) => (
<Cell key={`seller-revenue-${entry.id}`} fill={entry.fill} /> <Cell
key={`seller-revenue-${entry.id}`}
fill={entry.fill}
fillOpacity={BAR_FILL_OPACITY}
stroke={entry.fill}
strokeOpacity={BAR_STROKE_OPACITY}
strokeWidth={1.25}
style={getBarGlowStyle(entry.fill)}
/>
))} ))}
<LabelList dataKey="value" position="right" fill="#ededed" fontSize={11} fontWeight={700} formatter={(value) => formatCurrency(Number(value) || 0)} />
</Bar> </Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -171,53 +289,82 @@ const Dashboard = () => {
<p className="text-sm font-semibold text-dark-muted">Nenhuma venda com vendedor no período.</p> <p className="text-sm font-semibold text-dark-muted">Nenhuma venda com vendedor no período.</p>
)} )}
</div> </div>
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
{revenueBySeller.map((entry) => (
<div key={`seller-revenue-legend-${entry.id}`} className="flex min-w-0 items-center justify-between gap-3 text-[10px]">
<div className="flex min-w-0 items-center">
<span className="mr-2 h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: entry.fill }} />
<span className="truncate font-semibold text-dark-muted" title={entry.name}>{entry.name}</span>
</div>
<span className="shrink-0 font-bold text-dark-text">{formatCurrency(entry.value)}</span>
</div>
))}
</div>
</div> </div>
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm flex flex-col"> <div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm flex flex-col">
<h3 className="text-lg font-bold mb-6 text-dark-text">Pedidos por Vendedor</h3> <h3 className="text-lg font-bold mb-6 text-dark-text">Ticket Médio por Vendedor</h3>
<div className="h-72 w-full flex items-center justify-center"> <div className="h-80 w-full flex items-center justify-center">
{ordersBySeller.length ? ( {ticketBySellerChartData.length ? (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={ordersBySeller} margin={{ top: 5, right: 24, left: 18, bottom: 5 }}> <BarChart data={ticketBySellerChartData} layout="vertical" margin={{ top: 5, right: 88, left: 8, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="#222222" horizontal={false} />
<XAxis dataKey="name" stroke="#888888" fontSize={10} tickLine={false} axisLine={false} tick={false} /> <XAxis type="number" stroke="#888888" fontSize={11} tickLine={false} axisLine={false} tickFormatter={(value) => `${Number(value) / 1000}k`} />
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} allowDecimals={false} /> <YAxis
<Tooltip content={<CustomTooltip valueLabel="Pedidos:" />} cursor={{ fill: '#222222' }} /> dataKey="name"
<Bar dataKey="value" radius={[4, 4, 0, 0]}> type="category"
{ordersBySeller.map((entry) => ( width={150}
<Cell key={`seller-orders-${entry.id}`} fill={entry.fill} /> stroke="#888888"
fontSize={11}
tickLine={false}
axisLine={false}
tickFormatter={(value) => truncateLabel(String(value))}
/>
<Tooltip content={<CustomTooltip isCurrency valueLabel="Ticket médio:" />} cursor={{ fill: '#222222' }} />
<Bar dataKey="value" radius={[0, 4, 4, 0]} isAnimationActive={false}>
{ticketBySellerChartData.map((entry) => (
<Cell
key={`seller-ticket-${entry.id}`}
fill={entry.fill}
fillOpacity={BAR_FILL_OPACITY}
stroke={entry.fill}
strokeOpacity={BAR_STROKE_OPACITY}
strokeWidth={1.25}
style={getBarGlowStyle(entry.fill)}
/>
))} ))}
<LabelList dataKey="value" position="right" fill="#ededed" fontSize={11} fontWeight={700} formatter={(value) => formatCurrency(Number(value) || 0)} />
</Bar> </Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<p className="text-sm font-semibold text-dark-muted">Nenhum pedido com vendedor no período.</p> <p className="text-sm font-semibold text-dark-muted">Nenhum ticket dio por vendedor no período.</p>
)} )}
</div> </div>
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
{ordersBySeller.map((entry) => (
<div key={`seller-orders-legend-${entry.id}`} className="flex min-w-0 items-center justify-between gap-3 text-[10px]">
<div className="flex min-w-0 items-center">
<span className="mr-2 h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: entry.fill }} />
<span className="truncate font-semibold text-dark-muted" title={entry.name}>{entry.name}</span>
</div> </div>
<span className="shrink-0 font-bold text-dark-text">{entry.value}</span>
</div> </div>
{sellerPerformanceRows.length > 0 && (
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<h3 className="text-lg font-bold mb-4 text-dark-text">Resumo por Vendedor</h3>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-left text-sm">
<thead>
<tr className="border-b border-dark-border text-xs uppercase tracking-wide text-dark-muted">
<th className="pb-3 font-bold">Vendedor</th>
<th className="pb-3 text-right font-bold">Receita</th>
<th className="pb-3 text-right font-bold">Pedidos</th>
<th className="pb-3 text-right font-bold">Ticket médio</th>
</tr>
</thead>
<tbody>
{sellerPerformanceRows.map((seller) => (
<tr key={`seller-summary-${seller.id}`} className="border-b border-dark-border/70 last:border-0">
<td className="max-w-[320px] py-3 font-semibold text-dark-text">
<div className="flex min-w-0 items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: seller.fill }} />
<span className="block truncate" title={seller.name}>{seller.name}</span>
</div>
</td>
<td className="py-3 text-right font-bold text-dark-text">{formatCurrency(seller.revenue)}</td>
<td className="py-3 text-right font-semibold text-dark-muted">{seller.orders}</td>
<td className="py-3 text-right font-semibold text-dark-muted">{seller.orders ? formatCurrency(seller.revenue / seller.orders) : '-'}</td>
</tr>
))} ))}
</tbody>
</table>
</div> </div>
</div> </div>
</div> )}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm flex flex-col"> <div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm flex flex-col">
@@ -234,7 +381,15 @@ const Dashboard = () => {
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#222222' }} /> <Tooltip content={<CustomTooltip />} cursor={{ fill: '#222222' }} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} onClick={(data) => { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}> <Bar dataKey="value" radius={[4, 4, 0, 0]} onClick={(data) => { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}>
{salesByProduct.map((entry) => ( {salesByProduct.map((entry) => (
<Cell key={`cell-${entry.name}`} fill={entry.fill} style={{ cursor: 'pointer' }} /> <Cell
key={`cell-${entry.name}`}
fill={entry.fill}
fillOpacity={BAR_FILL_OPACITY}
stroke={entry.fill}
strokeOpacity={BAR_STROKE_OPACITY}
strokeWidth={1.25}
style={{ ...getBarGlowStyle(entry.fill), cursor: 'pointer' }}
/>
))} ))}
</Bar> </Bar>
</BarChart> </BarChart>
@@ -255,9 +410,17 @@ const Dashboard = () => {
<div className="h-80 w-full flex items-center justify-center"> <div className="h-80 w-full flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <PieChart>
<Pie data={revenueByProduct} cx="50%" cy="50%" innerRadius={80} outerRadius={110} paddingAngle={5} dataKey="value" stroke="none" onClick={(data) => { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}> <Pie data={revenueByProduct} cx="50%" cy="50%" innerRadius={80} outerRadius={110} paddingAngle={5} dataKey="value" onClick={(data) => { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}>
{revenueByProduct.map((entry) => ( {revenueByProduct.map((entry) => (
<Cell key={`cell-${entry.name}`} fill={entry.fill} style={{ cursor: 'pointer' }} /> <Cell
key={`cell-${entry.name}`}
fill={entry.fill}
fillOpacity={PIE_FILL_OPACITY}
stroke={entry.fill}
strokeOpacity={BAR_STROKE_OPACITY}
strokeWidth={1.5}
style={getPieGlowStyle(entry.fill)}
/>
))} ))}
</Pie> </Pie>
<Tooltip content={<CustomTooltip isCurrency={true} />} cursor={{ fill: '#222222' }} /> <Tooltip content={<CustomTooltip isCurrency={true} />} cursor={{ fill: '#222222' }} />

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { ChevronLeft, ChevronRight, Download, Filter, Loader2, Search, Users } from 'lucide-react'; import { ChevronLeft, ChevronRight, Download, Filter, Loader2, Search, Users } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
@@ -8,16 +8,23 @@ import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types';
const emptyClients: RfmClient[] = []; const emptyClients: RfmClient[] = [];
const emptySegments: RfmSegment[] = []; const emptySegments: RfmSegment[] = [];
const segmentStyles: Record<string, { accent: string; bg: string; text: string }> = { type SegmentStyle = {
champions: { accent: 'bg-emerald-500', bg: 'bg-emerald-500/10', text: 'text-emerald-400' }, accent: string;
potential_loyalists: { accent: 'bg-sky-500', bg: 'bg-sky-500/10', text: 'text-sky-400' }, bg: string;
new_customers: { accent: 'bg-cyan-500', bg: 'bg-cyan-500/10', text: 'text-cyan-400' }, border: string;
loyal_customers: { accent: 'bg-blue-500', bg: 'bg-blue-500/10', text: 'text-blue-400' }, text: string;
need_attention: { accent: 'bg-amber-500', bg: 'bg-amber-500/10', text: 'text-amber-400' }, };
about_to_sleep: { accent: 'bg-orange-500', bg: 'bg-orange-500/10', text: 'text-orange-400' },
at_risk: { accent: 'bg-rose-500', bg: 'bg-rose-500/10', text: 'text-rose-400' }, const segmentStyles: Record<string, SegmentStyle> = {
hibernating: { accent: 'bg-fuchsia-500', bg: 'bg-fuchsia-500/10', text: 'text-fuchsia-400' }, champions: { accent: '#18D6B5', bg: 'rgba(24, 214, 181, 0.10)', border: 'rgba(24, 214, 181, 0.34)', text: '#18D6B5' },
lost: { accent: 'bg-zinc-500', bg: 'bg-zinc-500/10', text: 'text-zinc-400' } potential_loyalists: { accent: '#A3E635', bg: 'rgba(163, 230, 53, 0.10)', border: 'rgba(163, 230, 53, 0.34)', text: '#A3E635' },
new_customers: { accent: '#25C2FF', bg: 'rgba(37, 194, 255, 0.10)', border: 'rgba(37, 194, 255, 0.34)', text: '#25C2FF' },
loyal_customers: { accent: '#6EA5FF', bg: 'rgba(110, 165, 255, 0.10)', border: 'rgba(110, 165, 255, 0.34)', text: '#6EA5FF' },
need_attention: { accent: '#FFC247', bg: 'rgba(255, 194, 71, 0.11)', border: 'rgba(255, 194, 71, 0.34)', text: '#FFC247' },
about_to_sleep: { accent: '#FF8A3D', bg: 'rgba(255, 138, 61, 0.11)', border: 'rgba(255, 138, 61, 0.34)', text: '#FF8A3D' },
at_risk: { accent: '#FF5D7D', bg: 'rgba(255, 93, 125, 0.11)', border: 'rgba(255, 93, 125, 0.34)', text: '#FF5D7D' },
hibernating: { accent: '#C77DFF', bg: 'rgba(199, 125, 255, 0.11)', border: 'rgba(199, 125, 255, 0.34)', text: '#C77DFF' },
lost: { accent: '#8c9298', bg: 'rgba(140, 146, 152, 0.08)', border: 'rgba(140, 146, 152, 0.24)', text: '#b3b7bb' }
}; };
const rfmSegmentDefinitions: Array<Pick<RfmSegment, 'key' | 'label'>> = [ const rfmSegmentDefinitions: Array<Pick<RfmSegment, 'key' | 'label'>> = [
@@ -72,6 +79,23 @@ const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
}; };
const segmentPanelStyle = (style: SegmentStyle, isActive = false) => ({
backgroundColor: style.bg,
borderColor: isActive ? style.text : style.border,
boxShadow: isActive ? `0 0 0 1px ${style.border}, 0 0 14px ${style.accent}22` : undefined
});
const segmentPillStyle = (style: SegmentStyle) => ({
backgroundColor: style.bg,
color: style.text,
borderColor: style.border
});
const segmentDotStyle = (style: SegmentStyle) => ({
backgroundColor: style.accent,
boxShadow: `0 0 6px ${style.accent}55`
});
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const parseLocalDate = (value: string) => { const parseLocalDate = (value: string) => {
@@ -135,6 +159,7 @@ const Rfm = () => {
const [selectedSegmentKey, setSelectedSegmentKey] = useState(''); const [selectedSegmentKey, setSelectedSegmentKey] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20); const [itemsPerPage, setItemsPerPage] = useState(20);
const segmentFocusRef = useRef<HTMLDivElement | null>(null);
const loadRfm = useCallback(async (range: DateRange, options?: { force?: boolean }) => { const loadRfm = useCallback(async (range: DateRange, options?: { force?: boolean }) => {
const cachedAnalytics = options?.force ? undefined : getCachedRfmAnalytics(range); const cachedAnalytics = options?.force ? undefined : getCachedRfmAnalytics(range);
@@ -166,6 +191,47 @@ const Rfm = () => {
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [dateRange, loadRfm, refreshInterval]); }, [dateRange, loadRfm, refreshInterval]);
const clearSegmentFocus = useCallback(() => {
setSegmentFilter('all');
setSelectedSegmentKey('');
setCurrentPage(1);
}, []);
const selectSegment = useCallback((segmentKey: string) => {
if (segmentFilter === segmentKey) {
clearSegmentFocus();
return;
}
setSegmentFilter(segmentKey);
setSelectedSegmentKey(segmentKey);
setCurrentPage(1);
}, [clearSegmentFocus, segmentFilter]);
useEffect(() => {
if (segmentFilter === 'all' && !selectedSegmentKey) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
clearSegmentFocus();
}
};
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && segmentFocusRef.current?.contains(target)) return;
clearSegmentFocus();
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('pointerdown', handlePointerDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('pointerdown', handlePointerDown);
};
}, [clearSegmentFocus, segmentFilter, selectedSegmentKey]);
const visibleAnalytics = analytics; const visibleAnalytics = analytics;
const clients = visibleAnalytics?.clients || emptyClients; const clients = visibleAnalytics?.clients || emptyClients;
const segments = useMemo(() => { const segments = useMemo(() => {
@@ -315,6 +381,7 @@ const Rfm = () => {
</div> </div>
</div> </div>
<div ref={segmentFocusRef} className="space-y-6">
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-6"> <div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-6">
<section className="bg-dark-card p-5 rounded-2xl border border-dark-border shadow-sm"> <section className="bg-dark-card p-5 rounded-2xl border border-dark-border shadow-sm">
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between"> <div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
@@ -324,7 +391,10 @@ const Rfm = () => {
</div> </div>
<div className="flex items-center gap-2 text-xs font-semibold text-dark-muted"> <div className="flex items-center gap-2 text-xs font-semibold text-dark-muted">
<span>Menor prioridade</span> <span>Menor prioridade</span>
<span className="h-2 w-12 rounded-full bg-gradient-to-r from-rose-500 via-amber-400 to-emerald-500" /> <span
className="h-2 w-12 rounded-full"
style={{ background: 'linear-gradient(90deg, #FF6B8A, #FFC247, #18D6B5)' }}
/>
<span>Maior prioridade</span> <span>Maior prioridade</span>
</div> </div>
</div> </div>
@@ -358,40 +428,36 @@ const Rfm = () => {
{[1, 2, 3].map(valueScore => { {[1, 2, 3].map(valueScore => {
const segment = segmentForCell(segments, recencyScore, valueScore); const segment = segmentForCell(segments, recencyScore, valueScore);
const style = segmentStyles[segment?.key || 'lost']; const segmentKey = segment?.key || 'lost';
const isActive = segmentFilter === segment?.key; const style = segmentStyles[segmentKey];
const isActive = segmentFilter === segmentKey;
const intensity = segment?.count ? 'opacity-100' : 'opacity-75'; const intensity = segment?.count ? 'opacity-100' : 'opacity-75';
return ( return (
<button <button
key={`${recencyScore}-${valueScore}`} key={`${recencyScore}-${valueScore}`}
onClick={() => { onClick={() => selectSegment(segmentKey)}
setSegmentFilter(segment?.key || 'all'); className={`group min-h-28 rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5 cursor-pointer ${intensity}`}
setSelectedSegmentKey(segment?.key || ''); style={segmentPanelStyle(style, isActive)}
setCurrentPage(1);
}}
className={`group min-h-28 rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5 hover:border-brand-primary cursor-pointer ${
isActive ? 'border-brand-primary ring-1 ring-brand-primary/50' : 'border-dark-border'
} ${style.bg} ${intensity}`}
> >
<div className="flex h-full flex-col justify-between gap-3"> <div className="flex h-full flex-col justify-between gap-3">
<div> <div>
<div className="mb-2 flex items-start justify-between gap-2"> <div className="mb-2 flex items-start justify-between gap-2">
<span className={`h-2 w-8 rounded-full ${style.accent}`} /> <span className="h-2 w-8 rounded-full" style={segmentDotStyle(style)} />
<span className="rounded-md border border-dark-border bg-black/10 px-1.5 py-0.5 text-[10px] font-bold text-dark-muted"> <span className="rounded-md border border-dark-border bg-black/10 px-1.5 py-0.5 text-[10px] font-bold text-dark-muted">
R{recencyScore} P{valueScore} R{recencyScore} P{valueScore}
</span> </span>
</div> </div>
<p className="text-sm font-bold text-dark-text">{segment?.label}</p> <p className="text-sm font-bold text-dark-text">{segment?.label}</p>
<p className="mt-1 truncate text-xs font-medium text-dark-muted"> <p className="mt-1 truncate text-xs font-medium text-dark-muted">
{segmentDescriptions[segment?.key || 'lost']} {segmentDescriptions[segmentKey]}
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Clientes</p> <p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Clientes</p>
<p className={`text-xl font-bold leading-none ${style.text}`}>{segment?.count || 0}</p> <p className="text-xl font-bold leading-none" style={{ color: style.text }}>{segment?.count || 0}</p>
</div> </div>
<div> <div>
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Receita período</p> <p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Receita período</p>
@@ -416,21 +482,18 @@ const Rfm = () => {
return ( return (
<button <button
key={segment.key} key={segment.key}
onClick={() => { onClick={() => selectSegment(segment.key)}
setSegmentFilter(segment.key);
setSelectedSegmentKey(segment.key);
setCurrentPage(1);
}}
className={`w-full rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${ className={`w-full rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${
segmentFilter === segment.key ? 'border-brand-primary bg-dark-input' : 'border-dark-border bg-transparent hover:border-brand-primary' segmentFilter === segment.key ? '' : 'hover:border-brand-primary'
}`} }`}
style={segmentFilter === segment.key ? segmentPanelStyle(style, true) : { borderColor: '#282828' }}
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${style.accent}`} /> <span className="h-2.5 w-2.5 shrink-0 rounded-full" style={segmentDotStyle(style)} />
<span className="truncate text-sm font-bold text-dark-text">{segment.label}</span> <span className="truncate text-sm font-bold text-dark-text">{segment.label}</span>
</div> </div>
<span className={`text-sm font-bold ${style.text}`}>{segment.count}</span> <span className="text-sm font-bold" style={{ color: style.text }}>{segment.count}</span>
</div> </div>
<p className="mt-1 text-xs font-semibold text-dark-muted"> <p className="mt-1 text-xs font-semibold text-dark-muted">
{(segmentInsights[segment.key]?.customerPercent || 0).toFixed(1)}% clientes · {formatCurrency(segment.totalRevenue)} no período {(segmentInsights[segment.key]?.customerPercent || 0).toFixed(1)}% clientes · {formatCurrency(segment.totalRevenue)} no período
@@ -443,12 +506,12 @@ const Rfm = () => {
</div> </div>
{selectedSegment && selectedSegmentInsights && ( {selectedSegment && selectedSegmentInsights && (
<section className={`rounded-2xl border border-dark-border ${selectedSegmentStyle.bg} p-4 shadow-sm`}> <section className="rounded-2xl border p-4 shadow-sm" style={segmentPanelStyle(selectedSegmentStyle, true)}>
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between"> <div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-1 flex items-center gap-2"> <div className="mb-1 flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${selectedSegmentStyle.accent}`} /> <span className="h-2.5 w-2.5 rounded-full" style={segmentDotStyle(selectedSegmentStyle)} />
<p className={`text-xs font-bold uppercase tracking-widest ${selectedSegmentStyle.text}`}>Segmento selecionado</p> <p className="text-xs font-bold uppercase tracking-widest" style={{ color: selectedSegmentStyle.text }}>Segmento selecionado</p>
</div> </div>
<h3 className="text-lg font-bold text-dark-text">{selectedSegment.label}</h3> <h3 className="text-lg font-bold text-dark-text">{selectedSegment.label}</h3>
<p className="mt-1 text-sm font-semibold text-dark-muted">{segmentActions[selectedSegment.key]}</p> <p className="mt-1 text-sm font-semibold text-dark-muted">{segmentActions[selectedSegment.key]}</p>
@@ -475,6 +538,7 @@ const Rfm = () => {
</div> </div>
</section> </section>
)} )}
</div>
<section className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden shadow-sm"> <section className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden shadow-sm">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 border-b border-dark-border p-4"> <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 border-b border-dark-border p-4">
@@ -552,8 +616,8 @@ const Rfm = () => {
</Link> </Link>
</td> </td>
<td className="px-6 py-3"> <td className="px-6 py-3">
<span className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-bold ${style.bg} ${style.text}`}> <span className="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-bold" style={segmentPillStyle(style)}>
<span className={`h-2 w-2 rounded-full ${style.accent}`} /> <span className="h-2 w-2 rounded-full" style={segmentDotStyle(style)} />
{client.segmentLabel} {client.segmentLabel}
</span> </span>
</td> </td>