feat(admin): global pagination component + apply to brands/promotions/reviews

This commit is contained in:
Deploy
2026-08-26 22:52:39 +02:00
parent 8eeac378da
commit 06ac1918b2
7 changed files with 237 additions and 92 deletions

View File

@@ -8156,6 +8156,22 @@
}, },
"phase": "admin", "phase": "admin",
"completed_at": "2026-08-26T20:43:08Z" "completed_at": "2026-08-26T20:43:08Z"
},
{
"id": "ADMIN-PAGINATION-GLOBAL",
"type": "fix",
"title": "Global pagination component for all admin list pages",
"description": "Add a reusable Pagination component to all admin list/CRUD pages: customers, products, payments, reviews, users, brands, categories, promotions, shipping, inventory, club. Apply consistent pattern (offset+limit+total).",
"priority": "med",
"risk": "med",
"status": "in_progress",
"created_at": "2026-08-26",
"gates": {
"reviewer": false,
"security": false,
"qa": false
},
"phase": "admin"
} }
] ]
} }

View File

@@ -3,6 +3,7 @@ import { useState, useCallback, useEffect } from 'react';
import type { Brand } from '@/types'; import type { Brand } from '@/types';
import { brandsApi } from '@/lib/api-client'; import { brandsApi } from '@/lib/api-client';
import { RowActions } from '@/components/ui/RowActions'; import { RowActions } from '@/components/ui/RowActions';
import Pagination from '@/components/ui/Pagination';
function slugify(text: string): string { function slugify(text: string): string {
return text return text
@@ -22,7 +23,10 @@ function autoSeoDescription(name: string): string {
} }
export default function BrandsPage() { export default function BrandsPage() {
const PAGE_SIZE = 20;
const [brands, setBrands] = useState<Brand[]>([]); const [brands, setBrands] = useState<Brand[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@@ -42,14 +46,15 @@ export default function BrandsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const data = await brandsApi.list(); const data = await brandsApi.list({ limit: PAGE_SIZE, offset });
setBrands((data as { items?: Brand[] }).items ?? []); setBrands(data.items ?? []);
setTotal(data.total ?? 0);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Error'); setError(e instanceof Error ? e.message : 'Error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [offset]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
@@ -210,9 +215,14 @@ export default function BrandsPage() {
)} )}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden"> <div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> : {loading ? (
error ? <div className="text-center text-red-600">{error}</div> : <div className="p-12 text-center text-gray-400">Cargando...</div>
brands.length === 0 ? <div className="p-12 text-center text-gray-400">No hay marcas</div> : ) : error ? (
<div className="text-center text-red-600 p-12">{error}</div>
) : brands.length === 0 ? (
<div className="p-12 text-center text-gray-400">No hay marcas</div>
) : (
<>
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="bg-gray-50 border-b border-gray-200"> <tr className="bg-gray-50 border-b border-gray-200">
@@ -235,7 +245,9 @@ export default function BrandsPage() {
))} ))}
</tbody> </tbody>
</table> </table>
} <Pagination total={total} offset={offset} limit={PAGE_SIZE} onOffsetChange={setOffset} />
</>
)}
</div> </div>
</div> </div>
); );

View File

