Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { useAuditLog } from '@/lib/hooks/use-audit-log';
import { ChevronLeft, ChevronRight, Search, X, FileWarning, ShieldAlert } from 'lucide-react';
const ACTION_GROUPS = [
{ value: '', label: 'Todas las acciones' },
{ value: 'user.', label: 'Usuarios (login, logout, password)' },
{ value: 'subscription.', label: 'Suscripciones (crear, cancelar, cambiar, reactivar)' },
{ value: 'trial.', label: 'Trials' },
{ value: 'price.', label: 'Precios' },
{ value: 'invoice.', label: 'Facturación' },
{ value: 'payment.', label: 'Pagos' },
{ value: 'tenant.', label: 'Tenants' },
{ value: 'fiel.', label: 'FIEL' },
];
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
'user.login': { label: 'Login', color: 'bg-blue-50 text-blue-700' },
'user.logout': { label: 'Logout', color: 'bg-slate-50 text-slate-700' },
'user.password_changed': { label: 'Cambio password', color: 'bg-amber-50 text-amber-700' },
'trial.started': { label: 'Trial iniciado', color: 'bg-sky-50 text-sky-700' },
'subscription.created': { label: 'Suscripción creada',color: 'bg-green-50 text-green-700' },
'subscription.cancelled': { label: 'Suscripción cancelada', color: 'bg-orange-50 text-orange-700' },
'subscription.reactivated': { label: 'Reactivada', color: 'bg-teal-50 text-teal-700' },
'subscription.plan_changed': { label: 'Cambio de plan', color: 'bg-indigo-50 text-indigo-700' },
'price.updated': { label: 'Precio editado', color: 'bg-purple-50 text-purple-700' },
'invoice.emitted_auto': { label: 'Factura auto', color: 'bg-emerald-50 text-emerald-700' },
'invoice.emitted_manual': { label: 'Factura manual', color: 'bg-emerald-50 text-emerald-700' },
'payment.marked_paid_manually': { label: 'Pago marcado manual', color: 'bg-lime-50 text-lime-700' },
};
function formatDateTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('es-MX', { dateStyle: 'short', timeStyle: 'medium' });
}
function ActionBadge({ action }: { action: string }) {
const cfg = ACTION_LABELS[action] || { label: action, color: 'bg-muted text-muted-foreground' };
return <span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${cfg.color}`}>{cfg.label}</span>;
}
export default function AuditLogPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const [filters, setFilters] = useState({
action: '',
tenantId: '',
userId: '',
from: '',
to: '',
page: 1,
limit: 50,
});
const [expandedId, setExpandedId] = useState<string | null>(null);
const { data, isLoading } = useAuditLog(filters);
if (!isGlobalAdmin) {
return (
<>
<Header title="Audit Log" />
<main className="p-6">
<Card>
<CardContent className="py-12 text-center">
<ShieldAlert className="h-12 w-12 text-muted-foreground/40 mx-auto mb-3" />
<p className="font-semibold">Acceso restringido</p>
<p className="text-sm text-muted-foreground mt-1">
Solo el administrador global puede consultar el audit log.
</p>
</CardContent>
</Card>
</main>
</>
);
}
const clearFilters = () => setFilters({ action: '', tenantId: '', userId: '', from: '', to: '', page: 1, limit: 50 });
return (
<>
<Header title="Audit Log" />
<main className="p-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileWarning className="h-5 w-5" />
Filtros
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<div className="space-y-1">
<Label>Acción</Label>
<Select value={filters.action || 'all'} onValueChange={v => setFilters(f => ({ ...f, action: v === 'all' ? '' : v, page: 1 }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ACTION_GROUPS.map(g => (
<SelectItem key={g.value || 'all'} value={g.value || 'all'}>{g.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Tenant ID</Label>
<Input value={filters.tenantId} onChange={e => setFilters(f => ({ ...f, tenantId: e.target.value, page: 1 }))} placeholder="UUID del tenant" />
</div>
<div className="space-y-1">
<Label>User ID</Label>
<Input value={filters.userId} onChange={e => setFilters(f => ({ ...f, userId: e.target.value, page: 1 }))} placeholder="UUID del usuario" />
</div>
<div className="space-y-1">
<Label>Desde</Label>
<Input type="datetime-local" value={filters.from} onChange={e => setFilters(f => ({ ...f, from: e.target.value, page: 1 }))} />
</div>
<div className="space-y-1">
<Label>Hasta</Label>
<Input type="datetime-local" value={filters.to} onChange={e => setFilters(f => ({ ...f, to: e.target.value, page: 1 }))} />
</div>
</div>
<div className="flex gap-2 mt-4">
<Button variant="outline" size="sm" onClick={clearFilters}>
<X className="h-4 w-4 mr-1" />
Limpiar filtros
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Eventos {data?.total !== undefined && <span className="text-sm font-normal text-muted-foreground">({data.total.toLocaleString('es-MX')} totales)</span>}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-center py-8 text-muted-foreground">Cargando...</p>
) : !data || data.data.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay eventos con estos filtros.</p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">Fecha</th>
<th className="py-2 pr-4 font-medium">Acción</th>
<th className="py-2 pr-4 font-medium">Usuario</th>
<th className="py-2 pr-4 font-medium">Tenant</th>
<th className="py-2 pr-4 font-medium">Entidad</th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{data.data.map(row => (
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-3 pr-4 text-xs text-muted-foreground whitespace-nowrap">{formatDateTime(row.createdAt)}</td>
<td className="py-3 pr-4"><ActionBadge action={row.action} /></td>
<td className="py-3 pr-4">
{row.user ? (
<div className="text-xs">
<div className="font-medium">{row.user.nombre}</div>
<div className="text-muted-foreground">{row.user.email}</div>
</div>
) : <span className="text-muted-foreground text-xs">Sistema</span>}
</td>
<td className="py-3 pr-4">
{row.tenant ? (
<div className="text-xs">
<div className="font-medium">{row.tenant.nombre}</div>
<div className="text-muted-foreground font-mono">{row.tenant.rfc}</div>
</div>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="py-3 pr-4 text-xs text-muted-foreground">
{row.entityType ? `${row.entityType}${row.entityId ? ` ${row.entityId.slice(0, 8)}` : ''}` : '—'}
</td>
<td className="py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => setExpandedId(expandedId === row.id ? null : row.id)}>
{expandedId === row.id ? 'Ocultar' : 'Ver detalle'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{expandedId && (() => {
const row = data.data.find(r => r.id === expandedId);
if (!row) return null;
return (
<Card className="mt-4 bg-muted/20">
<CardContent className="pt-4">
<p className="text-xs text-muted-foreground mb-2">Metadata del evento <code className="font-mono">{row.id}</code></p>
<pre className="text-xs whitespace-pre-wrap break-all bg-background p-3 rounded border">{JSON.stringify(row.metadata, null, 2)}</pre>
</CardContent>
</Card>
);
})()}
{data.totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t mt-4">
<p className="text-sm text-muted-foreground">Página {data.page} de {data.totalPages}</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={data.page <= 1} onClick={() => setFilters(f => ({ ...f, page: f.page - 1 }))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" disabled={data.page >= data.totalPages} onClick={() => setFilters(f => ({ ...f, page: f.page + 1 }))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</main>
</>
);
}