Add full pagination controls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
This commit is contained in:
@@ -8,8 +8,6 @@ export interface ProductSummary {
|
||||
revenue: number;
|
||||
lastPrice: number;
|
||||
stock: number;
|
||||
firstSaleDate?: string | null;
|
||||
lastSaleDate?: string | null;
|
||||
}
|
||||
|
||||
export interface ProductDetailsMetrics {
|
||||
|
||||
133
src/components/PaginationControls.tsx
Normal file
133
src/components/PaginationControls.tsx
Normal 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;
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import type { ClientDetailsAnalytics, DateRange, OrderData } from '../types';
|
||||
import { fetchClientDetailsAnalytics } from '../dataService';
|
||||
@@ -556,47 +557,23 @@ const ClientDetails = () => {
|
||||
})}
|
||||
</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">
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-dark-muted">
|
||||
<span>Mostrar</span>
|
||||
<select
|
||||
value={ordersPerPage}
|
||||
onChange={(event) => {
|
||||
setOrdersPerPage(Number(event.target.value));
|
||||
<PaginationControls
|
||||
totalItems={groupedOrders.length}
|
||||
currentPage={safeCurrentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={ordersPerPage}
|
||||
pageSizeOptions={[5, 10, 20, 50]}
|
||||
itemLabel="pedidos"
|
||||
pageSizeLabel="pedidos por página"
|
||||
startIndex={startIndex}
|
||||
endIndex={Math.min(startIndex + ordersPerPage, groupedOrders.length)}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(pageSize) => {
|
||||
setOrdersPerPage(pageSize);
|
||||
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={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>
|
||||
className="px-6 py-4 border border-zinc-200 dark:border-dark-border rounded-2xl bg-white dark:bg-dark-card"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
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 PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, DateRange, RfmAnalytics, RfmClient } from '../types';
|
||||
import { fetchClientAnalytics, fetchClientFilterOptions, fetchClientPurchasePatternAnalytics, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService';
|
||||
@@ -390,7 +391,8 @@ const Clients = () => {
|
||||
|
||||
// Pagination logic
|
||||
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 formatCurrency = (value: number) => {
|
||||
@@ -803,48 +805,22 @@ const Clients = () => {
|
||||
</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));
|
||||
<PaginationControls
|
||||
totalItems={clientsData.length}
|
||||
currentPage={safeCurrentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={itemsPerPage}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
itemLabel="clientes"
|
||||
pageSizeLabel="itens por página"
|
||||
startIndex={startIndex}
|
||||
endIndex={Math.min(startIndex + itemsPerPage, clientsData.length)}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(pageSize) => {
|
||||
setItemsPerPage(pageSize);
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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));
|
||||
<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);
|
||||
}}
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
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 PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { exportToCSV, fetchRfmAnalytics, getCachedRfmAnalytics } from '../dataService';
|
||||
import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types';
|
||||
@@ -393,7 +394,8 @@ const Rfm = () => {
|
||||
}, [clients, searchTerm, segmentFilter]);
|
||||
|
||||
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 totals = useMemo(() => {
|
||||
@@ -766,47 +768,23 @@ const Rfm = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-6 py-4 border-t 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-dark-muted">
|
||||
<span>Mostrar</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(event) => {
|
||||
setItemsPerPage(Number(event.target.value));
|
||||
<PaginationControls
|
||||
totalItems={filteredClients.length}
|
||||
currentPage={safeCurrentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={itemsPerPage}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
itemLabel="clientes"
|
||||
pageSizeLabel="clientes por página"
|
||||
startIndex={startIndex}
|
||||
endIndex={Math.min(startIndex + itemsPerPage, filteredClients.length)}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(pageSize) => {
|
||||
setItemsPerPage(pageSize);
|
||||
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>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>
|
||||
className="px-6 py-4 border-t border-dark-border"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user