Add client purchase pattern charts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m9s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m9s
This commit is contained in:
@@ -414,6 +414,35 @@ const getDateOnly = (value) => {
|
|||||||
return match ? match[1] : null;
|
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) => {
|
const buildRfmSegments = (clients) => {
|
||||||
return Object.values(RFM_SEGMENTS).map(segment => {
|
return Object.values(RFM_SEGMENTS).map(segment => {
|
||||||
const segmentClients = clients.filter(client => client.segmentKey === segment.key);
|
const segmentClients = clients.filter(client => client.segmentKey === segment.key);
|
||||||
@@ -910,7 +939,8 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => {
|
|||||||
nome_vendedor,
|
nome_vendedor,
|
||||||
marketplace,
|
marketplace,
|
||||||
canal_venda,
|
canal_venda,
|
||||||
numero_ecommerce
|
numero_ecommerce,
|
||||||
|
created_at
|
||||||
FROM identity_orders
|
FROM identity_orders
|
||||||
WHERE ${periodFilters.join(' AND ')}
|
WHERE ${periodFilters.join(' AND ')}
|
||||||
ORDER BY data_pedido_date DESC, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) DESC;
|
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 groupedOrdersByKey = new Map();
|
||||||
const spentByDate = 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 periodSpent = 0;
|
||||||
let periodItems = 0;
|
let periodItems = 0;
|
||||||
|
|
||||||
@@ -932,6 +968,19 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => {
|
|||||||
const dateLabel = row.data_pedido || dateKey;
|
const dateLabel = row.data_pedido || dateKey;
|
||||||
const groupKey = getOrderGroupKey(row);
|
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;
|
periodSpent += itemRevenue;
|
||||||
periodItems += toNumber(row.quantidade);
|
periodItems += toNumber(row.quantidade);
|
||||||
|
|
||||||
@@ -968,7 +1017,8 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => {
|
|||||||
nome_vendedor: row.nome_vendedor || '',
|
nome_vendedor: row.nome_vendedor || '',
|
||||||
marketplace: row.marketplace || '',
|
marketplace: row.marketplace || '',
|
||||||
canal_venda: row.canal_venda || '',
|
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,
|
periodOrderCount,
|
||||||
periodItems,
|
periodItems,
|
||||||
chartData,
|
chartData,
|
||||||
|
purchaseWeekdays: weekdayCounts,
|
||||||
|
purchaseHours: hourCounts,
|
||||||
groupedOrders
|
groupedOrders
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ export interface ClientDetailsMetrics {
|
|||||||
date: string;
|
date: string;
|
||||||
value: number;
|
value: number;
|
||||||
}>;
|
}>;
|
||||||
|
purchaseWeekdays: Array<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
|
purchaseHours: Array<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
groupedOrders: GroupedClientOrder[];
|
groupedOrders: GroupedClientOrder[];
|
||||||
allTimeOrderCount: number;
|
allTimeOrderCount: number;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
@@ -49,6 +57,22 @@ export interface ClientDetailsMetrics {
|
|||||||
periodItems: number;
|
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 scoreTertile = (value: number, values: number[], higherIsBetter = true) => {
|
||||||
const numericValues = values.filter(Number.isFinite);
|
const numericValues = values.filter(Number.isFinite);
|
||||||
if (!numericValues.length) return 1;
|
if (!numericValues.length) return 1;
|
||||||
@@ -230,9 +254,29 @@ export const buildClientDetailsMetrics = (ordersData: OrderData[], customerKey:
|
|||||||
date,
|
date,
|
||||||
value: spentByDate[date]
|
value: spentByDate[date]
|
||||||
})).sort((a, b) => parseOrderDate(a.date).getTime() - parseOrderDate(b.date).getTime());
|
})).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 {
|
return {
|
||||||
chartData,
|
chartData,
|
||||||
|
purchaseWeekdays: weekdayCounts,
|
||||||
|
purchaseHours: hourCounts,
|
||||||
groupedOrders,
|
groupedOrders,
|
||||||
allTimeOrderCount: allTimeOrderIds.size,
|
allTimeOrderCount: allTimeOrderIds.size,
|
||||||
clientName,
|
clientName,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, Link, useOutletContext } from 'react-router-dom';
|
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 { 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 DateRangePicker from '../components/DateRangePicker';
|
||||||
import type { ClientDetailsAnalytics, DateRange, OrderData } from '../types';
|
import type { ClientDetailsAnalytics, DateRange, OrderData } from '../types';
|
||||||
import { fetchClientDetailsAnalytics } from '../dataService';
|
import { fetchClientDetailsAnalytics } from '../dataService';
|
||||||
@@ -11,6 +11,8 @@ const CHART_GRID_COLOR = 'var(--chart-grid)';
|
|||||||
const CHART_AXIS_COLOR = 'var(--chart-axis)';
|
const CHART_AXIS_COLOR = 'var(--chart-axis)';
|
||||||
const CHART_CURSOR_COLOR = 'var(--chart-cursor)';
|
const CHART_CURSOR_COLOR = 'var(--chart-cursor)';
|
||||||
const CHART_DETAIL_BAR_COLOR = 'var(--chart-detail-bar)';
|
const CHART_DETAIL_BAR_COLOR = 'var(--chart-detail-bar)';
|
||||||
|
const WEEKDAY_BAR_COLOR = '#25C2FF';
|
||||||
|
const HOUR_BAR_COLOR = '#52DFA0';
|
||||||
|
|
||||||
type CustomTooltipProps = {
|
type CustomTooltipProps = {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
@@ -32,6 +34,21 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PatternTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const value = payload[0].value;
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-dark-card p-3 shadow-lg">
|
||||||
|
<p className="mb-1 font-bold text-brand-primary">{label}</p>
|
||||||
|
<p className="m-0 text-dark-text">
|
||||||
|
{value} {value === 1 ? 'pedido' : 'pedidos'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const getOrderMetadata = (order: OrderData) => {
|
const getOrderMetadata = (order: OrderData) => {
|
||||||
const sellerName = formatDisplayName(removeTrailingSellerId(order.nome_vendedor || ''));
|
const sellerName = formatDisplayName(removeTrailingSellerId(order.nome_vendedor || ''));
|
||||||
|
|
||||||
@@ -131,6 +148,8 @@ const ClientDetails = () => {
|
|||||||
const {
|
const {
|
||||||
chartData,
|
chartData,
|
||||||
groupedOrders,
|
groupedOrders,
|
||||||
|
purchaseHours = [],
|
||||||
|
purchaseWeekdays = [],
|
||||||
allTimeOrderCount,
|
allTimeOrderCount,
|
||||||
clientName,
|
clientName,
|
||||||
clientPhone,
|
clientPhone,
|
||||||
@@ -144,6 +163,8 @@ const ClientDetails = () => {
|
|||||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||||
const startIndex = (safeCurrentPage - 1) * ordersPerPage;
|
const startIndex = (safeCurrentPage - 1) * ordersPerPage;
|
||||||
const paginatedOrders = groupedOrders.slice(startIndex, startIndex + 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -268,6 +289,73 @@ const ClientDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Padrão de Compra</h3>
|
||||||
|
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">Quando este cliente costuma comprar.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||||
|
<h4 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Dia</h4>
|
||||||
|
{hasWeekdayPattern ? (
|
||||||
|
<div className="mt-5 h-56">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={purchaseWeekdays} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
|
||||||
|
<XAxis dataKey="label" stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
|
||||||
|
<YAxis allowDecimals={false} stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip content={<PatternTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
|
||||||
|
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||||
|
{purchaseWeekdays.map(day => (
|
||||||
|
<Cell key={`weekday-${day.label}`} fill={WEEKDAY_BAR_COLOR} fillOpacity={0.62} stroke={WEEKDAY_BAR_COLOR} strokeOpacity={0.9} strokeWidth={1.25} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-56 items-center justify-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">
|
||||||
|
Sem compras no período.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||||
|
<h4 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Horário</h4>
|
||||||
|
{hasHourPattern ? (
|
||||||
|
<div className="mt-5 h-56">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={purchaseHours} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
stroke={CHART_AXIS_COLOR}
|
||||||
|
fontSize={10}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
interval={0}
|
||||||
|
tickFormatter={(value) => Number(String(value).replace('h', '')) % 3 === 0 ? String(value) : ''}
|
||||||
|
/>
|
||||||
|
<YAxis allowDecimals={false} stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip content={<PatternTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
|
||||||
|
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||||
|
{purchaseHours.map(hour => (
|
||||||
|
<Cell key={`hour-${hour.label}`} fill={HOUR_BAR_COLOR} fillOpacity={0.56} stroke={HOUR_BAR_COLOR} strokeOpacity={0.86} strokeWidth={1.1} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-56 items-center justify-center px-6 text-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">
|
||||||
|
Sem horário de compra disponível para este período.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{/* Orders List */}
|
{/* Orders List */}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{paginatedOrders.length === 0 ? (
|
{paginatedOrders.length === 0 ? (
|
||||||
|
|||||||
@@ -203,6 +203,14 @@ export interface ClientDetailsAnalytics {
|
|||||||
date: string;
|
date: string;
|
||||||
value: number;
|
value: number;
|
||||||
}>;
|
}>;
|
||||||
|
purchaseWeekdays?: Array<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
|
purchaseHours?: Array<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
groupedOrders: GroupedClientOrder[];
|
groupedOrders: GroupedClientOrder[];
|
||||||
allTimeOrderCount: number;
|
allTimeOrderCount: number;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user