Add client purchase pattern analytics
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
This commit is contained in:
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const ClientPurchasePatternSkeleton = () => (
|
||||
<section className="space-y-4" aria-label="Carregando padrão de compra">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="skeleton h-5 w-44" />
|
||||
<div className="skeleton mt-2 h-4 w-64" />
|
||||
</div>
|
||||
<div className="skeleton h-7 w-28 rounded-full" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{[0, 1].map(item => (
|
||||
<div key={`clients-pattern-skeleton-${item}`} className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||
<div className="skeleton h-4 w-40" />
|
||||
<div className="mt-5 flex h-56 items-end gap-3">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map(bar => (
|
||||
<div key={`clients-pattern-bar-skeleton-${item}-${bar}`} className="skeleton flex-1" style={{ height: `${20 + ((bar * 17) % 65)}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const ClientsTableSkeleton = () => (
|
||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm" aria-label="Carregando clientes">
|
||||
@@ -189,6 +239,8 @@ const Clients = () => {
|
||||
const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>(initialClientAnalytics || []);
|
||||
const [isLoading, setIsLoading] = useState(!initialClientAnalytics);
|
||||
const [isRfmLoading, setIsRfmLoading] = useState(!initialRfmAnalytics && Boolean(initialClientAnalytics));
|
||||
const [purchasePattern, setPurchasePattern] = useState<ClientPurchasePatternAnalytics | null>(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 (
|
||||
<div className="space-y-6">
|
||||
@@ -773,6 +847,82 @@ const Clients = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPatternLoading ? (
|
||||
<ClientPurchasePatternSkeleton />
|
||||
) : (
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Padrão de Compra</h2>
|
||||
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">Quando os clientes costumam comprar.</p>
|
||||
</div>
|
||||
<span className="w-fit rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-bold uppercase tracking-wide text-zinc-500 dark:border-dark-border dark:bg-dark-card dark:text-dark-muted">
|
||||
Todo período
|
||||
</span>
|
||||
</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">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Dia</h3>
|
||||
{hasWeekdayPattern ? (
|
||||
<div className="mt-5 h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={purchasePattern?.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]} animationDuration={850} animationEasing="ease-out">
|
||||
{(purchasePattern?.purchaseWeekdays || []).map(day => (
|
||||
<Cell key={`clients-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 registradas.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Horário</h3>
|
||||
{hasHourPattern ? (
|
||||
<div className="mt-5 h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={purchasePattern?.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]} animationDuration={850} animationEasing="ease-out">
|
||||
{(purchasePattern?.purchaseHours || []).map(hour => (
|
||||
<Cell key={`clients-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.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user