Add seller analytics pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m47s

This commit is contained in:
Cauê Faleiros
2026-08-04 09:59:49 -03:00
parent f18e7c4845
commit ff0f73e87c
8 changed files with 369 additions and 2 deletions

View File

@@ -0,0 +1,59 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext, useParams } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import { fetchSellerDetailsAnalytics } from '../dataService';
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
import type { DateRange, SellerDetailsAnalytics } from '../types';
const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(value);
const formatCurrency = (value: number) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
const SellerDetails = () => {
const { sellerId = '' } = useParams<{ sellerId: string }>();
const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange; setDateRange: (range: DateRange) => void }>();
const [details, setDetails] = useState<SellerDetailsAnalytics | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
const result = await fetchSellerDetailsAnalytics(sellerId, dateRange);
if (isMounted) { setDetails(result); setIsLoading(false); }
};
void load();
return () => { isMounted = false; };
}, [dateRange, sellerId]);
const maxRevenue = useMemo(() => Math.max(...(details?.chartData.map(item => item.revenue) || [1]), 1), [details]);
if (isLoading && !details) return <div className="space-y-4">{[1, 2, 3, 4].map(item => <div key={item} className="skeleton h-28" />)}</div>;
if (!details) return <div className="rounded-2xl border border-dark-border bg-dark-card p-10 text-center"><p className="font-bold text-dark-text">Vendedor não encontrado no período.</p><Link to="/sellers" className="mt-4 inline-flex text-sm font-bold text-brand-primary">Voltar para vendedores</Link></div>;
const { seller } = details;
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<Link to="/sellers" className="inline-flex items-center gap-2 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text"><ArrowLeft className="h-4 w-4" /> Vendedores</Link>
<h1 className="mt-4 text-2xl font-bold text-dark-text">{formatDisplayName(removeTrailingSellerId(seller.name))}</h1>
<p className="mt-2 font-medium text-dark-muted">Carteira, produtos e resultado comercial no período selecionado.</p>
</div>
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{[['Receita', formatCurrency(seller.revenue), 'text-brand-primary'], ['Pedidos', formatNumber(seller.orderCount), 'text-dark-text'], ['Clientes', formatNumber(seller.customerCount), 'text-dark-text'], ['Ticket médio', formatCurrency(seller.averageTicket), 'text-dark-text']].map(([label, value, color]) => <div key={label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{label}</p><p className={`mt-2 text-2xl font-bold ${color}`}>{value}</p></div>)}
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.3fr_0.7fr]">
<section className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><h2 className="text-base font-bold text-dark-text">Evolução da receita</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Vendas por dia no período.</p><div className="mt-5 space-y-3">{details.chartData.length ? details.chartData.map(point => <div key={point.date} className="grid grid-cols-[76px_1fr_auto] items-center gap-3 text-xs"><span className="font-semibold text-dark-muted">{new Date(`${point.date}T12:00:00`).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}</span><div className="h-2 overflow-hidden rounded-full bg-dark-input"><div className="h-full rounded-full bg-brand-primary" style={{ width: `${(point.revenue / maxRevenue) * 100}%` }} /></div><span className="font-bold text-dark-text">{formatCurrency(point.revenue)}</span></div>) : <p className="py-10 text-center text-sm font-semibold text-dark-muted">Sem vendas no período.</p>}</div></section>
<section className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><h2 className="text-base font-bold text-dark-text">Canais</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Receita por marketplace.</p><div className="mt-5 space-y-3">{details.marketplaces.map(item => <div key={item.name} className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-sm font-bold text-dark-text">{item.name}</p><p className="mt-1 text-xs font-semibold text-brand-primary">{formatCurrency(item.revenue)} · {formatNumber(item.orderCount)} pedidos</p></div>)}</div></section>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">Produtos mais vendidos</h2></div><div className="divide-y divide-dark-border">{details.topProducts.map(product => <Link key={product.id} to={`/products/${product.id}`} className="flex items-center justify-between gap-4 p-4 transition-colors hover:bg-dark-input/35"><div className="min-w-0"><p className="truncate text-sm font-bold text-dark-text">{product.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(product.quantitySold)} un. vendidas</p></div><span className="shrink-0 text-sm font-bold text-brand-primary">{formatCurrency(product.revenue)}</span></Link>)}</div></section>
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">Carteira no período</h2></div><div className="divide-y divide-dark-border">{details.topClients.map(client => <Link key={client.clientToken} to={`/clients/${client.clientToken}`} className="flex items-center justify-between gap-4 p-4 transition-colors hover:bg-dark-input/35"><div className="min-w-0"><p className="truncate text-sm font-bold text-dark-text">{client.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(client.orderCount)} pedidos</p></div><span className="shrink-0 text-sm font-bold text-brand-primary">{formatCurrency(client.totalSpent)}</span></Link>)}</div></section>
</div>
</div>
);
};
export default SellerDetails;

