Show all clients in clients page
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s

This commit is contained in:
Cauê Faleiros
2026-06-16 16:24:10 -03:00
parent 37067751c7
commit ed1f129b07
4 changed files with 70 additions and 33 deletions

View File

@@ -235,27 +235,24 @@ const getClientAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range); const { params, whereClause } = buildDateFilter(range);
const result = await pool.query(` const result = await pool.query(`
SELECT SELECT
MAX(cliente_nome) as name, COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido') as name,
cliente_fone as phone, MAX(NULLIF(cliente_fone, '')) as phone,
COALESCE(SUM(quantidade), 0) as quantity_purchased, COALESCE(SUM(quantidade), 0) as quantity_purchased,
COALESCE(SUM(quantidade * valor_unitario), 0) as total_spent, 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 MAX(data_pedido_date) as last_purchase_date
FROM orders FROM orders
${whereClause} ${whereClause}
AND cliente_fone IS NOT NULL GROUP BY COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')
AND cliente_fone != '' ORDER BY total_spent DESC;
GROUP BY cliente_fone
ORDER BY total_spent DESC
LIMIT 500;
`, params); `, params);
return result.rows.map(row => ({ return result.rows.map(row => ({
name: row.name, name: row.name,
phone: row.phone, phone: row.phone || '',
quantityPurchased: toNumber(row.quantity_purchased), quantityPurchased: toNumber(row.quantity_purchased),
totalSpent: toNumber(row.total_spent), totalSpent: toNumber(row.total_spent),
orderLineCount: toNumber(row.order_line_count), orderCount: toNumber(row.order_count),
lastPurchaseDate: row.last_purchase_date lastPurchaseDate: row.last_purchase_date
})); }));
}; };
@@ -282,8 +279,7 @@ const getRfmAnalytics = async (range = {}) => {
AND cliente_fone IS NOT NULL AND cliente_fone IS NOT NULL
AND cliente_fone != '' AND cliente_fone != ''
GROUP BY cliente_fone GROUP BY cliente_fone
ORDER BY monetary DESC ORDER BY monetary DESC;
LIMIT 1000;
`, params), `, params),
pool.query(` pool.query(`
SELECT SELECT

View File

@@ -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'; import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -139,6 +139,21 @@ export const fetchRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalyt
} }
}; };
export const fetchClientAnalytics = async (dateRange: DateRange): Promise<ClientAnalyticsItem[]> => {
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<CampaignQueueSummary | null> => { export const fetchCampaigns = async (): Promise<CampaignQueueSummary | null> => {
try { try {
const response = await authFetch('/campaigns'); const response = await authFetch('/campaigns');

View File

@@ -1,12 +1,13 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react'; import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react';
import type { DateRange, RfmAnalytics, RfmClient } from '../types'; import type { ClientAnalyticsItem, DateRange, RfmAnalytics, RfmClient } from '../types';
import { exportToCSV, fetchRfmAnalytics } from '../dataService'; import { exportToCSV, fetchClientAnalytics, fetchRfmAnalytics } from '../dataService';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import type { ClientSortOption, ClientSummary } from '../analytics/clients'; import type { ClientSortOption, ClientSummary } from '../analytics/clients';
const clientTypeStyles: Record<string, string> = { const clientTypeStyles: Record<string, string> = {
'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', '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', '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', 'Novo Cliente': 'border-cyan-500/30 bg-cyan-500/15 text-cyan-300',
@@ -19,6 +20,7 @@ const clientTypeStyles: Record<string, string> = {
}; };
const clientTypes = [ const clientTypes = [
'Sem análise',
'Campeão', 'Campeão',
'Potencial Leal', 'Potencial Leal',
'Novo Cliente', 'Novo Cliente',
@@ -71,6 +73,7 @@ const Clients = () => {
const [sortBy, setSortBy] = useState<ClientSortOption>('recent'); const [sortBy, setSortBy] = useState<ClientSortOption>('recent');
const [clientTypeFilter, setClientTypeFilter] = useState('all'); const [clientTypeFilter, setClientTypeFilter] = useState('all');
const [rfmAnalytics, setRfmAnalytics] = useState<RfmAnalytics | null>(null); const [rfmAnalytics, setRfmAnalytics] = useState<RfmAnalytics | null>(null);
const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>([]);
// Pagination state // Pagination state
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@@ -79,12 +82,18 @@ const Clients = () => {
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
const loadRfm = async () => { const loadClients = async () => {
const data = await fetchRfmAnalytics(dateRange); const [clientsData, rfmData] = await Promise.all([
if (isMounted) setRfmAnalytics(data); fetchClientAnalytics(dateRange),
fetchRfmAnalytics(dateRange)
]);
if (isMounted) {
setClientAnalytics(clientsData);
setRfmAnalytics(rfmData);
}
}; };
void loadRfm(); void loadClients();
return () => { return () => {
isMounted = false; isMounted = false;
@@ -93,18 +102,22 @@ const Clients = () => {
const allClientsData = useMemo(() => { const allClientsData = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase(); const normalizedSearch = searchTerm.trim().toLowerCase();
const clients = (rfmAnalytics?.clients || []).map((client): ClientSummary => ({ 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, name: client.name,
phone: client.phone, phone: client.phone,
totalSpent: client.monetary, totalSpent: client.totalSpent,
averageTicket: client.frequency ? client.monetary / client.frequency : 0, averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0,
totalItems: client.quantityPurchased, totalItems: client.quantityPurchased,
orderCount: client.frequency, orderCount: client.orderCount,
lastPurchase: client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : 0, lastPurchase: client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : 0,
clientType: backendSegmentToClientType[client.segmentKey] || client.segmentLabel, clientType: rfmClient ? (backendSegmentToClientType[rfmClient.segmentKey] || rfmClient.segmentLabel) : 'Sem análise',
rfmScore: client.rfmScore, rfmScore: rfmClient?.rfmScore || '000',
rfmPriority: getRfmPriority(client) rfmPriority: rfmClient ? getRfmPriority(rfmClient) : 0
})); };
});
const filteredClients = normalizedSearch const filteredClients = normalizedSearch
? clients.filter(client => ? clients.filter(client =>
@@ -114,7 +127,7 @@ const Clients = () => {
: clients; : clients;
return sortClients(filteredClients, sortBy); return sortClients(filteredClients, sortBy);
}, [searchTerm, sortBy, rfmAnalytics]); }, [clientAnalytics, searchTerm, sortBy, rfmAnalytics]);
const clientsData = useMemo(() => { const clientsData = useMemo(() => {
if (clientTypeFilter === 'all') return allClientsData; if (clientTypeFilter === 'all') return allClientsData;
@@ -137,6 +150,10 @@ const Clients = () => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); 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) => { const selectClientType = (type: string) => {
setClientTypeFilter(current => current === type ? 'all' : type); setClientTypeFilter(current => current === type ? 'all' : type);
setCurrentPage(1); setCurrentPage(1);
@@ -291,7 +308,7 @@ const Clients = () => {
<td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(client.totalSpent)}</td> <td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(client.totalSpent)}</td>
<td className="px-6 py-2.5 text-zinc-700 dark:text-dark-text font-semibold">{formatCurrency(client.averageTicket)}</td> <td className="px-6 py-2.5 text-zinc-700 dark:text-dark-text font-semibold">{formatCurrency(client.averageTicket)}</td>
<td className="px-6 py-2.5 text-zinc-500 dark:text-dark-muted text-xs font-medium"> <td className="px-6 py-2.5 text-zinc-500 dark:text-dark-muted text-xs font-medium">
{client.totalItems} un. ({client.orderCount} {client.orderCount === 1 ? 'pedido' : 'pedidos'}) {formatNumber(client.totalItems)} un. ({formatNumber(client.orderCount)} {client.orderCount === 1 ? 'pedido' : 'pedidos'})
</td> </td>
<td className="px-6 py-2.5 text-right"> <td className="px-6 py-2.5 text-right">
<Link <Link

View File

@@ -63,6 +63,15 @@ export interface DashboardAnalytics {
}>; }>;
} }
export interface ClientAnalyticsItem {
name: string;
phone: string;
quantityPurchased: number;
totalSpent: number;
orderCount: number;
lastPurchaseDate: string;
}
export interface RfmClient { export interface RfmClient {
name: string; name: string;
phone: string; phone: string;