diff --git a/backend/routes/analyticsRoutes.js b/backend/routes/analyticsRoutes.js index ac33eb9..bb425e1 100644 --- a/backend/routes/analyticsRoutes.js +++ b/backend/routes/analyticsRoutes.js @@ -4,6 +4,7 @@ const { getClientAnalytics, getClientDetailsAnalytics, getClientFilterOptions, + getClientPurchasePatternAnalytics, getDashboardAnalytics, getProductAnalytics, getProductDetailsAnalytics, @@ -75,6 +76,15 @@ router.get('/analytics/clients/filters', verifyToken, async (req, res) => { } }); +router.get('/analytics/clients/purchase-pattern', verifyToken, async (req, res) => { + try { + res.json(await getClientPurchasePatternAnalytics()); + } catch (error) { + console.error('Error fetching client purchase pattern analytics:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, res) => { try { const details = await getClientDetailsAnalytics(req.params.clientToken, getRange(req.query)); diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 446e1ef..5dee928 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -942,6 +942,58 @@ const getClientFilterOptions = async () => { }; }; +const getClientPurchasePatternAnalytics = async () => { + const result = await pool.query(` + ${CUSTOMER_IDENTITY_CTE}, + order_events AS ( + SELECT DISTINCT ON ( + ${CUSTOMER_KEY_SQL}, + COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) + ) + data_pedido_date, + created_at + FROM identity_orders + WHERE data_pedido_date IS NOT NULL + ORDER BY + ${CUSTOMER_KEY_SQL}, + COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text), + created_at ASC NULLS LAST + ) + SELECT + EXTRACT(DOW FROM data_pedido_date)::int as weekday, + EXTRACT(HOUR FROM created_at AT TIME ZONE 'America/Sao_Paulo')::int as hour, + COUNT(*)::int as order_count + FROM order_events + GROUP BY weekday, hour + ORDER BY weekday ASC, hour ASC; + `); + + const purchaseWeekdays = WEEKDAY_LABELS.map(label => ({ label, value: 0 })); + const purchaseHours = Array.from({ length: 24 }, (_, hour) => ({ + label: `${String(hour).padStart(2, '0')}h`, + value: 0 + })); + + result.rows.forEach(row => { + const weekday = row.weekday === null || row.weekday === undefined ? null : Number(row.weekday); + const hour = row.hour === null || row.hour === undefined ? null : Number(row.hour); + const orderCount = toNumber(row.order_count); + + if (weekday !== null && Number.isInteger(weekday) && purchaseWeekdays[weekday]) { + purchaseWeekdays[weekday].value += orderCount; + } + + if (hour !== null && Number.isInteger(hour) && purchaseHours[hour]) { + purchaseHours[hour].value += orderCount; + } + }); + + return { + purchaseWeekdays, + purchaseHours + }; +}; + const getOrderGroupKey = (row) => ( row.pedido_id || `${row.data_pedido || getDateOnly(row.data_pedido_date) || ''}_${row.valor_pedido || 0}` @@ -1353,6 +1405,7 @@ module.exports = { getFrequencyScore, getClientDetailsAnalytics, getClientFilterOptions, + getClientPurchasePatternAnalytics, getPreviousDate, getRecencyScore, getRfmAnalytics, diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 5ed8aa1..2bda131 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -11,6 +11,7 @@ const { getClientAnalytics, getClientDetailsAnalytics, getClientFilterOptions, + getClientPurchasePatternAnalytics, getDashboardAnalytics, getPreviousDate, getProductAnalytics, @@ -791,6 +792,40 @@ test('getClientFilterOptions returns all distinct order metadata options', async } }); +test('getClientPurchasePatternAnalytics returns all-time weekday and hour counts', async () => { + const originalQuery = pool.query; + + pool.query = async (sql, params = []) => { + assert.deepEqual(params, []); + assert.match(sql, /order_events AS/); + assert.match(sql, /DISTINCT ON \(\s+customer_key,\s+COALESCE\(NULLIF\(pedido_id, ''\), data_pedido \|\| '_' \|\| valor_pedido::text\)\s+\)/); + assert.match(sql, /EXTRACT\(DOW FROM data_pedido_date\)::int as weekday/); + assert.match(sql, /EXTRACT\(HOUR FROM created_at AT TIME ZONE 'America\/Sao_Paulo'\)::int as hour/); + + return { + rows: [ + { weekday: 1, hour: 9, order_count: 2 }, + { weekday: 5, hour: 18, order_count: 1 }, + { weekday: 5, hour: null, order_count: 1 } + ] + }; + }; + + try { + const pattern = await getClientPurchasePatternAnalytics(); + + assert.equal(pattern.purchaseWeekdays.length, 7); + assert.equal(pattern.purchaseHours.length, 24); + assert.deepEqual(pattern.purchaseWeekdays[1], { label: 'Seg', value: 2 }); + assert.deepEqual(pattern.purchaseWeekdays[5], { label: 'Sex', value: 2 }); + assert.deepEqual(pattern.purchaseHours[9], { label: '09h', value: 2 }); + assert.deepEqual(pattern.purchaseHours[18], { label: '18h', value: 1 }); + assert.deepEqual(pattern.purchaseHours[0], { label: '00h', value: 0 }); + } finally { + pool.query = originalQuery; + } +}); + test('getClientDetailsAnalytics resolves legacy name tokens to the canonical phone client', async () => { const originalQuery = pool.query; const calls = []; diff --git a/src/dataService.ts b/src/dataService.ts index 1a544c2..189efbf 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, RfmAnalytics, StockData } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, RfmAnalytics, StockData } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -300,6 +300,28 @@ export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Parti }, options); }; +const emptyClientPurchasePattern = (): ClientPurchasePatternAnalytics => ({ + purchaseWeekdays: ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab'].map(label => ({ label, value: 0 })), + purchaseHours: Array.from({ length: 24 }, (_, hour) => ({ + label: `${String(hour).padStart(2, '0')}h`, + value: 0 + })) +}); + +export const fetchClientPurchasePatternAnalytics = async (options?: CacheOptions): Promise => { + const path = '/analytics/clients/purchase-pattern'; + return getCachedAnalytics(path, async () => { + try { + const response = await authFetch(path, options?.force ? { cache: 'no-store' } : {}); + if (!response.ok) return emptyClientPurchasePattern(); + return await response.json(); + } catch (error) { + console.error('Fetch client purchase pattern analytics failed', error); + return emptyClientPurchasePattern(); + } + }, options); +}; + export const fetchClientDetailsAnalytics = async (clientToken: string, dateRange: DateRange): Promise => { const path = `/analytics/clients/${encodeURIComponent(clientToken)}/details?${buildDateRangeParams(dateRange).toString()}`; return getCachedAnalytics(path, async () => { diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index 5f341a1..56099d1 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -1,9 +1,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react'; +import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import RefreshStatus from '../components/RefreshStatus'; -import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, DateRange, RfmAnalytics, RfmClient } from '../types'; -import { fetchClientAnalytics, fetchClientFilterOptions, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService'; +import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, DateRange, RfmAnalytics, RfmClient } from '../types'; +import { fetchClientAnalytics, fetchClientFilterOptions, fetchClientPurchasePatternAnalytics, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService'; import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges'; import type { ClientSortOption, ClientSummary } from '../analytics/clients'; import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters'; @@ -133,6 +134,55 @@ const dateFilterPresets = [ ]; const filterSelectClassName = "w-full h-9 bg-dark-input border border-dark-border text-dark-text text-sm rounded-lg px-3 focus:outline-none focus:border-brand-primary transition-colors cursor-pointer"; +const CHART_GRID_COLOR = 'var(--chart-grid)'; +const CHART_AXIS_COLOR = 'var(--chart-axis)'; +const CHART_CURSOR_COLOR = 'var(--chart-cursor)'; +const WEEKDAY_BAR_COLOR = '#25C2FF'; +const HOUR_BAR_COLOR = '#52DFA0'; + +type PatternTooltipProps = { + active?: boolean; + payload?: Array<{ value: number }>; + label?: string; +}; + +const PatternTooltip = ({ active, payload, label }: PatternTooltipProps) => { + if (!active || !payload?.length) return null; + + const value = payload[0].value; + return ( +
+

{label}

+

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

+
+ ); +}; + +const ClientPurchasePatternSkeleton = () => ( +
+
+
+
+
+
+
+
+
+ {[0, 1].map(item => ( +
+
+
+ {[0, 1, 2, 3, 4, 5, 6].map(bar => ( +
+ ))} +
+
+ ))} +
+
+); const ClientsTableSkeleton = () => (
@@ -189,6 +239,8 @@ const Clients = () => { const [clientAnalytics, setClientAnalytics] = useState(initialClientAnalytics || []); const [isLoading, setIsLoading] = useState(!initialClientAnalytics); const [isRfmLoading, setIsRfmLoading] = useState(!initialRfmAnalytics && Boolean(initialClientAnalytics)); + const [purchasePattern, setPurchasePattern] = useState(null); + const [isPatternLoading, setIsPatternLoading] = useState(true); // Pagination state const [currentPage, setCurrentPage] = useState(1); @@ -237,6 +289,26 @@ const Clients = () => { }; }, [dateRange, metadataFilters]); + useEffect(() => { + let isMounted = true; + + const loadPurchasePattern = async () => { + setIsPatternLoading(true); + const nextPattern = await fetchClientPurchasePatternAnalytics(); + + if (isMounted) { + setPurchasePattern(nextPattern); + setIsPatternLoading(false); + } + }; + + void loadPurchasePattern(); + + return () => { + isMounted = false; + }; + }, []); + useEffect(() => { if (!isFilterMenuOpen) return; @@ -446,6 +518,8 @@ const Clients = () => { const hasActiveFilters = activeFilterCount > 0; const isRefreshing = isLoading && clientAnalytics.length > 0; const shouldShowRfmLoading = isRfmLoading && clientAnalytics.length > 0; + const hasWeekdayPattern = purchasePattern?.purchaseWeekdays.some(day => day.value > 0) ?? false; + const hasHourPattern = purchasePattern?.purchaseHours.some(hour => hour.value > 0) ?? false; return (
@@ -773,6 +847,82 @@ const Clients = () => {
)} + + {isPatternLoading ? ( + + ) : ( +
+
+
+

Padrão de Compra

+

Quando os clientes costumam comprar.

+
+ + Todo período + +
+ +
+
+

Compras por Dia

+ {hasWeekdayPattern ? ( +
+ + + + + + } cursor={{ fill: CHART_CURSOR_COLOR }} /> + + {(purchasePattern?.purchaseWeekdays || []).map(day => ( + + ))} + + + +
+ ) : ( +
+ Sem compras registradas. +
+ )} +
+ +
+

Compras por Horário

+ {hasHourPattern ? ( +
+ + + + Number(String(value).replace('h', '')) % 3 === 0 ? String(value) : ''} + /> + + } cursor={{ fill: CHART_CURSOR_COLOR }} /> + + {(purchasePattern?.purchaseHours || []).map(hour => ( + + ))} + + + +
+ ) : ( +
+ Sem horário de compra disponível. +
+ )} +
+
+
+ )} ); }; diff --git a/src/types.ts b/src/types.ts index fe47218..df2a753 100644 --- a/src/types.ts +++ b/src/types.ts @@ -153,6 +153,17 @@ export interface ClientFilterOptions { sellers: ClientSellerFilterOption[]; } +export interface ClientPurchasePatternAnalytics { + purchaseWeekdays: Array<{ + label: string; + value: number; + }>; + purchaseHours: Array<{ + label: string; + value: number; + }>; +} + export interface RfmClient { customerKey: string; clientToken: string;