import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react'; 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'; const clientTypeStyles: Record = { 'Sem análise': 'border-zinc-600/30 bg-zinc-600/15 text-zinc-300', '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 = [ '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 Clients = () => { const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void }>(); 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 || []); // 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); if (isMounted) { if (cachedOptions) setFilterOptions(cachedOptions); if (cachedClients) setClientAnalytics(cachedClients); setRfmAnalytics(cachedRfm || null); } 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); } 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]) => { if (seller.id && seller.name && seller.id !== seller.name) return `${seller.name} (${seller.id})`; return 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; 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

)}
{paginatedData.map((client, index) => ( ))}
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;