From c35f8f8a51e96bb1fef1c53b8e88e48738453dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 18 Jun 2026 16:31:22 -0300 Subject: [PATCH] Fix client detail customer identity --- src/App.tsx | 2 +- src/analytics/clients.test.ts | 119 ++++++++++++++++++++++++++++++++++ src/analytics/clients.ts | 28 ++++++-- src/analytics/orders.ts | 23 ++++++- src/dataService.ts | 17 ----- src/pages/ClientDetails.tsx | 16 +++-- src/pages/Clients.tsx | 2 +- src/pages/Rfm.tsx | 2 +- 8 files changed, 173 insertions(+), 36 deletions(-) create mode 100644 src/analytics/clients.test.ts diff --git a/src/App.tsx b/src/App.tsx index 1f355bc..c701567 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,7 +46,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/analytics/clients.test.ts b/src/analytics/clients.test.ts new file mode 100644 index 0000000..5ec2180 --- /dev/null +++ b/src/analytics/clients.test.ts @@ -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 => ({ + 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); +}); diff --git a/src/analytics/clients.ts b/src/analytics/clients.ts index 94e16d4..3417433 100644 --- a/src/analytics/clients.ts +++ b/src/analytics/clients.ts @@ -1,6 +1,5 @@ -import type { DateRange, OrderData } from '../types'; -import { parseOrderDate } from '../dataService'; -import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue } from './orders'; +import type { DateRange, OrderData } from '../types.ts'; +import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue, getOrderCustomerKey, parseOrderDate } from './orders.ts'; export type ClientSortOption = | 'recent' @@ -40,6 +39,7 @@ export interface ClientDetailsMetrics { }>; groupedOrders: GroupedClientOrder[]; allTimeOrderCount: number; + clientName: string; clientPhone: string; hasClient: boolean; periodAverageTicket: number; @@ -124,7 +124,7 @@ export const buildClientsSummary = ( orders.forEach(order => { const clientName = getClientDisplayName(order); - const customerKey = order.Fone_Cliente || `name:${clientName}`; + const customerKey = getOrderCustomerKey(order); if (!clientMap[customerKey]) { 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 => { - const clientOrders = ordersData.filter(order => getClientDisplayName(order) === clientName); +export const buildClientDetailsMetrics = (ordersData: OrderData[], customerKey: string, dateRange: DateRange): ClientDetailsMetrics => { + 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 groupedOrdersMap: Record = {}; const spentByDate: Record = {}; let clientPhone = ''; + let clientName = ''; let periodSpent = 0; let periodItems = 0; clientOrders.forEach(order => { if (order.Fone_Cliente && !clientPhone) clientPhone = order.Fone_Cliente; + if (!clientName) clientName = getClientDisplayName(order); }); periodOrders.forEach(order => { @@ -197,11 +209,12 @@ export const buildClientDetailsMetrics = (ordersData: OrderData[], clientName: s groupedOrdersMap[key] = { date: order.Data_Pedido, orderId: order.ID_Pedido || key, - orderTotal: order.Valor_Pedido, + orderTotal: 0, items: [] }; } groupedOrdersMap[key].items.push(order); + groupedOrdersMap[key].orderTotal += getOrderItemRevenue(order); }); const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => { @@ -219,6 +232,7 @@ export const buildClientDetailsMetrics = (ordersData: OrderData[], clientName: s chartData, groupedOrders, allTimeOrderCount: allTimeOrderIds.size, + clientName, clientPhone, hasClient: clientOrders.length > 0, periodAverageTicket: periodOrderCount ? periodSpent / periodOrderCount : 0, diff --git a/src/analytics/orders.ts b/src/analytics/orders.ts index 971ccfc..fa8350e 100644 --- a/src/analytics/orders.ts +++ b/src/analytics/orders.ts @@ -1,8 +1,21 @@ -import type { DateRange, OrderData } from '../types'; -import { parseOrderDate } from '../dataService'; +import type { DateRange, OrderData } from '../types.ts'; 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 => { const productName = description.trim(); if (productName.toLocaleUpperCase('pt-BR').startsWith('ETIQUETA')) { @@ -13,7 +26,11 @@ export const getBaseProductName = (description: string): 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 => { diff --git a/src/dataService.ts b/src/dataService.ts index 3d72298..5242853 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -258,23 +258,6 @@ export const deleteUser = async (id: number): Promise => { } }; -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[], filename: string) => { if (!data || !data.length) return; diff --git a/src/pages/ClientDetails.tsx b/src/pages/ClientDetails.tsx index 26bf8ad..77719ee 100644 --- a/src/pages/ClientDetails.tsx +++ b/src/pages/ClientDetails.tsx @@ -1,5 +1,5 @@ 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 { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import DateRangePicker from '../components/DateRangePicker'; @@ -27,8 +27,10 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { }; const ClientDetails = () => { - const { name } = useParams<{ name: string }>(); - const decodedName = name ? decodeURIComponent(name) : ''; + const { customerKey } = useParams<{ customerKey: string }>(); + const decodedCustomerKey = customerKey ? decodeURIComponent(customerKey) : ''; + const [searchParams] = useSearchParams(); + const requestedName = searchParams.get('name') || ''; const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, @@ -42,6 +44,7 @@ const ClientDetails = () => { chartData, groupedOrders, allTimeOrderCount, + clientName, clientPhone, hasClient, periodAverageTicket, @@ -49,8 +52,9 @@ const ClientDetails = () => { periodOrderCount, periodSpent } = useMemo(() => { - return buildClientDetailsMetrics(ordersData, decodedName, dateRange); - }, [dateRange, decodedName, ordersData]); + return buildClientDetailsMetrics(ordersData, decodedCustomerKey, dateRange); + }, [dateRange, decodedCustomerKey, ordersData]); + const displayName = requestedName || clientName || decodedCustomerKey.replace(/^name:/, ''); const formatCurrency = (value: number) => { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); @@ -102,7 +106,7 @@ const ClientDetails = () => {
-

{decodedName}

+

{displayName}

{formatNumber(allTimeOrderCount)} pedidos no histórico completo diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index b88adad..96b22d0 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -315,7 +315,7 @@ const Clients = () => { Ver detalhes diff --git a/src/pages/Rfm.tsx b/src/pages/Rfm.tsx index 53848ae..66d2c10 100644 --- a/src/pages/Rfm.tsx +++ b/src/pages/Rfm.tsx @@ -533,7 +533,7 @@ const Rfm = () => { return ( - +