feat(F-194): completed feature

This commit is contained in:
chattie
2026-08-22 22:29:26 +02:00
parent ca12f46bff
commit b85670cb51
15 changed files with 73 additions and 6 deletions

View File

@@ -0,0 +1,174 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import Link from 'next/link';
import { auditApi, type AuditEntry } from '@/lib/api-client';
const PAGE_SIZE = 50;
const ACTION_COLORS: Record<string, string> = {
'admin.mfa.enroll': 'bg-purple-100 text-purple-700',
'admin.mfa.status': 'bg-purple-100 text-purple-700',
'admin.mfa.challenge': 'bg-purple-100 text-purple-700',
'auth.login': 'bg-blue-100 text-blue-700',
'auth.logout': 'bg-gray-100 text-gray-600',
'product.created': 'bg-green-100 text-green-700',
'product.updated': 'bg-green-100 text-green-700',
'product.deleted': 'bg-red-100 text-red-700',
'order.placed': 'bg-indigo-100 text-indigo-700',
'order.state_changed': 'bg-indigo-100 text-indigo-700',
};
function colorForAction(action: string): string {
return ACTION_COLORS[action] ?? 'bg-gray-100 text-gray-600';
}
export default function AuditLogPage() {
const [items, setItems] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState('');
const [debounced, setDebounced] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [newCount, setNewCount] = useState(0);
const [pollingActive, setPollingActive] = useState(false);
const itemsRef = useRef<AuditEntry[]>([]);
useEffect(() => {
const t = setTimeout(() => setDebounced(filter), 400);
return () => clearTimeout(t);
}, [filter]);
useEffect(() => { setPage(0); }, [debounced]);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const data = await auditApi.list({ action: debounced || undefined, limit: PAGE_SIZE, offset: page * PAGE_SIZE });
setItems(data.items ?? []);
setTotal(data.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); }
}, [page, debounced]);
useEffect(() => { load(); }, [load]);
// Keep ref in sync with items state for polling deduplication
useEffect(() => { itemsRef.current = items; }, [items]);
// Real-time polling: fetch new entries every 5s when on page 0 with no filter
useEffect(() => {
if (page !== 0 || debounced) {
setPollingActive(false);
return;
}
setPollingActive(true);
const interval = setInterval(async () => {
try {
const data = await auditApi.list({ limit: PAGE_SIZE, offset: 0 });
const existingIds = new Set(itemsRef.current.map(i => i.id));
const newItems = (data.items ?? []).filter(i => !existingIds.has(i.id));
if (newItems.length > 0) {
setItems(prev => [...newItems, ...prev].slice(0, PAGE_SIZE));
setNewCount(prev => prev + newItems.length);
}
} catch { /* silent */ }
}, 5000);
return () => { clearInterval(interval); setPollingActive(false); };
}, [page, debounced]);
return (
<div className="space-y-6">
<div>
<Link href="/settings" className="text-sm text-[#2D6A4F] hover:underline"> Ajustes</Link>
<h1 className="text-2xl font-bold text-gray-900">Log de auditoría
{pollingActive && (
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-green-600 font-medium align-middle">
<span className="inline-block w-2 h-2 rounded-full bg-green-500 animate-pulse" />
En vivo
</span>
)}
{newCount > 0 && (
<button
onClick={() => setNewCount(0)}
title="Nuevas entradas — haz clic para marcar como vistas"
className="ml-2 px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full hover:bg-green-200 transition-colors"
>
+{newCount} nueva{newCount !== 1 ? 's' : ''}
</button>
)}
</h1>
<p className="text-sm text-gray-500 mt-0.5">
{total > 0 ? `${total} entrada${total !== 1 ? 's' : ''}` : 'Sin entradas'}
</p>
</div>
<div className="max-w-xs">
<div className="relative">
<input type="text" placeholder="Filtrar por acción..." value={filter}
onChange={e => setFilter(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
</div>
</div>
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
<span className="text-3xl">📋</span><span>Sin entradas de auditoría</span>
</div>
) : (
<>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Fecha</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acción</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Objetivo</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Actor</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Detalles</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{items.map(entry => (
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
{new Date(entry.createdAt).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' })}
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorForAction(entry.action)}`}>
{entry.action}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">{entry.target}</td>
<td className="px-6 py-4 text-sm text-gray-400 font-mono">{entry.actorId?.slice(0, 8) ?? '—'}</td>
<td className="px-6 py-4 text-xs text-gray-400 font-mono max-w-xs truncate">
{Object.keys(entry.metadata ?? {}).length > 0 ? JSON.stringify(entry.metadata) : '—'}
</td>
</tr>
))}
</tbody>
</table>
{total > PAGE_SIZE && (
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<span className="text-sm text-gray-500">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} de {total}
</span>
<div className="flex gap-2">
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Anterior</button>
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Siguiente</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import Link from 'next/link';
import { ServerLogViewer } from '@/components/ServerLogViewer';
export default function ServerLogsPage() {
const backendUrl =
typeof window !== 'undefined'
? `${window.location.protocol}//${window.location.hostname}:3000`
: '';
return (
<div className="space-y-6">
<div>
<Link href="/settings" className="text-sm text-[#2D6A4F] hover:underline"> Ajustes</Link>
<h1 className="text-2xl font-bold text-gray-900">Logs del servidor</h1>
<p className="text-sm text-gray-500 mt-0.5">
Stream en tiempo real via SSE. Las líneas erróneas se resaltan en rojo. Máximo 200 líneas.
</p>
</div>
{/* Altura acotada al viewport: el visor ocupa solo la parte visible y el resto hace scroll interno. */}
<div className="h-[calc(100vh-220px)] min-h-[320px]">
<ServerLogViewer backendUrl={backendUrl} />
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { settingsApi, type StoreSettings } from '@/lib/api-client';
type FormData = StoreSettings;
@@ -105,6 +106,17 @@ export default function SettingsPage() {
{t.label}
</button>
))}
<div className="my-3 border-t border-gray-200" />
<p className="px-4 text-xs font-semibold uppercase tracking-wide text-gray-400">Sistema</p>
<Link href="/settings/tax-rates" className="flex w-full items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors">
<span>💰</span> IVA
</Link>
<Link href="/settings/audit" className="flex w-full items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors">
<span>📋</span> Auditoría
</Link>
<Link href="/settings/logs" className="flex w-full items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors">
<span>🖥</span> Logs
</Link>
</nav>
{/* Form area */}

View File

@@ -0,0 +1,211 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { taxApi, type TaxRate } from '@/lib/api-client';
import Link from 'next/link';
import { RowActions } from '@/components/ui/RowActions';
export default function TaxRatesPage() {
const [rates, setRates] = useState<TaxRate[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editRate, setEditRate] = useState('');
const [editActive, setEditActive] = useState(true);
const [saving, setSaving] = useState(false);
const [toggling, setToggling] = useState<Record<string, boolean>>({});
const [msg, setMsg] = useState('');
const [tipoEditing, setTipoEditing] = useState<string | null>(null);
const [savingTipo, setSavingTipo] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
try { const d = await taxApi.list(); setRates(d.items ?? []); }
catch { /* silent */ }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const startEdit = (r: TaxRate) => {
setEditing(r.id); setEditName(r.name); setEditRate(String(r.ratePercent)); setEditActive(r.active);
};
const handleSave = async (id: string) => {
setSaving(true);
try {
await taxApi.update(id, { name: editName, ratePercent: parseFloat(editRate), active: editActive });
setEditing(null); setMsg('Tipo impositivo actualizado'); setTimeout(() => setMsg(''), 3000); load();
} catch (er) { alert(er instanceof Error ? er.message : 'Error'); }
finally { setSaving(false); }
};
const fmt = (r: TaxRate) => `${r.ratePercent}%`;
const toggleActive = async (id: string, newActive: boolean) => {
setToggling(prev => ({ ...prev, [id]: true }));
try {
await taxApi.update(id, { active: newActive });
setRates(prev => prev.map(r => r.id === id ? { ...r, active: newActive } : r));
} catch (er) {
alert(er instanceof Error ? er.message : 'Error al cambiar estado');
} finally {
setToggling(prev => ({ ...prev, [id]: false }));
}
};
const saveTipo = async (id: string, newTipo: 'general' | 'reduced' | 'super-reduced') => {
setSavingTipo(id);
try {
await taxApi.update(id, { appliesTo: newTipo });
setRates(prev => prev.map(r => r.id === id ? { ...r, appliesTo: newTipo } : r));
setTipoEditing(null);
} catch (er) {
alert(er instanceof Error ? er.message : 'Error al cambiar tipo');
} finally {
setSavingTipo(null);
}
};
return (
<div className="space-y-6">
<div>
<Link href="/settings" className="text-sm text-[#2D6A4F] hover:underline"> Ajustes</Link>
<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>
</div>
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-2.5 rounded-xl border border-green-200">{msg}</div>}
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b bg-gray-50">
<h2 className="text-base font-semibold text-gray-800">IVA en España (ES)</h2>
</div>
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
) : (
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Nombre</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Tipo</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Tasa</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Estado</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rates.map(r => (
<tr key={r.id} className="hover:bg-gray-50 transition-colors">
{editing === r.id ? (
<>
<td className="px-4 py-3">
<input value={editName} onChange={e => setEditName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</td>
<td className="px-4 py-3 text-sm text-gray-500">{r.appliesTo}</td>
<td className="px-4 py-3">
<input type="number" step="0.01" value={editRate} onChange={e => setEditRate(e.target.value)}
className="w-24 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</td>
<td className="px-4 py-3">
<select value={String(editActive)} onChange={e => setEditActive(e.target.value === 'true')}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="true">Activo</option><option value="false">Inactivo</option></select>
</td>
<td className="px-4 py-3">
<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>
</td>
</>
) : (
<>
<td className="px-6 py-4 text-sm font-medium text-gray-900">{r.name}</td>
<td className="px-6 py-4 text-sm text-gray-500">
{tipoEditing === r.id ? (
<select
autoFocus
defaultValue={r.appliesTo}
disabled={savingTipo === r.id}
onBlur={(e) => {
const next = e.target.value as 'general' | 'reduced' | 'super-reduced';
if (next !== r.appliesTo) saveTipo(r.id, next);
else setTipoEditing(null);
}}
onChange={(e) => {
const next = e.target.value as 'general' | 'reduced' | 'super-reduced';
if (next !== r.appliesTo) saveTipo(r.id, next);
}}
className="px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
>
<option value="general">General</option>
<option value="reduced">Reducido</option>
<option value="super-reduced">Superreducido</option>
</select>
) : (
<button
onClick={() => setTipoEditing(r.id)}
title="Clic para cambiar tipo"
className="capitalize text-gray-500 hover:text-[#2D6A4F] cursor-text"
>
{r.appliesTo}
</button>
)}
</td>
<td className="px-6 py-4 text-sm font-bold text-gray-800">{fmt(r)}</td>
<td className="px-6 py-4">
<button
onClick={() => toggleActive(r.id, !r.active)}
disabled={toggling[r.id] || saving}
title={r.active ? 'Desactivar' : 'Activar'}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-[#2D6A4F] focus:ring-offset-2 ${
r.active ? 'bg-green-500' : 'bg-gray-300'
}`}
>
{toggling[r.id] ? (
<span className="w-full text-center text-white text-xs animate-pulse"></span>
) : (
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
r.active ? 'translate-x-6' : 'translate-x-1'
}`} />
)}
</button>
</td>
<td className="px-6 py-4 text-right">
{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>
</>
)}
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="bg-amber-50 rounded-xl border border-amber-200 px-5 py-4">
<p className="text-sm text-amber-800">
<strong>España:</strong> IVA General 21%, IVA Reducido 10%, IVA Superreducido 4%. Los tipos se aplican a los precios sin IVA (netos) del producto.
</p>
</div>
</div>
);
}