From eb77dd3ba247ae5c508f1d882645fd66ca5a5db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 25 Jun 2026 10:38:31 -0300 Subject: [PATCH] Refine dashboard and RFV visuals --- src/analytics/dashboard.ts | 8 +- src/components/Layout.tsx | 10 +- src/dataService.ts | 10 +- src/index.css | 38 +++- src/pages/Dashboard.tsx | 269 ++++++++++++++++++++----- src/pages/Rfm.tsx | 390 +++++++++++++++++++++---------------- 6 files changed, 489 insertions(+), 236 deletions(-) diff --git a/src/analytics/dashboard.ts b/src/analytics/dashboard.ts index b9597b9..e182635 100644 --- a/src/analytics/dashboard.ts +++ b/src/analytics/dashboard.ts @@ -3,10 +3,10 @@ import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue } from import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters'; const COLORS = [ - '#10b981', '#3b82f6', '#8b5cf6', '#f43f5e', '#f97316', - '#06b6d4', '#ec4899', '#eab308', '#6366f1', '#14b8a6', - '#6ee7b7', '#93c5fd', '#c4b5fd', '#fda4af', '#fdba74', - '#67e8f9', '#f9a8d4', '#fde047', '#a5b4fc', '#5eead4' + '#25C2FF', '#18D6B5', '#A06BFF', '#FF6B8A', '#FFC247', + '#70E083', '#6EA5FF', '#FF6EC7', '#FF8A63', '#22C7E6', + '#B8E84D', '#FFA24A', '#B992FF', '#38E0C4', '#FFD166', + '#FF7A9B', '#82B8FF', '#52DFA0', '#D28BFF', '#F7B23B' ]; const globalColorMap: Record = {}; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index fb0eaed..34ad9b8 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -62,12 +62,12 @@ const Layout = () => { ]; return ( -
+
{/* Sidebar */}
-
- +
+
@@ -152,18 +252,36 @@ const Dashboard = () => {

Receita por Vendedor

-
- {revenueBySeller.length ? ( +
+ {revenueBySellerChartData.length ? ( - - - - + + + `${Number(value) / 1000}k`} /> + truncateLabel(String(value))} + /> } cursor={{ fill: '#222222' }} /> - - {revenueBySeller.map((entry) => ( - + + {revenueBySellerChartData.map((entry) => ( + ))} + formatCurrency(Number(value) || 0)} /> @@ -171,54 +289,83 @@ const Dashboard = () => {

Nenhuma venda com vendedor no período.

)}
-
- {revenueBySeller.map((entry) => ( -
-
- - {entry.name} -
- {formatCurrency(entry.value)} -
- ))} -
-

Pedidos por Vendedor

-
- {ordersBySeller.length ? ( +

Ticket Médio por Vendedor

+
+ {ticketBySellerChartData.length ? ( - - - - - } cursor={{ fill: '#222222' }} /> - - {ordersBySeller.map((entry) => ( - + + + `${Number(value) / 1000}k`} /> + truncateLabel(String(value))} + /> + } cursor={{ fill: '#222222' }} /> + + {ticketBySellerChartData.map((entry) => ( + ))} + formatCurrency(Number(value) || 0)} /> ) : ( -

Nenhum pedido com vendedor no período.

+

Nenhum ticket médio por vendedor no período.

)}
-
- {ordersBySeller.map((entry) => ( -
-
- - {entry.name} -
- {entry.value} -
- ))} -
+ {sellerPerformanceRows.length > 0 && ( +
+

Resumo por Vendedor

+
+ + + + + + + + + + + {sellerPerformanceRows.map((seller) => ( + + + + + + + ))} + +
VendedorReceitaPedidosTicket médio
+
+ + {seller.name} +
+
{formatCurrency(seller.revenue)}{seller.orders}{seller.orders ? formatCurrency(seller.revenue / seller.orders) : '-'}
+
+
+ )} +

Produtos Mais Vendidos

@@ -234,7 +381,15 @@ const Dashboard = () => { } cursor={{ fill: '#222222' }} /> { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}> {salesByProduct.map((entry) => ( - + ))} @@ -255,9 +410,17 @@ const Dashboard = () => {
- { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}> + { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}> {revenueByProduct.map((entry) => ( - + ))} } cursor={{ fill: '#222222' }} /> diff --git a/src/pages/Rfm.tsx b/src/pages/Rfm.tsx index fbdc4ba..a0a4bf0 100644 --- a/src/pages/Rfm.tsx +++ b/src/pages/Rfm.tsx @@ -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 { ChevronLeft, ChevronRight, Download, Filter, Loader2, Search, Users } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; @@ -8,16 +8,23 @@ import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types'; const emptyClients: RfmClient[] = []; const emptySegments: RfmSegment[] = []; -const segmentStyles: Record = { - champions: { accent: 'bg-emerald-500', bg: 'bg-emerald-500/10', text: 'text-emerald-400' }, - potential_loyalists: { accent: 'bg-sky-500', bg: 'bg-sky-500/10', text: 'text-sky-400' }, - new_customers: { accent: 'bg-cyan-500', bg: 'bg-cyan-500/10', text: 'text-cyan-400' }, - loyal_customers: { accent: 'bg-blue-500', bg: 'bg-blue-500/10', text: 'text-blue-400' }, - 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' }, - hibernating: { accent: 'bg-fuchsia-500', bg: 'bg-fuchsia-500/10', text: 'text-fuchsia-400' }, - lost: { accent: 'bg-zinc-500', bg: 'bg-zinc-500/10', text: 'text-zinc-400' } +type SegmentStyle = { + accent: string; + bg: string; + border: string; + text: string; +}; + +const segmentStyles: Record = { + champions: { accent: '#18D6B5', bg: 'rgba(24, 214, 181, 0.10)', border: 'rgba(24, 214, 181, 0.34)', text: '#18D6B5' }, + 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> = [ @@ -72,6 +79,23 @@ const formatCurrency = (value: number) => { 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 parseLocalDate = (value: string) => { @@ -135,6 +159,7 @@ const Rfm = () => { const [selectedSegmentKey, setSelectedSegmentKey] = useState(''); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(20); + const segmentFocusRef = useRef(null); const loadRfm = useCallback(async (range: DateRange, options?: { force?: boolean }) => { const cachedAnalytics = options?.force ? undefined : getCachedRfmAnalytics(range); @@ -166,6 +191,47 @@ const Rfm = () => { return () => clearInterval(intervalId); }, [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 clients = visibleAnalytics?.clients || emptyClients; const segments = useMemo(() => { @@ -315,167 +381,165 @@ const Rfm = () => {
-
-
-
-
-

Matriz RFV

-

Recência fixa, frequência por número de pedidos e valor relativo ao histórico.

-
-
- Menor prioridade - - Maior prioridade -
-
- -
-
-
-
-

Eixos

-

Recência / Perfil

-
+
+
+
+
+
+

Matriz RFV

+

Recência fixa, frequência por número de pedidos e valor relativo ao histórico.

- {[1, 2, 3].map(valueScore => ( -
-

Perfil {valueScore}

-

{scoreLabel(valueScore)}

+
+ Menor prioridade + + Maior prioridade
- ))} +
- {[3, 2, 1].map(recencyScore => ( -
-
+
+
+
-

Rec. {recencyScore}

-

{scoreLabel(recencyScore)}

-

- {recencyScore === 3 ? '0 a 7 dias' : recencyScore === 2 ? '8 a 15 dias' : '16+ dias'} -

+

Eixos

+

Recência / Perfil

+ {[1, 2, 3].map(valueScore => ( +
+

Perfil {valueScore}

+

{scoreLabel(valueScore)}

+
+ ))} - {[1, 2, 3].map(valueScore => { - const segment = segmentForCell(segments, recencyScore, valueScore); - const style = segmentStyles[segment?.key || 'lost']; - const isActive = segmentFilter === segment?.key; - const intensity = segment?.count ? 'opacity-100' : 'opacity-75'; - - return ( - - ); - })} -
- ))} -
-
-
- -
-

Segmentos

-
- {segments.map(segment => { - const style = segmentStyles[segment.key] || segmentStyles.lost; - return ( - - ); - })} -
-
+ + {[1, 2, 3].map(valueScore => { + const segment = segmentForCell(segments, recencyScore, valueScore); + const segmentKey = segment?.key || 'lost'; + const style = segmentStyles[segmentKey]; + const isActive = segmentFilter === segmentKey; + const intensity = segment?.count ? 'opacity-100' : 'opacity-75'; + + return ( + + ); + })} +
+ ))} +
+
+
+ +
+

Segmentos

+
+ {segments.map(segment => { + const style = segmentStyles[segment.key] || segmentStyles.lost; + return ( + + ); + })} +
+
+
+ + {selectedSegment && selectedSegmentInsights && ( +
+
+
+
+ +

Segmento selecionado

+
+

{selectedSegment.label}

+

{segmentActions[selectedSegment.key]}

+
+ +
+
+

Clientes

+

{selectedSegmentInsights.customerPercent.toFixed(1)}%

+
+
+

Receita período

+

{selectedSegmentInsights.revenuePercent.toFixed(1)}%

+
+
+

Ticket período

+

{formatCurrency(selectedSegmentInsights.averageTicket)}

+
+
+

Compra período

+

{selectedSegmentInsights.averageRecencyDays.toFixed(0)} dias

+
+
+
+
+ )}
- {selectedSegment && selectedSegmentInsights && ( -
-
-
-
- -

Segmento selecionado

-
-

{selectedSegment.label}

-

{segmentActions[selectedSegment.key]}

-
- -
-
-

Clientes

-

{selectedSegmentInsights.customerPercent.toFixed(1)}%

-
-
-

Receita período

-

{selectedSegmentInsights.revenuePercent.toFixed(1)}%

-
-
-

Ticket período

-

{formatCurrency(selectedSegmentInsights.averageTicket)}

-
-
-

Compra período

-

{selectedSegmentInsights.averageRecencyDays.toFixed(0)} dias

-
-
-
-
- )} -
@@ -552,8 +616,8 @@ const Rfm = () => { - - + + {client.segmentLabel}