diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 0d0683d..097dace 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -235,27 +235,24 @@ const getClientAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); const result = await pool.query(` SELECT - MAX(cliente_nome) as name, - cliente_fone as phone, + COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido') as name, + MAX(NULLIF(cliente_fone, '')) as phone, COALESCE(SUM(quantidade), 0) as quantity_purchased, COALESCE(SUM(quantidade * valor_unitario), 0) as total_spent, - COUNT(*)::int as order_line_count, + COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count, MAX(data_pedido_date) as last_purchase_date FROM orders ${whereClause} - AND cliente_fone IS NOT NULL - AND cliente_fone != '' - GROUP BY cliente_fone - ORDER BY total_spent DESC - LIMIT 500; + GROUP BY COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido') + ORDER BY total_spent DESC; `, params); return result.rows.map(row => ({ name: row.name, - phone: row.phone, + phone: row.phone || '', quantityPurchased: toNumber(row.quantity_purchased), totalSpent: toNumber(row.total_spent), - orderLineCount: toNumber(row.order_line_count), + orderCount: toNumber(row.order_count), lastPurchaseDate: row.last_purchase_date })); }; @@ -282,8 +279,7 @@ const getRfmAnalytics = async (range = {}) => { AND cliente_fone IS NOT NULL AND cliente_fone != '' GROUP BY cliente_fone - ORDER BY monetary DESC - LIMIT 1000; + ORDER BY monetary DESC; `, params), pool.query(` SELECT diff --git a/src/dataService.ts b/src/dataService.ts index 7861d44..3d72298 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, RfmAnalytics, StockData } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, RfmAnalytics, StockData } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -139,6 +139,21 @@ export const fetchRfmAnalytics = async (dateRange: DateRange): Promise => { + try { + const params = new URLSearchParams({ + start: formatDateParam(dateRange.start), + end: formatDateParam(dateRange.end) + }); + const response = await authFetch(`/analytics/clients?${params.toString()}`); + if (!response.ok) return []; + return await response.json(); + } catch (error) { + console.error('Fetch client analytics failed', error); + return []; + } +}; + export const fetchCampaigns = async (): Promise => { try { const response = await authFetch('/campaigns'); diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index 34f07ff..d1219c2 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -1,12 +1,13 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react'; -import type { DateRange, RfmAnalytics, RfmClient } from '../types'; -import { exportToCSV, fetchRfmAnalytics } from '../dataService'; +import type { ClientAnalyticsItem, DateRange, RfmAnalytics, RfmClient } from '../types'; +import { exportToCSV, fetchClientAnalytics, fetchRfmAnalytics } from '../dataService'; import DateRangePicker from '../components/DateRangePicker'; 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', @@ -19,6 +20,7 @@ const clientTypeStyles: Record = { }; const clientTypes = [ + 'Sem análise', 'Campeão', 'Potencial Leal', 'Novo Cliente', @@ -71,6 +73,7 @@ const Clients = () => { const [sortBy, setSortBy] = useState('recent'); const [clientTypeFilter, setClientTypeFilter] = useState('all'); const [rfmAnalytics, setRfmAnalytics] = useState(null); + const [clientAnalytics, setClientAnalytics] = useState([]); // Pagination state const [currentPage, setCurrentPage] = useState(1); @@ -79,12 +82,18 @@ const Clients = () => { useEffect(() => { let isMounted = true; - const loadRfm = async () => { - const data = await fetchRfmAnalytics(dateRange); - if (isMounted) setRfmAnalytics(data); + const loadClients = async () => { + const [clientsData, rfmData] = await Promise.all([ + fetchClientAnalytics(dateRange), + fetchRfmAnalytics(dateRange) + ]); + if (isMounted) { + setClientAnalytics(clientsData); + setRfmAnalytics(rfmData); + } }; - void loadRfm(); + void loadClients(); return () => { isMounted = false; @@ -93,18 +102,22 @@ const Clients = () => { const allClientsData = useMemo(() => { const normalizedSearch = searchTerm.trim().toLowerCase(); - const clients = (rfmAnalytics?.clients || []).map((client): ClientSummary => ({ - name: client.name, - phone: client.phone, - totalSpent: client.monetary, - averageTicket: client.frequency ? client.monetary / client.frequency : 0, - totalItems: client.quantityPurchased, - orderCount: client.frequency, - lastPurchase: client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : 0, - clientType: backendSegmentToClientType[client.segmentKey] || client.segmentLabel, - rfmScore: client.rfmScore, - rfmPriority: getRfmPriority(client) - })); + const rfmByPhone = new Map((rfmAnalytics?.clients || []).map(client => [client.phone, client])); + const clients = clientAnalytics.map((client): ClientSummary => { + const rfmClient = client.phone ? rfmByPhone.get(client.phone) : undefined; + return { + name: client.name, + phone: client.phone, + totalSpent: client.totalSpent, + averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0, + totalItems: client.quantityPurchased, + orderCount: client.orderCount, + lastPurchase: client.lastPurchaseDate ? new Date(client.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 => @@ -114,7 +127,7 @@ const Clients = () => { : clients; return sortClients(filteredClients, sortBy); - }, [searchTerm, sortBy, rfmAnalytics]); + }, [clientAnalytics, searchTerm, sortBy, rfmAnalytics]); const clientsData = useMemo(() => { if (clientTypeFilter === 'all') return allClientsData; @@ -137,6 +150,10 @@ const Clients = () => { 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 selectClientType = (type: string) => { setClientTypeFilter(current => current === type ? 'all' : type); setCurrentPage(1); @@ -291,7 +308,7 @@ const Clients = () => { {formatCurrency(client.totalSpent)} {formatCurrency(client.averageTicket)} - {client.totalItems} un. ({client.orderCount} {client.orderCount === 1 ? 'pedido' : 'pedidos'}) + {formatNumber(client.totalItems)} un. ({formatNumber(client.orderCount)} {client.orderCount === 1 ? 'pedido' : 'pedidos'}) ; } +export interface ClientAnalyticsItem { + name: string; + phone: string; + quantityPurchased: number; + totalSpent: number; + orderCount: number; + lastPurchaseDate: string; +} + export interface RfmClient { name: string; phone: string;