- Backend: getSupervisor devuelve supervisorNombre desde Prisma - Frontend: usa SelectTrigger con renderizado manual del label seleccionado en lugar de depender de SelectValue, que no siempre encontraba el texto del SelectItem cuando el supervisor no estaba en la lista de carteras
528 lines
23 KiB
TypeScript
528 lines
23 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 }[] = [
|
|
{ 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; supervisorNombre?: string | null } | null>(null);
|
|
const [selectedSupervisorId, setSelectedSupervisorId] = useState<string>('');
|
|
const [savingSupervisor, setSavingSupervisor] = useState(false);
|
|
|
|
const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState<string>('');
|
|
|
|
const openEditSupervisor = async (userId: string, nombre: string) => {
|
|
try {
|
|
const res = await apiClient.get<{ supervisorUserId: string | null; supervisorNombre: string | null }>(`/usuarios/${userId}/supervisor`);
|
|
setSelectedSupervisorId(res.data.supervisorUserId ?? '');
|
|
setCurrentSupervisorNombre(res.data.supervisorNombre ?? '');
|
|
setEditingSupervisorUser({ id: userId, nombre, supervisorNombre: res.data.supervisorNombre });
|
|
} 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 && (
|
|
<Button variant="outline" className="flex items-center gap-2" asChild>
|
|
<Link href="/carteras">
|
|
<FolderOpen className="h-4 w-4" /> Gestionar Carteras
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
{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: { value: string; label: string; description?: string }) => (
|
|
<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">Tú</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 className="w-full">
|
|
{(() => {
|
|
if (!selectedSupervisorId || selectedSupervisorId === 'none') return <span className="text-muted-foreground">Sin supervisor asignado</span>;
|
|
const s = supervisores?.find(x => x.userId === selectedSupervisorId);
|
|
if (s) return <span>{s.nombre} — {s.email}</span>;
|
|
return <span>{currentSupervisorNombre || editingSupervisorUser?.supervisorNombre || selectedSupervisorId}</span>;
|
|
})()}
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">Sin supervisor asignado</SelectItem>
|
|
{supervisores.map(s => (
|
|
<SelectItem key={s.userId} value={s.userId}>
|
|
{s.nombre} — {s.email}
|
|
</SelectItem>
|
|
))}
|
|
{/* Si el supervisor actual no está en la lista de carteras, mostrarlo igual */}
|
|
{selectedSupervisorId && !supervisores.some(s => s.userId === selectedSupervisorId) && (
|
|
<SelectItem value={selectedSupervisorId}>
|
|
{currentSupervisorNombre || editingSupervisorUser?.supervisorNombre || selectedSupervisorId}
|
|
</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>
|
|
);
|
|
}
|