Refine dashboard and RFV visuals
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
This commit is contained in:
@@ -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> = {};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[]> => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 mé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' }} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user