Initial commit: Horux Despachos project
This commit is contained in:
229
apps/web/app/(dashboard)/admin/audit-log/page.tsx
Normal file
229
apps/web/app/(dashboard)/admin/audit-log/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
264
apps/web/app/(dashboard)/admin/staff/page.tsx
Normal file
264
apps/web/app/(dashboard)/admin/staff/page.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@horux/shared-ui';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared';
|
||||
import { useStaff, useSearchUsers, useGrantRole, useRevokeRole } from '@/lib/hooks/use-platform-staff';
|
||||
import { ShieldAlert, Shield, ShieldCheck, HeadphonesIcon, TrendingUp, DollarSign, UserPlus, X, Loader2, Search, Cpu } from 'lucide-react';
|
||||
|
||||
const ROLE_META: Record<PlatformRole, { label: string; desc: string; icon: any; color: string }> = {
|
||||
platform_admin: { label: 'Admin', desc: 'Todo: gestión staff, precios, clientes, facturas', icon: ShieldCheck, color: 'bg-red-100 text-red-700 border-red-200' },
|
||||
platform_ti: { label: 'TI', desc: 'Equipo de TI. Mismos permisos que Admin (diferencia solo en trazabilidad)', icon: Cpu, color: 'bg-slate-100 text-slate-700 border-slate-200' },
|
||||
platform_support: { label: 'Support', desc: 'Ver tenants, resolver tickets', icon: HeadphonesIcon, color: 'bg-blue-100 text-blue-700 border-blue-200' },
|
||||
platform_sales: { label: 'Sales', desc: 'Crear/editar clientes, ver suscripciones', icon: TrendingUp, color: 'bg-green-100 text-green-700 border-green-200' },
|
||||
platform_finance: { label: 'Finance', desc: 'Pagos, facturas manuales, editar precios', icon: DollarSign, color: 'bg-amber-100 text-amber-700 border-amber-200' },
|
||||
};
|
||||
|
||||
const ALL_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti', 'platform_support', 'platform_sales', 'platform_finance'];
|
||||
|
||||
export default function StaffPage() {
|
||||
const { user } = useAuthStore();
|
||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
|
||||
|
||||
const { data: staff = [], isLoading } = useStaff();
|
||||
const grantRole = useGrantRole();
|
||||
const revokeRole = useRevokeRole();
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [searchQ, setSearchQ] = useState('');
|
||||
const { data: candidates = [] } = useSearchUsers(searchQ);
|
||||
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
|
||||
const [pickedRole, setPickedRole] = useState<PlatformRole>('platform_support');
|
||||
|
||||
if (!isGlobalAdmin) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Gestión de Staff" />
|
||||
<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 platform_admin puede gestionar staff.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const handleGrant = async () => {
|
||||
if (!pickedUserId) return;
|
||||
try {
|
||||
await grantRole.mutateAsync({ userId: pickedUserId, role: pickedRole });
|
||||
setAddOpen(false);
|
||||
setSearchQ('');
|
||||
setPickedUserId(null);
|
||||
} catch (err: any) {
|
||||
alert(err?.response?.data?.message || err?.message || 'Error al asignar rol');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (userId: string, role: PlatformRole, userEmail: string) => {
|
||||
if (!confirm(`¿Quitar el rol "${ROLE_META[role].label}" a ${userEmail}?`)) return;
|
||||
try {
|
||||
await revokeRole.mutateAsync({ userId, role });
|
||||
} catch (err: any) {
|
||||
alert(err?.response?.data?.message || err?.message || 'Error al quitar rol');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Gestión de Staff" />
|
||||
<main className="p-6 space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Staff interno de Horux 360 con poderes transversales. <code className="font-mono text-xs">platform_admin</code> implica todos los otros roles.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setAddOpen(true)}>
|
||||
<UserPlus className="h-4 w-4 mr-1" />
|
||||
Agregar staff
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Equipo ({staff.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-center py-8 text-muted-foreground">Cargando...</p>
|
||||
) : staff.length === 0 ? (
|
||||
<p className="text-center py-8 text-muted-foreground">
|
||||
Todavía no hay staff. Agrega al primer miembro con el botón arriba.
|
||||
</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">Usuario</th>
|
||||
<th className="py-2 pr-4 font-medium">Tenant origen</th>
|
||||
<th className="py-2 pr-4 font-medium">Roles</th>
|
||||
<th className="py-2 font-medium text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{staff.map(s => (
|
||||
<tr key={s.id} className="border-b last:border-0 hover:bg-muted/40">
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-medium">{s.nombre}</div>
|
||||
<div className="text-xs text-muted-foreground">{s.email}</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-xs">
|
||||
{s.tenant ? (
|
||||
<div>
|
||||
<div>{s.tenant.nombre}</div>
|
||||
<div className="font-mono text-muted-foreground">{s.tenant.rfc}</div>
|
||||
</div>
|
||||
) : <span className="text-muted-foreground">—</span>}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{s.roles.map(r => {
|
||||
const meta = ROLE_META[r];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<span key={r} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border ${meta.color}`}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{meta.label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRevoke(s.id, r, s.email)}
|
||||
className="hover:opacity-60 ml-1"
|
||||
title="Quitar"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => { setPickedUserId(s.id); setSearchQ(s.email); setAddOpen(true); }}>
|
||||
Agregar rol
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Descripción de roles
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{ALL_ROLES.map(r => {
|
||||
const m = ROLE_META[r];
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<div key={r} className={`rounded border p-3 ${m.color}`}>
|
||||
<div className="flex items-center gap-2 font-medium mb-1">
|
||||
<Icon className="h-4 w-4" />
|
||||
{m.label}
|
||||
</div>
|
||||
<p className="text-xs opacity-80">{m.desc}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add staff dialog */}
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Agregar rol de staff</DialogTitle>
|
||||
<DialogDescription>
|
||||
Busca al usuario por email o nombre y asígnale un rol de plataforma.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Buscar usuario</Label>
|
||||
<div className="relative">
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQ}
|
||||
onChange={e => { setSearchQ(e.target.value); setPickedUserId(null); }}
|
||||
placeholder="email o nombre (min 2 caracteres)"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
{searchQ.length >= 2 && candidates.length > 0 && !pickedUserId && (
|
||||
<div className="border rounded mt-1 max-h-40 overflow-auto">
|
||||
{candidates.map(c => (
|
||||
<button
|
||||
type="button"
|
||||
key={c.id}
|
||||
onClick={() => { setPickedUserId(c.id); setSearchQ(c.email); }}
|
||||
className="w-full text-left px-3 py-2 hover:bg-muted text-sm border-b last:border-0"
|
||||
>
|
||||
<div className="font-medium">{c.nombre}</div>
|
||||
<div className="text-xs text-muted-foreground">{c.email} · {c.tenant?.rfc || '—'}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Rol a asignar</Label>
|
||||
<div className="grid gap-2">
|
||||
{ALL_ROLES.map(r => {
|
||||
const m = ROLE_META[r];
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={r}
|
||||
onClick={() => setPickedRole(r)}
|
||||
className={`text-left rounded border-2 p-3 transition-all ${pickedRole === r ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-medium text-sm mb-0.5">
|
||||
<Icon className="h-4 w-4" />
|
||||
{m.label}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{m.desc}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={handleGrant} disabled={!pickedUserId || grantRole.isPending}>
|
||||
{grantRole.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
|
||||
Asignar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
311
apps/web/app/(dashboard)/admin/usuarios/page.tsx
Normal file
311
apps/web/app/(dashboard)/admin/usuarios/page.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
|
||||
import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
|
||||
import { getTenants, type Tenant } from '@/lib/api/tenants';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase } from 'lucide-react';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
|
||||
// Mapa de roles + fallback defensivo. El fork despacho introduce roles
|
||||
// adicionales (cfo, supervisor, auxiliar, cliente) que no estaban en
|
||||
// Horux 360 single-tenant; si llega un rol no mapeado (ej. uno nuevo
|
||||
// agregado en BD sin tocar este archivo), `defaultRoleInfo` previene
|
||||
// runtime errors al hacer `roleInfo.icon`.
|
||||
const roleLabels: Record<string, { label: string; icon: typeof Shield; color: string }> = {
|
||||
owner: { label: 'Dueño', icon: Shield, color: 'text-primary' },
|
||||
cfo: { label: 'CFO', icon: Briefcase, color: 'text-indigo-600' },
|
||||
contador: { label: 'Contador', icon: Calculator, color: 'text-green-600' },
|
||||
supervisor: { label: 'Supervisor', icon: UserCheck, color: 'text-blue-600' },
|
||||
auxiliar: { label: 'Auxiliar', icon: UserCog, color: 'text-cyan-600' },
|
||||
cliente: { label: 'Cliente', icon: User, color: 'text-amber-600' },
|
||||
visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' },
|
||||
};
|
||||
const defaultRoleInfo = { label: 'Sin rol', icon: User, color: 'text-muted-foreground' };
|
||||
|
||||
interface EditingUser {
|
||||
id: string;
|
||||
nombre: string;
|
||||
role: 'owner' | 'contador' | 'visor';
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
export default function AdminUsuariosPage() {
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const { data: usuarios, isLoading, error } = useAllUsuarios();
|
||||
const updateUsuario = useUpdateUsuarioGlobal();
|
||||
const deleteUsuario = useDeleteUsuarioGlobal();
|
||||
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
|
||||
const [filterTenant, setFilterTenant] = useState<string>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getTenants().then(setTenants).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleEdit = (usuario: any) => {
|
||||
setEditingUser({
|
||||
id: usuario.id,
|
||||
nombre: usuario.nombre,
|
||||
role: usuario.role,
|
||||
tenantId: usuario.tenantId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingUser) return;
|
||||
try {
|
||||
await updateUsuario.mutateAsync({
|
||||
id: editingUser.id,
|
||||
data: {
|
||||
nombre: editingUser.nombre,
|
||||
role: editingUser.role,
|
||||
tenantId: editingUser.tenantId,
|
||||
},
|
||||
});
|
||||
setEditingUser(null);
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Error al actualizar usuario');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Estas seguro de eliminar este usuario?')) return;
|
||||
try {
|
||||
await deleteUsuario.mutateAsync(id);
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Error al eliminar usuario');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsuarios = usuarios?.filter(u => {
|
||||
const matchesTenant = filterTenant === 'all' || u.tenantId === filterTenant;
|
||||
const matchesSearch = !searchTerm ||
|
||||
u.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return matchesTenant && matchesSearch;
|
||||
});
|
||||
|
||||
// Agrupar por empresa
|
||||
const groupedByTenant = filteredUsuarios?.reduce((acc, u) => {
|
||||
const key = u.tenantId || 'sin-empresa';
|
||||
if (!acc[key]) {
|
||||
acc[key] = {
|
||||
tenantName: u.tenantName || 'Sin empresa',
|
||||
users: [],
|
||||
};
|
||||
}
|
||||
acc[key].users.push(u);
|
||||
return acc;
|
||||
}, {} as Record<string, { tenantName: string; users: typeof filteredUsuarios }>);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<DashboardShell title="Administracion de Usuarios">
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-destructive">
|
||||
No tienes permisos para ver esta pagina o ocurrio un error.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell title="Administracion de Usuarios">
|
||||
<div className="space-y-4">
|
||||
{/* Filtros */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
placeholder="Buscar por nombre o email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[250px]">
|
||||
<Select value={filterTenant} onValueChange={setFilterTenant}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Filtrar por empresa" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas las empresas</SelectItem>
|
||||
{tenants.map(t => (
|
||||
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
<span className="font-medium">{filteredUsuarios?.length || 0} usuarios</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="text-sm">{Object.keys(groupedByTenant || {}).length} empresas</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users by tenant */}
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Cargando usuarios...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
Object.entries(groupedByTenant || {}).map(([tenantId, { tenantName, users }]) => (
|
||||
<Card key={tenantId}>
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{tenantName}
|
||||
<span className="text-muted-foreground font-normal text-sm">
|
||||
({users?.length} usuarios)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{users?.map(usuario => {
|
||||
const roleInfo = roleLabels[usuario.role] || defaultRoleInfo;
|
||||
const RoleIcon = roleInfo.icon;
|
||||
const isCurrentUser = usuario.id === currentUser?.id;
|
||||
const isEditing = editingUser?.id === usuario.id;
|
||||
|
||||
return (
|
||||
<div key={usuario.id} className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className={cn(
|
||||
'w-10 h-10 rounded-full flex items-center justify-center',
|
||||
'bg-primary/10 text-primary font-medium'
|
||||
)}>
|
||||
{usuario.nombre.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={editingUser.nombre}
|
||||
onChange={(e) => setEditingUser({ ...editingUser, nombre: e.target.value })}
|
||||
className="h-8"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={editingUser.role}
|
||||
onValueChange={(v) => setEditingUser({ ...editingUser, role: v as any })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">Dueño</SelectItem>
|
||||
<SelectItem value="contador">Contador</SelectItem>
|
||||
<SelectItem value="visor">Visor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={editingUser.tenantId}
|
||||
onValueChange={(v) => setEditingUser({ ...editingUser, tenantId: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tenants.map(t => (
|
||||
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{usuario.nombre}</span>
|
||||
{isCurrentUser && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">Tu</span>
|
||||
)}
|
||||
{!usuario.active && (
|
||||
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded">Inactivo</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{usuario.email}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{!isEditing && (
|
||||
<div className={cn('flex items-center gap-1', roleInfo.color)}>
|
||||
<RoleIcon className="h-4 w-4" />
|
||||
<span className="text-sm">{roleInfo.label}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isCurrentUser && (
|
||||
<div className="flex gap-1">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleSave}
|
||||
disabled={updateUsuario.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditingUser(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(usuario)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(usuario.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user