Show all clients in clients page
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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]));
|
||||||
name: client.name,
|
const clients = clientAnalytics.map((client): ClientSummary => {
|
||||||
phone: client.phone,
|
const rfmClient = client.phone ? rfmByPhone.get(client.phone) : undefined;
|
||||||
totalSpent: client.monetary,
|
return {
|
||||||
averageTicket: client.frequency ? client.monetary / client.frequency : 0,
|
name: client.name,
|
||||||
totalItems: client.quantityPurchased,
|
phone: client.phone,
|
||||||
orderCount: client.frequency,
|
totalSpent: client.totalSpent,
|
||||||
lastPurchase: client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : 0,
|
averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0,
|
||||||
clientType: backendSegmentToClientType[client.segmentKey] || client.segmentLabel,
|
totalItems: client.quantityPurchased,
|
||||||
rfmScore: client.rfmScore,
|
orderCount: client.orderCount,
|
||||||
rfmPriority: getRfmPriority(client)
|
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
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user