@@ -2,11 +2,15 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { promotionsApi } from '@/lib/api-client'; import { promotionsApi } from '@/lib/api-client';
import { RowActions } from '@/components/ui/RowActions'; import { RowActions } from '@/components/ui/RowActions';
import Pagination from '@/components/ui/Pagination';
interface Promo { code: string; type: string; value: number; startsAt: string; endsAt: string; active: boolean; usageLimit: number | null; usageCount: number; } interface Promo { code: string; type: string; value: number; startsAt: string; endsAt: string; active: boolean; usageLimit: number | null; usageCount: number; }
export default function PromotionsPage() { export default function PromotionsPage() {
const PAGE_SIZE = 20;
const [items, setItems] = useState<Promo[]>([]); const [items, setItems] = useState<Promo[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@@ -22,11 +26,12 @@ export default function PromotionsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const d = await promotionsApi.list() as { items: Promo[] }; const d = await promotionsApi.list({ limit: PAGE_SIZE, offset }) as { items: Promo[]; total: number };
setItems(d.items ?? []); setItems(d.items ?? []);
setTotal(d.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); } } catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); } finally { setLoading(false); }
}, []); }, [offset]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
@@ -106,9 +111,14 @@ export default function PromotionsPage() {
)} )}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden"> <div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> : {loading ? (
error ? <div className="text-center text-red-600">{error}</div> : <div className="p-12 text-center text-gray-400">Cargando...</div>
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay promociones</div> : ) : error ? (
<div className="text-center text-red-600 p-12">{error}</div>
) : items.length === 0 ? (
<div className="p-12 text-center text-gray-400">No hay promociones</div>
) : (
<>
<table className="w-full"> <table className="w-full">
<thead><tr className="bg-gray-50 border-b border-gray-200"> <thead><tr className="bg-gray-50 border-b border-gray-200">
{['Código','Tipo','Valor','Activa','Límite','Usos','Fin'].map(h => <th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>)} {['Código','Tipo','Valor','Activa','Límite','Usos','Fin'].map(h => <th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>)}
@@ -131,7 +141,9 @@ export default function PromotionsPage() {
))} ))}
</tbody> </tbody>
</table> </table>
} <Pagination total={total} offset={offset} limit={PAGE_SIZE} onOffsetChange={setOffset} />
</>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { reviewsApi } from '@/lib/api-client'; import { reviewsApi } from '@/lib/api-client';
import Pagination from '@/components/ui/Pagination';
interface Review { interface Review {
id: string; productId: string; userId: string; orderId: string; id: string; productId: string; userId: string; orderId: string;
@@ -23,8 +24,10 @@ function Stars({ n }: { n: number }) {
} }
export default function ReviewsPage() { export default function ReviewsPage() {
const PAGE_SIZE = 20;
const [items, setItems] = useState<Review[]>([]); const [items, setItems] = useState<Review[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [filter, setFilter] = useState<string>(''); const [filter, setFilter] = useState<string>('');
@@ -32,14 +35,15 @@ export default function ReviewsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const d = await reviewsApi.listAdmin({ status: filter || undefined, limit: 50 }) as { items: Review[]; total: number }; const d = await reviewsApi.listAdmin({ status: filter || undefined, limit: PAGE_SIZE, offset }) as { items: Review[]; total: number };
setItems(d.items ?? []); setItems(d.items ?? []);
setTotal(d.total ?? 0); setTotal(d.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); } } catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); } finally { setLoading(false); }
}, [filter]); }, [filter, offset]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
useEffect(() => { setOffset(0); }, [filter]);
const moderate = async (id: string, status: 'published' | 'rejected') => { const moderate = async (id: string, status: 'published' | 'rejected') => {
try { try {
@@ -62,10 +66,15 @@ export default function ReviewsPage() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> : {loading ? (
error ? <div className="text-center text-red-600">{error}</div> : <div className="p-12 text-center text-gray-400">Cargando...</div>
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay reseñas</div> : ) : error ? (
items.map(r => ( <div className="text-center text-red-600 p-12">{error}</div>
) : items.length === 0 ? (
<div className="p-12 text-center text-gray-400">No hay reseñas</div>
) : (
<>
{items.map(r => (
<div key={r.id} className="bg-white border border-gray-200 rounded-xl p-5"> <div key={r.id} className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div> <div>
@@ -88,8 +97,10 @@ export default function ReviewsPage() {
</button> </button>
)} )}
</div> </div>
)) ))}
} <Pagination total={total} offset={offset} limit={PAGE_SIZE} onOffsetChange={setOffset} />
</>
)}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,67 @@
'use client';
interface PaginationProps {
/** Total number of items across all pages */
total: number;
/** Current offset (0-indexed) */
offset: number;
/** Items per page (default: 20) */
limit?: number;
/** Called when the user navigates to a new offset */
onOffsetChange: (newOffset: number) => void;
/** Optional className for the container */
className?: string;
/** Show when total is 0 (default: false — caller decides) */
hideWhenEmpty?: boolean;
}
/**
* Reusable pagination footer for admin list pages.
* Shows "XY de Z" counter and prev/next buttons.
* Buttons are disabled at the boundaries.
*/
export default function Pagination({
total,
offset,
limit = 20,
onOffsetChange,
className = '',
hideWhenEmpty = true,
}: PaginationProps) {
if (hideWhenEmpty && total === 0) return null;
const from = total === 0 ? 0 : offset + 1;
const to = Math.min(offset + limit, total);
const hasPrev = offset > 0;
const hasNext = offset + limit < total;
return (
<div
className={`flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50 ${className}`}
>
<p className="text-xs text-gray-500">
{total === 0 ? 'Sin resultados' : `Mostrando ${from}${to} de ${total}`}
</p>
<div className="flex gap-1">
<button
type="button"
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
disabled={!hasPrev}
aria-label="Página anterior"
className="px-3 py-1.5 text-xs border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white transition-colors"
>
Anterior
</button>
<button
type="button"
onClick={() => onOffsetChange(offset + limit)}
disabled={!hasNext}
aria-label="Página siguiente"
className="px-3 py-1.5 text-xs border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white transition-colors"
>
Siguiente
</button>
</div>
</div>
);
}

