diff --git a/backend/db.js b/backend/db.js index 3d7d62b..d97898e 100644 --- a/backend/db.js +++ b/backend/db.js @@ -97,6 +97,15 @@ const initDB = async () => { ); `); + await pool.query(` + CREATE TABLE IF NOT EXISTS client_identity_tokens ( + customer_key TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + `); + await pool.query(` ALTER TABLE app_users ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', @@ -140,6 +149,13 @@ const initDB = async () => { await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`); + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_orders_customer_key_date + ON orders ( + (COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido'))), + data_pedido_date + ); + `); await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_produto_id ON orders (produto_id);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_data_pedido_date ON orders (data_pedido_date);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_app_users_email ON app_users (LOWER(email));`); diff --git a/backend/routes/analyticsRoutes.js b/backend/routes/analyticsRoutes.js index f8c9011..d8d2765 100644 --- a/backend/routes/analyticsRoutes.js +++ b/backend/routes/analyticsRoutes.js @@ -2,6 +2,7 @@ const express = require('express'); const { verifyToken } = require('../auth'); const { getClientAnalytics, + getClientDetailsAnalytics, getDashboardAnalytics, getProductAnalytics, getRfmAnalytics @@ -41,6 +42,21 @@ router.get('/analytics/clients', verifyToken, async (req, res) => { } }); +router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, res) => { + try { + const details = await getClientDetailsAnalytics(req.params.clientToken, getRange(req.query)); + if (!details) { + res.status(404).json({ error: 'Client not found' }); + return; + } + + res.json(details); + } catch (error) { + console.error('Error fetching client details analytics:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + router.get('/analytics/rfm', verifyToken, async (req, res) => { try { res.json(await getRfmAnalytics(getRange(req.query))); diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 44d4ced..c729c29 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -1,3 +1,4 @@ +const crypto = require('node:crypto'); const { pool } = require('../db'); const RFM_QUERY_TIMEOUT_MS = 15000; @@ -5,6 +6,7 @@ const RECENT_MAX_DAYS = 60; const COOLING_MAX_DAYS = 180; const LOST_MIN_DAYS = 366; const FREQUENCY_MEDIUM_MAX_ORDERS = 4; +const CLIENT_TOKEN_VERSION = 'v1'; const SIZE_SUFFIX_SQL_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}))*)$'; const PRODUCT_NAME_SQL = ` CASE @@ -14,6 +16,92 @@ const PRODUCT_NAME_SQL = ` `; const CUSTOMER_KEY_SQL = "COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido'))"; +const getClientTokenSecret = () => ( + process.env.CLIENT_TOKEN_SECRET || + process.env.JWT_SECRET || + process.env.API_KEY || + 'nexstar-client-token-development-secret' +); + +let clientTokenSecretCache = null; +const getClientTokenHashSecret = () => { + const secret = getClientTokenSecret(); + if (clientTokenSecretCache?.secret === secret) return clientTokenSecretCache.hashSecret; + + clientTokenSecretCache = { + secret, + hashSecret: crypto.createHash('sha256').update(`${secret}:client-token`).digest('base64url') + }; + return clientTokenSecretCache.hashSecret; +}; + +const createClientToken = (customerKey) => { + if (!customerKey) return ''; + + const normalizedCustomerKey = String(customerKey); + const digest = crypto + .createHash('sha256') + .update(getClientTokenHashSecret()) + .update(':') + .update(normalizedCustomerKey) + .digest('base64url'); + + return `${CLIENT_TOKEN_VERSION}.${digest}`; +}; + +const isClientToken = (clientToken) => { + const parts = String(clientToken || '').split('.'); + return parts.length === 2 && parts[0] === CLIENT_TOKEN_VERSION && /^[A-Za-z0-9_-]+$/.test(parts[1] || ''); +}; + +const persistClientTokenMappings = async (clients, queryable = pool) => { + const mappingsByCustomerKey = new Map(); + + clients.forEach(client => { + const customerKey = client.customerKey || client.customer_key; + const clientToken = client.clientToken || createClientToken(customerKey); + if (customerKey && clientToken) { + mappingsByCustomerKey.set(customerKey, clientToken); + } + }); + + const mappings = [...mappingsByCustomerKey.entries()]; + const chunkSize = 5000; + + for (let index = 0; index < mappings.length; index += chunkSize) { + const chunk = mappings.slice(index, index + chunkSize); + const params = []; + const values = chunk.map(([customerKey, clientToken], chunkIndex) => { + params.push(customerKey, clientToken); + const offset = chunkIndex * 2; + return `($${offset + 1}, $${offset + 2})`; + }); + + await queryable.query(` + INSERT INTO client_identity_tokens (customer_key, token) + VALUES ${values.join(', ')} + ON CONFLICT (customer_key) + DO UPDATE SET + token = EXCLUDED.token, + updated_at = NOW() + WHERE client_identity_tokens.token IS DISTINCT FROM EXCLUDED.token; + `, params); + } +}; + +const resolveClientToken = async (clientToken) => { + if (!isClientToken(clientToken)) return null; + + const result = await pool.query(` + SELECT customer_key + FROM client_identity_tokens + WHERE token = $1 + LIMIT 1; + `, [clientToken]); + + return result.rows[0]?.customer_key || null; +}; + const normalizeDateParam = (value) => { if (!value) return null; @@ -356,8 +444,9 @@ const getClientAnalytics = async (range = {}) => { ORDER BY total_spent DESC; `, params); - return result.rows.map(row => ({ + const clients = result.rows.map(row => ({ customerKey: row.customer_key, + clientToken: createClientToken(row.customer_key), name: row.name, phone: row.phone || '', quantityPurchased: toNumber(row.quantity_purchased), @@ -365,6 +454,140 @@ const getClientAnalytics = async (range = {}) => { orderCount: toNumber(row.order_count), lastPurchaseDate: row.last_purchase_date })); + + await persistClientTokenMappings(clients); + return clients; +}; + +const getOrderGroupKey = (row) => ( + row.pedido_id || + `${row.data_pedido || getDateOnly(row.data_pedido_date) || ''}_${row.valor_pedido || 0}` +); + +const getClientDetailsAnalytics = async (clientToken, range = {}) => { + const customerKey = await resolveClientToken(clientToken); + if (!customerKey) return null; + + const normalizedStart = normalizeDateParam(range.start); + const normalizedEnd = normalizeDateParam(range.end); + const periodParams = [customerKey]; + const periodFilters = [ + `${CUSTOMER_KEY_SQL} = $1`, + 'data_pedido_date IS NOT NULL' + ]; + + if (normalizedStart) { + periodParams.push(normalizedStart); + periodFilters.push(`data_pedido_date >= $${periodParams.length}::date`); + } + + if (normalizedEnd) { + periodParams.push(normalizedEnd); + periodFilters.push(`data_pedido_date <= $${periodParams.length}::date`); + } + + const [summaryResult, periodResult] = await Promise.all([ + pool.query(` + SELECT + MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, + MAX(NULLIF(cliente_fone, '')) as phone, + COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as all_time_order_count + FROM orders + WHERE ${CUSTOMER_KEY_SQL} = $1 + AND data_pedido_date IS NOT NULL; + `, [customerKey]), + pool.query(` + SELECT + cliente_nome, + cliente_fone, + data_pedido, + data_pedido_date, + valor_pedido, + produto_id, + produto_descricao, + quantidade, + valor_unitario, + pedido_id + FROM orders + WHERE ${periodFilters.join(' AND ')} + ORDER BY data_pedido_date DESC, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) DESC; + `, periodParams) + ]); + + const summary = summaryResult.rows[0] || {}; + const allTimeOrderCount = toNumber(summary.all_time_order_count); + if (!allTimeOrderCount) return null; + + const groupedOrdersByKey = new Map(); + const spentByDate = new Map(); + let periodSpent = 0; + let periodItems = 0; + + periodResult.rows.forEach(row => { + const itemRevenue = toNumber(row.quantidade) * toNumber(row.valor_unitario); + const dateKey = getDateOnly(row.data_pedido_date) || getDateOnly(row.data_pedido) || ''; + const dateLabel = row.data_pedido || dateKey; + const groupKey = getOrderGroupKey(row); + + periodSpent += itemRevenue; + periodItems += toNumber(row.quantidade); + + if (dateLabel) { + const currentDateSpend = spentByDate.get(dateLabel) || { date: dateLabel, sortDate: dateKey, value: 0 }; + currentDateSpend.value += itemRevenue; + spentByDate.set(dateLabel, currentDateSpend); + } + + if (!groupedOrdersByKey.has(groupKey)) { + groupedOrdersByKey.set(groupKey, { + date: dateLabel, + sortDate: dateKey, + orderId: row.pedido_id || groupKey, + orderTotal: 0, + items: [] + }); + } + + const group = groupedOrdersByKey.get(groupKey); + group.orderTotal += itemRevenue; + group.items.push({ + Nome_Cliente: row.cliente_nome || summary.name || 'Cliente Desconhecido', + Data_Pedido: dateLabel, + Valor_Pedido: toNumber(row.valor_pedido), + ID_Produto: row.produto_id || '', + Descricao_Produto: row.produto_descricao || 'Unknown', + Quantidade: toNumber(row.quantidade), + Valor_Unitario: toNumber(row.valor_unitario), + ID_Pedido: row.pedido_id || '', + Fone_Cliente: row.cliente_fone || '' + }); + }); + + const groupedOrders = [...groupedOrdersByKey.values()] + .sort((a, b) => String(b.sortDate).localeCompare(String(a.sortDate))) + .map(({ sortDate, ...group }) => group); + const chartData = [...spentByDate.values()] + .sort((a, b) => String(a.sortDate).localeCompare(String(b.sortDate))) + .map(({ sortDate, ...entry }) => entry); + const periodOrderCount = groupedOrders.length; + + return { + range: { + start: normalizedStart, + end: normalizedEnd + }, + clientToken, + clientName: summary.name || 'Cliente Desconhecido', + clientPhone: summary.phone || '', + hasClient: true, + allTimeOrderCount, + periodSpent, + periodAverageTicket: periodOrderCount ? periodSpent / periodOrderCount : 0, + periodOrderCount, + periodItems, + chartData, + groupedOrders + }; }; const getRfmAnalytics = async (range = {}) => { @@ -379,6 +602,7 @@ const getRfmAnalytics = async (range = {}) => { const recencyEnd = normalizedEnd || new Date().toISOString().slice(0, 10); const clients = buildRfmClients(clientRows.map(row => ({ customerKey: row.customerKey, + clientToken: row.clientToken || createClientToken(row.customerKey), name: row.name, phone: row.phone || '', monetary: row.totalSpent, @@ -492,6 +716,7 @@ const getRfmAnalytics = async (range = {}) => { const historyClients = buildRfmClients(historyRows.map(row => ({ customerKey: row.customer_key, + clientToken: createClientToken(row.customer_key), name: row.name, phone: row.phone || '', monetary: toNumber(row.monetary), @@ -509,6 +734,7 @@ const getRfmAnalytics = async (range = {}) => { if (!taggedClient) { const [fallbackClient] = buildRfmClients([{ customerKey: row.customer_key, + clientToken: createClientToken(row.customer_key), name: row.name, phone: row.phone || '', monetary: toNumber(row.monetary), @@ -523,6 +749,7 @@ const getRfmAnalytics = async (range = {}) => { return { ...taggedClient, customerKey: row.customer_key, + clientToken: taggedClient.clientToken || createClientToken(row.customer_key), name: row.name, phone: row.phone || '', monetary: toNumber(row.monetary), @@ -536,6 +763,7 @@ const getRfmAnalytics = async (range = {}) => { return b.monetary - a.monetary; }); + await persistClientTokenMappings(clients, client); await client.query('COMMIT'); return { @@ -562,7 +790,10 @@ module.exports = { buildDateFilter, buildRfmClients, buildRfmSegments, + createClientToken, + isClientToken, getFrequencyScore, + getClientDetailsAnalytics, getPreviousDate, getRecencyScore, getRfmAnalytics, diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 31f8a48..90dd8df 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -6,6 +6,10 @@ const { buildRfmSegments, buildDateFilter, getFrequencyScore, + createClientToken, + isClientToken, + getClientAnalytics, + getClientDetailsAnalytics, getPreviousDate, getRecencyScore, getRfmAnalytics, @@ -91,6 +95,21 @@ test('getPreviousDate returns the calendar day before an ISO date', () => { assert.equal(getPreviousDate('invalid'), null); }); +test('client tokens are opaque and stable for customer keys', () => { + const phoneKey = '(16) 99103-6131'; + const nameKey = 'name:Cliente Sem Fone'; + const phoneToken = createClientToken(phoneKey); + const nameToken = createClientToken(nameKey); + + assert.ok(isClientToken(phoneToken)); + assert.ok(isClientToken(nameToken)); + assert.equal(createClientToken(phoneKey), phoneToken); + assert.notEqual(createClientToken('name:Marcela Abreu'), phoneToken); + assert.doesNotMatch(phoneToken, /99103|6131|\(16\)/); + assert.doesNotMatch(nameToken, /Cliente|Sem|Fone/); + assert.equal(isClientToken('invalid-token'), false); +}); + test('scoreTertile scores higher values higher by default', () => { const values = [10, 20, 30, 40, 50]; @@ -311,6 +330,154 @@ test('buildRfmClients applies lifecycle protections to new, hibernating, at-risk assert.equal(byKey.get('lost').rfmScore, '113'); }); +test('getClientAnalytics returns opaque client tokens', async () => { + const originalQuery = pool.query; + const calls = []; + + pool.query = async (sql, params = []) => { + calls.push({ sql, params }); + + if (sql.includes('INSERT INTO client_identity_tokens')) { + return { rows: [] }; + } + + return { + rows: [ + { + customer_key: '(16) 99103-6131', + name: 'Marcela Abreu', + phone: '(16) 99103-6131', + quantity_purchased: 10, + total_spent: 500, + order_count: 2, + last_purchase_date: '2026-06-15' + }, + { + customer_key: 'name:Cliente Sem Fone', + name: 'Cliente Sem Fone', + phone: null, + quantity_purchased: 1, + total_spent: 50, + order_count: 1, + last_purchase_date: '2026-06-10' + } + ] + }; + }; + + try { + const clients = await getClientAnalytics({ start: '2026-06-01', end: '2026-06-15' }); + + assert.equal(calls.length, 2); + assert.equal(clients.length, 2); + assert.match(calls[1].sql, /INSERT INTO client_identity_tokens/); + assert.deepEqual(calls[1].params, [ + '(16) 99103-6131', + clients[0].clientToken, + 'name:Cliente Sem Fone', + clients[1].clientToken + ]); + assert.doesNotMatch(clients[0].clientToken, /99103|6131|Marcela/); + assert.doesNotMatch(clients[1].clientToken, /Cliente|Fone/); + assert.ok(isClientToken(clients[0].clientToken)); + assert.ok(isClientToken(clients[1].clientToken)); + } finally { + pool.query = originalQuery; + } +}); + +test('getClientDetailsAnalytics fetches only the tokenized client and period rows', async () => { + const originalQuery = pool.query; + const calls = []; + const clientToken = createClientToken('name:Cliente Sem Fone'); + + pool.query = async (sql, params = []) => { + calls.push({ sql, params }); + + if (sql.includes('FROM client_identity_tokens')) { + return { rows: [{ customer_key: 'name:Cliente Sem Fone' }] }; + } + + if (sql.includes('all_time_order_count')) { + return { + rows: [{ + name: 'Cliente Sem Fone', + phone: null, + all_time_order_count: 3 + }] + }; + } + + return { + rows: [ + { + cliente_nome: 'Cliente Sem Fone', + cliente_fone: null, + data_pedido: '10-06-2026', + data_pedido_date: '2026-06-10', + valor_pedido: 25, + produto_id: 'produto-1', + produto_descricao: 'Produto A', + quantidade: 2, + valor_unitario: 10, + pedido_id: 'pedido-1' + }, + { + cliente_nome: 'Cliente Sem Fone', + cliente_fone: null, + data_pedido: '10-06-2026', + data_pedido_date: '2026-06-10', + valor_pedido: 25, + produto_id: 'produto-2', + produto_descricao: 'Produto B', + quantidade: 1, + valor_unitario: 5, + pedido_id: 'pedido-1' + } + ] + }; + }; + + try { + const details = await getClientDetailsAnalytics(clientToken, { start: '2026-06-01', end: '2026-06-15' }); + + assert.equal(calls.length, 3); + assert.match(calls[0].sql, /FROM client_identity_tokens/); + assert.deepEqual(calls[0].params, [clientToken]); + assert.match(calls[1].sql, /WHERE COALESCE\(NULLIF\(cliente_fone, ''\), 'name:' \|\| COALESCE\(NULLIF\(cliente_nome, ''\), 'Cliente Desconhecido'\)\) = \$1/); + assert.deepEqual(calls[1].params, ['name:Cliente Sem Fone']); + assert.match(calls[2].sql, /data_pedido_date >= \$2::date/); + assert.match(calls[2].sql, /data_pedido_date <= \$3::date/); + assert.deepEqual(calls[2].params, ['name:Cliente Sem Fone', '2026-06-01', '2026-06-15']); + assert.equal(details.clientName, 'Cliente Sem Fone'); + assert.equal(details.clientPhone, ''); + assert.equal(details.allTimeOrderCount, 3); + assert.equal(details.periodSpent, 25); + assert.equal(details.periodItems, 3); + assert.equal(details.periodOrderCount, 1); + assert.equal(details.periodAverageTicket, 25); + assert.deepEqual(details.chartData, [{ date: '10-06-2026', value: 25 }]); + assert.equal(details.groupedOrders.length, 1); + assert.equal(details.groupedOrders[0].orderTotal, 25); + assert.equal(details.groupedOrders[0].items.length, 2); + } finally { + pool.query = originalQuery; + } +}); + +test('getClientDetailsAnalytics rejects invalid client tokens before querying', async () => { + const originalQuery = pool.query; + pool.query = async () => { + throw new Error('invalid client token should not query'); + }; + + try { + assert.equal(await getClientDetailsAnalytics('not-a-token', { start: '2026-06-01', end: '2026-06-15' }), null); + } finally { + pool.query = originalQuery; + } +}); + test('getRfmAnalytics classifies period buyers by history through the selected range end', async () => { const originalConnect = pool.connect; const calls = []; @@ -452,6 +619,10 @@ test('getRfmAnalytics reuses client aggregate rows as RFV history for all-period pool.query = async (sql, params = []) => { calls.push({ sql, params }); + if (sql.includes('INSERT INTO client_identity_tokens')) { + return { rows: [] }; + } + return { rows: [ { @@ -482,11 +653,12 @@ test('getRfmAnalytics reuses client aggregate rows as RFV history for all-period try { const result = await getRfmAnalytics({ start: '2000-01-01', end: '2026-06-15' }); - assert.equal(calls.length, 1); + assert.equal(calls.length, 2); assert.match(calls[0].sql, /GROUP BY customer_key/); assert.doesNotMatch(calls[0].sql, /recency_days/); assert.doesNotMatch(calls[0].sql, /data_pedido_date >=/); assert.deepEqual(calls[0].params, ['2026-06-15']); + assert.match(calls[1].sql, /INSERT INTO client_identity_tokens/); assert.equal(result.clients.length, 2); assert.equal(result.segments.reduce((total, segment) => total + segment.count, 0), 2); assert.ok(result.clients.some(client => client.customerKey === 'name:Cliente Sem Fone' && client.phone === '')); diff --git a/src/App.tsx b/src/App.tsx index c701567..9e48606 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,7 +46,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/analytics/clients.ts b/src/analytics/clients.ts index 3417433..6b65df6 100644 --- a/src/analytics/clients.ts +++ b/src/analytics/clients.ts @@ -13,6 +13,7 @@ export type ClientSortOption = export interface ClientSummary { customerKey: string; + clientToken: string; name: string; phone: string; totalSpent: number; @@ -98,6 +99,7 @@ const enrichClientsWithRfmType = ( return { ...client, + clientToken: client.clientToken || client.customerKey, averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0, clientType: getClientType(recencyScore, valueScore), rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`, @@ -147,6 +149,7 @@ export const buildClientsSummary = ( const normalizedSearch = searchTerm.trim().toLowerCase(); const clients = enrichClientsWithRfmType(Object.keys(clientMap).map(customerKey => ({ customerKey, + clientToken: customerKey, name: clientMap[customerKey].name, phone: clientMap[customerKey].phone, totalSpent: clientMap[customerKey].totalSpent, diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index d2c4e39..12c67ba 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -7,8 +7,7 @@ import { rangeForLastDays } from '../dateRanges'; const Layout = () => { const location = useLocation(); - const needsRawData = location.pathname.startsWith('/products') || - (location.pathname.startsWith('/clients/') && location.pathname !== '/clients'); + const needsRawData = location.pathname.startsWith('/products'); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { return localStorage.getItem('graph_sidebar_collapsed') === 'true'; }); @@ -46,7 +45,7 @@ const Layout = () => { useEffect(() => { if (!needsRawData) return; - // Product pages and client details still depend on raw orders until their API migration is complete. + // Product pages still depend on raw orders until their API migration is complete. // eslint-disable-next-line react-hooks/set-state-in-effect void loadData(true); }, [loadData, needsRawData]); diff --git a/src/dataService.ts b/src/dataService.ts index 5242853..f3af46d 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, RfmAnalytics, StockData } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, RfmAnalytics, StockData } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -154,6 +154,21 @@ export const fetchClientAnalytics = async (dateRange: DateRange): Promise => { + try { + const params = new URLSearchParams({ + start: formatDateParam(dateRange.start), + end: formatDateParam(dateRange.end) + }); + const response = await authFetch(`/analytics/clients/${encodeURIComponent(clientToken)}/details?${params.toString()}`); + if (!response.ok) return null; + return await response.json(); + } catch (error) { + console.error('Fetch client details analytics failed', error); + return null; + } +}; + export const fetchCampaigns = async (): Promise => { try { const response = await authFetch('/campaigns'); diff --git a/src/pages/ClientDetails.tsx b/src/pages/ClientDetails.tsx index 77719ee..d5d2b30 100644 --- a/src/pages/ClientDetails.tsx +++ b/src/pages/ClientDetails.tsx @@ -1,10 +1,10 @@ -import { useMemo, useState } from 'react'; -import { useParams, Link, useOutletContext, useSearchParams } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { useParams, Link, useOutletContext } 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'; -import type { DateRange, OrderData } from '../types'; -import { buildClientDetailsMetrics } from '../analytics/clients'; +import type { ClientDetailsAnalytics, DateRange } from '../types'; +import { fetchClientDetailsAnalytics } from '../dataService'; type CustomTooltipProps = { active?: boolean; @@ -27,34 +27,44 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { }; const ClientDetails = () => { - const { customerKey } = useParams<{ customerKey: string }>(); - const decodedCustomerKey = customerKey ? decodeURIComponent(customerKey) : ''; - const [searchParams] = useSearchParams(); - const requestedName = searchParams.get('name') || ''; - const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{ + const { clientToken } = useParams<{ clientToken: string }>(); + const decodedClientToken = clientToken ? decodeURIComponent(clientToken) : ''; + const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange, - setDateRange: (range: DateRange) => void, - ordersData: OrderData[], - isDataLoading: boolean + setDateRange: (range: DateRange) => void }>(); + const [details, setDetails] = useState(null); + const [isLoading, setIsLoading] = useState(true); const [currentPage, setCurrentPage] = useState(1); const [ordersPerPage, setOrdersPerPage] = useState(5); - const { - chartData, - groupedOrders, - allTimeOrderCount, - clientName, - clientPhone, - hasClient, - periodAverageTicket, - periodItems, - periodOrderCount, - periodSpent - } = useMemo(() => { - return buildClientDetailsMetrics(ordersData, decodedCustomerKey, dateRange); - }, [dateRange, decodedCustomerKey, ordersData]); - const displayName = requestedName || clientName || decodedCustomerKey.replace(/^name:/, ''); + useEffect(() => { + let isMounted = true; + + const loadClientDetails = async () => { + if (!decodedClientToken) { + if (isMounted) { + setDetails(null); + setIsLoading(false); + } + return; + } + + setIsLoading(true); + const nextDetails = await fetchClientDetailsAnalytics(decodedClientToken, dateRange); + + if (isMounted) { + setDetails(nextDetails); + setIsLoading(false); + } + }; + + void loadClientDetails(); + + return () => { + isMounted = false; + }; + }, [dateRange, decodedClientToken]); const formatCurrency = (value: number) => { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); @@ -69,12 +79,7 @@ const ClientDetails = () => { setDateRange(range); }; - const totalPages = Math.ceil(groupedOrders.length / ordersPerPage); - const safeCurrentPage = Math.min(currentPage, totalPages || 1); - const startIndex = (safeCurrentPage - 1) * ordersPerPage; - const paginatedOrders = groupedOrders.slice(startIndex, startIndex + ordersPerPage); - - if (!hasClient && isDataLoading) { + if (isLoading) { return (

Carregando cliente...

@@ -82,7 +87,7 @@ const ClientDetails = () => { ); } - if (!hasClient) { + if (!details?.hasClient) { return (

Cliente não encontrado.

@@ -91,6 +96,23 @@ const ClientDetails = () => { ); } + const { + chartData, + groupedOrders, + allTimeOrderCount, + clientName, + clientPhone, + periodAverageTicket, + periodItems, + periodOrderCount, + periodSpent + } = details; + const displayName = clientName || 'Cliente'; + const totalPages = Math.ceil(groupedOrders.length / ordersPerPage); + const safeCurrentPage = Math.min(currentPage, totalPages || 1); + const startIndex = (safeCurrentPage - 1) * ordersPerPage; + const paginatedOrders = groupedOrders.slice(startIndex, startIndex + ordersPerPage); + return (
{/* Header Area */} diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index 96b22d0..08b5775 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -109,6 +109,7 @@ const Clients = () => { const rfmClient = rfmByCustomerKey.get(client.customerKey); return { customerKey: client.customerKey, + clientToken: client.clientToken, name: client.name, phone: client.phone, totalSpent: client.totalSpent, @@ -315,7 +316,7 @@ const Clients = () => { Ver detalhes diff --git a/src/pages/Rfm.tsx b/src/pages/Rfm.tsx index 1d7ae2f..e46d1e2 100644 --- a/src/pages/Rfm.tsx +++ b/src/pages/Rfm.tsx @@ -533,7 +533,7 @@ const Rfm = () => { return ( - + diff --git a/src/types.ts b/src/types.ts index 3abbb59..c144461 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,6 +65,7 @@ export interface DashboardAnalytics { export interface ClientAnalyticsItem { customerKey: string; + clientToken: string; name: string; phone: string; quantityPurchased: number; @@ -75,6 +76,7 @@ export interface ClientAnalyticsItem { export interface RfmClient { customerKey: string; + clientToken: string; name: string; phone: string; monetary: number; @@ -112,6 +114,34 @@ export interface RfmAnalytics { }; } +export interface GroupedClientOrder { + date: string; + orderId: string; + orderTotal: number; + items: OrderData[]; +} + +export interface ClientDetailsAnalytics { + range: { + start: string | null; + end: string | null; + }; + clientToken: string; + chartData: Array<{ + date: string; + value: number; + }>; + groupedOrders: GroupedClientOrder[]; + allTimeOrderCount: number; + clientName: string; + clientPhone: string; + hasClient: boolean; + periodAverageTicket: number; + periodOrderCount: number; + periodSpent: number; + periodItems: number; +} + export type CampaignStatus = 'pending' | 'processing' | 'sent' | 'failed' | 'skipped'; export interface CampaignQueueItem {