import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react'; import RefreshStatus from '../components/RefreshStatus'; import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, DateRange, RfmAnalytics, RfmClient } from '../types'; import { fetchClientAnalytics, fetchClientFilterOptions, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService'; import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges'; import type { ClientSortOption, ClientSummary } from '../analytics/clients'; import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters'; type ClientTypeStyle = { bg: string; border: string; text: string; }; type ThemeMode = 'dark' | 'offwhite'; const darkClientTypeStyles: Record = { 'Sem análise': { bg: 'rgba(140, 146, 152, 0.08)', border: 'rgba(140, 146, 152, 0.24)', text: '#b3b7bb' }, 'Campeão': { bg: 'rgba(24, 214, 181, 0.10)', border: 'rgba(24, 214, 181, 0.34)', text: '#18D6B5' }, 'Potencial Leal': { bg: 'rgba(163, 230, 53, 0.10)', border: 'rgba(163, 230, 53, 0.34)', text: '#A3E635' }, 'Novo Cliente': { bg: 'rgba(37, 194, 255, 0.10)', border: 'rgba(37, 194, 255, 0.34)', text: '#25C2FF' }, 'Cliente Leal': { bg: 'rgba(110, 165, 255, 0.10)', border: 'rgba(110, 165, 255, 0.34)', text: '#6EA5FF' }, 'Precisa de Atenção': { bg: 'rgba(255, 194, 71, 0.11)', border: 'rgba(255, 194, 71, 0.34)', text: '#FFC247' }, 'Quase Dormindo': { bg: 'rgba(255, 138, 61, 0.11)', border: 'rgba(255, 138, 61, 0.34)', text: '#FF8A3D' }, 'Em Risco': { bg: 'rgba(255, 93, 125, 0.11)', border: 'rgba(255, 93, 125, 0.34)', text: '#FF5D7D' }, 'Hibernando': { bg: 'rgba(199, 125, 255, 0.11)', border: 'rgba(199, 125, 255, 0.34)', text: '#C77DFF' }, 'Perdido': { bg: 'rgba(140, 146, 152, 0.08)', border: 'rgba(140, 146, 152, 0.24)', text: '#b3b7bb' } }; const offwhiteClientTypeStyles: Record = { 'Sem análise': { bg: 'rgba(117, 124, 130, 0.08)', border: 'rgba(117, 124, 130, 0.26)', text: '#757C82' }, 'Campeão': { bg: 'rgba(19, 139, 117, 0.10)', border: 'rgba(19, 139, 117, 0.34)', text: '#138B75' }, 'Potencial Leal': { bg: 'rgba(95, 143, 18, 0.11)', border: 'rgba(95, 143, 18, 0.34)', text: '#5F8F12' }, 'Novo Cliente': { bg: 'rgba(11, 126, 168, 0.10)', border: 'rgba(11, 126, 168, 0.34)', text: '#0B7EA8' }, 'Cliente Leal': { bg: 'rgba(53, 109, 204, 0.10)', border: 'rgba(53, 109, 204, 0.34)', text: '#356DCC' }, 'Precisa de Atenção': { bg: 'rgba(163, 108, 0, 0.11)', border: 'rgba(163, 108, 0, 0.34)', text: '#A36C00' }, 'Quase Dormindo': { bg: 'rgba(183, 91, 18, 0.11)', border: 'rgba(183, 91, 18, 0.34)', text: '#B75B12' }, 'Em Risco': { bg: 'rgba(199, 54, 86, 0.10)', border: 'rgba(199, 54, 86, 0.34)', text: '#C73656' }, 'Hibernando': { bg: 'rgba(140, 82, 214, 0.10)', border: 'rgba(140, 82, 214, 0.34)', text: '#8C52D6' }, 'Perdido': { bg: 'rgba(117, 124, 130, 0.08)', border: 'rgba(117, 124, 130, 0.26)', text: '#757C82' } }; const clientTypePillStyle = (style: ClientTypeStyle) => ({ backgroundColor: style.bg, borderColor: style.border, color: style.text }); const clientTypes = [ 'Sem análise', '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 emptyClientFilters: ClientMetadataFilters = { marketplace: '', canal_venda: '', seller: '' }; const emptyClientFilterOptions: ClientFilterOptions = { marketplaces: [], salesChannels: [], sellers: [] }; const dateFilterPresets = [ { value: 'today', label: 'Hoje', getRange: () => rangeForDay(new Date()) }, { value: 'yesterday', label: 'Ontem', getRange: () => rangeForPreviousDay() }, { value: '7d', label: 'Últimos 7 dias', getRange: () => rangeForLastDays(7) }, { value: '30d', label: 'Últimos 30 dias', getRange: () => rangeForLastDays(30) }, { value: 'month', label: 'Este mês', getRange: () => { const end = endOfLocalDay(new Date()); return { start: startOfLocalDay(new Date(end.getFullYear(), end.getMonth(), 1)), end }; } }, { value: 'previous-month', label: 'Mês passado', getRange: () => { const today = new Date(); return { start: startOfLocalDay(new Date(today.getFullYear(), today.getMonth() - 1, 1)), end: endOfLocalDay(new Date(today.getFullYear(), today.getMonth(), 0)) }; } }, { value: '90d', label: 'Últimos 90 dias', getRange: () => rangeForLastDays(90) }, { value: 'year', label: 'Este ano', getRange: () => { const end = endOfLocalDay(new Date()); return { start: startOfLocalDay(new Date(end.getFullYear(), 0, 1)), end }; } }, { value: 'all', label: 'Todo o período', getRange: () => ({ start: startOfLocalDay(new Date(2000, 0, 1)), end: endOfLocalDay(new Date()) }) } ]; const filterSelectClassName = "w-full h-9 bg-dark-input border border-dark-border text-dark-text text-sm rounded-lg px-3 focus:outline-none focus:border-brand-primary transition-colors cursor-pointer"; const ClientsTableSkeleton = () => (
{[0, 1, 2, 3, 4, 5, 6, 7].map(item => (
))}
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
))}
); const Clients = () => { const { dateRange, setDateRange, themeMode } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, themeMode?: ThemeMode }>(); const clientTypeStyles = themeMode === 'offwhite' ? offwhiteClientTypeStyles : darkClientTypeStyles; const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState('recent'); const [clientTypeFilter, setClientTypeFilter] = useState('all'); const [metadataFilters, setMetadataFilters] = useState(emptyClientFilters); const initialFilterOptions = getCachedClientFilterOptions(dateRange); const initialClientAnalytics = getCachedClientAnalytics(dateRange, emptyClientFilters); const initialRfmAnalytics = getCachedRfmAnalytics(dateRange, emptyClientFilters); const [filterOptions, setFilterOptions] = useState(initialFilterOptions || emptyClientFilterOptions); const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); const filterMenuRef = useRef(null); const [rfmAnalytics, setRfmAnalytics] = useState(initialRfmAnalytics || null); const [clientAnalytics, setClientAnalytics] = useState(initialClientAnalytics || []); const [isLoading, setIsLoading] = useState(!initialClientAnalytics); // Pagination state const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); useEffect(() => { let isMounted = true; const loadClients = async () => { const cachedOptions = getCachedClientFilterOptions(dateRange); const cachedClients = getCachedClientAnalytics(dateRange, metadataFilters); const cachedRfm = getCachedRfmAnalytics(dateRange, metadataFilters); const hasCachedClients = Boolean(cachedClients); if (isMounted) { if (cachedOptions) setFilterOptions(cachedOptions); if (cachedClients) setClientAnalytics(cachedClients); setRfmAnalytics(cachedRfm || null); setIsLoading(!hasCachedClients); } const filterOptionsPromise = fetchClientFilterOptions(dateRange, { force: true }); const clientsPromise = fetchClientAnalytics(dateRange, metadataFilters); const rfmPromise = fetchRfmAnalytics(dateRange, metadataFilters); const [options, clientsData] = await Promise.all([filterOptionsPromise, clientsPromise]); if (isMounted) { setFilterOptions(options); setClientAnalytics(clientsData); setIsLoading(false); } const rfmData = await rfmPromise; if (isMounted) { setRfmAnalytics(rfmData); } }; void loadClients(); return () => { isMounted = false; }; }, [dateRange, metadataFilters]); useEffect(() => { if (!isFilterMenuOpen) return; const handlePointerDown = (event: PointerEvent) => { if (!filterMenuRef.current?.contains(event.target as Node)) { setIsFilterMenuOpen(false); } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setIsFilterMenuOpen(false); } }; document.addEventListener('pointerdown', handlePointerDown); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('pointerdown', handlePointerDown); document.removeEventListener('keydown', handleKeyDown); }; }, [isFilterMenuOpen]); const allClientsData = useMemo(() => { const normalizedSearch = searchTerm.trim().toLowerCase(); const rfmByCustomerKey = new Map((rfmAnalytics?.clients || []).map(client => [client.customerKey, client])); const clients = clientAnalytics.map((client): ClientSummary => { const rawClient = client as ClientAnalyticsItem & { client_token?: string; customer_key?: string; last_purchase_date?: string; order_count?: number; quantity_purchased?: number; total_spent?: number; }; const rfmClient = rfmByCustomerKey.get(client.customerKey); const orderCount = Number(client.orderCount ?? rawClient.order_count ?? 0); const totalSpent = Number(client.totalSpent ?? rawClient.total_spent ?? 0); const totalItems = Number(client.quantityPurchased ?? rawClient.quantity_purchased ?? 0); const lastPurchaseDate = client.lastPurchaseDate ?? rawClient.last_purchase_date; return { customerKey: client.customerKey ?? rawClient.customer_key ?? '', clientToken: client.clientToken ?? rawClient.client_token ?? '', name: client.name, phone: client.phone, totalSpent, averageTicket: orderCount ? totalSpent / orderCount : 0, totalItems, orderCount, lastPurchase: lastPurchaseDate ? new Date(lastPurchaseDate).getTime() : 0, clientType: rfmClient ? (backendSegmentToClientType[rfmClient.segmentKey] || rfmClient.segmentLabel) : 'Sem análise', rfmScore: rfmClient?.rfmScore || '000', rfmPriority: rfmClient ? getRfmPriority(rfmClient) : 0 }; }); const filteredClients = normalizedSearch ? clients.filter(client => client.name.toLowerCase().includes(normalizedSearch) || client.phone.toLowerCase().includes(normalizedSearch) ) : clients; return sortClients(filteredClients, sortBy); }, [clientAnalytics, searchTerm, sortBy, rfmAnalytics]); const clientsData = useMemo(() => { 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); const startIndex = (currentPage - 1) * itemsPerPage; const paginatedData = clientsData.slice(startIndex, startIndex + itemsPerPage); const formatCurrency = (value: number) => { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); }; const formatNumber = (value: number) => { return new Intl.NumberFormat('pt-BR').format(value); }; const datePresetValue = useMemo(() => { const currentStart = formatDateParam(dateRange.start); const currentEnd = formatDateParam(dateRange.end); const preset = dateFilterPresets.find(option => { const range = option.getRange(); return formatDateParam(range.start) === currentStart && formatDateParam(range.end) === currentEnd; }); return preset?.value || 'custom'; }, [dateRange]); const updateDatePreset = (value: string) => { if (value === 'custom') return; const preset = dateFilterPresets.find(option => option.value === value); if (!preset) return; setDateRange(preset.getRange()); setCurrentPage(1); }; const updateDateStart = (value: string) => { const nextStart = parseLocalDateInput(value); if (!nextStart) return; const start = startOfLocalDay(nextStart); const end = dateRange.end < start ? endOfLocalDay(start) : dateRange.end; setDateRange({ start, end }); setCurrentPage(1); }; const updateDateEnd = (value: string) => { const nextEnd = parseLocalDateInput(value); if (!nextEnd) return; const end = endOfLocalDay(nextEnd); const start = dateRange.start > end ? startOfLocalDay(end) : dateRange.start; setDateRange({ start, end }); setCurrentPage(1); }; const selectClientType = (type: string) => { setClientTypeFilter(current => current === type ? 'all' : type); setCurrentPage(1); }; const updateMetadataFilter = (key: keyof ClientMetadataFilters, value: string) => { setMetadataFilters(current => ({ ...current, [key]: value })); setCurrentPage(1); }; const resetMetadataFilters = () => { setMetadataFilters(emptyClientFilters); setClientTypeFilter('all'); setSortBy('recent'); setCurrentPage(1); }; const getSellerOptionLabel = (seller: ClientFilterOptions['sellers'][number]) => { return formatDisplayName(removeTrailingSellerId(seller.name || seller.id)); }; const marketplaceOptions = useMemo(() => { if (!metadataFilters.marketplace || filterOptions.marketplaces.includes(metadataFilters.marketplace)) { return filterOptions.marketplaces; } return [metadataFilters.marketplace, ...filterOptions.marketplaces]; }, [filterOptions.marketplaces, metadataFilters.marketplace]); const salesChannelOptions = useMemo(() => { if (!metadataFilters.canal_venda || filterOptions.salesChannels.includes(metadataFilters.canal_venda)) { return filterOptions.salesChannels; } return [metadataFilters.canal_venda, ...filterOptions.salesChannels]; }, [filterOptions.salesChannels, metadataFilters.canal_venda]); const sellerOptions = useMemo(() => { if (!metadataFilters.seller || filterOptions.sellers.some(seller => seller.value === metadataFilters.seller)) { return filterOptions.sellers; } const fallbackSeller = metadataFilters.seller.startsWith('id:') ? { value: metadataFilters.seller, id: metadataFilters.seller.slice(3), name: metadataFilters.seller.slice(3) } : { value: metadataFilters.seller, id: '', name: metadataFilters.seller.replace(/^name:/, '') }; return [fallbackSeller, ...filterOptions.sellers]; }, [filterOptions.sellers, metadataFilters.seller]); const activeMetadataFilters = useMemo(() => { const filters = []; const selectedSeller = sellerOptions.find(seller => seller.value === metadataFilters.seller); if (metadataFilters.marketplace) { filters.push({ key: 'marketplace' as const, label: `Marketplace: ${metadataFilters.marketplace}` }); } if (metadataFilters.canal_venda) { filters.push({ key: 'canal_venda' as const, label: `Canal: ${metadataFilters.canal_venda}` }); } if (metadataFilters.seller) { filters.push({ key: 'seller' as const, label: `Vendedor: ${selectedSeller ? getSellerOptionLabel(selectedSeller) : metadataFilters.seller}` }); } return filters; }, [metadataFilters, sellerOptions]); const activeFilterCount = activeMetadataFilters.length + (clientTypeFilter === 'all' ? 0 : 1) + (sortBy === 'recent' ? 0 : 1); const hasActiveFilters = activeFilterCount > 0; const isRefreshing = isLoading && clientAnalytics.length > 0; return (

Clientes

Métricas de engajamento e histórico de consumo dos seus clientes.

{ setSearchTerm(e.target.value); setCurrentPage(1); }} className="w-full bg-dark-card 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" />
{isFilterMenuOpen && (

Filtros

)}
{isLoading && clientAnalytics.length === 0 ? ( ) : (
{paginatedData.map((client, index) => { const clientTypeStyle = clientTypeStyles[client.clientType] || clientTypeStyles.Perdido; return ( ); })}
Posição Nome do Cliente Tipo RFV Total Gasto Ticket Médio Produtos Comprados Ações
{startIndex + index + 1} {client.name} {client.clientType}
{client.rfmScore.split('').map((score, scoreIndex) => ( {score} ))}
{formatCurrency(client.totalSpent)} {formatCurrency(client.averageTicket)} {formatNumber(client.totalItems)} un. ({formatNumber(client.orderCount)} {client.orderCount === 1 ? 'pedido' : 'pedidos'}) Ver detalhes
{/* Pagination Controls */}
Mostrar itens por página
Mostrando {clientsData.length > 0 ? startIndex + 1 : 0} a {Math.min(startIndex + itemsPerPage, clientsData.length)} de {clientsData.length} clientes
)}
); }; export default Clients;