796 lines
38 KiB
TypeScript
796 lines
38 KiB
TypeScript
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<string, SegmentStyle> = {
|
|
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<string, SegmentStyle> = {
|
|
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<Pick<RfmSegment, 'key' | 'label'>> = [
|
|
{ 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<string, string> = {
|
|
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<string, string> = {
|
|
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<string, string> = {
|
|
'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 }) => (
|
|
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-xs font-bold text-dark-text">
|
|
{value}
|
|
</span>
|
|
);
|
|
|
|
const RfmSkeleton = () => (
|
|
<div className="space-y-6" aria-label="Carregando RFV">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
{[0, 1, 2].map(item => (
|
|
<div key={`rfv-kpi-skeleton-${item}`} className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
|
<div className="skeleton h-4 w-36" />
|
|
<div className="skeleton mt-3 h-8 w-24" />
|
|
<div className="skeleton mt-3 h-3 w-44" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<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">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<div className="skeleton h-5 w-28" />
|
|
<div className="skeleton mt-2 h-3 w-72 max-w-full" />
|
|
</div>
|
|
<div className="skeleton h-3 w-44" />
|
|
</div>
|
|
<div className="mt-5 overflow-x-auto">
|
|
<div className="grid min-w-[720px] grid-cols-[104px_repeat(3,minmax(0,1fr))] gap-2">
|
|
{[0, 1, 2, 3].map(item => (
|
|
<div key={`rfv-matrix-head-skeleton-${item}`} className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
|
<div className="skeleton mx-auto h-3 w-16" />
|
|
<div className="skeleton mx-auto mt-2 h-3 w-20" />
|
|
</div>
|
|
))}
|
|
{[0, 1, 2].map(row => (
|
|
<div key={`rfv-matrix-row-skeleton-${row}`} className="contents">
|
|
{[0, 1, 2, 3].map(column => (
|
|
<div
|
|
key={`rfv-matrix-cell-skeleton-${row}-${column}`}
|
|
className={`min-h-28 rounded-xl border border-dark-border bg-dark-input/50 p-3 ${
|
|
column === 0 ? 'flex flex-col items-center justify-center text-center' : ''
|
|
}`}
|
|
>
|
|
{column === 0 ? (
|
|
<>
|
|
<div className="skeleton h-3 w-12" />
|
|
<div className="skeleton mt-2 h-4 w-12" />
|
|
<div className="skeleton mt-3 h-3 w-14" />
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="skeleton h-2 w-8" />
|
|
<div className="skeleton mt-5 h-4 w-28" />
|
|
<div className="skeleton mt-2 h-3 w-36" />
|
|
<div className="mt-5 grid grid-cols-2 gap-3">
|
|
<div className="skeleton h-5" />
|
|
<div className="skeleton h-5" />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
|
<div className="skeleton h-5 w-28" />
|
|
<div className="mt-5 space-y-2">
|
|
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => (
|
|
<div key={`rfv-segment-skeleton-${item}`} className="rounded-xl border border-dark-border p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex flex-1 items-center gap-3">
|
|
<div className="skeleton h-2.5 w-2.5 rounded-full" />
|
|
<div className="skeleton h-4 w-32" />
|
|
</div>
|
|
<div className="skeleton h-4 w-8" />
|
|
</div>
|
|
<div className="skeleton mt-3 h-3 w-48" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<section className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
|
<div className="flex flex-col gap-3 border-b border-dark-border p-4 sm:flex-row">
|
|
<div className="skeleton h-10 w-48" />
|
|
<div className="skeleton h-10 w-72" />
|
|
<div className="skeleton h-10 w-28 sm:ml-auto" />
|
|
</div>
|
|
<div className="p-6">
|
|
<div className="grid grid-cols-[1.5fr_1fr_120px_1fr_1fr_1fr_1fr] gap-5">
|
|
{[0, 1, 2, 3, 4, 5, 6].map(item => (
|
|
<div key={`rfv-table-head-skeleton-${item}`} className="skeleton h-3" />
|
|
))}
|
|
</div>
|
|
<div className="mt-6 space-y-5">
|
|
{[0, 1, 2, 3, 4, 5].map(row => (
|
|
<div key={`rfv-table-row-skeleton-${row}`} className="grid grid-cols-[1.5fr_1fr_120px_1fr_1fr_1fr_1fr] gap-5">
|
|
{[0, 1, 2, 3, 4, 5, 6].map(column => (
|
|
<div key={`rfv-table-cell-skeleton-${row}-${column}`} className="skeleton h-4" />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
|
|
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<RfmAnalytics | null>(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<Record<string, {
|
|
averageRecencyDays: number;
|
|
averageTicket: number;
|
|
customerPercent: number;
|
|
revenuePercent: number;
|
|
topClients: RfmClient[];
|
|
}>>((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 (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold mb-2 text-dark-text">RFV</h1>
|
|
<p className="text-dark-muted font-medium">Segmentação de clientes por recência, frequência e valor.</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row flex-wrap gap-3 items-start sm:items-center">
|
|
<DateRangePicker
|
|
dateRange={dateRange}
|
|
onChange={setDateRange}
|
|
refreshInterval={refreshInterval}
|
|
setRefreshInterval={setRefreshInterval}
|
|
onManualRefresh={() => void loadRfm(dateRange, { force: true })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<RefreshStatus isRefreshing={isRefreshing} />
|
|
|
|
{shouldShowSkeleton ? (
|
|
<RfmSkeleton />
|
|
) : (
|
|
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
|
<p className="text-dark-muted text-sm font-medium mb-1">Clientes no Período</p>
|
|
<h3 className="text-3xl font-bold text-dark-text">{clients.length}</h3>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Segmento RFV calculado até o fim do período</p>
|
|
</div>
|
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
|
<p className="text-dark-muted text-sm font-medium mb-1">Receita no Período</p>
|
|
<h3 className="text-3xl font-bold text-dark-text">{formatCurrency(totals.totalRevenue)}</h3>
|
|
</div>
|
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
|
<p className="text-dark-muted text-sm font-medium mb-1">Segmentos com Compra</p>
|
|
<h3 className="text-3xl font-bold text-dark-text">{totals.activeSegments}</h3>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
|
Maior receita no período: {totals.topSegment?.label || 'N/A'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-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">
|
|
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-dark-text">Matriz RFV</h2>
|
|
<p className="text-sm font-medium text-dark-muted">Recência fixa, frequência por número de pedidos e valor relativo ao histórico.</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs font-semibold text-dark-muted">
|
|
<span>Menor prioridade</span>
|
|
<span
|
|
className="h-2 w-12 rounded-full"
|
|
style={{ background: 'linear-gradient(90deg, #FF6B8A, #FFC247, #18D6B5)' }}
|
|
/>
|
|
<span>Maior prioridade</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<div className="grid min-w-[720px] grid-cols-[104px_repeat(3,minmax(0,1fr))] gap-2">
|
|
<div className="flex items-center justify-center rounded-xl border border-dark-border bg-dark-input/60 px-3 py-2.5 text-center">
|
|
<div>
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Eixos</p>
|
|
<p className="text-xs font-semibold text-dark-text">Recência / Perfil</p>
|
|
</div>
|
|
</div>
|
|
{[1, 2, 3].map(valueScore => (
|
|
<div key={valueScore} className="rounded-xl border border-dark-border bg-dark-input/70 px-3 py-2.5 text-center">
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Perfil {valueScore}</p>
|
|
<p className="text-xs font-bold text-dark-text">{scoreLabel(valueScore)}</p>
|
|
</div>
|
|
))}
|
|
|
|
{[3, 2, 1].map(recencyScore => (
|
|
<div key={recencyScore} className="contents">
|
|
<div className="flex min-h-28 items-center justify-center rounded-xl border border-dark-border bg-dark-input/70 px-2.5 text-center">
|
|
<div>
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Rec. {recencyScore}</p>
|
|
<p className="text-xs font-bold text-dark-text">{scoreLabel(recencyScore)}</p>
|
|
<p className="mt-1 text-[10px] font-semibold text-dark-muted">
|
|
{recencyScore === 3 ? '0 a 7 dias' : recencyScore === 2 ? '8 a 15 dias' : '16+ dias'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{[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 (
|
|
<button
|
|
key={`${recencyScore}-${valueScore}`}
|
|
onClick={() => selectSegment(segmentKey)}
|
|
className={`group min-h-28 rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5 cursor-pointer ${intensity}`}
|
|
style={segmentPanelStyle(style, isActive)}
|
|
>
|
|
<div className="flex h-full flex-col justify-between gap-3">
|
|
<div>
|
|
<div className="mb-2 flex items-start justify-between gap-2">
|
|
<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">
|
|
R{recencyScore} P{valueScore}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm font-bold text-dark-text">{segment?.label}</p>
|
|
<p className="mt-1 truncate text-xs font-medium text-dark-muted">
|
|
{segmentDescriptions[segmentKey]}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Clientes</p>
|
|
<p className="text-xl font-bold leading-none" style={{ color: style.text }}>{segment?.count || 0}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Receita período</p>
|
|
<p className="truncate text-xs font-bold text-dark-text">{formatCurrency(segment?.totalRevenue || 0)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
|
<h2 className="mb-5 text-lg font-bold text-dark-text">Segmentos</h2>
|
|
<div className="space-y-2">
|
|
{segments.map(segment => {
|
|
const style = segmentStyles[segment.key] || segmentStyles.lost;
|
|
return (
|
|
<button
|
|
key={segment.key}
|
|
onClick={() => selectSegment(segment.key)}
|
|
className={`w-full rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${
|
|
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 min-w-0 items-center gap-3">
|
|
<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>
|
|
</div>
|
|
<span className="text-sm font-bold" style={{ color: style.text }}>{segment.count}</span>
|
|
</div>
|
|
<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
|
|
</p>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{selectedSegment && selectedSegmentInsights && (
|
|
<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="min-w-0">
|
|
<div className="mb-1 flex items-center gap-2">
|
|
<span className="h-2.5 w-2.5 rounded-full" style={segmentDotStyle(selectedSegmentStyle)} />
|
|
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: selectedSegmentStyle.text }}>Segmento selecionado</p>
|
|
</div>
|
|
<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>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:w-[560px]">
|
|
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Clientes</p>
|
|
<p className="mt-1 text-base font-bold text-dark-text">{selectedSegmentInsights.customerPercent.toFixed(1)}%</p>
|
|
</div>
|
|
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Receita período</p>
|
|
<p className="mt-1 text-base font-bold text-dark-text">{selectedSegmentInsights.revenuePercent.toFixed(1)}%</p>
|
|
</div>
|
|
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Ticket período</p>
|
|
<p className="mt-1 text-base font-bold text-dark-text">{formatCurrency(selectedSegmentInsights.averageTicket)}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Compra período</p>
|
|
<p className="mt-1 text-base font-bold text-dark-text">{selectedSegmentInsights.averageRecencyDays.toFixed(0)} dias</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
|
|
<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 sm:flex-row gap-3">
|
|
<div className="relative">
|
|
<Filter className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-muted" />
|
|
<select
|
|
value={segmentFilter}
|
|
onChange={(event) => {
|
|
setSegmentFilter(event.target.value);
|
|
setCurrentPage(1);
|
|
}}
|
|
className="appearance-none bg-dark-input border border-dark-border text-dark-text text-sm rounded-xl pl-9 pr-8 py-2.5 focus:outline-none focus:border-brand-primary transition-colors shadow-sm cursor-pointer"
|
|
>
|
|
<option value="all">Todos os segmentos</option>
|
|
{segments.map(segment => (
|
|
<option key={segment.key} value={segment.key}>{segment.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar cliente..."
|
|
value={searchTerm}
|
|
onChange={(event) => {
|
|
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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleExport}
|
|
disabled={!filteredClients.length}
|
|
className="flex items-center justify-center gap-2 bg-dark-input border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
|
title="Exportar para CSV"
|
|
>
|
|
<Download size={16} className="text-brand-primary" />
|
|
Exportar
|
|
</button>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="bg-dark-header border-b border-dark-border text-dark-muted">
|
|
<tr>
|
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Cliente</th>
|
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Segmento</th>
|
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">RFV</th>
|
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Última Compra</th>
|
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Pedidos no Período</th>
|
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Ticket no Período</th>
|
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Receita no Período</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-dark-border">
|
|
{paginatedClients.map((client: RfmClient) => {
|
|
const style = segmentStyles[client.segmentKey] || segmentStyles.lost;
|
|
return (
|
|
<tr key={client.customerKey} className="hover:bg-dark-input/50 transition-colors">
|
|
<td className="px-6 py-3">
|
|
<Link to={`/clients/${encodeURIComponent(client.clientToken)}`} className="flex items-center gap-3 hover:text-brand-primary transition-colors">
|
|
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-dark-input text-dark-muted">
|
|
<Users className="h-4 w-4" />
|
|
</span>
|
|
<span>
|
|
<span className="block font-bold text-dark-text">{client.name}</span>
|
|
<span className="block text-xs font-medium text-dark-muted">{client.phone || 'N/A'}</span>
|
|
</span>
|
|
</Link>
|
|
</td>
|
|
<td className="px-6 py-3">
|
|
<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={segmentDotStyle(style)} />
|
|
{client.segmentLabel}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<ScoreBadge value={client.recencyScore} />
|
|
<ScoreBadge value={client.frequencyScore} />
|
|
<ScoreBadge value={client.monetaryScore} />
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-3 text-dark-muted font-medium">
|
|
{formatDate(client.lastPurchaseDate)}
|
|
<span className="block text-xs">{formatPeriodRecency(client.lastPurchaseDate)}</span>
|
|
</td>
|
|
<td className="px-6 py-3 text-dark-text font-bold">{client.frequency}</td>
|
|
<td className="px-6 py-3 text-dark-text font-bold">{formatCurrency(client.frequency ? client.monetary / client.frequency : 0)}</td>
|
|
<td className="px-6 py-3 text-brand-primary font-bold">{formatCurrency(client.monetary)}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{!filteredClients.length && (
|
|
<div className="p-8 text-center text-sm font-semibold text-dark-muted">
|
|
Nenhum cliente encontrado.
|
|
</div>
|
|
)}
|
|
|
|
<PaginationControls
|
|
totalItems={filteredClients.length}
|
|
currentPage={safeCurrentPage}
|
|
totalPages={totalPages}
|
|
pageSize={itemsPerPage}
|
|
pageSizeOptions={[10, 20, 50, 100]}
|
|
itemLabel="clientes"
|
|
pageSizeLabel="clientes por página"
|
|
startIndex={startIndex}
|
|
endIndex={Math.min(startIndex + itemsPerPage, filteredClients.length)}
|
|
onPageChange={setCurrentPage}
|
|
onPageSizeChange={(pageSize) => {
|
|
setItemsPerPage(pageSize);
|
|
setCurrentPage(1);
|
|
}}
|
|
className="px-6 py-4 border-t border-dark-border"
|
|
/>
|
|
</section>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Rfm;
|