feat(F-050): completed feature

This commit is contained in:
chattie
2026-08-19 07:54:36 +02:00
parent e86034a62c
commit 984f278bca
32 changed files with 472 additions and 131 deletions

View File

@@ -50,7 +50,7 @@ export default function AuditLogPage() {
useEffect(() => { load(); }, [load]);
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Log de auditoría</h1>
<p className="text-sm text-gray-500 mt-0.5">

View File

@@ -113,7 +113,7 @@ export default function BrandsPage() {
};
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Marcas</h1>
<button onClick={openCreate} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva marca</button>
@@ -211,7 +211,7 @@ 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="p-8 text-center text-red-600">{error}</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>

View File

@@ -103,14 +103,27 @@ export default function CategoriesPage() {
useEffect(() => { load(); }, [load]);
// Listado plano de categorías que son PARENT (contenedores) y pueden tener hijos.
// Excluye la propia categoría que se está editando para evitar auto-anidado.
const parentOptions = useMemo(() => {
const flat = (cats: Category[]): Category[] =>
cats.flatMap((c) => [c, ...flat(c.children ?? [])]);
return flat(tree)
.filter((c) => c.isParent && (!editing || c.id !== editing.id));
}, [tree, editing]);
// Listado plano de categorías, editable: si se está editando, una categoría puede
// pasar a ser parent aunque no lo fuese antes. Mantenemos al usuario fuera de su
// propio ID y fuera de sus descendientes para no producir ciclos.
const allFlat = useMemo(() => {
const flat = (cats: Category[]): Category[] => cats.flatMap((c) => [c, ...flat(c.children ?? [])]);
return flat(tree);
}, [tree]);
const excludedIds = useMemo(() => {
if (!editing) return new Set<string>();
const descendants = (id: string, cats: Category[] = allFlat): Set<string> => {
const out = new Set<string>([id]);
for (const c of cats) if (c.parentId === id) for (const d of descendants(c.id, cats)) out.add(d);
return out;
};
return descendants(editing.id);
}, [editing, allFlat]);
// Cualquier categoría puede ser padre: las parent son contenedores, las child
// se promocionan en el momento de guardarlas con isParent=true.
const parentOptions = allFlat.filter((c) => !excludedIds.has(c.id));
const openCreate = () => {
setEditing(null);
@@ -187,7 +200,7 @@ export default function CategoriesPage() {
cats.flatMap((c) => [c, ...flat(c.children ?? [])]);
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Categorías</h1>
<button onClick={openCreate} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">
@@ -229,7 +242,7 @@ export default function CategoriesPage() {
</div>
{/* FIX-19: tipo parent/child + categoría padre */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Categoría padre</label>
<select
@@ -239,11 +252,13 @@ export default function CategoriesPage() {
>
<option value=""> Sin padre (raíz) </option>
{parentOptions.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
<option key={c.id} value={c.id}>
{c.name}{c.isParent ? ' 📂' : ' (hoja)'}
</option>
))}
</select>
<p className="text-xs text-gray-400 mt-1">
Solo se listan las categorías marcadas como parent (contenedor).
Cualquier categoría (parent o child) puede ser padre; si la elegida aún no lo es, promuévela abajo.
</p>
</div>
<div className="flex items-end">
@@ -251,12 +266,12 @@ export default function CategoriesPage() {
<input type="checkbox" checked={form.isParent}
onChange={(e) => setForm((f) => ({ ...f, isParent: e.target.checked }))}
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
<span className="font-medium">📂 Es categoría parent (contenedor)</span>
<span className="font-medium">📂 Promover a categoría parent (contenedor)</span>
</label>
</div>
</div>
<p className="text-xs text-gray-400">
Una categoría <strong>child</strong> es una hoja: no puede contener hijos. Si necesita tener subcategorías, márquela como parent.
Una categoría <strong>child</strong> es una hoja: no puede contener hijos. Marque la casilla para promocionarla; desmárquela para dejarla como hoja.
</p>
{/* Descripción */}

View File

@@ -73,7 +73,7 @@ export default function CmsPage() {
};
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Páginas CMS</h1>
<button onClick={openCreate} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva página</button>
@@ -103,7 +103,7 @@ export default function CmsPage() {
<div className="space-y-3">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
error ? <div className="p-8 text-center text-red-600">{error}</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 páginas</div> :
items.map(p => (
<div key={p.id} className="bg-white border border-gray-200 rounded-xl p-4">

View File

@@ -211,11 +211,11 @@ export default function CustomerDetailPage() {
}
};
if (loading) return <div className="p-8 text-gray-400">Cargando...</div>;
if (error || !customer) return <div className="p-8 text-red-600">{error || 'No encontrado'}</div>;
if (loading) return <div className="text-gray-400">Cargando...</div>;
if (error || !customer) return <div className="text-red-600">{error || 'No encontrado'}</div>;
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
<div className="flex items-center gap-4">
<button onClick={() => router.push('/customers')} className="text-sm text-gray-500 hover:text-gray-700"> Clientes</button>
<h1 className="text-2xl font-bold text-gray-900">{customer.email}</h1>

View File

@@ -124,7 +124,7 @@ export default function CustomersPage() {
const handleCreated = () => { setMsg('Cliente creado correctamente'); setTimeout(() => setMsg(''), 3000); load(); };
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
@@ -161,7 +161,7 @@ export default function CustomersPage() {
<span className="text-sm">Cargando...</span>
</div>
) : error ? (
<div className="p-8 text-center">
<div className="text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">Reintentar</button>
</div>

View File

@@ -120,7 +120,7 @@ export default function InventoryPage() {
const outOfStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) === 0).length;
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
@@ -190,7 +190,7 @@ export default function InventoryPage() {
<span className="text-sm">Cargando inventario...</span>
</div>
) : error ? (
<div className="p-8 text-center">
<div className="text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">Reintentar</button>
</div>

View File

@@ -103,7 +103,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
<div className="flex min-h-screen bg-gray-50">
<Sidebar navItems={navItems} user={user} onLogout={logout} />
<main className="flex-1 min-w-0">
<div className="w-full max-w-[1280px] mx-auto">
<div className="w-full px-4 sm:px-6 lg:px-10 py-6 lg:py-8">
{children}
</div>
</main>

View File

@@ -98,7 +98,7 @@ export default function OrderDetailPage() {
if (loading) {
return (
<div className="p-8 flex items-center justify-center min-h-64">
<div className="flex items-center justify-center min-h-64">
<div className="text-gray-400">Cargando...</div>
</div>
);
@@ -106,7 +106,7 @@ export default function OrderDetailPage() {
if (error || !order) {
return (
<div className="p-8">
<div>
<p className="text-red-600">{error || 'Pedido no encontrado'}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline mt-2">
Reintentar
@@ -119,14 +119,14 @@ export default function OrderDetailPage() {
const allowed = ALLOWED_TRANSITIONS[currentState] ?? [];
return (
<div className="p-8">
<div className="space-y-6">
{/* Back */}
<Link href="/orders" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6">
<Link href="/orders" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
Volver a pedidos
</Link>
{/* Header */}
<div className="flex items-start justify-between mb-8">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 font-mono">#{order.id.slice(0, 8)}</h1>
<p className="text-sm text-gray-500 mt-1">

View File

@@ -131,12 +131,12 @@ export default function OrdersPage() {
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-400">
<div className="text-center text-gray-400">
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
<p className="mt-2 text-sm">Cargando...</p>
</div>
) : error ? (
<div className="p-8 text-center">
<div className="text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">
Reintentar

View File

@@ -98,13 +98,16 @@ export default function DashboardPage() {
api
.get<Stats>('/api/admin/stats')
.then(setStats)
.catch(() => setError('No se pudieron cargar las estadísticas'))
.catch((err) => {
const code = err instanceof Error ? err.message : 'Error';
setError(`No se pudieron cargar las estadísticas (${code})`);
})
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white border border-gray-200 rounded-xl p-5 animate-pulse">
@@ -127,7 +130,7 @@ export default function DashboardPage() {
if (error || !stats) {
return (
<div className="p-8">
<div>
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
{error ?? 'Error desconocido'}
</div>
@@ -141,7 +144,7 @@ export default function DashboardPage() {
);
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
{/* KPI Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KPICard

View File

@@ -48,7 +48,7 @@ export default function PaymentsPage() {
const fmtDate = (d: string) => new Date(d).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' });
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Pagos</h1>

View File

@@ -111,12 +111,12 @@ export default function ProductsPage() {
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-400">
<div className="text-center text-gray-400">
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
<p className="mt-2 text-sm">Cargando...</p>
</div>
) : error ? (
<div className="p-8 text-center">
<div className="text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button
onClick={load}

View File

@@ -61,7 +61,7 @@ export default function PromotionsPage() {
};
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Promociones</h1>
<button onClick={() => setShowForm(true)} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva promoción</button>
@@ -107,7 +107,7 @@ 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="p-8 text-center text-red-600">{error}</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">

View File

@@ -49,7 +49,7 @@ export default function ReviewsPage() {
};
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold text-gray-900">Reseñas</h1><p className="text-sm text-gray-500 mt-0.5">{total} reseñas pendientes de moderación</p></div>
<div className="flex gap-2">
@@ -63,7 +63,7 @@ export default function ReviewsPage() {
<div className="space-y-4">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
error ? <div className="p-8 text-center text-red-600">{error}</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">

View File

@@ -60,7 +60,7 @@ export default function SettingsPage() {
);
return (
<div className="p-8 space-y-6 max-w-4xl">
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Ajustes de tienda</h1>
<p className="text-sm text-gray-500 mt-0.5">Configuración general de la tienda visible para los clientes.</p>

View File

@@ -190,7 +190,7 @@ export default function ShippingPage() {
};
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Envíos</h1>
{msg && <span className="text-sm text-green-600 bg-green-50 px-3 py-1 rounded-full">{msg}</span>}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { taxApi, type TaxRate } from '@/lib/api-client';
import { RowActions } from '@/components/ui/RowActions';
export default function TaxRatesPage() {
const [rates, setRates] = useState<TaxRate[]>([]);
@@ -37,7 +38,7 @@ export default function TaxRatesPage() {
const fmt = (r: TaxRate) => `${r.ratePercent}%`;
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Tipos impositivos (IVA)</h1>
<p className="text-sm text-gray-500 mt-0.5">Configura los tipos de IVA aplicables a los productos.</p>
@@ -104,10 +105,18 @@ export default function TaxRatesPage() {
</span>
</td>
<td className="px-6 py-4 text-right">
<button onClick={() => startEdit(r)}
className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 transition-colors">
Editar
</button>
{editing === r.id ? (
<div className="flex gap-1 justify-end">
<button disabled={saving} onClick={() => handleSave(r.id)}
className="px-3 py-1.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-lg hover:bg-[#1B4332] disabled:opacity-50">
{saving ? '...' : 'Guardar'}
</button>
<button onClick={() => setEditing(null)}
className="px-3 py-1.5 border border-gray-300 text-gray-600 text-xs rounded-lg hover:bg-gray-50">Cancelar</button>
</div>
) : (
<RowActions onEdit={() => startEdit(r)} editTitle="Editar tipo" />
)}
</td>
</>
)}

View File

@@ -125,7 +125,7 @@ export default function AdminUsersPage() {
};
return (
<div className="p-8 space-y-6">
<div className="space-y-6">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold text-gray-900">Usuarios backoffice</h1>
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} usuario${total !== 1 ? 's' : ''}` : ''}</p></div>

View File

@@ -168,8 +168,8 @@ export function ProductEditor({ productId }: ProductEditorProps) {
);
return (
<div className="p-8 max-w-4xl">
<div className="flex items-center justify-between mb-8">
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<button onClick={() => router.push('/products')} className="text-sm text-gray-500 hover:text-gray-700 mb-1 flex items-center gap-1"> Productos</button>
<h1 className="text-2xl font-bold text-gray-900">{isCreate ? 'Nuevo producto' : `Editar: ${name}`}</h1>
@@ -184,7 +184,7 @@ export function ProductEditor({ productId }: ProductEditorProps) {
{success && <div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700">{success}</div>}
{/* Tabs */}
<div className="hidden md:flex border-b border-gray-200 mb-8">
<div className="flex border-b border-gray-200 mb-8 overflow-x-auto">
{(['general', 'pricing', 'inventory', 'images', 'seo', 'publish'] as const).map(t => (
<button key={t} onClick={() => setTab(t)}
className={`px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${

View File

@@ -47,12 +47,15 @@ export const api = {
// ── Auth ──────────────────────────────────────────────────────────────────────
// FIX-14: admin panel auth resolves through the backoffice endpoint.
const BACKOFFICE_AUTH_BASE = '/api/backoffice/auth';
export const authApi = {
login: (email: string, password: string) =>
api.post<{ id: string; email: string; role: string }>('/api/auth/login', { email, password }),
logout: () => api.post('/api/auth/logout'),
api.post<{ id: string; email: string; role: string }>(`${BACKOFFICE_AUTH_BASE}/login`, { email, password }),
logout: () => api.post(`${BACKOFFICE_AUTH_BASE}/logout`),
me: () =>
api.get<{ id: string; email: string; role: string } | { user: null }>('/api/auth/me'),
api.get<{ id: string; email: string; role: string } | { user: null }>(`${BACKOFFICE_AUTH_BASE}/me`),
};
// ── Products ──────────────────────────────────────────────────────────────────

File diff suppressed because one or more lines are too long