From 8b3ac60883bea180a249666c631767cc6c688a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 26 Jun 2026 15:28:33 -0300 Subject: [PATCH] Add client purchase pattern charts --- backend/services/analyticsService.js | 56 ++++++++++++++++- src/analytics/clients.ts | 44 ++++++++++++++ src/pages/ClientDetails.tsx | 90 +++++++++++++++++++++++++++- src/types.ts | 8 +++ 4 files changed, 195 insertions(+), 3 deletions(-) diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 7ae9ce3..74c232e 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -414,6 +414,35 @@ const getDateOnly = (value) => { return match ? match[1] : null; }; +const WEEKDAY_LABELS = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab']; + +const getWeekdayIndex = (value) => { + const dateKey = getDateOnly(value); + if (!dateKey) return null; + + const date = new Date(`${dateKey}T00:00:00`); + if (Number.isNaN(date.getTime())) return null; + + return date.getDay(); +}; + +const getHourFromTimestamp = (value) => { + if (!value) return null; + + const rawValue = String(value); + const hasTime = /\b\d{1,2}:\d{2}/.test(rawValue); + if (!hasTime) return null; + + const date = value instanceof Date ? value : new Date(rawValue); + if (!Number.isNaN(date.getTime())) return date.getHours(); + + const timeMatch = rawValue.match(/\b(\d{1,2}):\d{2}/); + if (!timeMatch) return null; + + const hour = Number(timeMatch[1]); + return hour >= 0 && hour <= 23 ? hour : null; +}; + const buildRfmSegments = (clients) => { return Object.values(RFM_SEGMENTS).map(segment => { const segmentClients = clients.filter(client => client.segmentKey === segment.key); @@ -910,7 +939,8 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { nome_vendedor, marketplace, canal_venda, - numero_ecommerce + numero_ecommerce, + created_at FROM identity_orders WHERE ${periodFilters.join(' AND ')} ORDER BY data_pedido_date DESC, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) DESC; @@ -923,6 +953,12 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { const groupedOrdersByKey = new Map(); const spentByDate = new Map(); + const weekdayCounts = WEEKDAY_LABELS.map(label => ({ label, value: 0 })); + const hourCounts = Array.from({ length: 24 }, (_, hour) => ({ + label: `${String(hour).padStart(2, '0')}h`, + value: 0 + })); + const patternOrderKeys = new Set(); let periodSpent = 0; let periodItems = 0; @@ -932,6 +968,19 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { const dateLabel = row.data_pedido || dateKey; const groupKey = getOrderGroupKey(row); + if (!patternOrderKeys.has(groupKey)) { + patternOrderKeys.add(groupKey); + const weekdayIndex = getWeekdayIndex(row.data_pedido_date) ?? getWeekdayIndex(row.data_pedido); + if (weekdayIndex !== null) { + weekdayCounts[weekdayIndex].value += 1; + } + + const orderHour = getHourFromTimestamp(row.created_at) ?? getHourFromTimestamp(row.data_pedido); + if (orderHour !== null) { + hourCounts[orderHour].value += 1; + } + } + periodSpent += itemRevenue; periodItems += toNumber(row.quantidade); @@ -968,7 +1017,8 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { nome_vendedor: row.nome_vendedor || '', marketplace: row.marketplace || '', canal_venda: row.canal_venda || '', - numero_ecommerce: row.numero_ecommerce || '' + numero_ecommerce: row.numero_ecommerce || '', + Recebido_Em: row.created_at || '' }); }); @@ -995,6 +1045,8 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { periodOrderCount, periodItems, chartData, + purchaseWeekdays: weekdayCounts, + purchaseHours: hourCounts, groupedOrders }; }; diff --git a/src/analytics/clients.ts b/src/analytics/clients.ts index 6b65df6..534b419 100644 --- a/src/analytics/clients.ts +++ b/src/analytics/clients.ts @@ -38,6 +38,14 @@ export interface ClientDetailsMetrics { date: string; value: number; }>; + purchaseWeekdays: Array<{ + label: string; + value: number; + }>; + purchaseHours: Array<{ + label: string; + value: number; + }>; groupedOrders: GroupedClientOrder[]; allTimeOrderCount: number; clientName: string; @@ -49,6 +57,22 @@ export interface ClientDetailsMetrics { periodItems: number; } +const WEEKDAY_LABELS = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab']; + +const getOrderHour = (order: OrderData): number | null => { + const timestamp = order.Recebido_Em || (/\d{1,2}:\d{2}/.test(order.Data_Pedido || '') ? order.Data_Pedido : ''); + if (!timestamp) return null; + + const date = new Date(timestamp); + if (!Number.isNaN(date.getTime())) return date.getHours(); + + const timeMatch = String(timestamp).match(/\b(\d{1,2}):\d{2}/); + if (!timeMatch) return null; + + const hour = Number(timeMatch[1]); + return hour >= 0 && hour <= 23 ? hour : null; +}; + const scoreTertile = (value: number, values: number[], higherIsBetter = true) => { const numericValues = values.filter(Number.isFinite); if (!numericValues.length) return 1; @@ -230,9 +254,29 @@ export const buildClientDetailsMetrics = (ordersData: OrderData[], customerKey: date, value: spentByDate[date] })).sort((a, b) => parseOrderDate(a.date).getTime() - parseOrderDate(b.date).getTime()); + const weekdayCounts = WEEKDAY_LABELS.map(label => ({ label, value: 0 })); + const hourCounts = Array.from({ length: 24 }, (_, hour) => ({ + label: `${String(hour).padStart(2, '0')}h`, + value: 0 + })); + + groupedOrders.forEach(group => { + const firstItem = group.items[0]; + const orderDate = parseOrderDate(group.date); + if (!Number.isNaN(orderDate.getTime())) { + weekdayCounts[orderDate.getDay()].value += 1; + } + + const orderHour = firstItem ? getOrderHour(firstItem) : null; + if (orderHour !== null) { + hourCounts[orderHour].value += 1; + } + }); return { chartData, + purchaseWeekdays: weekdayCounts, + purchaseHours: hourCounts, groupedOrders, allTimeOrderCount: allTimeOrderIds.size, clientName, diff --git a/src/pages/ClientDetails.tsx b/src/pages/ClientDetails.tsx index 7055ff4..e1224d8 100644 --- a/src/pages/ClientDetails.tsx +++ b/src/pages/ClientDetails.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useParams, Link, useOutletContext } from 'react-router-dom'; import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone, ChevronDown, ChevronLeft, ChevronRight, ShoppingBag, ReceiptText } from 'lucide-react'; -import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { AreaChart, Area, BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import DateRangePicker from '../components/DateRangePicker'; import type { ClientDetailsAnalytics, DateRange, OrderData } from '../types'; import { fetchClientDetailsAnalytics } from '../dataService'; @@ -11,6 +11,8 @@ const CHART_GRID_COLOR = 'var(--chart-grid)'; const CHART_AXIS_COLOR = 'var(--chart-axis)'; const CHART_CURSOR_COLOR = 'var(--chart-cursor)'; const CHART_DETAIL_BAR_COLOR = 'var(--chart-detail-bar)'; +const WEEKDAY_BAR_COLOR = '#25C2FF'; +const HOUR_BAR_COLOR = '#52DFA0'; type CustomTooltipProps = { active?: boolean; @@ -32,6 +34,21 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { return null; }; +const PatternTooltip = ({ active, payload, label }: CustomTooltipProps) => { + if (active && payload && payload.length) { + const value = payload[0].value; + return ( +
+

{label}

+

+ {value} {value === 1 ? 'pedido' : 'pedidos'} +

+
+ ); + } + return null; +}; + const getOrderMetadata = (order: OrderData) => { const sellerName = formatDisplayName(removeTrailingSellerId(order.nome_vendedor || '')); @@ -131,6 +148,8 @@ const ClientDetails = () => { const { chartData, groupedOrders, + purchaseHours = [], + purchaseWeekdays = [], allTimeOrderCount, clientName, clientPhone, @@ -144,6 +163,8 @@ const ClientDetails = () => { const safeCurrentPage = Math.min(currentPage, totalPages || 1); const startIndex = (safeCurrentPage - 1) * ordersPerPage; const paginatedOrders = groupedOrders.slice(startIndex, startIndex + ordersPerPage); + const hasWeekdayPattern = purchaseWeekdays.some(day => day.value > 0); + const hasHourPattern = purchaseHours.some(hour => hour.value > 0); return (
@@ -268,6 +289,73 @@ const ClientDetails = () => {
)} + +
+
+

Padrão de Compra

+

Quando este cliente costuma comprar.

+
+ +
+
+

Compras por Dia

+ {hasWeekdayPattern ? ( +
+ + + + + + } cursor={{ fill: CHART_CURSOR_COLOR }} /> + + {purchaseWeekdays.map(day => ( + + ))} + + + +
+ ) : ( +
+ Sem compras no período. +
+ )} +
+ +
+

Compras por Horário

+ {hasHourPattern ? ( +
+ + + + Number(String(value).replace('h', '')) % 3 === 0 ? String(value) : ''} + /> + + } cursor={{ fill: CHART_CURSOR_COLOR }} /> + + {purchaseHours.map(hour => ( + + ))} + + + +
+ ) : ( +
+ Sem horário de compra disponível para este período. +
+ )} +
+
+
{/* Orders List */}
{paginatedOrders.length === 0 ? ( diff --git a/src/types.ts b/src/types.ts index df2ddd8..d7f3af1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -203,6 +203,14 @@ export interface ClientDetailsAnalytics { date: string; value: number; }>; + purchaseWeekdays?: Array<{ + label: string; + value: number; + }>; + purchaseHours?: Array<{ + label: string; + value: number; + }>; groupedOrders: GroupedClientOrder[]; allTimeOrderCount: number; clientName: string;