From 06ac1918b2f91a605965b5e52a148f9d66ad0f25 Mon Sep 17 00:00:00 2001 From: Deploy Date: Wed, 26 Aug 2026 22:52:39 +0200 Subject: [PATCH] feat(admin): global pagination component + apply to brands/promotions/reviews --- backlog/features.json | 16 +++++ .../admin/src/app/(dashboard)/brands/page.tsx | 70 ++++++++++-------- .../src/app/(dashboard)/promotions/page.tsx | 68 ++++++++++-------- .../src/app/(dashboard)/reviews/page.tsx | 71 +++++++++++-------- .../admin/src/components/ui/Pagination.tsx | 67 +++++++++++++++++ project/apps/admin/src/lib/api-client.ts | 33 ++++++++- work/runtime-status.json | 4 +- 7 files changed, 237 insertions(+), 92 deletions(-) create mode 100644 project/apps/admin/src/components/ui/Pagination.tsx diff --git a/backlog/features.json b/backlog/features.json index db5f7f7..3e67223 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -8156,6 +8156,22 @@ }, "phase": "admin", "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" } ] } diff --git a/project/apps/admin/src/app/(dashboard)/brands/page.tsx b/project/apps/admin/src/app/(dashboard)/brands/page.tsx index ad0d737..877c24d 100644 --- a/project/apps/admin/src/app/(dashboard)/brands/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/brands/page.tsx @@ -3,6 +3,7 @@ import { useState, useCallback, useEffect } from 'react'; import type { Brand } from '@/types'; import { brandsApi } from '@/lib/api-client'; import { RowActions } from '@/components/ui/RowActions'; +import Pagination from '@/components/ui/Pagination'; function slugify(text: string): string { return text @@ -22,7 +23,10 @@ function autoSeoDescription(name: string): string { } export default function BrandsPage() { + const PAGE_SIZE = 20; const [brands, setBrands] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [showForm, setShowForm] = useState(false); @@ -42,14 +46,15 @@ export default function BrandsPage() { const load = useCallback(async () => { setLoading(true); try { - const data = await brandsApi.list(); - setBrands((data as { items?: Brand[] }).items ?? []); + const data = await brandsApi.list({ limit: PAGE_SIZE, offset }); + setBrands(data.items ?? []); + setTotal(data.total ?? 0); } catch (e) { setError(e instanceof Error ? e.message : 'Error'); } finally { setLoading(false); } - }, []); + }, [offset]); useEffect(() => { load(); }, [load]); @@ -210,32 +215,39 @@ export default function BrandsPage() { )}
- {loading ?
Cargando...
: - error ?
{error}
: - brands.length === 0 ?
No hay marcas
: - - - - {['Nombre', 'Slug', 'SEO Title'].map(h => ( - - ))} - - - - - {brands.map(b => ( - - - - - - - ))} - -
{h}Acciones
{b.name}/{b.slug}{b.seoTitle ?? '—'} - openEdit(b)} onDelete={() => handleDelete(b.id)} /> -
- } + {loading ? ( +
Cargando...
+ ) : error ? ( +
{error}
+ ) : brands.length === 0 ? ( +
No hay marcas
+ ) : ( + <> + + + + {['Nombre', 'Slug', 'SEO Title'].map(h => ( + + ))} + + + + + {brands.map(b => ( + + + + + + + ))} + +
{h}Acciones
{b.name}/{b.slug}{b.seoTitle ?? '—'} + openEdit(b)} onDelete={() => handleDelete(b.id)} /> +
+ + + )}
); diff --git a/project/apps/admin/src/app/(dashboard)/promotions/page.tsx b/project/apps/admin/src/app/(dashboard)/promotions/page.tsx index dcadacd..b7fa5dd 100644 --- a/project/apps/admin/src/app/(dashboard)/promotions/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/promotions/page.tsx @@ -2,11 +2,15 @@ import { useState, useEffect, useCallback } from 'react'; import { promotionsApi } from '@/lib/api-client'; 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; } export default function PromotionsPage() { + const PAGE_SIZE = 20; const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [showForm, setShowForm] = useState(false); @@ -22,11 +26,12 @@ export default function PromotionsPage() { const load = useCallback(async () => { setLoading(true); 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 ?? []); + setTotal(d.total ?? 0); } catch (e) { setError(e instanceof Error ? e.message : 'Error'); } finally { setLoading(false); } - }, []); + }, [offset]); useEffect(() => { load(); }, [load]); @@ -106,32 +111,39 @@ export default function PromotionsPage() { )}
- {loading ?
Cargando...
: - error ?
{error}
: - items.length === 0 ?
No hay promociones
: - - - {['Código','Tipo','Valor','Activa','Límite','Usos','Fin'].map(h => )} - - - - {items.map(p => ( - - - - - - - - - - - ))} - -
{h}
{p.code}{p.type === 'percent' ? '%' : 'Fijo'}{p.type === 'percent' ? `${p.value / 100}%` : `€${(p.value / 100).toFixed(2)}`} - - {p.usageLimit ?? '∞'}{p.usageCount}{new Date(p.endsAt).toLocaleDateString('es-ES')} handleDelete(p.code)} />
- } + {loading ? ( +
Cargando...
+ ) : error ? ( +
{error}
+ ) : items.length === 0 ? ( +
No hay promociones
+ ) : ( + <> + + + {['Código','Tipo','Valor','Activa','Límite','Usos','Fin'].map(h => )} + + + + {items.map(p => ( + + + + + + + + + + + ))} + +
{h}
{p.code}{p.type === 'percent' ? '%' : 'Fijo'}{p.type === 'percent' ? `${p.value / 100}%` : `€${(p.value / 100).toFixed(2)}`} + + {p.usageLimit ?? '∞'}{p.usageCount}{new Date(p.endsAt).toLocaleDateString('es-ES')} handleDelete(p.code)} />
+ + + )}
); diff --git a/project/apps/admin/src/app/(dashboard)/reviews/page.tsx b/project/apps/admin/src/app/(dashboard)/reviews/page.tsx index 7322603..e798d83 100644 --- a/project/apps/admin/src/app/(dashboard)/reviews/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/reviews/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; import { reviewsApi } from '@/lib/api-client'; +import Pagination from '@/components/ui/Pagination'; interface Review { id: string; productId: string; userId: string; orderId: string; @@ -23,8 +24,10 @@ function Stars({ n }: { n: number }) { } export default function ReviewsPage() { + const PAGE_SIZE = 20; const [items, setItems] = useState([]); const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [filter, setFilter] = useState(''); @@ -32,14 +35,15 @@ export default function ReviewsPage() { const load = useCallback(async () => { setLoading(true); 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 ?? []); setTotal(d.total ?? 0); } catch (e) { setError(e instanceof Error ? e.message : 'Error'); } finally { setLoading(false); } - }, [filter]); + }, [filter, offset]); useEffect(() => { load(); }, [load]); + useEffect(() => { setOffset(0); }, [filter]); const moderate = async (id: string, status: 'published' | 'rejected') => { try { @@ -62,34 +66,41 @@ export default function ReviewsPage() {
- {loading ?
Cargando...
: - error ?
{error}
: - items.length === 0 ?
No hay reseñas
: - items.map(r => ( -
-
-
- -

{r.title}

-

{new Date(r.createdAt).toLocaleString('es-ES')}

-
- {STATUS_LABELS[r.status] ?? r.status} -
-

{r.body}

- {r.status === 'pending' && ( -
- - -
- )} - {r.status !== 'pending' && ( - - )} -
- )) - } + {loading ? ( +
Cargando...
+ ) : error ? ( +
{error}
+ ) : items.length === 0 ? ( +
No hay reseñas
+ ) : ( + <> + {items.map(r => ( +
+
+
+ +

{r.title}

+

{new Date(r.createdAt).toLocaleString('es-ES')}

+
+ {STATUS_LABELS[r.status] ?? r.status} +
+

{r.body}

+ {r.status === 'pending' && ( +
+ + +
+ )} + {r.status !== 'pending' && ( + + )} +
+ ))} + + + )}
); diff --git a/project/apps/admin/src/components/ui/Pagination.tsx b/project/apps/admin/src/components/ui/Pagination.tsx new file mode 100644 index 0000000..9d4d444 --- /dev/null +++ b/project/apps/admin/src/components/ui/Pagination.tsx @@ -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 "X–Y 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 ( +
+

+ {total === 0 ? 'Sin resultados' : `Mostrando ${from}–${to} de ${total}`} +

+
+ + +
+
+ ); +} diff --git a/project/apps/admin/src/lib/api-client.ts b/project/apps/admin/src/lib/api-client.ts index bf4e52c..1f1a214 100644 --- a/project/apps/admin/src/lib/api-client.ts +++ b/project/apps/admin/src/lib/api-client.ts @@ -180,7 +180,16 @@ export const customersApi = { // ── Brands ──────────────────────────────────────────────────────────────────── 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('/api/brands', data), update: (id: string, data: unknown) => api.patch(`/api/brands/${id}`, data), @@ -190,7 +199,16 @@ export const brandsApi = { // ── Categories ──────────────────────────────────────────────────────────────── 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('/api/categories', data), update: (id: string, data: unknown) => api.patch(`/api/categories/${id}`, data), @@ -248,7 +266,16 @@ export const pricingApi = { // ── Promotions ──────────────────────────────────────────────────────────────── 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), update: (code: string, data: unknown) => api.patch(`/api/promotions/${code}`, data), delete: (code: string) => api.delete(`/api/promotions/${code}`), diff --git a/work/runtime-status.json b/work/runtime-status.json index f6f2ae5..6a54812 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -2,11 +2,11 @@ "feature_id": "CHECKOUT-STOCK-RECHECK", "stage": "build", "agent": "implementer", - "action": "Add pagination to admin orders page", + "action": "Add Pagination component to all admin list pages", "state": "running", "next_agent": "reviewer", "waiting_for": "build", - "updated_at": "2026-08-26T20:42:28.158071Z", + "updated_at": "2026-08-26T20:49:22.330241Z", "timeline": [ { "ts": "2026-08-26T17:21:37Z",