116
src/pages/Sellers.tsx Normal file
View File

@@ -0,0 +1,116 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { ArrowRight, BriefcaseBusiness, Search } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import RefreshStatus from '../components/RefreshStatus';
import { fetchSellerAnalytics } from '../dataService';
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
import type { DateRange, SellerAnalyticsItem } from '../types';
const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(value);
const formatCurrency = (value: number) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
const Sellers = () => {
const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange; setDateRange: (range: DateRange) => void }>();
const [sellers, setSellers] = useState<SellerAnalyticsItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
const rows = await fetchSellerAnalytics(dateRange);
if (isMounted) {
setSellers(rows);
setIsLoading(false);
}
};
void load();
return () => { isMounted = false; };
}, [dateRange]);
const visibleSellers = useMemo(() => {
const term = search.trim().toLocaleLowerCase('pt-BR');
if (!term) return sellers;
return sellers.filter(seller => `${seller.name} ${seller.id}`.toLocaleLowerCase('pt-BR').includes(term));
}, [search, sellers]);
const totalRevenue = sellers.reduce((sum, seller) => sum + seller.revenue, 0);
const totalOrders = sellers.reduce((sum, seller) => sum + seller.orderCount, 0);
const totalCustomers = sellers.reduce((sum, seller) => sum + seller.customerCount, 0);
const topSeller = sellers[0];
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<h1 className="text-2xl font-bold text-dark-text">Vendedores</h1>
<p className="mt-2 font-medium text-dark-muted">Performance comercial, carteira e produtos vendidos por responsável.</p>
</div>
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
</div>
<RefreshStatus isRefreshing={isLoading && sellers.length > 0} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Receita da equipe</p>
<p className="mt-2 text-2xl font-bold text-brand-primary">{formatCurrency(totalRevenue)}</p>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Pedidos</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(totalOrders)}</p>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Clientes atendidos</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(totalCustomers)}</p>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Maior receita</p>
<p className="mt-2 truncate text-lg font-bold text-dark-text">{topSeller ? formatDisplayName(removeTrailingSellerId(topSeller.name)) : '—'}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{topSeller ? formatCurrency(topSeller.revenue) : 'Sem vendas no período'}</p>
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="flex flex-col gap-4 border-b border-dark-border p-4 sm:p-5 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Equipe comercial</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Selecione um vendedor para ver carteira, produtos e evolução.</p>
</div>
<label className="relative block w-full lg:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-muted" />
<input value={search} onChange={event => setSearch(event.target.value)} placeholder="Buscar vendedor..." className="h-11 w-full rounded-xl border border-dark-border bg-dark-input pl-10 pr-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" />
</label>
</div>
{isLoading && !sellers.length ? (
<div className="space-y-3 p-5">{[1, 2, 3, 4].map(item => <div key={item} className="skeleton h-24" />)}</div>
) : visibleSellers.length ? (
<div className="divide-y divide-dark-border">
{visibleSellers.map((seller, index) => (
<Link key={seller.id} to={`/sellers/${encodeURIComponent(seller.id)}`} className="group grid gap-4 px-4 py-4 transition-colors hover:bg-dark-input/35 sm:px-5 xl:grid-cols-[minmax(260px,1.25fr)_repeat(4,minmax(110px,0.55fr))_auto] xl:items-center">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-brand-primary/25 bg-brand-primary/10 text-sm font-bold text-brand-primary">{index + 1}</div>
<div className="min-w-0">
<p className="truncate text-sm font-bold text-dark-text">{formatDisplayName(removeTrailingSellerId(seller.name))}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{seller.id.startsWith('name:') ? 'Sem ID Tiny' : `ID ${seller.id}`}</p>
</div>
</div>
<div><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted xl:hidden">Receita</p><p className="text-sm font-bold text-brand-primary">{formatCurrency(seller.revenue)}</p></div>
<div><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted xl:hidden">Pedidos</p><p className="text-sm font-bold text-dark-text">{formatNumber(seller.orderCount)}</p></div>
<div><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted xl:hidden">Clientes</p><p className="text-sm font-bold text-dark-text">{formatNumber(seller.customerCount)}</p></div>
<div><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted xl:hidden">Ticket médio</p><p className="text-sm font-bold text-dark-text">{formatCurrency(seller.averageTicket)}</p></div>
<ArrowRight className="hidden h-5 w-5 text-dark-muted transition-transform group-hover:translate-x-1 group-hover:text-brand-primary xl:block" />
</Link>
))}
</div>
) : (
<div className="flex flex-col items-center px-6 py-16 text-center"><BriefcaseBusiness className="h-10 w-10 text-dark-muted" /><p className="mt-4 text-sm font-bold text-dark-text">Nenhum vendedor encontrado.</p><p className="mt-1 text-sm font-semibold text-dark-muted">Ajuste a busca ou o período selecionado.</p></div>
)}
</div>
</div>
);
};
export default Sellers;