Add full pagination controls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s

This commit is contained in:
Cauê Faleiros
2026-07-01 11:26:09 -03:00
parent f5e2de9a35
commit e2b2163c83
6 changed files with 219 additions and 238 deletions

View File

@@ -1,74 +1,31 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import { Search, Package, TrendingUp, Download } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus';
import type { DateRange, ProductAnalyticsItem } from '../types';
import { exportToCSV, fetchProductAnalytics } from '../dataService';
import type { ProductSummary } from '../analytics/products';
type ProductHealth = {
label: string;
className: string;
};
const getDateOnlyTime = (value?: string | null) => {
if (!value) return 0;
const date = new Date(`${String(value).slice(0, 10)}T00:00:00`);
return Number.isNaN(date.getTime()) ? 0 : date.getTime();
};
const getProductHealth = (product: ProductSummary, dateRange: DateRange): ProductHealth => {
const endTime = new Date(dateRange.end.getFullYear(), dateRange.end.getMonth(), dateRange.end.getDate()).getTime();
const startTime = new Date(dateRange.start.getFullYear(), dateRange.start.getMonth(), dateRange.start.getDate()).getTime();
const rangeDays = Math.max(1, Math.round((endTime - startTime) / 86400000) + 1);
const lastSaleTime = getDateOnlyTime(product.lastSaleDate);
const firstSaleTime = getDateOnlyTime(product.firstSaleDate);
const daysSinceLastSale = lastSaleTime ? Math.max(0, Math.round((endTime - lastSaleTime) / 86400000)) : Infinity;
const daysSinceFirstSale = firstSaleTime ? Math.max(0, Math.round((endTime - firstSaleTime) / 86400000)) : Infinity;
if (product.totalSold > 0 && product.stock > 0 && product.stock <= Math.max(3, product.totalSold * 0.15)) {
return { label: 'Estoque baixo', className: 'border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300' };
}
if (!product.totalSold) {
return { label: 'Sem venda', className: 'border-zinc-500/25 bg-zinc-500/10 text-zinc-600 dark:text-zinc-400' };
}
if (daysSinceFirstSale <= Math.min(14, rangeDays)) {
return { label: 'Novo', className: 'border-cyan-500/35 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300' };
}
if (daysSinceLastSale <= Math.max(1, Math.min(7, Math.ceil(rangeDays * 0.2)))) {
return { label: 'Quente', className: 'border-emerald-500/35 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' };
}
if (daysSinceLastSale > Math.max(14, Math.ceil(rangeDays * 0.55))) {
return { label: 'Esfriando', className: 'border-orange-500/35 bg-orange-500/10 text-orange-700 dark:text-orange-300' };
}
return { label: 'Estável', className: 'border-sky-500/35 bg-sky-500/10 text-sky-700 dark:text-sky-300' };
};
const ProductTableSkeleton = () => (
<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 produtos">
<div className="border-b border-zinc-100 p-4 dark:border-dark-border">
<div className="grid grid-cols-[120px_1.5fr_120px_120px_100px_140px_110px] gap-6">
{[0, 1, 2, 3, 4, 5, 6].map(item => (
<div className="grid grid-cols-[120px_1.5fr_120px_100px_140px_110px] gap-6">
{[0, 1, 2, 3, 4, 5].map(item => (
<div key={`products-head-skeleton-${item}`} className="skeleton h-3" />
))}
</div>
</div>
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
<div key={`products-row-skeleton-${row}`} className="grid grid-cols-[120px_1.5fr_120px_120px_100px_140px_110px] gap-6 px-6 py-4">
<div key={`products-row-skeleton-${row}`} className="grid grid-cols-[120px_1.5fr_120px_100px_140px_110px] gap-6 px-6 py-4">
<div className="skeleton h-4" />
<div>
<div className="skeleton h-4 w-4/5" />
<div className="skeleton mt-2 h-3 w-32" />
</div>
<div className="skeleton h-4" />
<div className="skeleton h-7 rounded-full" />
<div className="skeleton h-4" />
<div className="skeleton h-4" />
<div className="skeleton h-7 rounded-lg" />
@@ -123,9 +80,7 @@ const Products = () => {
totalSold: product.quantitySold,
revenue: product.revenue,
lastPrice: product.lastPrice,
stock: product.stock,
firstSaleDate: product.firstSaleDate,
lastSaleDate: product.lastSaleDate
stock: product.stock
}));
const filteredProducts = normalizedSearch
? products.filter(product =>
@@ -139,7 +94,8 @@ const Products = () => {
// Pagination logic
const totalPages = Math.ceil(productsData.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedData = productsData.slice(startIndex, startIndex + itemsPerPage);
const isRefreshing = isLoading && productAnalytics.length > 0;
@@ -185,7 +141,6 @@ const Products = () => {
'Descrição': product.name,
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
'Total Vendido (un.)': product.totalSold,
'Status': getProductHealth(product, dateRange).label,
'Estoque': product.stock,
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
}));
@@ -213,17 +168,13 @@ const Products = () => {
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">ID Produto</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Descrição</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Total Vendido</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Status</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Estoque</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Receita Gerada</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
{paginatedData.map((product) => {
const health = getProductHealth(product, dateRange);
return (
{paginatedData.map((product) => (
<tr key={product.id} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{product.id}</td>
<td className="px-6 py-2.5">
@@ -236,11 +187,6 @@ const Products = () => {
<span className="font-bold text-zinc-900 dark:text-dark-text">{product.totalSold} un.</span>
</div>
</td>
<td className="px-6 py-2.5">
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${health.className}`}>
{health.label}
</span>
</td>
<td className="px-6 py-2.5">
<span className="font-bold text-zinc-900 dark:text-dark-text">
{product.stock} un.
@@ -257,54 +203,27 @@ const Products = () => {
</Link>
</td>
</tr>
);
})}
))}
</tbody>
</table>
</div>
{/* Pagination Controls */}
<div className="px-6 py-4 border-t border-zinc-100 dark:border-dark-border flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-dark-muted">
<span>Mostrar</span>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="bg-dark-card border border-dark-border rounded-lg px-2 py-1 focus:outline-none focus:border-brand-primary cursor-pointer text-dark-text"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
<span>itens por página</span>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-zinc-500 dark:text-dark-muted">
Mostrando {productsData.length > 0 ? startIndex + 1 : 0} a {Math.min(startIndex + itemsPerPage, productsData.length)} de {productsData.length} produtos
</span>
<div className="flex gap-1">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages || totalPages === 0}
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
<PaginationControls
totalItems={productsData.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="produtos"
pageSizeLabel="itens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, productsData.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1);
}}
/>
</div>
)}
</div>