From f88ae2d4911a8f5ea213521c3482633121e709f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 17 Jun 2026 11:23:46 -0300 Subject: [PATCH] Keep client data visible when RFV fails --- src/dataService.ts | 134 +++++++++++++++++++++++++++++++++++++++--- src/pages/Clients.tsx | 13 ++-- 2 files changed, 133 insertions(+), 14 deletions(-) diff --git a/src/dataService.ts b/src/dataService.ts index 3d72298..ff5d093 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -109,6 +109,128 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise = { + champions: 'Champions', + potential_loyalists: 'Potenciais Leais', + new_customers: 'Novos Clientes', + loyal_customers: 'Clientes Leais', + need_attention: 'Precisam de Atenção', + about_to_sleep: 'Quase Dormindo', + at_risk: 'Em Risco', + hibernating: 'Hibernando', + lost: 'Perdidos' +}; + +const rfmSegmentByScore: Record = { + '3-3': 'champions', + '3-2': 'potential_loyalists', + '3-1': 'new_customers', + '2-3': 'loyal_customers', + '2-2': 'need_attention', + '2-1': 'about_to_sleep', + '1-3': 'at_risk', + '1-2': 'hibernating', + '1-1': 'lost' +}; + +const scoreTertile = (value: number, values: number[], higherIsBetter = true): 1 | 2 | 3 => { + const numericValues = values.filter(Number.isFinite); + if (!numericValues.length) return 1; + if (numericValues.length === 1) return 3; + + const min = Math.min(...numericValues); + const max = Math.max(...numericValues); + if (min === max) return higherIsBetter ? 2 : 3; + + const sorted = [...numericValues].sort((a, b) => higherIsBetter ? a - b : b - a); + const index = sorted.findIndex(candidate => candidate === value); + const percentile = index / (sorted.length - 1); + + return Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1)) as 1 | 2 | 3; +}; + +const fetchClientAnalyticsForRange = async (dateRange: DateRange): Promise => { + 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(); +}; + +const buildFallbackRfmAnalytics = async (dateRange: DateRange): Promise => { + let clientRows: ClientAnalyticsItem[]; + try { + clientRows = await fetchClientAnalyticsForRange(dateRange); + } catch (error) { + console.error('Fetch fallback RFM analytics failed', error); + return null; + } + + if (!clientRows.length) return null; + + const rangeEndTime = dateRange.end.getTime(); + const recencyValues = clientRows.map(client => { + const lastPurchaseTime = client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : rangeEndTime; + return Math.max(0, Math.floor((rangeEndTime - lastPurchaseTime) / 86400000)); + }); + const frequencyValues = clientRows.map(client => client.orderCount); + const monetaryValues = clientRows.map(client => client.totalSpent); + + const clients = clientRows.map((client, index) => { + const recencyScore = scoreTertile(recencyValues[index], recencyValues, false); + const frequencyScore = scoreTertile(client.orderCount, frequencyValues); + const monetaryScore = scoreTertile(client.totalSpent, monetaryValues); + const valueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2))) as 1 | 2 | 3; + const segmentKey = rfmSegmentByScore[`${recencyScore}-${valueScore}`] || 'lost'; + + return { + customerKey: client.customerKey, + name: client.name, + phone: client.phone, + monetary: client.totalSpent, + frequency: client.orderCount, + quantityPurchased: client.quantityPurchased, + lastPurchaseDate: client.lastPurchaseDate, + recencyDays: recencyValues[index], + recencyScore, + frequencyScore, + monetaryScore, + valueScore, + rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`, + segmentKey, + segmentLabel: rfmSegmentLabels[segmentKey] || 'Perdidos' + }; + }); + + const segments = Object.entries(rfmSegmentLabels).map(([key, label]) => { + const segmentClients = clients.filter(client => client.segmentKey === key); + const totalRevenue = segmentClients.reduce((sum, client) => sum + client.monetary, 0); + + return { + key, + label, + count: segmentClients.length, + totalRevenue, + averageRevenue: segmentClients.length ? totalRevenue / segmentClients.length : 0 + }; + }); + + return { + range: { + start: formatDateParam(dateRange.start), + end: formatDateParam(dateRange.end) + }, + clients, + segments, + matrix: { + recencyScores: [3, 2, 1], + valueScores: [1, 2, 3] + } + }; +}; + export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise => { try { const params = new URLSearchParams({ @@ -131,23 +253,17 @@ export const fetchRfmAnalytics = async (dateRange: DateRange): Promise => { 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(); + return await fetchClientAnalyticsForRange(dateRange); } catch (error) { console.error('Fetch client analytics failed', error); return []; diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index 5e16317..11f2b3d 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -7,6 +7,7 @@ import DateRangePicker from '../components/DateRangePicker'; import type { ClientSortOption, ClientSummary } from '../analytics/clients'; const clientTypeStyles: Record = { + 'Sem análise': 'border-zinc-500/30 bg-zinc-500/15 text-zinc-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', 'Novo Cliente': 'border-cyan-500/30 bg-cyan-500/15 text-cyan-300', @@ -19,6 +20,7 @@ const clientTypeStyles: Record = { }; const clientTypes = [ + 'Sem análise', 'Campeão', 'Potencial Leal', 'Novo Cliente', @@ -81,12 +83,13 @@ const Clients = () => { let isMounted = true; const loadClients = async () => { - const [clientsData, rfmData] = await Promise.all([ - fetchClientAnalytics(dateRange), - fetchRfmAnalytics(dateRange) - ]); + const clientsData = await fetchClientAnalytics(dateRange); if (isMounted) { setClientAnalytics(clientsData); + } + + const rfmData = await fetchRfmAnalytics(dateRange); + if (isMounted) { setRfmAnalytics(rfmData); } }; @@ -111,7 +114,7 @@ const Clients = () => { totalItems: client.quantityPurchased, orderCount: client.orderCount, lastPurchase: client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : 0, - clientType: rfmClient ? (backendSegmentToClientType[rfmClient.segmentKey] || rfmClient.segmentLabel) : 'Perdido', + clientType: rfmClient ? (backendSegmentToClientType[rfmClient.segmentKey] || rfmClient.segmentLabel) : 'Sem análise', rfmScore: rfmClient?.rfmScore || '000', rfmPriority: rfmClient ? getRfmPriority(rfmClient) : 0 };