diff --git a/backend/routes/analyticsRoutes.js b/backend/routes/analyticsRoutes.js index fc1d9cb..f8c9011 100644 --- a/backend/routes/analyticsRoutes.js +++ b/backend/routes/analyticsRoutes.js @@ -3,7 +3,8 @@ const { verifyToken } = require('../auth'); const { getClientAnalytics, getDashboardAnalytics, - getProductAnalytics + getProductAnalytics, + getRfmAnalytics } = require('../services/analyticsService'); const router = express.Router(); @@ -40,4 +41,13 @@ router.get('/analytics/clients', verifyToken, async (req, res) => { } }); +router.get('/analytics/rfm', verifyToken, async (req, res) => { + try { + res.json(await getRfmAnalytics(getRange(req.query))); + } catch (error) { + console.error('Error fetching RFM analytics:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + module.exports = router; diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 46fd914..e559630 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -55,6 +55,52 @@ const buildDateFilter = ({ start, end } = {}) => { const toNumber = (value) => Number(value || 0); +const RFM_SEGMENTS = { + '3-3': { key: 'champions', label: 'Champions' }, + '3-2': { key: 'potential_loyalists', label: 'Potenciais Leais' }, + '3-1': { key: 'new_customers', label: 'Novos Clientes' }, + '2-3': { key: 'loyal_customers', label: 'Clientes Leais' }, + '2-2': { key: 'need_attention', label: 'Precisam de Atenção' }, + '2-1': { key: 'about_to_sleep', label: 'Quase Dormindo' }, + '1-3': { key: 'at_risk', label: 'Em Risco' }, + '1-2': { key: 'hibernating', label: 'Hibernando' }, + '1-1': { key: 'lost', label: 'Perdidos' } +}; + +const scoreTertile = (value, values, higherIsBetter = true) => { + const numericValues = values.map(toNumber).filter(Number.isFinite); + if (!numericValues.length) return 1; + if (numericValues.length === 1) return 3; + + const min = Math.min(...numericValues); + const max = Math.max(...numericValues); + if (min === max) return 2; + + const sorted = [...numericValues].sort((a, b) => higherIsBetter ? a - b : b - a); + const index = sorted.findIndex(candidate => candidate === toNumber(value)); + const percentile = index / (sorted.length - 1); + + return Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1)); +}; + +const getRfmSegment = (recencyScore, valueScore) => { + return RFM_SEGMENTS[`${recencyScore}-${valueScore}`] || RFM_SEGMENTS['1-1']; +}; + +const buildRfmSegments = (clients) => { + return Object.values(RFM_SEGMENTS).map(segment => { + const segmentClients = clients.filter(client => client.segmentKey === segment.key); + const totalRevenue = segmentClients.reduce((sum, client) => sum + client.monetary, 0); + + return { + ...segment, + count: segmentClients.length, + totalRevenue, + averageRevenue: segmentClients.length ? totalRevenue / segmentClients.length : 0 + }; + }); +}; + const getDashboardAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); const [totalsResult, salesResult, revenueResult] = await Promise.all([ @@ -174,10 +220,85 @@ const getClientAnalytics = async (range = {}) => { })); }; +const getRfmAnalytics = async (range = {}) => { + const { params, whereClause } = buildDateFilter(range); + const result = await pool.query(` + SELECT + MAX(cliente_nome) as name, + cliente_fone as phone, + COALESCE(SUM(quantidade * valor_unitario), 0) as monetary, + COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency, + COALESCE(SUM(quantidade), 0) as quantity_purchased, + MAX(data_pedido_date) as last_purchase_date, + GREATEST((CURRENT_DATE - MAX(data_pedido_date))::int, 0) as recency_days + FROM orders + ${whereClause} + AND cliente_fone IS NOT NULL + AND cliente_fone != '' + GROUP BY cliente_fone + ORDER BY monetary DESC + LIMIT 1000; + `, params); + + const baseClients = result.rows.map(row => ({ + name: row.name, + phone: row.phone, + monetary: toNumber(row.monetary), + frequency: toNumber(row.frequency), + quantityPurchased: toNumber(row.quantity_purchased), + lastPurchaseDate: row.last_purchase_date, + recencyDays: toNumber(row.recency_days) + })); + + const recencyValues = baseClients.map(client => client.recencyDays); + const frequencyValues = baseClients.map(client => client.frequency); + const monetaryValues = baseClients.map(client => client.monetary); + + const clients = baseClients.map(client => { + const recencyScore = scoreTertile(client.recencyDays, recencyValues, false); + const frequencyScore = scoreTertile(client.frequency, frequencyValues, true); + const monetaryScore = scoreTertile(client.monetary, monetaryValues, true); + const valueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2))); + const segment = getRfmSegment(recencyScore, valueScore); + + return { + ...client, + recencyScore, + frequencyScore, + monetaryScore, + valueScore, + rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`, + segmentKey: segment.key, + segmentLabel: segment.label + }; + }).sort((a, b) => { + if (b.recencyScore !== a.recencyScore) return b.recencyScore - a.recencyScore; + if (b.valueScore !== a.valueScore) return b.valueScore - a.valueScore; + return b.monetary - a.monetary; + }); + + return { + range: { + start: normalizeDateParam(range.start), + end: normalizeDateParam(range.end) + }, + clients, + segments: buildRfmSegments(clients), + matrix: { + recencyScores: [3, 2, 1], + valueScores: [1, 2, 3] + } + }; +}; + module.exports = { buildDateFilter, + buildRfmSegments, + getRfmAnalytics, + getRfmSegment, getClientAnalytics, getDashboardAnalytics, getProductAnalytics, - normalizeDateParam + normalizeDateParam, + scoreTertile }; diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 1163291..7d757d3 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -1,7 +1,13 @@ const assert = require('node:assert/strict'); const test = require('node:test'); -const { buildDateFilter, normalizeDateParam } = require('../services/analyticsService'); +const { + buildRfmSegments, + buildDateFilter, + getRfmSegment, + normalizeDateParam, + scoreTertile +} = require('../services/analyticsService'); test('normalizeDateParam accepts strict ISO dates', () => { assert.equal(normalizeDateParam('2026-05-28'), '2026-05-28'); @@ -32,3 +38,42 @@ test('buildDateFilter ignores invalid bounds', () => { 'WHERE data_pedido_date IS NOT NULL AND data_pedido_date <= $1::date' ); }); + +test('scoreTertile scores higher values higher by default', () => { + const values = [10, 20, 30, 40, 50]; + + assert.equal(scoreTertile(10, values), 1); + assert.equal(scoreTertile(30, values), 2); + assert.equal(scoreTertile(50, values), 3); +}); + +test('scoreTertile can score lower values higher for recency', () => { + const recencyDays = [2, 10, 20, 40, 80]; + + assert.equal(scoreTertile(2, recencyDays, false), 3); + assert.equal(scoreTertile(20, recencyDays, false), 2); + assert.equal(scoreTertile(80, recencyDays, false), 1); +}); + +test('getRfmSegment maps the 3x3 RFM matrix', () => { + assert.deepEqual(getRfmSegment(3, 3), { key: 'champions', label: 'Champions' }); + assert.deepEqual(getRfmSegment(2, 2), { key: 'need_attention', label: 'Precisam de Atenção' }); + assert.deepEqual(getRfmSegment(1, 1), { key: 'lost', label: 'Perdidos' }); +}); + +test('buildRfmSegments summarizes segment count and revenue', () => { + const segments = buildRfmSegments([ + { segmentKey: 'champions', monetary: 100 }, + { segmentKey: 'champions', monetary: 50 }, + { segmentKey: 'lost', monetary: 25 } + ]); + + const champions = segments.find(segment => segment.key === 'champions'); + const lost = segments.find(segment => segment.key === 'lost'); + + assert.equal(champions.count, 2); + assert.equal(champions.totalRevenue, 150); + assert.equal(champions.averageRevenue, 75); + assert.equal(lost.count, 1); + assert.equal(lost.totalRevenue, 25); +}); diff --git a/src/App.tsx b/src/App.tsx index f0f7f40..48ed4f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ const ProductDetails = React.lazy(() => import('./pages/ProductDetails')); const Clients = React.lazy(() => import('./pages/Clients')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); const Campaigns = React.lazy(() => import('./pages/Campaigns')); +const Rfm = React.lazy(() => import('./pages/Rfm')); const Login = React.lazy(() => import('./pages/Login')); function PrivateRoute({ children }: { children: React.ReactNode }) { @@ -38,6 +39,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/src/analytics/clients.ts b/src/analytics/clients.ts index 33d60a5..2427d6e 100644 --- a/src/analytics/clients.ts +++ b/src/analytics/clients.ts @@ -2,15 +2,27 @@ import type { DateRange, OrderData } from '../types'; import { parseOrderDate } from '../dataService'; import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue } from './orders'; -export type ClientSortOption = 'recent' | 'spent_desc' | 'spent_asc' | 'items_desc' | 'items_asc'; +export type ClientSortOption = + | 'recent' + | 'spent_desc' + | 'spent_asc' + | 'ticket_desc' + | 'ticket_asc' + | 'rfm_priority' + | 'items_desc' + | 'items_asc'; export interface ClientSummary { name: string; phone: string; totalSpent: number; + averageTicket: number; totalItems: number; orderCount: number; lastPurchase: number; + clientType: string; + rfmScore: string; + rfmPriority: number; } export interface GroupedClientOrder { @@ -27,6 +39,64 @@ export interface ClientDetailsMetrics { clientPhone: string; } +const scoreTertile = (value: number, values: number[], higherIsBetter = true) => { + const numericValues = values.filter(Number.isFinite); + if (!numericValues.length) return 1; + if (numericValues.length === 1) return 3; + + const min = Math.min(...numericValues); + const max = Math.max(...numericValues); + if (min === max) return 2; + + const sorted = [...numericValues].sort((a, b) => higherIsBetter ? a - b : b - a); + const index = sorted.findIndex(candidate => candidate === value); + const percentile = index / (sorted.length - 1); + + return Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1)); +}; + +const getClientType = (recencyScore: number, valueScore: number) => { + const segmentMap: Record = { + '3-3': 'Campeão', + '3-2': 'Potencial Leal', + '3-1': 'Novo Cliente', + '2-3': 'Cliente Leal', + '2-2': 'Precisa de Atenção', + '2-1': 'Quase Dormindo', + '1-3': 'Em Risco', + '1-2': 'Hibernando', + '1-1': 'Perdido' + }; + + return segmentMap[`${recencyScore}-${valueScore}`] || 'Perdido'; +}; + +const enrichClientsWithRfmType = ( + clients: Array>, + dateRange: DateRange +): ClientSummary[] => { + const rangeEndTime = dateRange.end.getTime(); + const recencyValues = clients.map(client => Math.max(0, Math.floor((rangeEndTime - client.lastPurchase) / 86400000))); + const frequencyValues = clients.map(client => client.orderCount); + const monetaryValues = clients.map(client => client.totalSpent); + + return clients.map((client, index) => { + const recencyScore = scoreTertile(recencyValues[index], recencyValues, false); + const frequencyScore = scoreTertile(client.orderCount, frequencyValues); + const monetaryScore = scoreTertile(client.totalSpent, monetaryValues); + const valueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2))); + const rfmPriority = (recencyScore * 100) + (valueScore * 10) + monetaryScore; + + return { + ...client, + averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0, + clientType: getClientType(recencyScore, valueScore), + rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`, + rfmPriority + }; + }); +}; + export const buildClientsSummary = ( ordersData: OrderData[], dateRange: DateRange, @@ -64,14 +134,14 @@ export const buildClientsSummary = ( }); const normalizedSearch = searchTerm.trim().toLowerCase(); - const clients = Object.keys(clientMap).map(name => ({ + const clients = enrichClientsWithRfmType(Object.keys(clientMap).map(name => ({ name, phone: clientMap[name].phone, totalSpent: clientMap[name].totalSpent, totalItems: clientMap[name].totalItems, orderCount: clientMap[name].uniqueOrders.size, lastPurchase: clientMap[name].lastPurchase - })); + })), dateRange); const filteredClients = normalizedSearch ? clients.filter(client => client.name.toLowerCase().includes(normalizedSearch)) @@ -82,6 +152,9 @@ export const buildClientsSummary = ( case 'recent': return b.lastPurchase - a.lastPurchase; case 'spent_desc': return b.totalSpent - a.totalSpent; case 'spent_asc': return a.totalSpent - b.totalSpent; + case 'ticket_desc': return b.averageTicket - a.averageTicket; + case 'ticket_asc': return a.averageTicket - b.averageTicket; + case 'rfm_priority': return b.rfmPriority - a.rfmPriority; case 'items_desc': return b.totalItems - a.totalItems; case 'items_asc': return a.totalItems - b.totalItems; default: return 0; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 337e976..2a9657d 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useCallback, useState, useEffect } from 'react'; import { Outlet, Link, useLocation } from 'react-router-dom'; -import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone } from 'lucide-react'; +import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone, Grid3X3 } from 'lucide-react'; import type { DateRange, OrderData, StockData } from '../types'; import { fetchData, fetchStock, logout } from '../dataService'; @@ -83,6 +83,7 @@ const Layout = () => { { name: 'Dashboard', href: '/graph', icon: LayoutDashboard }, { name: 'Produtos', href: '/products', icon: Package }, { name: 'Clientes', href: '/clients', icon: Users }, + { name: 'RFM', href: '/rfm', icon: Grid3X3 }, { name: 'Campanhas', href: '/campaigns', icon: Megaphone }, ]; diff --git a/src/dataService.ts b/src/dataService.ts index 58d84b2..38b06ec 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, DashboardAnalytics, DateRange, OrderData, StockData } from './types'; +import type { CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, DashboardAnalytics, DateRange, OrderData, RfmAnalytics, StockData } from './types'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -111,6 +111,21 @@ export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise => { + try { + const params = new URLSearchParams({ + start: formatDateParam(dateRange.start), + end: formatDateParam(dateRange.end) + }); + const response = await authFetch(`/analytics/rfm?${params.toString()}`); + if (!response.ok) return null; + return await response.json(); + } catch (error) { + console.error('Fetch RFM analytics failed', error); + return null; + } +}; + export const fetchCampaigns = async (): Promise => { try { const response = await authFetch('/campaigns'); diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index d3dabf7..355f299 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -1,10 +1,66 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react'; -import type { OrderData, DateRange } from '../types'; -import { exportToCSV } from '../dataService'; +import type { OrderData, DateRange, RfmAnalytics, RfmClient } from '../types'; +import { exportToCSV, fetchRfmAnalytics } from '../dataService'; import DateRangePicker from '../components/DateRangePicker'; -import { buildClientsSummary, type ClientSortOption } from '../analytics/clients'; +import { buildClientsSummary, type ClientSortOption, type ClientSummary } from '../analytics/clients'; + +const clientTypeStyles: Record = { + 'Campeão': 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', + 'Potencial Leal': 'border-sky-500/30 bg-sky-500/15 text-sky-300', + 'Novo Cliente': 'border-cyan-500/30 bg-cyan-500/15 text-cyan-300', + 'Cliente Leal': 'border-blue-500/30 bg-blue-500/15 text-blue-300', + 'Precisa de Atenção': 'border-amber-500/30 bg-amber-500/15 text-amber-300', + 'Quase Dormindo': 'border-orange-500/30 bg-orange-500/15 text-orange-300', + 'Em Risco': 'border-rose-500/30 bg-rose-500/15 text-rose-300', + 'Hibernando': 'border-fuchsia-500/30 bg-fuchsia-500/15 text-fuchsia-300', + 'Perdido': 'border-zinc-500/30 bg-zinc-500/15 text-zinc-300' +}; + +const clientTypes = [ + 'Campeão', + 'Potencial Leal', + 'Novo Cliente', + 'Cliente Leal', + 'Precisa de Atenção', + 'Quase Dormindo', + 'Em Risco', + 'Hibernando', + 'Perdido' +]; + +const backendSegmentToClientType: Record = { + champions: 'Campeão', + potential_loyalists: 'Potencial Leal', + new_customers: 'Novo Cliente', + loyal_customers: 'Cliente Leal', + need_attention: 'Precisa de Atenção', + about_to_sleep: 'Quase Dormindo', + at_risk: 'Em Risco', + hibernating: 'Hibernando', + lost: 'Perdido' +}; + +const getRfmPriority = (client: Pick) => { + return (client.recencyScore * 100) + (client.valueScore * 10) + client.monetaryScore; +}; + +const sortClients = (clients: ClientSummary[], sortBy: ClientSortOption) => { + return [...clients].sort((a, b) => { + switch (sortBy) { + case 'recent': return b.lastPurchase - a.lastPurchase; + case 'spent_desc': return b.totalSpent - a.totalSpent; + case 'spent_asc': return a.totalSpent - b.totalSpent; + case 'ticket_desc': return b.averageTicket - a.averageTicket; + case 'ticket_asc': return a.averageTicket - b.averageTicket; + case 'rfm_priority': return b.rfmPriority - a.rfmPriority; + case 'items_desc': return b.totalItems - a.totalItems; + case 'items_asc': return a.totalItems - b.totalItems; + default: return 0; + } + }); +}; const Clients = () => { const { dateRange, setDateRange, ordersData } = useOutletContext<{ @@ -14,14 +70,69 @@ const Clients = () => { }>(); const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState('recent'); + const [clientTypeFilter, setClientTypeFilter] = useState('all'); + const [rfmAnalytics, setRfmAnalytics] = useState(null); // Pagination state const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); + useEffect(() => { + let isMounted = true; + + const loadRfm = async () => { + const data = await fetchRfmAnalytics(dateRange); + if (isMounted) setRfmAnalytics(data); + }; + + void loadRfm(); + + return () => { + isMounted = false; + }; + }, [dateRange]); + + const rfmClientsByIdentity = useMemo(() => { + const map = new Map(); + + (rfmAnalytics?.clients || []).forEach(client => { + if (client.phone) map.set(`phone:${client.phone}`, client); + map.set(`name:${client.name.toLowerCase()}`, client); + }); + + return map; + }, [rfmAnalytics]); + + const allClientsData = useMemo(() => { + const localClients = buildClientsSummary(ordersData, dateRange, searchTerm, sortBy); + const enrichedClients = localClients.map(client => { + const rfmClient = (client.phone && rfmClientsByIdentity.get(`phone:${client.phone}`)) || + rfmClientsByIdentity.get(`name:${client.name.toLowerCase()}`); + + if (!rfmClient) return client; + + return { + ...client, + clientType: backendSegmentToClientType[rfmClient.segmentKey] || client.clientType, + rfmScore: rfmClient.rfmScore, + rfmPriority: getRfmPriority(rfmClient) + }; + }); + + return sortClients(enrichedClients, sortBy); + }, [searchTerm, sortBy, ordersData, dateRange, rfmClientsByIdentity]); + const clientsData = useMemo(() => { - return buildClientsSummary(ordersData, dateRange, searchTerm, sortBy); - }, [searchTerm, sortBy, ordersData, dateRange]); + if (clientTypeFilter === 'all') return allClientsData; + return allClientsData.filter(client => client.clientType === clientTypeFilter); + }, [allClientsData, clientTypeFilter]); + + const clientTypeCounts = useMemo(() => { + return clientTypes.reduce>((counts, type) => { + counts[type] = allClientsData.filter(client => client.clientType === type).length; + return counts; + }, {}); + }, [allClientsData]); // Pagination logic const totalPages = Math.ceil(clientsData.length / itemsPerPage); @@ -32,6 +143,11 @@ const Clients = () => { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); }; + const selectClientType = (type: string) => { + setClientTypeFilter(current => current === type ? 'all' : type); + setCurrentPage(1); + }; + return (
@@ -62,6 +178,9 @@ const Clients = () => { + + + @@ -86,7 +205,10 @@ const Clients = () => { const exportData = clientsData.map(client => ({ 'Nome do Cliente': client.name, 'Telefone/WhatsApp': client.phone || 'N/A', + 'Tipo de Cliente': client.clientType, + 'RFM': client.rfmScore, 'Total Gasto (R$)': client.totalSpent.toFixed(2).replace('.', ','), + 'Ticket Médio (R$)': client.averageTicket.toFixed(2).replace('.', ','), 'Produtos Comprados': client.totalItems, 'Total de Pedidos': client.orderCount, 'Última Compra': new Date(client.lastPurchase).toLocaleDateString('pt-BR') @@ -102,6 +224,35 @@ const Clients = () => {
+
+
+
+

Tipos de Clientes

+

+ Classificação RFM sincronizada com a página RFM quando disponível. +

+
+
+ +
+ {clientTypes.map(type => ( + + ))} +
+
+
@@ -109,7 +260,10 @@ const Clients = () => { + + + @@ -123,7 +277,25 @@ const Clients = () => { + + + diff --git a/src/pages/Rfm.tsx b/src/pages/Rfm.tsx new file mode 100644 index 0000000..d860944 --- /dev/null +++ b/src/pages/Rfm.tsx @@ -0,0 +1,740 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useOutletContext } from 'react-router-dom'; +import { Database, Download, Filter, Loader2, Search, Sparkles, Users } from 'lucide-react'; +import DateRangePicker from '../components/DateRangePicker'; +import { exportToCSV, fetchRfmAnalytics } from '../dataService'; +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' } +}; + +const rfmSegmentDefinitions: Array> = [ + { key: 'champions', label: 'Champions' }, + { key: 'potential_loyalists', label: 'Potenciais Leais' }, + { key: 'new_customers', label: 'Novos Clientes' }, + { key: 'loyal_customers', label: 'Clientes Leais' }, + { key: 'need_attention', label: 'Precisam de Atenção' }, + { key: 'about_to_sleep', label: 'Quase Dormindo' }, + { key: 'at_risk', label: 'Em Risco' }, + { key: 'hibernating', label: 'Hibernando' }, + { key: 'lost', label: 'Perdidos' } +]; + +const segmentDescriptions: Record = { + champions: 'Recentes, frequentes e valiosos', + potential_loyalists: 'Recentes com bom potencial', + new_customers: 'Primeira compra recente', + loyal_customers: 'Valiosos, mas menos recentes', + need_attention: 'Base intermediária para nutrir', + about_to_sleep: 'Baixo valor começando a esfriar', + at_risk: 'Valiosos sem compra recente', + hibernating: 'Sem compra recente e valor médio', + lost: 'Baixa atividade e distante' +}; + +const segmentActions: Record = { + champions: 'Oferecer acesso antecipado, benefícios VIP e lançamentos.', + potential_loyalists: 'Estimular a próxima compra com recomendações personalizadas.', + new_customers: 'Enviar boas-vindas e incentivo para a segunda compra.', + loyal_customers: 'Manter relacionamento com ofertas relevantes e recorrentes.', + need_attention: 'Reativar interesse com campanha leve e produtos recentes.', + about_to_sleep: 'Enviar lembrete antes que o cliente esfrie completamente.', + at_risk: 'Priorizar recuperação com oferta forte ou contato direto.', + hibernating: 'Testar reativação de baixo custo com mensagem objetiva.', + lost: 'Evitar alto investimento; usar apenas campanhas ocasionais.' +}; + +const segmentByScore: Record = { + '3-3': 'champions', + '3-2': 'potential_loyalists', + '3-1': 'new_customers', + '2-3': 'loyal_customers', + '2-2': 'need_attention', + '2-1': 'about_to_sleep', + '1-3': 'at_risk', + '1-2': 'hibernating', + '1-1': 'lost' +}; + +const demoClients: RfmClient[] = [ + { + name: 'Ana Paula Ribeiro', + phone: '+55 11 90000-0001', + monetary: 4820, + frequency: 12, + quantityPurchased: 38, + lastPurchaseDate: '2026-06-10', + recencyDays: 1, + recencyScore: 3, + frequencyScore: 3, + monetaryScore: 3, + valueScore: 3, + rfmScore: '333', + segmentKey: 'champions', + segmentLabel: 'Champions' + }, + { + name: 'Bruno Ferreira', + phone: '+55 11 90000-0002', + monetary: 1680, + frequency: 5, + quantityPurchased: 14, + lastPurchaseDate: '2026-06-09', + recencyDays: 2, + recencyScore: 3, + frequencyScore: 2, + monetaryScore: 2, + valueScore: 2, + rfmScore: '322', + segmentKey: 'potential_loyalists', + segmentLabel: 'Potenciais Leais' + }, + { + name: 'Carla Mendes', + phone: '+55 11 90000-0003', + monetary: 249, + frequency: 1, + quantityPurchased: 2, + lastPurchaseDate: '2026-06-08', + recencyDays: 3, + recencyScore: 3, + frequencyScore: 1, + monetaryScore: 1, + valueScore: 1, + rfmScore: '311', + segmentKey: 'new_customers', + segmentLabel: 'Novos Clientes' + }, + { + name: 'Diego Nascimento', + phone: '+55 11 90000-0004', + monetary: 3920, + frequency: 10, + quantityPurchased: 31, + lastPurchaseDate: '2026-05-24', + recencyDays: 18, + recencyScore: 2, + frequencyScore: 3, + monetaryScore: 3, + valueScore: 3, + rfmScore: '233', + segmentKey: 'loyal_customers', + segmentLabel: 'Clientes Leais' + }, + { + name: 'Elisa Costa', + phone: '+55 11 90000-0005', + monetary: 1210, + frequency: 4, + quantityPurchased: 9, + lastPurchaseDate: '2026-05-18', + recencyDays: 24, + recencyScore: 2, + frequencyScore: 2, + monetaryScore: 2, + valueScore: 2, + rfmScore: '222', + segmentKey: 'need_attention', + segmentLabel: 'Precisam de Atenção' + }, + { + name: 'Felipe Azevedo', + phone: '+55 11 90000-0006', + monetary: 390, + frequency: 2, + quantityPurchased: 3, + lastPurchaseDate: '2026-05-12', + recencyDays: 30, + recencyScore: 2, + frequencyScore: 1, + monetaryScore: 1, + valueScore: 1, + rfmScore: '211', + segmentKey: 'about_to_sleep', + segmentLabel: 'Quase Dormindo' + }, + { + name: 'Gabriela Martins', + phone: '+55 11 90000-0007', + monetary: 5350, + frequency: 14, + quantityPurchased: 44, + lastPurchaseDate: '2026-03-28', + recencyDays: 75, + recencyScore: 1, + frequencyScore: 3, + monetaryScore: 3, + valueScore: 3, + rfmScore: '133', + segmentKey: 'at_risk', + segmentLabel: 'Em Risco' + }, + { + name: 'Henrique Lima', + phone: '+55 11 90000-0008', + monetary: 980, + frequency: 4, + quantityPurchased: 8, + lastPurchaseDate: '2026-03-06', + recencyDays: 97, + recencyScore: 1, + frequencyScore: 2, + monetaryScore: 2, + valueScore: 2, + rfmScore: '122', + segmentKey: 'hibernating', + segmentLabel: 'Hibernando' + }, + { + name: 'Isabela Rocha', + phone: '+55 11 90000-0009', + monetary: 120, + frequency: 1, + quantityPurchased: 1, + lastPurchaseDate: '2026-01-29', + recencyDays: 133, + recencyScore: 1, + frequencyScore: 1, + monetaryScore: 1, + valueScore: 1, + rfmScore: '111', + segmentKey: 'lost', + segmentLabel: 'Perdidos' + } +]; + +const demoSegments: RfmSegment[] = rfmSegmentDefinitions.map(segment => { + const segmentClients = demoClients.filter(client => client.segmentKey === segment.key); + const totalRevenue = segmentClients.reduce((sum, client) => sum + client.monetary, 0); + + return { + ...segment, + count: segmentClients.length, + totalRevenue, + averageRevenue: segmentClients.length ? totalRevenue / segmentClients.length : 0 + }; +}); + +const demoAnalytics: RfmAnalytics = { + range: { + start: '2026-01-01', + end: '2026-06-11' + }, + clients: demoClients, + segments: demoSegments, + matrix: { + recencyScores: [3, 2, 1], + valueScores: [1, 2, 3] + } +}; + +const formatCurrency = (value: number) => { + return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); +}; + +const formatDate = (value: string) => { + if (!value) return 'N/A'; + return new Date(value).toLocaleDateString('pt-BR'); +}; + +const scoreLabel = (score: number) => { + if (score === 3) return 'Alto'; + if (score === 2) return 'Médio'; + return 'Baixo'; +}; + +const getSegment = (segments: RfmSegment[], key: string) => { + return segments.find(segment => segment.key === key); +}; + +const segmentForCell = (segments: RfmSegment[], recencyScore: number, valueScore: number) => { + const matrixKey = `${recencyScore}-${valueScore}`; + return getSegment(segments, segmentByScore[matrixKey]); +}; + +const ScoreBadge = ({ value }: { value: number }) => ( + + {value} + +); + +const Rfm = () => { + const { dateRange, setDateRange, refreshInterval, setRefreshInterval } = useOutletContext<{ + dateRange: DateRange; + setDateRange: (range: DateRange) => void; + refreshInterval: number; + setRefreshInterval: (interval: number) => void; + }>(); + + const [analytics, setAnalytics] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [segmentFilter, setSegmentFilter] = useState('all'); + const [selectedSegmentKey, setSelectedSegmentKey] = useState(''); + const [isDemoMode, setIsDemoMode] = useState(false); + + const loadRfm = useCallback(async (range: DateRange) => { + setIsLoading(true); + const data = await fetchRfmAnalytics(range); + setAnalytics(data); + setIsLoading(false); + }, []); + + useEffect(() => { + // RFM is calculated server-side from the selected date range. + // eslint-disable-next-line react-hooks/set-state-in-effect + void loadRfm(dateRange); + }, [dateRange, loadRfm]); + + useEffect(() => { + if (refreshInterval === 0) return; + + const intervalId = setInterval(() => { + void loadRfm(dateRange); + }, refreshInterval); + + return () => clearInterval(intervalId); + }, [dateRange, loadRfm, refreshInterval]); + + const visibleAnalytics = isDemoMode ? demoAnalytics : analytics; + const clients = visibleAnalytics?.clients || emptyClients; + const segments = useMemo(() => { + const sourceSegments = visibleAnalytics?.segments || emptySegments; + return rfmSegmentDefinitions.map(definition => { + const segment = sourceSegments.find(item => item.key === definition.key); + return segment || { + ...definition, + count: 0, + totalRevenue: 0, + averageRevenue: 0 + }; + }); + }, [visibleAnalytics]); + + const toggleDemoMode = () => { + setIsDemoMode(current => !current); + setSegmentFilter('all'); + setSearchTerm(''); + setSelectedSegmentKey(''); + }; + + const filteredClients = useMemo(() => { + const normalizedSearch = searchTerm.trim().toLowerCase(); + + return clients.filter(client => { + const matchesSegment = segmentFilter === 'all' || client.segmentKey === segmentFilter; + const matchesSearch = !normalizedSearch || + client.name.toLowerCase().includes(normalizedSearch) || + client.phone.toLowerCase().includes(normalizedSearch); + + return matchesSegment && matchesSearch; + }); + }, [clients, searchTerm, segmentFilter]); + + const totals = useMemo(() => { + const totalRevenue = clients.reduce((sum, client) => sum + client.monetary, 0); + const activeSegments = segments.filter(segment => segment.count > 0).length; + const topSegment = [...segments].sort((a, b) => b.totalRevenue - a.totalRevenue)[0]; + + return { totalRevenue, activeSegments, topSegment }; + }, [clients, segments]); + + const segmentInsights = useMemo(() => { + return segments.reduce>((insights, segment) => { + const segmentClients = clients.filter(client => client.segmentKey === segment.key); + const totalOrders = segmentClients.reduce((sum, client) => sum + client.frequency, 0); + const totalRecency = segmentClients.reduce((sum, client) => sum + client.recencyDays, 0); + + insights[segment.key] = { + averageRecencyDays: segmentClients.length ? totalRecency / segmentClients.length : 0, + averageTicket: totalOrders ? segment.totalRevenue / totalOrders : 0, + customerPercent: clients.length ? (segment.count / clients.length) * 100 : 0, + revenuePercent: totals.totalRevenue ? (segment.totalRevenue / totals.totalRevenue) * 100 : 0, + topClients: [...segmentClients].sort((a, b) => b.monetary - a.monetary).slice(0, 3) + }; + + return insights; + }, {}); + }, [clients, segments, totals.totalRevenue]); + + const selectedSegment = selectedSegmentKey ? getSegment(segments, selectedSegmentKey) : undefined; + const selectedSegmentStyle = segmentStyles[selectedSegment?.key || 'lost'] || segmentStyles.lost; + const selectedSegmentInsights = selectedSegment ? segmentInsights[selectedSegment.key] : null; + + const handleExport = () => { + const exportData = filteredClients.map(client => ({ + Cliente: client.name, + Telefone: client.phone, + Segmento: client.segmentLabel, + RFM: client.rfmScore, + Recencia: client.recencyScore, + Frequencia: client.frequencyScore, + Monetario: client.monetaryScore, + 'Dias desde ultima compra': client.recencyDays, + Pedidos: client.frequency, + 'Ticket medio (R$)': (client.frequency ? client.monetary / client.frequency : 0).toFixed(2).replace('.', ','), + 'Total gasto (R$)': client.monetary.toFixed(2).replace('.', ','), + 'Ultima compra': formatDate(client.lastPurchaseDate) + })); + + exportToCSV(exportData, `rfm_${new Date().toISOString().split('T')[0]}.csv`); + }; + + return ( +
+
+
+

RFM

+

Segmentação de clientes por recência, frequência e valor monetário.

+
+ +
+ + + void loadRfm(dateRange)} + /> +
+
+ + {isDemoMode && ( +
+ Visualização com dados fictícios. Nenhum cliente real ou dado do banco foi alterado. +
+ )} + + {!isDemoMode && isLoading && ( +
+ + Atualizando RFM +
+ )} + +
+
+

Clientes Classificados

+

{clients.length}

+
+
+

Receita no Período

+

{formatCurrency(totals.totalRevenue)}

+
+
+

Segmentos Ativos

+

{totals.activeSegments}

+

+ Principal: {totals.topSegment?.label || 'N/A'} +

+
+
+ +
+
+
+
+

Matriz RFM

+

Distribuição dos clientes por recência e valor.

+
+
+ Menor prioridade + + Maior prioridade +
+
+ +
+
+
+
+

Eixos

+

Recência / Valor

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

Valor {valueScore}

+

{scoreLabel(valueScore)}

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

Rec. {recencyScore}

+

{scoreLabel(recencyScore)}

+

+ {recencyScore === 3 ? 'Compra recente' : recencyScore === 2 ? 'Intermediário' : 'Distante'} +

+
+
+ + {[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 ( + + ); + })} +
+
+
+ + {selectedSegment && selectedSegmentInsights && ( +
+
+
+
+ +

Segmento selecionado

+
+

{selectedSegment.label}

+

{segmentActions[selectedSegment.key]}

+
+ +
+
+

Clientes

+

{selectedSegmentInsights.customerPercent.toFixed(1)}%

+
+
+

Receita

+

{selectedSegmentInsights.revenuePercent.toFixed(1)}%

+
+
+

Ticket

+

{formatCurrency(selectedSegmentInsights.averageTicket)}

+
+
+

Recência

+

{selectedSegmentInsights.averageRecencyDays.toFixed(0)} dias

+
+
+
+
+ )} + +
+
+
+
+ + +
+ +
+ + setSearchTerm(event.target.value)} + className="w-full sm:w-72 bg-dark-input border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm" + /> +
+
+ + +
+ +
+
Posição Nome do ClienteTipoRFM Total GastoTicket Médio Produtos Comprados Ações
{client.name} + + {client.clientType} + + +
+ {client.rfmScore.split('').map((score, scoreIndex) => ( + + {score} + + ))} +
+
{formatCurrency(client.totalSpent)}{formatCurrency(client.averageTicket)} {client.totalItems} un. ({client.orderCount} {client.orderCount === 1 ? 'pedido' : 'pedidos'})
+ + + + + + + + + + + + + {filteredClients.map((client: RfmClient) => { + const style = segmentStyles[client.segmentKey] || segmentStyles.lost; + return ( + + + + + + + + + + ); + })} + +
ClienteSegmentoRFMÚltima CompraPedidosTicket MédioTotal Gasto
+ {isDemoMode ? ( +
+ + + + + {client.name} + {client.phone || 'N/A'} + +
+ ) : ( + + + + + + {client.name} + {client.phone || 'N/A'} + + + )} +
+ + + {client.segmentLabel} + + +
+ + + +
+
+ {formatDate(client.lastPurchaseDate)} + {client.recencyDays} dias + {client.frequency}{formatCurrency(client.frequency ? client.monetary / client.frequency : 0)}{formatCurrency(client.monetary)}
+
+ + {!filteredClients.length && ( +
+ Nenhum cliente encontrado. +
+ )} + +
+ ); +}; + +export default Rfm; diff --git a/src/types.ts b/src/types.ts index 54ab872..716d3b0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,6 +40,44 @@ export interface DashboardAnalytics { }>; } +export interface RfmClient { + name: string; + phone: string; + monetary: number; + frequency: number; + quantityPurchased: number; + lastPurchaseDate: string; + recencyDays: number; + recencyScore: 1 | 2 | 3; + frequencyScore: 1 | 2 | 3; + monetaryScore: 1 | 2 | 3; + valueScore: 1 | 2 | 3; + rfmScore: string; + segmentKey: string; + segmentLabel: string; +} + +export interface RfmSegment { + key: string; + label: string; + count: number; + totalRevenue: number; + averageRevenue: number; +} + +export interface RfmAnalytics { + range: { + start: string | null; + end: string | null; + }; + clients: RfmClient[]; + segments: RfmSegment[]; + matrix: { + recencyScores: number[]; + valueScores: number[]; + }; +} + export type CampaignStatus = 'pending' | 'processing' | 'sent' | 'failed' | 'skipped'; export interface CampaignQueueItem {