Files
HoruxDespachos/apps/web/app/(dashboard)/usuarios/page.tsx
Horux Dev 2ac8e4d055 fix: TypeScript build errors in frontend
- Fix CFDI type errors (ivaTraslado, tipoCambio, id types)
- Fix sidebar navigation type errors (Role vs literal tuples)
- Fix user invite type errors (UserInvite['role'])
- Fix login page PlatformRole type cast
2026-04-28 04:51:52 +00:00

512 lines
22 KiB
TypeScript

'use client';
import { useState } 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 { useUsuarios, useInviteUsuario, useUpdateUsuario, useDeleteUsuario } from '@/lib/hooks/use-usuarios';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { addClienteAcceso } from '@/lib/api/contribuyentes';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { Users, UserPlus, Trash2, Shield, Eye, Calculator, UserCheck, UserCog, Building2, FolderOpen, KeyRound } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
import Link from 'next/link';
import { cn } from '@horux/shared-ui';
import { isDespachoTenant } from '@horux/shared';
import type { Role, UserInvite } from '@horux/shared';
// ── Horux360 legacy roles ─────────────────────────────────────────────────────
const legacyRoleLabels: Record<string, { label: string; icon: React.ElementType; color: string }> = {
owner: { label: 'Dueño', icon: Shield, color: 'text-primary' },
cfo: { label: 'CFO', icon: Shield, color: 'text-primary' },
contador: { label: 'Contador', icon: Calculator, color: 'text-success' },
auxiliar: { label: 'Auxiliar', icon: UserCog, color: 'text-success' },
visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' },
};
const legacyInviteRoles: { value: string; label: string; description?: string }[] = [
{ value: 'contador', label: 'Contador' },
{ value: 'visor', label: 'Visor' },
{ value: 'auxiliar', label: 'Auxiliar' },
];
// ── Despacho roles ────────────────────────────────────────────────────────────
const despachoRoleLabels: Record<string, { label: string; icon: React.ElementType; color: string }> = {
owner: { label: 'Owner', icon: Shield, color: 'text-primary' },
supervisor: { label: 'Supervisor', icon: UserCheck, color: 'text-success' },
auxiliar: { label: 'Auxiliar', icon: UserCog, color: 'text-success' },
cliente: { label: 'Cliente', icon: Building2, color: 'text-muted-foreground' },
};
const despachoInviteRoles: { value: string; label: string; description?: string }[] = [
{
value: 'supervisor',
label: 'Supervisor',
description: 'Titular de RFCs, crea carteras y gestiona auxiliares',
},
{
value: 'auxiliar',
label: 'Auxiliar',
description: 'Accede a RFCs asignados vía carteras',
},
{
value: 'cliente',
label: 'Cliente',
description: 'Visor externo — acceso read-only a sus RFCs',
},
];
// ── Fallback for unknown roles ─────────────────────────────────────────────────
function getRoleInfo(
role: string,
isDespacho: boolean,
): { label: string; icon: React.ElementType; color: string } {
if (isDespacho) {
return despachoRoleLabels[role] ?? { label: role, icon: Eye, color: 'text-muted-foreground' };
}
return legacyRoleLabels[role] ?? { label: role, icon: Eye, color: 'text-muted-foreground' };
}
export default function UsuariosPage() {
const { user: currentUser } = useAuthStore();
const { data: usuarios, isLoading } = useUsuarios();
const { data: contribuyentes } = useContribuyentes();
const inviteUsuario = useInviteUsuario();
const updateUsuario = useUpdateUsuario();
const deleteUsuario = useDeleteUsuario();
const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
const inviteRoles = isDespacho ? despachoInviteRoles : legacyInviteRoles;
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo';
const [showInvite, setShowInvite] = useState(false);
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: UserInvite['role']; supervisorUserId?: string }>({
email: '',
nombre: '',
role: defaultInviteRole as UserInvite['role'],
});
const [selectedRfcIds, setSelectedRfcIds] = useState<string[]>([]);
// Edit accesos modal
const [editingAccesosUser, setEditingAccesosUser] = useState<{ id: string; nombre: string } | null>(null);
const [accesosRfcIds, setAccesosRfcIds] = useState<string[]>([]);
const [savingAccesos, setSavingAccesos] = useState(false);
// Edit supervisor modal (para auxiliares)
const [editingSupervisorUser, setEditingSupervisorUser] = useState<{ id: string; nombre: string } | null>(null);
const [selectedSupervisorId, setSelectedSupervisorId] = useState<string>('');
const [savingSupervisor, setSavingSupervisor] = useState(false);
const openEditSupervisor = async (userId: string, nombre: string) => {
try {
const res = await apiClient.get<{ supervisorUserId: string | null }>(`/usuarios/${userId}/supervisor`);
setSelectedSupervisorId(res.data.supervisorUserId ?? '');
setEditingSupervisorUser({ id: userId, nombre });
} catch {
alert('Error al cargar supervisor');
}
};
const handleSaveSupervisor = async () => {
if (!editingSupervisorUser) return;
setSavingSupervisor(true);
try {
await apiClient.put(`/usuarios/${editingSupervisorUser.id}/supervisor`, {
supervisorUserId: selectedSupervisorId || null,
});
setEditingSupervisorUser(null);
} catch {
alert('Error al guardar supervisor');
} finally {
setSavingSupervisor(false);
}
};
const openEditAccesos = async (userId: string, nombre: string) => {
try {
const res = await apiClient.get<{ data: string[] }>(`/usuarios/${userId}/accesos`);
setAccesosRfcIds(res.data.data);
setEditingAccesosUser({ id: userId, nombre });
} catch {
alert('Error al cargar accesos');
}
};
const handleSaveAccesos = async () => {
if (!editingAccesosUser) return;
setSavingAccesos(true);
try {
await apiClient.post(`/usuarios/${editingAccesosUser.id}/accesos`, { entidadIds: accesosRfcIds });
setEditingAccesosUser(null);
} catch {
alert('Error al guardar accesos');
} finally {
setSavingAccesos(false);
}
};
const toggleAccesoRfc = (id: string) => {
setAccesosRfcIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
};
// Fetch supervisors for auxiliar invite dropdown
const { data: supervisores } = useQuery({
queryKey: ['cartera-supervisores'],
queryFn: async () => {
const res = await apiClient.get<{ data: Array<{ userId: string; nombre: string; email: string }> }>('/carteras/supervisores');
return res.data.data;
},
enabled: isDespacho && isAdmin,
});
const toggleRfc = (id: string) => {
setSelectedRfcIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (inviteForm.role === 'auxiliar' && !inviteForm.supervisorUserId) {
alert('Debes asignar un supervisor al auxiliar');
return;
}
try {
const newUser = await inviteUsuario.mutateAsync(inviteForm);
// If role is 'cliente' and RFCs were selected, grant access to each
if (inviteForm.role === 'cliente' && selectedRfcIds.length > 0) {
await Promise.all(
selectedRfcIds.map((rfcId) => addClienteAcceso(rfcId, newUser.id)),
);
}
setShowInvite(false);
setInviteForm({ email: '', nombre: '', role: defaultInviteRole as UserInvite['role'], supervisorUserId: undefined });
setSelectedRfcIds([]);
} catch (error: any) {
alert(error.response?.data?.message || 'Error al invitar usuario');
}
};
const handleToggleActive = (id: string, active: boolean) => {
updateUsuario.mutate({ id, data: { active: !active } });
};
const handleDelete = (id: string) => {
if (confirm('¿Eliminar este usuario?')) {
deleteUsuario.mutate(id);
}
};
return (
<DashboardShell title="Usuarios">
<div className="space-y-4">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Usuarios</h1>
<p className="text-sm text-muted-foreground">Gestiona tu equipo</p>
</div>
<div className="flex items-center gap-2">
{isAdmin && isDespacho && (
<Link href="/carteras">
<Button variant="outline" className="flex items-center gap-2">
<FolderOpen className="h-4 w-4" /> Gestionar Carteras
</Button>
</Link>
)}
{isAdmin && (
<Button onClick={() => setShowInvite(true)}>
<UserPlus className="h-4 w-4 mr-2" />
Invitar Usuario
</Button>
)}
</div>
</div>
{/* User count */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
<span>{usuarios?.length || 0} usuarios</span>
</div>
{/* Invite Form */}
{showInvite && (
<Card>
<CardHeader>
<CardTitle>Invitar Nuevo Usuario</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={inviteForm.email}
onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="nombre">Nombre</Label>
<Input
id="nombre"
value={inviteForm.nombre}
onChange={e => setInviteForm({ ...inviteForm, nombre: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Rol</Label>
<Select
value={inviteForm.role}
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as UserInvite['role'], supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{inviteRoles.map(r => (
<SelectItem key={r.value} value={r.value}>
<div className="flex flex-col">
<span>{r.label}</span>
{'description' in r && r.description && (
<span className="text-xs text-muted-foreground">{r.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Supervisor selector for auxiliar role */}
{isDespacho && inviteForm.role === 'auxiliar' && (
<div className="space-y-2">
<Label>Supervisor asignado</Label>
{supervisores && supervisores.length > 0 ? (
<Select
value={inviteForm.supervisorUserId || ''}
onValueChange={(v) => setInviteForm({ ...inviteForm, supervisorUserId: v })}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar supervisor..." />
</SelectTrigger>
<SelectContent>
{supervisores.map(s => (
<SelectItem key={s.userId} value={s.userId}>
{s.nombre} ({s.email})
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground border rounded-md p-3">
No hay supervisores registrados. Crea uno primero para poder asignar auxiliares.
</p>
)}
</div>
)}
{/* RFC access for cliente role */}
{isDespacho && inviteForm.role === 'cliente' && contribuyentes && contribuyentes.length > 0 && (
<div className="space-y-2">
<Label>RFCs con acceso (opcional)</Label>
<div className="border rounded-md p-3 space-y-2 max-h-48 overflow-y-auto">
{contribuyentes.map((c) => (
<div key={c.id} className="flex items-center gap-2">
<input
type="checkbox"
id={`rfc-${c.id}`}
checked={selectedRfcIds.includes(c.id)}
onChange={() => toggleRfc(c.id)}
className="h-4 w-4"
/>
<label htmlFor={`rfc-${c.id}`} className="text-sm cursor-pointer">
<span className="font-mono">{c.rfc}</span>
{' — '}
<span className="text-muted-foreground">{c.nombre}</span>
</label>
</div>
))}
</div>
</div>
)}
<div className="flex gap-2">
<Button type="submit" disabled={inviteUsuario.isPending}>
{inviteUsuario.isPending ? 'Enviando...' : 'Enviar Invitación'}
</Button>
<Button type="button" variant="outline" onClick={() => { setShowInvite(false); setSelectedRfcIds([]); }}>
Cancelar
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Users List */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : (
<div className="divide-y">
{usuarios?.map(usuario => {
const roleInfo = getRoleInfo(usuario.role, isDespacho);
const RoleIcon = roleInfo.icon;
const isCurrentUser = usuario.id === currentUser?.id;
return (
<div key={usuario.id} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<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>
<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"></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">
<div className={cn('flex items-center gap-1', roleInfo.color)}>
<RoleIcon className="h-4 w-4" />
<span className="text-sm">{roleInfo.label}</span>
</div>
{isAdmin && !isCurrentUser && (
<div className="flex gap-1">
{isDespacho && usuario.role === 'cliente' && (
<Button
variant="ghost"
size="sm"
onClick={() => openEditAccesos(usuario.id, usuario.nombre)}
title="Editar RFCs con acceso"
>
<KeyRound className="h-4 w-4 mr-1" /> Accesos
</Button>
)}
{isDespacho && usuario.role === 'auxiliar' && (
<Button
variant="ghost"
size="sm"
onClick={() => openEditSupervisor(usuario.id, usuario.nombre)}
title="Asignar supervisor"
>
<UserCheck className="h-4 w-4 mr-1" /> Supervisor
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleActive(usuario.id, usuario.active)}
>
{usuario.active ? 'Desactivar' : 'Activar'}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(usuario.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
{/* Edit Accesos Modal */}
{editingAccesosUser && (
<Dialog open onOpenChange={(open) => { if (!open) setEditingAccesosUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar accesos {editingAccesosUser.nombre}</DialogTitle>
</DialogHeader>
<div className="space-y-2 max-h-60 overflow-y-auto py-2">
{contribuyentes && contribuyentes.length > 0 ? (
contribuyentes.map((c) => (
<div key={c.id} className="flex items-center gap-2">
<input
type="checkbox"
id={`acceso-${c.id}`}
checked={accesosRfcIds.includes(c.id)}
onChange={() => toggleAccesoRfc(c.id)}
className="h-4 w-4"
/>
<label htmlFor={`acceso-${c.id}`} className="text-sm cursor-pointer">
<span className="font-mono">{c.rfc}</span>
{' — '}
<span className="text-muted-foreground">{c.nombre}</span>
</label>
</div>
))
) : (
<p className="text-sm text-muted-foreground">No hay contribuyentes registrados.</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingAccesosUser(null)}>Cancelar</Button>
<Button onClick={handleSaveAccesos} disabled={savingAccesos}>
{savingAccesos ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* Edit Supervisor Modal (auxiliares) */}
{editingSupervisorUser && (
<Dialog open onOpenChange={(open) => { if (!open) setEditingSupervisorUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Asignar supervisor {editingSupervisorUser.nombre}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
{supervisores && supervisores.length > 0 ? (
<Select value={selectedSupervisorId || 'none'} onValueChange={(v) => setSelectedSupervisorId(v === 'none' ? '' : v)}>
<SelectTrigger><SelectValue placeholder="Selecciona un supervisor..." /></SelectTrigger>
<SelectContent>
<SelectItem value="none">Sin supervisor asignado</SelectItem>
{supervisores.map(s => (
<SelectItem key={s.userId} value={s.userId}>
{s.nombre} {s.email}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">No hay supervisores registrados todavía.</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingSupervisorUser(null)}>Cancelar</Button>
<Button onClick={handleSaveSupervisor} disabled={savingSupervisor}>
{savingSupervisor ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DashboardShell>
);
}