import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; import { Download, Filter, Search, Users } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; import RefreshStatus from '../components/RefreshStatus'; import { exportToCSV, fetchRfmAnalytics, getCachedRfmAnalytics } from '../dataService'; import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types'; const emptyClients: RfmClient[] = []; const emptySegments: RfmSegment[] = []; type SegmentStyle = { accent: string; bg: string; border: string; text: string; }; type ThemeMode = 'dark' | 'offwhite'; const darkSegmentStyles: 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 offwhiteSegmentStyles: Record = { champions: { accent: '#138B75', bg: 'rgba(19, 139, 117, 0.10)', border: 'rgba(19, 139, 117, 0.34)', text: '#138B75' }, potential_loyalists: { accent: '#5F8F12', bg: 'rgba(95, 143, 18, 0.11)', border: 'rgba(95, 143, 18, 0.34)', text: '#5F8F12' }, new_customers: { accent: '#0B7EA8', bg: 'rgba(11, 126, 168, 0.10)', border: 'rgba(11, 126, 168, 0.34)', text: '#0B7EA8' }, loyal_customers: { accent: '#356DCC', bg: 'rgba(53, 109, 204, 0.10)', border: 'rgba(53, 109, 204, 0.34)', text: '#356DCC' }, need_attention: { accent: '#A36C00', bg: 'rgba(163, 108, 0, 0.11)', border: 'rgba(163, 108, 0, 0.34)', text: '#A36C00' }, about_to_sleep: { accent: '#B75B12', bg: 'rgba(183, 91, 18, 0.11)', border: 'rgba(183, 91, 18, 0.34)', text: '#B75B12' }, at_risk: { accent: '#C73656', bg: 'rgba(199, 54, 86, 0.10)', border: 'rgba(199, 54, 86, 0.34)', text: '#C73656' }, hibernating: { accent: '#8C52D6', bg: 'rgba(140, 82, 214, 0.10)', border: 'rgba(140, 82, 214, 0.34)', text: '#8C52D6' }, lost: { accent: '#757C82', bg: 'rgba(117, 124, 130, 0.08)', border: 'rgba(117, 124, 130, 0.26)', text: '#757C82' } }; 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, recorrentes e valiosos', potential_loyalists: 'Recentes e em evolução', new_customers: 'Primeira compra recente', loyal_customers: 'Bom histórico, compra entre 8 e 15 dias', need_attention: 'Perfil médio, compra entre 8 e 15 dias', about_to_sleep: 'Perfil baixo, compra entre 8 e 15 dias', at_risk: 'Histórico forte, sem compra entre 16 e 29 dias', hibernating: 'Sem compra entre 16 e 29 dias', lost: 'Sem compra há 30 dias ou mais' }; 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 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) => { if (!value) return null; const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/); if (match) { const [, year, month, day] = match; return new Date(Number(year), Number(month) - 1, Number(day)); } const parsedDate = new Date(value); return Number.isNaN(parsedDate.getTime()) ? null : parsedDate; }; const startOfDay = (date: Date) => { const nextDate = new Date(date); nextDate.setHours(0, 0, 0, 0); return nextDate; }; const formatDate = (value: string) => { if (!value) return 'N/A'; const date = parseLocalDate(value); return date ? date.toLocaleDateString('pt-BR') : 'N/A'; }; 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 RfmSkeleton = () => (
{[0, 1, 2].map(item => (
))}
{[0, 1, 2, 3].map(item => (
))} {[0, 1, 2].map(row => (
{[0, 1, 2, 3].map(column => (
{column === 0 ? ( <>
) : ( <>
)}
))}
))}
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => (
))}
{[0, 1, 2, 3, 4, 5, 6].map(item => (
))}
{[0, 1, 2, 3, 4, 5].map(row => (
{[0, 1, 2, 3, 4, 5, 6].map(column => (
))}
))}
); const Rfm = () => { const { dateRange, setDateRange, refreshInterval, setRefreshInterval, themeMode } = useOutletContext<{ dateRange: DateRange; setDateRange: (range: DateRange) => void; refreshInterval: number; setRefreshInterval: (interval: number) => void; themeMode?: ThemeMode; }>(); const segmentStyles = themeMode === 'offwhite' ? offwhiteSegmentStyles : darkSegmentStyles; const initialCachedAnalytics = getCachedRfmAnalytics(dateRange); const [analytics, setAnalytics] = useState(initialCachedAnalytics || null); const [isLoading, setIsLoading] = useState(!initialCachedAnalytics); const [searchTerm, setSearchTerm] = useState(''); const [segmentFilter, setSegmentFilter] = useState('all'); const [selectedSegmentKey, setSelectedSegmentKey] = useState(''); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(20); const loadRfm = useCallback(async (range: DateRange, options?: { force?: boolean }) => { const cachedAnalytics = options?.force ? undefined : getCachedRfmAnalytics(range); if (cachedAnalytics) { setAnalytics(cachedAnalytics); setIsLoading(false); return; } setIsLoading(true); const data = await fetchRfmAnalytics(range, undefined, options); setAnalytics(data); setIsLoading(false); }, []); useEffect(() => { // RFV 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, { force: true }); }, refreshInterval); 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(); } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [clearSegmentFocus, segmentFilter, selectedSegmentKey]); const visibleAnalytics = 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 getPeriodRecencyDays = useCallback((lastPurchaseDate: string) => { const purchaseDate = parseLocalDate(lastPurchaseDate); if (!purchaseDate) return 0; const purchaseDay = startOfDay(purchaseDate).getTime(); const rangeEndDay = startOfDay(dateRange.end).getTime(); return Math.max(0, Math.floor((rangeEndDay - purchaseDay) / DAY_MS)); }, [dateRange.end]); const formatPeriodRecency = useCallback((lastPurchaseDate: string) => { const days = getPeriodRecencyDays(lastPurchaseDate); if (days === 0) return 'Hoje'; if (days === 1) return 'Ontem'; return `${days} dias`; }, [getPeriodRecencyDays]); 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 totalPages = Math.ceil(filteredClients.length / itemsPerPage); const safeCurrentPage = Math.min(currentPage, totalPages || 1); const startIndex = (safeCurrentPage - 1) * itemsPerPage; const paginatedClients = filteredClients.slice(startIndex, startIndex + itemsPerPage); const totals = useMemo(() => { const totalRevenue = clients.reduce((sum, client) => sum + client.monetary, 0); const periodClientCount = clients.filter(client => client.frequency > 0 || client.monetary > 0).length; const activeSegments = segments.filter(segment => segment.count > 0).length; const topSegment = [...segments].sort((a, b) => b.totalRevenue - a.totalRevenue)[0]; return { totalRevenue, periodClientCount, 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 + getPeriodRecencyDays(client.lastPurchaseDate), 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, getPeriodRecencyDays, 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, RFV: client.rfmScore, Recencia: client.recencyScore, Frequencia: client.frequencyScore, Valor: client.monetaryScore, 'Compra no periodo': formatPeriodRecency(client.lastPurchaseDate), 'Pedidos no periodo': client.frequency, 'Ticket medio no periodo (R$)': (client.frequency ? client.monetary / client.frequency : 0).toFixed(2).replace('.', ','), 'Receita no periodo (R$)': client.monetary.toFixed(2).replace('.', ','), 'Ultima compra': formatDate(client.lastPurchaseDate) })); exportToCSV(exportData, `rfv_${new Date().toISOString().split('T')[0]}.csv`); }; const shouldShowSkeleton = isLoading && !analytics; const isRefreshing = isLoading && Boolean(analytics); return (

RFV

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

void loadRfm(dateRange, { force: true })} />
{shouldShowSkeleton ? ( ) : (

Clientes no Período

{clients.length}

Segmento RFV calculado até o fim do período

Receita no Período

{formatCurrency(totals.totalRevenue)}

Segmentos com Compra

{totals.activeSegments}

Maior receita no período: {totals.topSegment?.label || 'N/A'}

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

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

Perfil {valueScore}

{scoreLabel(valueScore)}

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

Rec. {recencyScore}

{scoreLabel(recencyScore)}

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

{[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

)}
{ setSearchTerm(event.target.value); setCurrentPage(1); }} 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" />
{paginatedClients.map((client: RfmClient) => { const style = segmentStyles[client.segmentKey] || segmentStyles.lost; return ( ); })}
Cliente Segmento RFV Última Compra Pedidos no Período Ticket no Período Receita no Período
{client.name} {client.phone || 'N/A'} {client.segmentLabel}
{formatDate(client.lastPurchaseDate)} {formatPeriodRecency(client.lastPurchaseDate)} {client.frequency} {formatCurrency(client.frequency ? client.monetary / client.frequency : 0)} {formatCurrency(client.monetary)}
{!filteredClients.length && (
Nenhum cliente encontrado.
)} { setItemsPerPage(pageSize); setCurrentPage(1); }} className="px-6 py-4 border-t border-dark-border" />
)}
); }; export default Rfm;