90 lines
9.8 KiB
TypeScript
90 lines
9.8 KiB
TypeScript
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 PaginationControls from '../components/PaginationControls';
|
|
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 SellerDetailsSkeleton = () => (
|
|
<div className="space-y-6" aria-label="Carregando detalhes do vendedor">
|
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
|
<div className="space-y-3"><div className="skeleton h-4 w-24" /><div className="skeleton h-7 w-64" /><div className="skeleton h-4 w-96 max-w-full" /></div>
|
|
<div className="skeleton h-11 w-56" />
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
{[1, 2, 3, 4].map(item => <div key={item} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="skeleton h-3 w-20" /><div className="skeleton mt-3 h-7 w-32" /></div>)}
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.3fr_0.7fr]">
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="skeleton h-5 w-40" /><div className="skeleton mt-2 h-4 w-48" /><div className="mt-6 space-y-4">{[1, 2, 3, 4, 5, 6].map(item => <div key={item} className="grid grid-cols-[52px_1fr_72px] items-center gap-3"><div className="skeleton h-3" /><div className="skeleton h-2" /><div className="skeleton h-3" /></div>)}</div></div>
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="skeleton h-5 w-20" /><div className="skeleton mt-2 h-4 w-40" /><div className="mt-6 space-y-3">{[1, 2, 3].map(item => <div key={item} className="rounded-xl border border-dark-border p-3"><div className="skeleton h-4 w-28" /><div className="skeleton mt-2 h-3 w-40" /></div>)}</div></div>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">{[1, 2].map(item => <div key={item} className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><div className="skeleton h-5 w-40" /></div><div className="space-y-0">{[1, 2, 3, 4].map(row => <div key={row} className="flex items-center justify-between gap-4 border-b border-dark-border p-4 last:border-b-0"><div><div className="skeleton h-4 w-48" /><div className="skeleton mt-2 h-3 w-20" /></div><div className="skeleton h-4 w-20" /></div>)}</div></div>)}</div>
|
|
</div>
|
|
);
|
|
|
|
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);
|
|
const [trendPage, setTrendPage] = useState(1);
|
|
const trendPageSize = 14;
|
|
|
|
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]);
|
|
const trendTotalPages = Math.ceil((details?.chartData.length || 0) / trendPageSize);
|
|
const safeTrendPage = Math.min(trendPage, trendTotalPages || 1);
|
|
const trendStartIndex = (safeTrendPage - 1) * trendPageSize;
|
|
const paginatedTrend = details?.chartData.slice(trendStartIndex, trendStartIndex + trendPageSize) || [];
|
|
if (isLoading && !details) return <SellerDetailsSkeleton />;
|
|
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={range => { setDateRange(range); setTrendPage(1); }} />
|
|
</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="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
|
<div className="p-5"><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>
|
|
{details.chartData.length ? <>
|
|
<div className="space-y-3 px-5 pb-5">{paginatedTrend.map(point => <div key={point.date} className="grid grid-cols-[52px_1fr_auto] items-center gap-3 text-xs sm:grid-cols-[76px_1fr_auto]"><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>)}</div>
|
|
<PaginationControls totalItems={details.chartData.length} currentPage={safeTrendPage} totalPages={trendTotalPages} pageSize={trendPageSize} pageSizeOptions={[14]} itemLabel="dias" pageSizeLabel="dias por página" startIndex={trendStartIndex} endIndex={Math.min(trendStartIndex + trendPageSize, details.chartData.length)} onPageChange={setTrendPage} onPageSizeChange={() => setTrendPage(1)} className="border-t border-dark-border px-4 py-3" />
|
|
</> : <p className="px-5 py-10 text-center text-sm font-semibold text-dark-muted">Sem vendas no período.</p>}
|
|
</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;
|