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'} +
+Quando este cliente costuma comprar.
+