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

@@ -8,8 +8,6 @@ export interface ProductSummary {
revenue: number; revenue: number;
lastPrice: number; lastPrice: number;
stock: number; stock: number;
firstSaleDate?: string | null;
lastSaleDate?: string | null;
} }
export interface ProductDetailsMetrics { export interface ProductDetailsMetrics {

View File

@@ -0,0 +1,133 @@
import { ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react';
type PaginationControlsProps = {
totalItems: number;
currentPage: number;
totalPages: number;
pageSize: number;
pageSizeOptions: number[];
itemLabel: string;
pageSizeLabel: string;
startIndex: number;
endIndex: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
className?: string;
};
const clampPage = (page: number, totalPages: number) => {
if (!Number.isFinite(page)) return 1;
return Math.min(Math.max(1, Math.trunc(page)), Math.max(1, totalPages));
};
const PaginationControls = ({
totalItems,
currentPage,
totalPages,
pageSize,
pageSizeOptions,
itemLabel,
pageSizeLabel,
startIndex,
endIndex,
onPageChange,
onPageSizeChange,
className = 'px-6 py-4 border-t border-zinc-100 dark:border-dark-border'
}: PaginationControlsProps) => {
const safeTotalPages = Math.max(1, totalPages);
const safeCurrentPage = clampPage(currentPage, safeTotalPages);
const isFirstPage = safeCurrentPage <= 1;
const isLastPage = safeCurrentPage >= safeTotalPages || totalItems === 0;
const goToPage = (page: number) => {
onPageChange(clampPage(page, safeTotalPages));
};
return (
<div className={`${className} flex flex-col items-center justify-between gap-4 lg:flex-row`}>
<div className="flex flex-wrap items-center justify-center gap-2 text-sm text-zinc-500 dark:text-dark-muted">
<span>Mostrar</span>
<select
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
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"
>
{pageSizeOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
<span>{pageSizeLabel}</span>
</div>
<div className="flex flex-wrap items-center justify-center gap-3 text-sm">
<span className="text-center text-zinc-500 dark:text-dark-muted">
Mostrando {totalItems > 0 ? startIndex + 1 : 0} a {endIndex} de {totalItems} {itemLabel}
</span>
<div className="flex items-center gap-2">
<div className="flex gap-1">
<button
type="button"
onClick={() => goToPage(1)}
disabled={isFirstPage || totalItems === 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"
aria-label="Primeira página"
title="Primeira página"
>
<ChevronsLeft className="w-5 h-5" />
</button>
<button
type="button"
onClick={() => goToPage(safeCurrentPage - 1)}
disabled={isFirstPage || totalItems === 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"
aria-label="Página anterior"
title="Página anterior"
>
<ChevronLeft className="w-5 h-5" />
</button>
</div>
<label className="flex items-center gap-1 text-xs font-semibold text-zinc-500 dark:text-dark-muted">
<span>Página</span>
<input
type="number"
min={1}
max={safeTotalPages}
value={safeCurrentPage}
onChange={(event) => goToPage(Number(event.target.value))}
className="h-8 w-16 rounded-lg border border-dark-border bg-dark-card px-2 text-center text-sm font-bold text-dark-text focus:outline-none focus:border-brand-primary"
aria-label="Ir para página"
/>
<span>de {safeTotalPages}</span>
</label>
<div className="flex gap-1">
<button
type="button"
onClick={() => goToPage(safeCurrentPage + 1)}
disabled={isLastPage}
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"
aria-label="Próxima página"
title="Próxima página"
>
<ChevronRight className="w-5 h-5" />
</button>
<button
type="button"
onClick={() => goToPage(safeTotalPages)}
disabled={isLastPage}
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"
aria-label="Última página"
title="Última página"
>
<ChevronsRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
);
};
export default PaginationControls;

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom'; import { useParams, Link, useOutletContext } from 'react-router-dom';
import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone, ChevronDown, ChevronLeft, ChevronRight, ShoppingBag, ReceiptText } from 'lucide-react'; import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone, ChevronDown, ShoppingBag, ReceiptText } from 'lucide-react';
import { AreaChart, Area, BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { AreaChart, Area, BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import type { ClientDetailsAnalytics, DateRange, OrderData } from '../types'; import type { ClientDetailsAnalytics, DateRange, OrderData } from '../types';
import { fetchClientDetailsAnalytics } from '../dataService'; import { fetchClientDetailsAnalytics } from '../dataService';
@@ -556,47 +557,23 @@ const ClientDetails = () => {
})} })}
</div> </div>
<div className="px-6 py-4 border border-zinc-200 dark:border-dark-border rounded-2xl bg-white dark:bg-dark-card flex flex-col sm:flex-row items-center justify-between gap-4"> <PaginationControls
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-dark-muted"> totalItems={groupedOrders.length}
<span>Mostrar</span> currentPage={safeCurrentPage}
<select totalPages={totalPages}
value={ordersPerPage} pageSize={ordersPerPage}
onChange={(event) => { pageSizeOptions={[5, 10, 20, 50]}
setOrdersPerPage(Number(event.target.value)); itemLabel="pedidos"
pageSizeLabel="pedidos por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + ordersPerPage, groupedOrders.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setOrdersPerPage(pageSize);
setCurrentPage(1); 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" className="px-6 py-4 border border-zinc-200 dark:border-dark-border rounded-2xl bg-white dark:bg-dark-card"
> />
<option value={5}>5</option>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
<span>pedidos por página</span>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-zinc-500 dark:text-dark-muted">
Mostrando {groupedOrders.length > 0 ? startIndex + 1 : 0} a {Math.min(startIndex + ordersPerPage, groupedOrders.length)} de {groupedOrders.length} pedidos
</span>
<div className="flex gap-1">
<button
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
disabled={safeCurrentPage === 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(page => Math.min(totalPages, page + 1))}
disabled={safeCurrentPage === 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>
</div> </div>
</div> </div>
); );

View File

@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react'; import { Search, ChevronRight, Filter, X } from 'lucide-react';
import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, DateRange, RfmAnalytics, RfmClient } from '../types'; import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, DateRange, RfmAnalytics, RfmClient } from '../types';
import { fetchClientAnalytics, fetchClientFilterOptions, fetchClientPurchasePatternAnalytics, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService'; import { fetchClientAnalytics, fetchClientFilterOptions, fetchClientPurchasePatternAnalytics, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService';
@@ -390,7 +391,8 @@ const Clients = () => {
// Pagination logic // Pagination logic
const totalPages = Math.ceil(clientsData.length / itemsPerPage); const totalPages = Math.ceil(clientsData.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage; const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedData = clientsData.slice(startIndex, startIndex + itemsPerPage); const paginatedData = clientsData.slice(startIndex, startIndex + itemsPerPage);
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
@@ -803,48 +805,22 @@ const Clients = () => {
</table> </table>
</div> </div>
{/* Pagination Controls */} <PaginationControls
<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"> totalItems={clientsData.length}
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-dark-muted"> currentPage={safeCurrentPage}
<span>Mostrar</span> totalPages={totalPages}
<select pageSize={itemsPerPage}
value={itemsPerPage} pageSizeOptions={[10, 20, 50, 100]}
onChange={(e) => { itemLabel="clientes"
setItemsPerPage(Number(e.target.value)); pageSizeLabel="itens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, clientsData.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1); 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 {clientsData.length > 0 ? startIndex + 1 : 0} a {Math.min(startIndex + itemsPerPage, clientsData.length)} de {clientsData.length} clientes
</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>
</div> </div>
)} )}

View File

@@ -1,74 +1,31 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; 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 DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import type { DateRange, ProductAnalyticsItem } from '../types'; import type { DateRange, ProductAnalyticsItem } from '../types';
import { exportToCSV, fetchProductAnalytics } from '../dataService'; import { exportToCSV, fetchProductAnalytics } from '../dataService';
import type { ProductSummary } from '../analytics/products'; 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 = () => ( 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="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="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"> <div className="grid grid-cols-[120px_1.5fr_120px_100px_140px_110px] gap-6">
{[0, 1, 2, 3, 4, 5, 6].map(item => ( {[0, 1, 2, 3, 4, 5].map(item => (
<div key={`products-head-skeleton-${item}`} className="skeleton h-3" /> <div key={`products-head-skeleton-${item}`} className="skeleton h-3" />
))} ))}
</div> </div>
</div> </div>
<div className="divide-y divide-zinc-100 dark:divide-dark-border"> <div className="divide-y divide-zinc-100 dark:divide-dark-border">
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => ( {[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 className="skeleton h-4" />
<div> <div>
<div className="skeleton h-4 w-4/5" /> <div className="skeleton h-4 w-4/5" />
<div className="skeleton mt-2 h-3 w-32" /> <div className="skeleton mt-2 h-3 w-32" />
</div> </div>
<div className="skeleton h-4" /> <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-4" /> <div className="skeleton h-4" />
<div className="skeleton h-7 rounded-lg" /> <div className="skeleton h-7 rounded-lg" />
@@ -123,9 +80,7 @@ const Products = () => {
totalSold: product.quantitySold, totalSold: product.quantitySold,
revenue: product.revenue, revenue: product.revenue,
lastPrice: product.lastPrice, lastPrice: product.lastPrice,
stock: product.stock, stock: product.stock
firstSaleDate: product.firstSaleDate,
lastSaleDate: product.lastSaleDate
})); }));
const filteredProducts = normalizedSearch const filteredProducts = normalizedSearch
? products.filter(product => ? products.filter(product =>
@@ -139,7 +94,8 @@ const Products = () => {
// Pagination logic // Pagination logic
const totalPages = Math.ceil(productsData.length / itemsPerPage); 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 paginatedData = productsData.slice(startIndex, startIndex + itemsPerPage);
const isRefreshing = isLoading && productAnalytics.length > 0; const isRefreshing = isLoading && productAnalytics.length > 0;
@@ -185,7 +141,6 @@ const Products = () => {
'Descrição': product.name, 'Descrição': product.name,
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','), 'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
'Total Vendido (un.)': product.totalSold, 'Total Vendido (un.)': product.totalSold,
'Status': getProductHealth(product, dateRange).label,
'Estoque': product.stock, 'Estoque': product.stock,
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',') '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]">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]">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]">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]">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]">Receita Gerada</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border"> <tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
{paginatedData.map((product) => { {paginatedData.map((product) => (
const health = getProductHealth(product, dateRange);
return (
<tr key={product.id} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group"> <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 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{product.id}</td>
<td className="px-6 py-2.5"> <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> <span className="font-bold text-zinc-900 dark:text-dark-text">{product.totalSold} un.</span>
</div> </div>
</td> </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"> <td className="px-6 py-2.5">
<span className="font-bold text-zinc-900 dark:text-dark-text"> <span className="font-bold text-zinc-900 dark:text-dark-text">
{product.stock} un. {product.stock} un.
@@ -257,54 +203,27 @@ const Products = () => {
</Link> </Link>
</td> </td>
</tr> </tr>
); ))}
})}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination Controls */} <PaginationControls
<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"> totalItems={productsData.length}
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-dark-muted"> currentPage={safeCurrentPage}
<span>Mostrar</span> totalPages={totalPages}
<select pageSize={itemsPerPage}
value={itemsPerPage} pageSizeOptions={[10, 20, 50, 100]}
onChange={(e) => { itemLabel="produtos"
setItemsPerPage(Number(e.target.value)); pageSizeLabel="itens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, productsData.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1); 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>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { ChevronLeft, ChevronRight, Download, Filter, Search, Users } from 'lucide-react'; import { Download, Filter, Search, Users } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import { exportToCSV, fetchRfmAnalytics, getCachedRfmAnalytics } from '../dataService'; import { exportToCSV, fetchRfmAnalytics, getCachedRfmAnalytics } from '../dataService';
import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types'; import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types';
@@ -393,7 +394,8 @@ const Rfm = () => {
}, [clients, searchTerm, segmentFilter]); }, [clients, searchTerm, segmentFilter]);
const totalPages = Math.ceil(filteredClients.length / itemsPerPage); const totalPages = Math.ceil(filteredClients.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage; const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedClients = filteredClients.slice(startIndex, startIndex + itemsPerPage); const paginatedClients = filteredClients.slice(startIndex, startIndex + itemsPerPage);
const totals = useMemo(() => { const totals = useMemo(() => {
@@ -766,47 +768,23 @@ const Rfm = () => {
</div> </div>
)} )}
<div className="px-6 py-4 border-t border-dark-border flex flex-col sm:flex-row items-center justify-between gap-4"> <PaginationControls
<div className="flex items-center gap-2 text-sm text-dark-muted"> totalItems={filteredClients.length}
<span>Mostrar</span> currentPage={safeCurrentPage}
<select totalPages={totalPages}
value={itemsPerPage} pageSize={itemsPerPage}
onChange={(event) => { pageSizeOptions={[10, 20, 50, 100]}
setItemsPerPage(Number(event.target.value)); itemLabel="clientes"
pageSizeLabel="clientes por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, filteredClients.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1); 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" className="px-6 py-4 border-t border-dark-border"
> />
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
<span>clientes por página</span>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-dark-muted">
Mostrando {filteredClients.length > 0 ? startIndex + 1 : 0} a {Math.min(startIndex + itemsPerPage, filteredClients.length)} de {filteredClients.length} clientes
</span>
<div className="flex gap-1">
<button
onClick={() => setCurrentPage(page => Math.max(1, page - 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(page => Math.min(totalPages, page + 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>
</section> </section>
</div> </div>
)} )}