All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
128 lines
8.4 KiB
TypeScript
128 lines
8.4 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Link, useOutletContext } from 'react-router-dom';
|
|
import { BriefcaseBusiness, Eye, Search } from 'lucide-react';
|
|
import DateRangePicker from '../components/DateRangePicker';
|
|
import PaginationControls from '../components/PaginationControls';
|
|
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 SellersTableSkeleton = () => (
|
|
<div className="overflow-x-auto">
|
|
<div className="min-w-[980px]">
|
|
<div className="grid grid-cols-[90px_1.5fr_repeat(5,120px)_80px] gap-5 border-b border-dark-border bg-dark-header px-6 py-4">
|
|
{[1, 2, 3, 4, 5, 6, 7, 8].map(item => <div key={item} className="skeleton h-3" />)}
|
|
</div>
|
|
<div className="divide-y divide-dark-border">
|
|
{[1, 2, 3, 4, 5, 6].map(row => <div key={row} className="grid grid-cols-[90px_1.5fr_repeat(5,120px)_80px] items-center gap-5 px-6 py-3"><div className="skeleton h-7 w-7 rounded-full" /><div><div className="skeleton h-4 w-40" /><div className="skeleton mt-2 h-3 w-20" /></div>{[1, 2, 3, 4, 5].map(column => <div key={column} className="skeleton h-4" />)}<div className="skeleton h-8 w-8 rounded-lg" /></div>)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
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('');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
|
|
|
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 totalPages = Math.ceil(visibleSellers.length / itemsPerPage);
|
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
|
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
|
const paginatedSellers = visibleSellers.slice(startIndex, startIndex + itemsPerPage);
|
|
|
|
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="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); setCurrentPage(1); }} 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 ? (
|
|
<SellersTableSkeleton />
|
|
) : visibleSellers.length ? (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[980px] text-left text-sm">
|
|
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
|
|
<tr>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Posição</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Nome do vendedor</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Receita</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Pedidos</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Clientes</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Ticket médio</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Produtos vendidos</th>
|
|
<th className="px-6 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-dark-border">
|
|
{paginatedSellers.map((seller, index) => (
|
|
<tr key={seller.id} className="group transition-colors hover:bg-dark-input/50">
|
|
<td className="px-6 py-2.5"><span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-dark-border text-xs font-bold text-dark-muted">{startIndex + index + 1}</span></td>
|
|
<td className="px-6 py-2.5"><p className="font-semibold text-dark-text">{formatDisplayName(removeTrailingSellerId(seller.name))}</p><p className="mt-1 text-[10px] font-semibold text-dark-muted">{seller.id.startsWith('name:') ? 'Sem ID Tiny' : `ID ${seller.id}`}</p></td>
|
|
<td className="px-6 py-2.5 font-bold text-brand-primary">{formatCurrency(seller.revenue)}</td>
|
|
<td className="px-6 py-2.5 font-semibold text-dark-text">{formatNumber(seller.orderCount)}</td>
|
|
<td className="px-6 py-2.5 font-semibold text-dark-text">{formatNumber(seller.customerCount)}</td>
|
|
<td className="px-6 py-2.5 font-semibold text-dark-text">{formatCurrency(seller.averageTicket)}</td>
|
|
<td className="px-6 py-2.5 text-xs font-medium text-dark-muted">{formatNumber(seller.quantitySold)} un. ({formatNumber(seller.orderCount)} {seller.orderCount === 1 ? 'pedido' : 'pedidos'})</td>
|
|
<td className="px-6 py-2.5 text-right"><Link to={`/sellers/${encodeURIComponent(seller.id)}`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80" title={`Ver detalhes de ${seller.name}`} aria-label={`Ver detalhes de ${seller.name}`}><Eye className="h-3.5 w-3.5" /></Link></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<PaginationControls totalItems={visibleSellers.length} currentPage={safeCurrentPage} totalPages={totalPages} pageSize={itemsPerPage} pageSizeOptions={[10, 20, 50]} itemLabel="vendedores" pageSizeLabel="itens por página" startIndex={startIndex} endIndex={Math.min(startIndex + itemsPerPage, visibleSellers.length)} onPageChange={setCurrentPage} onPageSizeChange={pageSize => { setItemsPerPage(pageSize); setCurrentPage(1); }} />
|
|
</>
|
|
) : (
|
|
<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;
|