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

@@ -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<Brand[]>([]);
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() {
)}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
error ? <div className="text-center text-red-600">{error}</div> :
brands.length === 0 ? <div className="p-12 text-center text-gray-400">No hay marcas</div> :
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['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>
))}
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{brands.map(b => (
<tr key={b.id} className="hover:bg-gray-50">
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{b.name}</td>
<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>
<td className="px-4 py-3.5">
<RowActions onEdit={() => openEdit(b)} onDelete={() => handleDelete(b.id)} />
</td>
</tr>
))}
</tbody>
</table>
}
{loading ? (
<div className="p-12 text-center text-gray-400">Cargando...</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">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['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>
))}
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{brands.map(b => (
<tr key={b.id} className="hover:bg-gray-50">
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{b.name}</td>
<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>
<td className="px-4 py-3.5">
<RowActions onEdit={() => openEdit(b)} onDelete={() => handleDelete(b.id)} />
</td>
</tr>
))}
</tbody>
</table>
<Pagination total={total} offset={offset} limit={PAGE_SIZE} onOffsetChange={setOffset} />
</>
)}
</div>
</div>
);

View File

@@ -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<Promo[]>([]);
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() {
)}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
error ? <div className="text-center text-red-600">{error}</div> :
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay promociones</div> :
<table className="w-full">
<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>)}
<th className="px-4 py-3"></th>
</tr></thead>
<tbody className="divide-y divide-gray-50">
{items.map(p => (
<tr key={p.code} className="hover:bg-gray-50">
<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-600">{p.type === 'percent' ? '%' : 'Fijo'}</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">
<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>
</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-500">{p.usageCount}</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"><RowActions onDelete={() => handleDelete(p.code)} /></td>
</tr>
))}
</tbody>
</table>
}
{loading ? (
<div className="p-12 text-center text-gray-400">Cargando...</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">
<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>)}
<th className="px-4 py-3"></th>
</tr></thead>
<tbody className="divide-y divide-gray-50">
{items.map(p => (
<tr key={p.code} className="hover:bg-gray-50">
<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-600">{p.type === 'percent' ? '%' : 'Fijo'}</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">
<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>
</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-500">{p.usageCount}</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"><RowActions onDelete={() => handleDelete(p.code)} /></td>
</tr>
))}
</tbody>
</table>
<Pagination total={total} offset={offset} limit={PAGE_SIZE} onOffsetChange={setOffset} />
</>
)}
</div>
</div>
);

View File

@@ -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<Review[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState<string>('');
@@ -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() {
</div>
<div className="space-y-4">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
error ? <div className="text-center text-red-600">{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 className="flex items-start justify-between mb-3">
<div>
<Stars n={r.rating} />
<p className="font-semibold text-gray-900 text-sm mt-1">{r.title}</p>
<p className="text-xs text-gray-400 mt-0.5">{new Date(r.createdAt).toLocaleString('es-ES')}</p>
</div>
<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>
<p className="text-sm text-gray-600 leading-relaxed mb-4">{r.body}</p>
{r.status === 'pending' && (
<div className="flex gap-3">
<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>
<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>
)}
{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>
))
}
{loading ? (
<div className="p-12 text-center text-gray-400">Cargando...</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 reseñas</div>
) : (
<>
{items.map(r => (
<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>
<Stars n={r.rating} />
<p className="font-semibold text-gray-900 text-sm mt-1">{r.title}</p>
<p className="text-xs text-gray-400 mt-0.5">{new Date(r.createdAt).toLocaleString('es-ES')}</p>
</div>
<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>
<p className="text-sm text-gray-600 leading-relaxed mb-4">{r.body}</p>
{r.status === 'pending' && (
<div className="flex gap-3">
<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>
<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>
)}
{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>
);