Fix client detail customer identity
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m17s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m17s
This commit is contained in:
@@ -46,7 +46,7 @@ function App() {
|
|||||||
<Route path="products" element={<Products />} />
|
<Route path="products" element={<Products />} />
|
||||||
<Route path="products/:id" element={<ProductDetails />} />
|
<Route path="products/:id" element={<ProductDetails />} />
|
||||||
<Route path="clients" element={<Clients />} />
|
<Route path="clients" element={<Clients />} />
|
||||||
<Route path="clients/:name" element={<ClientDetails />} />
|
<Route path="clients/:customerKey" element={<ClientDetails />} />
|
||||||
<Route path="rfm" element={<Rfm />} />
|
<Route path="rfm" element={<Rfm />} />
|
||||||
<Route path="campaigns" element={<Campaigns />} />
|
<Route path="campaigns" element={<Campaigns />} />
|
||||||
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
|
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
|
||||||
|
|||||||
119
src/analytics/clients.test.ts
Normal file
119
src/analytics/clients.test.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { DateRange, OrderData } from '../types.ts';
|
||||||
|
import { buildClientDetailsMetrics } from './clients.ts';
|
||||||
|
|
||||||
|
const allPeriod: DateRange = {
|
||||||
|
start: new Date(2000, 0, 1),
|
||||||
|
end: new Date(2026, 5, 18, 23, 59, 59, 999)
|
||||||
|
};
|
||||||
|
|
||||||
|
const order = (overrides: Partial<OrderData>): OrderData => ({
|
||||||
|
Nome_Cliente: 'Cliente',
|
||||||
|
Data_Pedido: '01-06-2026',
|
||||||
|
Valor_Pedido: 0,
|
||||||
|
ID_Produto: '1',
|
||||||
|
Descricao_Produto: 'Produto',
|
||||||
|
Quantidade: 1,
|
||||||
|
Valor_Unitario: 10,
|
||||||
|
ID_Pedido: 'pedido',
|
||||||
|
Fone_Cliente: '',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client details do not merge different phone keys that share the same name', () => {
|
||||||
|
const orders = [
|
||||||
|
order({
|
||||||
|
Nome_Cliente: 'Leonardo Barbosa',
|
||||||
|
Fone_Cliente: '111',
|
||||||
|
ID_Pedido: 'pedido-1',
|
||||||
|
Quantidade: 2,
|
||||||
|
Valor_Unitario: 10
|
||||||
|
}),
|
||||||
|
order({
|
||||||
|
Nome_Cliente: 'Leonardo Barbosa',
|
||||||
|
Fone_Cliente: '222',
|
||||||
|
ID_Pedido: 'pedido-2',
|
||||||
|
Quantidade: 5,
|
||||||
|
Valor_Unitario: 10
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
const metrics = buildClientDetailsMetrics(orders, '111', allPeriod);
|
||||||
|
|
||||||
|
assert.equal(metrics.periodOrderCount, 1);
|
||||||
|
assert.equal(metrics.periodItems, 2);
|
||||||
|
assert.equal(metrics.periodSpent, 20);
|
||||||
|
assert.equal(metrics.clientPhone, '111');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy name detail URLs reject ambiguous names instead of merging customers', () => {
|
||||||
|
const orders = [
|
||||||
|
order({
|
||||||
|
Nome_Cliente: 'Leonardo Barbosa',
|
||||||
|
Fone_Cliente: '111',
|
||||||
|
ID_Pedido: 'pedido-1'
|
||||||
|
}),
|
||||||
|
order({
|
||||||
|
Nome_Cliente: 'Leonardo Barbosa',
|
||||||
|
Fone_Cliente: '222',
|
||||||
|
ID_Pedido: 'pedido-2'
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
const metrics = buildClientDetailsMetrics(orders, 'Leonardo Barbosa', allPeriod);
|
||||||
|
|
||||||
|
assert.equal(metrics.hasClient, false);
|
||||||
|
assert.equal(metrics.periodOrderCount, 0);
|
||||||
|
assert.equal(metrics.periodItems, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client details merge name variations that share the same phone key', () => {
|
||||||
|
const orders = [
|
||||||
|
order({
|
||||||
|
Nome_Cliente: 'Jose de Matos Dreher',
|
||||||
|
Fone_Cliente: '333',
|
||||||
|
ID_Pedido: 'pedido-1',
|
||||||
|
Valor_Unitario: 10
|
||||||
|
}),
|
||||||
|
order({
|
||||||
|
Nome_Cliente: 'José Matos',
|
||||||
|
Fone_Cliente: '333',
|
||||||
|
ID_Pedido: 'pedido-2',
|
||||||
|
Valor_Unitario: 20
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
const metrics = buildClientDetailsMetrics(orders, '333', allPeriod);
|
||||||
|
|
||||||
|
assert.equal(metrics.periodOrderCount, 2);
|
||||||
|
assert.equal(metrics.periodItems, 2);
|
||||||
|
assert.equal(metrics.periodSpent, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client detail order totals use the same line-item revenue as summary metrics', () => {
|
||||||
|
const orders = [
|
||||||
|
order({
|
||||||
|
Nome_Cliente: 'Cliente Sem Fone',
|
||||||
|
ID_Pedido: 'pedido-1',
|
||||||
|
ID_Produto: 'produto-1',
|
||||||
|
Valor_Pedido: 100,
|
||||||
|
Quantidade: 2,
|
||||||
|
Valor_Unitario: 10
|
||||||
|
}),
|
||||||
|
order({
|
||||||
|
Nome_Cliente: 'Cliente Sem Fone',
|
||||||
|
ID_Pedido: 'pedido-1',
|
||||||
|
ID_Produto: 'produto-2',
|
||||||
|
Valor_Pedido: 100,
|
||||||
|
Quantidade: 1,
|
||||||
|
Valor_Unitario: 5
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
const metrics = buildClientDetailsMetrics(orders, 'name:Cliente Sem Fone', allPeriod);
|
||||||
|
|
||||||
|
assert.equal(metrics.periodSpent, 25);
|
||||||
|
assert.equal(metrics.groupedOrders[0].orderTotal, 25);
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { DateRange, OrderData } from '../types';
|
import type { DateRange, OrderData } from '../types.ts';
|
||||||
import { parseOrderDate } from '../dataService';
|
import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue, getOrderCustomerKey, parseOrderDate } from './orders.ts';
|
||||||
import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue } from './orders';
|
|
||||||
|
|
||||||
export type ClientSortOption =
|
export type ClientSortOption =
|
||||||
| 'recent'
|
| 'recent'
|
||||||
@@ -40,6 +39,7 @@ export interface ClientDetailsMetrics {
|
|||||||
}>;
|
}>;
|
||||||
groupedOrders: GroupedClientOrder[];
|
groupedOrders: GroupedClientOrder[];
|
||||||
allTimeOrderCount: number;
|
allTimeOrderCount: number;
|
||||||
|
clientName: string;
|
||||||
clientPhone: string;
|
clientPhone: string;
|
||||||
hasClient: boolean;
|
hasClient: boolean;
|
||||||
periodAverageTicket: number;
|
periodAverageTicket: number;
|
||||||
@@ -124,7 +124,7 @@ export const buildClientsSummary = (
|
|||||||
|
|
||||||
orders.forEach(order => {
|
orders.forEach(order => {
|
||||||
const clientName = getClientDisplayName(order);
|
const clientName = getClientDisplayName(order);
|
||||||
const customerKey = order.Fone_Cliente || `name:${clientName}`;
|
const customerKey = getOrderCustomerKey(order);
|
||||||
|
|
||||||
if (!clientMap[customerKey]) {
|
if (!clientMap[customerKey]) {
|
||||||
clientMap[customerKey] = { name: clientName, totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' };
|
clientMap[customerKey] = { name: clientName, totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' };
|
||||||
@@ -174,17 +174,29 @@ export const buildClientsSummary = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildClientDetailsMetrics = (ordersData: OrderData[], clientName: string, dateRange: DateRange): ClientDetailsMetrics => {
|
export const buildClientDetailsMetrics = (ordersData: OrderData[], customerKey: string, dateRange: DateRange): ClientDetailsMetrics => {
|
||||||
const clientOrders = ordersData.filter(order => getClientDisplayName(order) === clientName);
|
const keyedOrders = ordersData.filter(order => getOrderCustomerKey(order) === customerKey);
|
||||||
|
const legacyNameOrders = keyedOrders.length
|
||||||
|
? []
|
||||||
|
: ordersData.filter(order => getClientDisplayName(order) === customerKey);
|
||||||
|
const legacyCustomerKeys = new Set(legacyNameOrders.map(getOrderCustomerKey));
|
||||||
|
const resolvedLegacyKey = legacyCustomerKeys.size === 1 ? [...legacyCustomerKeys][0] : '';
|
||||||
|
const clientOrders = keyedOrders.length
|
||||||
|
? keyedOrders
|
||||||
|
: resolvedLegacyKey
|
||||||
|
? ordersData.filter(order => getOrderCustomerKey(order) === resolvedLegacyKey)
|
||||||
|
: [];
|
||||||
const periodOrders = filterOrdersByDateRange(clientOrders, dateRange);
|
const periodOrders = filterOrdersByDateRange(clientOrders, dateRange);
|
||||||
const groupedOrdersMap: Record<string, GroupedClientOrder> = {};
|
const groupedOrdersMap: Record<string, GroupedClientOrder> = {};
|
||||||
const spentByDate: Record<string, number> = {};
|
const spentByDate: Record<string, number> = {};
|
||||||
let clientPhone = '';
|
let clientPhone = '';
|
||||||
|
let clientName = '';
|
||||||
let periodSpent = 0;
|
let periodSpent = 0;
|
||||||
let periodItems = 0;
|
let periodItems = 0;
|
||||||
|
|
||||||
clientOrders.forEach(order => {
|
clientOrders.forEach(order => {
|
||||||
if (order.Fone_Cliente && !clientPhone) clientPhone = order.Fone_Cliente;
|
if (order.Fone_Cliente && !clientPhone) clientPhone = order.Fone_Cliente;
|
||||||
|
if (!clientName) clientName = getClientDisplayName(order);
|
||||||
});
|
});
|
||||||
|
|
||||||
periodOrders.forEach(order => {
|
periodOrders.forEach(order => {
|
||||||
@@ -197,11 +209,12 @@ export const buildClientDetailsMetrics = (ordersData: OrderData[], clientName: s
|
|||||||
groupedOrdersMap[key] = {
|
groupedOrdersMap[key] = {
|
||||||
date: order.Data_Pedido,
|
date: order.Data_Pedido,
|
||||||
orderId: order.ID_Pedido || key,
|
orderId: order.ID_Pedido || key,
|
||||||
orderTotal: order.Valor_Pedido,
|
orderTotal: 0,
|
||||||
items: []
|
items: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
groupedOrdersMap[key].items.push(order);
|
groupedOrdersMap[key].items.push(order);
|
||||||
|
groupedOrdersMap[key].orderTotal += getOrderItemRevenue(order);
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => {
|
const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => {
|
||||||
@@ -219,6 +232,7 @@ export const buildClientDetailsMetrics = (ordersData: OrderData[], clientName: s
|
|||||||
chartData,
|
chartData,
|
||||||
groupedOrders,
|
groupedOrders,
|
||||||
allTimeOrderCount: allTimeOrderIds.size,
|
allTimeOrderCount: allTimeOrderIds.size,
|
||||||
|
clientName,
|
||||||
clientPhone,
|
clientPhone,
|
||||||
hasClient: clientOrders.length > 0,
|
hasClient: clientOrders.length > 0,
|
||||||
periodAverageTicket: periodOrderCount ? periodSpent / periodOrderCount : 0,
|
periodAverageTicket: periodOrderCount ? periodSpent / periodOrderCount : 0,
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import type { DateRange, OrderData } from '../types';
|
import type { DateRange, OrderData } from '../types.ts';
|
||||||
import { parseOrderDate } from '../dataService';
|
|
||||||
|
|
||||||
const SIZE_SUFFIX_PATTERN = /\s+-\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2})(?:\/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2}))*)$/i;
|
const SIZE_SUFFIX_PATTERN = /\s+-\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2})(?:\/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2}))*)$/i;
|
||||||
|
|
||||||
|
export const parseOrderDate = (dateStr: string): Date => {
|
||||||
|
if (!dateStr) return new Date(0);
|
||||||
|
if (dateStr.includes('T')) return new Date(dateStr);
|
||||||
|
const parts = dateStr.split(/[-/]/);
|
||||||
|
if (parts.length === 3) {
|
||||||
|
if (parts[0].length === 4) {
|
||||||
|
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||||||
|
}
|
||||||
|
return new Date(Number(parts[2]), Number(parts[1]) - 1, Number(parts[0]));
|
||||||
|
}
|
||||||
|
const fallback = new Date(dateStr);
|
||||||
|
return Number.isNaN(fallback.getTime()) ? new Date(0) : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
export const getBaseProductName = (description: string): string => {
|
export const getBaseProductName = (description: string): string => {
|
||||||
const productName = description.trim();
|
const productName = description.trim();
|
||||||
if (productName.toLocaleUpperCase('pt-BR').startsWith('ETIQUETA')) {
|
if (productName.toLocaleUpperCase('pt-BR').startsWith('ETIQUETA')) {
|
||||||
@@ -13,7 +26,11 @@ export const getBaseProductName = (description: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getClientDisplayName = (order: OrderData): string => {
|
export const getClientDisplayName = (order: OrderData): string => {
|
||||||
return order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`;
|
return order.Nome_Cliente || 'Cliente Desconhecido';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOrderCustomerKey = (order: OrderData): string => {
|
||||||
|
return order.Fone_Cliente || `name:${getClientDisplayName(order)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isOrderInDateRange = (order: OrderData, dateRange: DateRange): boolean => {
|
export const isOrderInDateRange = (order: OrderData, dateRange: DateRange): boolean => {
|
||||||
|
|||||||
@@ -258,23 +258,6 @@ export const deleteUser = async (id: number): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parseOrderDate = (dateStr: string): Date => {
|
|
||||||
if (!dateStr) return new Date(0);
|
|
||||||
if (dateStr.includes('T')) return new Date(dateStr);
|
|
||||||
const parts = dateStr.split(/[-/]/);
|
|
||||||
if (parts.length === 3) {
|
|
||||||
if (parts[0].length === 4) {
|
|
||||||
// YYYY-MM-DD
|
|
||||||
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
|
||||||
} else {
|
|
||||||
// DD-MM-YYYY
|
|
||||||
return new Date(Number(parts[2]), Number(parts[1]) - 1, Number(parts[0]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fallback = new Date(dateStr);
|
|
||||||
return isNaN(fallback.getTime()) ? new Date(0) : fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const exportToCSV = (data: Record<string, unknown>[], filename: string) => {
|
export const exportToCSV = (data: Record<string, unknown>[], filename: string) => {
|
||||||
if (!data || !data.length) return;
|
if (!data || !data.length) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useParams, Link, useOutletContext } from 'react-router-dom';
|
import { useParams, Link, useOutletContext, useSearchParams } from 'react-router-dom';
|
||||||
import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone, ChevronLeft, ChevronRight, ShoppingBag, ReceiptText } from 'lucide-react';
|
import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone, ChevronLeft, ChevronRight, ShoppingBag, ReceiptText } from 'lucide-react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
@@ -27,8 +27,10 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ClientDetails = () => {
|
const ClientDetails = () => {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { customerKey } = useParams<{ customerKey: string }>();
|
||||||
const decodedName = name ? decodeURIComponent(name) : '';
|
const decodedCustomerKey = customerKey ? decodeURIComponent(customerKey) : '';
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const requestedName = searchParams.get('name') || '';
|
||||||
const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{
|
const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{
|
||||||
dateRange: DateRange,
|
dateRange: DateRange,
|
||||||
setDateRange: (range: DateRange) => void,
|
setDateRange: (range: DateRange) => void,
|
||||||
@@ -42,6 +44,7 @@ const ClientDetails = () => {
|
|||||||
chartData,
|
chartData,
|
||||||
groupedOrders,
|
groupedOrders,
|
||||||
allTimeOrderCount,
|
allTimeOrderCount,
|
||||||
|
clientName,
|
||||||
clientPhone,
|
clientPhone,
|
||||||
hasClient,
|
hasClient,
|
||||||
periodAverageTicket,
|
periodAverageTicket,
|
||||||
@@ -49,8 +52,9 @@ const ClientDetails = () => {
|
|||||||
periodOrderCount,
|
periodOrderCount,
|
||||||
periodSpent
|
periodSpent
|
||||||
} = useMemo(() => {
|
} = useMemo(() => {
|
||||||
return buildClientDetailsMetrics(ordersData, decodedName, dateRange);
|
return buildClientDetailsMetrics(ordersData, decodedCustomerKey, dateRange);
|
||||||
}, [dateRange, decodedName, ordersData]);
|
}, [dateRange, decodedCustomerKey, ordersData]);
|
||||||
|
const displayName = requestedName || clientName || decodedCustomerKey.replace(/^name:/, '');
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
@@ -102,7 +106,7 @@ const ClientDetails = () => {
|
|||||||
<User className="w-8 h-8 text-brand-primary" />
|
<User className="w-8 h-8 text-brand-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{decodedName}</h1>
|
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{displayName}</h1>
|
||||||
<div className="flex items-center gap-3 mt-1">
|
<div className="flex items-center gap-3 mt-1">
|
||||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">
|
<p className="text-zinc-500 dark:text-dark-muted font-medium">
|
||||||
{formatNumber(allTimeOrderCount)} pedidos no histórico completo
|
{formatNumber(allTimeOrderCount)} pedidos no histórico completo
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ const Clients = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-2.5 text-right">
|
<td className="px-6 py-2.5 text-right">
|
||||||
<Link
|
<Link
|
||||||
to={`/clients/${encodeURIComponent(client.name)}`}
|
to={`/clients/${encodeURIComponent(client.customerKey)}?name=${encodeURIComponent(client.name)}`}
|
||||||
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity cursor-pointer"
|
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
>
|
>
|
||||||
Ver detalhes
|
Ver detalhes
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ const Rfm = () => {
|
|||||||
return (
|
return (
|
||||||
<tr key={client.customerKey} className="hover:bg-dark-input/50 transition-colors">
|
<tr key={client.customerKey} className="hover:bg-dark-input/50 transition-colors">
|
||||||
<td className="px-6 py-3">
|
<td className="px-6 py-3">
|
||||||
<Link to={`/clients/${encodeURIComponent(client.name)}`} className="flex items-center gap-3 hover:text-brand-primary transition-colors">
|
<Link to={`/clients/${encodeURIComponent(client.customerKey)}?name=${encodeURIComponent(client.name)}`} className="flex items-center gap-3 hover:text-brand-primary transition-colors">
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-dark-input text-dark-muted">
|
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-dark-input text-dark-muted">
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user