feat(admin): global pagination component + apply to brands/promotions/reviews
This commit is contained in:
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,32 +215,39 @@ 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 ? (
|
||||||
<table className="w-full">
|
<div className="text-center text-red-600 p-12">{error}</div>
|
||||||
<thead>
|
) : brands.length === 0 ? (
|
||||||
<tr className="bg-gray-50 border-b border-gray-200">
|
<div className="p-12 text-center text-gray-400">No hay marcas</div>
|
||||||
{['Nombre', 'Slug', 'SEO Title'].map(h => (
|
) : (
|
||||||
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>
|
<>
|
||||||
))}
|
<table className="w-full">
|
||||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Acciones</th>
|
<thead>
|
||||||
</tr>
|
<tr className="bg-gray-50 border-b border-gray-200">
|
||||||
</thead>
|
{['Nombre', 'Slug', 'SEO Title'].map(h => (
|
||||||
<tbody className="divide-y divide-gray-50">
|
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>
|
||||||
{brands.map(b => (
|
))}
|
||||||
<tr key={b.id} className="hover:bg-gray-50">
|
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Acciones</th>
|
||||||
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{b.name}</td>
|
</tr>
|
||||||
<td className="px-4 py-3.5 text-sm text-gray-500 font-mono">/{b.slug}</td>
|
</thead>
|
||||||
<td className="px-4 py-3.5 text-sm text-gray-500">{b.seoTitle ?? '—'}</td>
|
<tbody className="divide-y divide-gray-50">
|
||||||
<td className="px-4 py-3.5">
|
{brands.map(b => (
|
||||||
<RowActions onEdit={() => openEdit(b)} onDelete={() => handleDelete(b.id)} />
|
<tr key={b.id} className="hover:bg-gray-50">
|
||||||
</td>
|
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{b.name}</td>
|
||||||
</tr>
|
<td className="px-4 py-3.5 text-sm text-gray-500 font-mono">/{b.slug}</td>
|
||||||
))}
|
<td className="px-4 py-3.5 text-sm text-gray-500">{b.seoTitle ?? '—'}</td>
|
||||||
</tbody>
|
<td className="px-4 py-3.5">
|
||||||
</table>
|
<RowActions onEdit={() => openEdit(b)} onDelete={() => handleDelete(b.id)} />
|
||||||
}
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<Pagination total={total} offset={offset} limit={PAGE_SIZE} onOffsetChange={setOffset} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,32 +111,39 @@ 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 ? (
|
||||||
<table className="w-full">
|
<div className="text-center text-red-600 p-12">{error}</div>
|
||||||
<thead><tr className="bg-gray-50 border-b border-gray-200">
|
) : items.length === 0 ? (
|
||||||
{['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>)}
|
<div className="p-12 text-center text-gray-400">No hay promociones</div>
|
||||||
<th className="px-4 py-3"></th>
|
) : (
|
||||||
</tr></thead>
|
<>
|
||||||
<tbody className="divide-y divide-gray-50">
|
<table className="w-full">
|
||||||
{items.map(p => (
|
<thead><tr className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr key={p.code} className="hover:bg-gray-50">
|
{['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>)}
|
||||||
<td className="px-4 py-3.5 font-mono text-sm font-medium text-gray-900">{p.code}</td>
|
<th className="px-4 py-3"></th>
|
||||||
<td className="px-4 py-3.5 text-sm text-gray-600">{p.type === 'percent' ? '%' : 'Fijo'}</td>
|
</tr></thead>
|
||||||
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{p.type === 'percent' ? `${p.value / 100}%` : `€${(p.value / 100).toFixed(2)}`}</td>
|
<tbody className="divide-y divide-gray-50">
|
||||||
<td className="px-4 py-3.5">
|
{items.map(p => (
|
||||||
<button onClick={() => toggleActive(p.code, p.active)} className={`px-2 py-0.5 rounded-full text-xs font-medium ${p.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>{p.active ? 'Sí' : 'No'}</button>
|
<tr key={p.code} className="hover:bg-gray-50">
|
||||||
</td>
|
<td className="px-4 py-3.5 font-mono text-sm font-medium text-gray-900">{p.code}</td>
|
||||||
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageLimit ?? '∞'}</td>
|
<td className="px-4 py-3.5 text-sm text-gray-600">{p.type === 'percent' ? '%' : 'Fijo'}</td>
|
||||||
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageCount}</td>
|
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{p.type === 'percent' ? `${p.value / 100}%` : `€${(p.value / 100).toFixed(2)}`}</td>
|
||||||
<td className="px-4 py-3.5 text-sm text-gray-500">{new Date(p.endsAt).toLocaleDateString('es-ES')}</td>
|
<td className="px-4 py-3.5">
|
||||||
<td className="px-4 py-3.5"><RowActions onDelete={() => handleDelete(p.code)} /></td>
|
<button onClick={() => toggleActive(p.code, p.active)} className={`px-2 py-0.5 rounded-full text-xs font-medium ${p.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>{p.active ? 'Sí' : 'No'}</button>
|
||||||
</tr>
|
</td>
|
||||||
))}
|
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageLimit ?? '∞'}</td>
|
||||||
</tbody>
|
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageCount}</td>
|
||||||
</table>
|
<td className="px-4 py-3.5 text-sm text-gray-500">{new Date(p.endsAt).toLocaleDateString('es-ES')}</td>
|
||||||
}
|
<td className="px-4 py-3.5"><RowActions onDelete={() => handleDelete(p.code)} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<Pagination total={total} offset={offset} limit={PAGE_SIZE} onOffsetChange={setOffset} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,34 +66,41 @@ 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>
|
||||||
<div key={r.id} className="bg-white border border-gray-200 rounded-xl p-5">
|
) : items.length === 0 ? (
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="p-12 text-center text-gray-400">No hay reseñas</div>
|
||||||
<div>
|
) : (
|
||||||
<Stars n={r.rating} />
|
<>
|
||||||
<p className="font-semibold text-gray-900 text-sm mt-1">{r.title}</p>
|
{items.map(r => (
|
||||||
<p className="text-xs text-gray-400 mt-0.5">{new Date(r.createdAt).toLocaleString('es-ES')}</p>
|
<div key={r.id} className="bg-white border border-gray-200 rounded-xl p-5">
|
||||||
</div>
|
<div className="flex items-start justify-between mb-3">
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_CLS[r.status] ?? 'bg-gray-100 text-gray-600'}`}>{STATUS_LABELS[r.status] ?? r.status}</span>
|
<div>
|
||||||
</div>
|
<Stars n={r.rating} />
|
||||||
<p className="text-sm text-gray-600 leading-relaxed mb-4">{r.body}</p>
|
<p className="font-semibold text-gray-900 text-sm mt-1">{r.title}</p>
|
||||||
{r.status === 'pending' && (
|
<p className="text-xs text-gray-400 mt-0.5">{new Date(r.createdAt).toLocaleString('es-ES')}</p>
|
||||||
<div className="flex gap-3">
|
</div>
|
||||||
<button onClick={() => moderate(r.id, 'published')} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-xs font-semibold rounded-lg">✓ Publicar</button>
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_CLS[r.status] ?? 'bg-gray-100 text-gray-600'}`}>{STATUS_LABELS[r.status] ?? r.status}</span>
|
||||||
<button onClick={() => moderate(r.id, 'rejected')} className="px-4 py-2 border border-red-200 text-red-600 hover:bg-red-50 text-xs font-semibold rounded-lg">✕ Rechazar</button>
|
</div>
|
||||||
</div>
|
<p className="text-sm text-gray-600 leading-relaxed mb-4">{r.body}</p>
|
||||||
)}
|
{r.status === 'pending' && (
|
||||||
{r.status !== 'pending' && (
|
<div className="flex gap-3">
|
||||||
<button onClick={() => moderate(r.id, r.status === 'published' ? 'rejected' : 'published')} className="text-xs text-gray-400 hover:text-gray-600">
|
<button onClick={() => moderate(r.id, 'published')} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-xs font-semibold rounded-lg">✓ Publicar</button>
|
||||||
{r.status === 'published' ? 'Despublicar' : 'Aprobar'}
|
<button onClick={() => moderate(r.id, 'rejected')} className="px-4 py-2 border border-red-200 text-red-600 hover:bg-red-50 text-xs font-semibold rounded-lg">✕ Rechazar</button>
|
||||||
</button>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{r.status !== 'pending' && (
|
||||||
))
|
<button onClick={() => moderate(r.id, r.status === 'published' ? 'rejected' : 'published')} className="text-xs text-gray-400 hover:text-gray-600">
|
||||||
}
|
{r.status === 'published' ? 'Despublicar' : 'Aprobar'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Pagination total={total} offset={offset} limit={PAGE_SIZE} onOffsetChange={setOffset} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
67
project/apps/admin/src/components/ui/Pagination.tsx
Normal file
67
project/apps/admin/src/components/ui/Pagination.tsx
Normal 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 "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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user