View File

@@ -180,7 +180,16 @@ export const customersApi = {
// ── Brands ──────────────────────────────────────────────────────────────────── // ── Brands ────────────────────────────────────────────────────────────────────
export const brandsApi = { export const brandsApi = {
list: () => api.get<{ items: import('@/types').Brand[] }>('/api/brands'), list: (params?: { limit?: number; offset?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Brand[]; total: number }>(
`/api/brands${qs ? `?${qs}` : ''}`,
).then((data) => Array.isArray(data) ? { items: data, total: data.length } : data);
},
create: (data: unknown) => api.post<import('@/types').Brand>('/api/brands', data), create: (data: unknown) => api.post<import('@/types').Brand>('/api/brands', data),
update: (id: string, data: unknown) => update: (id: string, data: unknown) =>
api.patch<import('@/types').Brand>(`/api/brands/${id}`, data), api.patch<import('@/types').Brand>(`/api/brands/${id}`, data),
@@ -190,7 +199,16 @@ export const brandsApi = {
// ── Categories ──────────────────────────────────────────────────────────────── // ── Categories ────────────────────────────────────────────────────────────────
export const categoriesApi = { export const categoriesApi = {
list: () => api.get<{ items: import('@/types').Category[] }>('/api/categories/tree'), list: (params?: { limit?: number; offset?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Category[]; total: number }>(
`/api/categories/tree${qs ? `?${qs}` : ''}`,
).then((data) => Array.isArray(data) ? { items: data, total: data.length } : data);
},
create: (data: unknown) => api.post<import('@/types').Category>('/api/categories', data), create: (data: unknown) => api.post<import('@/types').Category>('/api/categories', data),
update: (id: string, data: unknown) => update: (id: string, data: unknown) =>
api.patch<import('@/types').Category>(`/api/categories/${id}`, data), api.patch<import('@/types').Category>(`/api/categories/${id}`, data),
@@ -248,7 +266,16 @@ export const pricingApi = {
// ── Promotions ──────────────────────────────────────────────────────────────── // ── Promotions ────────────────────────────────────────────────────────────────
export const promotionsApi = { export const promotionsApi = {
list: () => api.get<{ items: unknown[] }>('/api/promotions'), list: (params?: { limit?: number; offset?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: unknown[]; total: number }>(
`/api/promotions${qs ? `?${qs}` : ''}`,
).then((data) => Array.isArray(data) ? { items: data, total: data.length } : data);
},
create: (data: unknown) => api.post('/api/promotions', data), create: (data: unknown) => api.post('/api/promotions', data),
update: (code: string, data: unknown) => api.patch(`/api/promotions/${code}`, data), update: (code: string, data: unknown) => api.patch(`/api/promotions/${code}`, data),
delete: (code: string) => api.delete(`/api/promotions/${code}`), delete: (code: string) => api.delete(`/api/promotions/${code}`),

View File

@@ -2,11 +2,11 @@
"feature_id": "CHECKOUT-STOCK-RECHECK", "feature_id": "CHECKOUT-STOCK-RECHECK",
"stage": "build", "stage": "build",
"agent": "implementer", "agent": "implementer",
"action": "Add pagination to admin orders page", "action": "Add Pagination component to all admin list pages",
"state": "running", "state": "running",
"next_agent": "reviewer", "next_agent": "reviewer",
"waiting_for": "build", "waiting_for": "build",
"updated_at": "2026-08-26T20:42:28.158071Z", "updated_at": "2026-08-26T20:49:22.330241Z",
"timeline": [ "timeline": [
{ {
"ts": "2026-08-26T17:21:37Z", "ts": "2026-08-26T17:21:37Z",