This reverts commit d3b326e.
The deployment caused reports of blank screens and 400 errors. Reverting to restore stable state while investigating root cause.
446 lines
19 KiB
TypeScript
446 lines
19 KiB
TypeScript
'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, Tabs, TabsList, TabsTrigger, TabsContent } from '@horux/shared-ui';
|
|
import { useAllUsuarios, useCreateUsuarioGlobal, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
|
|
import { getTenants, type Tenant } from '@/lib/api/tenants';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { isGlobalAdminRfc } from '@horux/shared';
|
|
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase, Plus } from 'lucide-react';
|
|
import { cn } from '@horux/shared-ui';
|
|
import InvitacionesTrialTab from '../_components/invitaciones-trial-tab';
|
|
|
|
// 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('');
|
|
const [activeTab, setActiveTab] = useState('usuarios');
|
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
|
const [createFormData, setCreateFormData] = useState({
|
|
email: '',
|
|
nombre: '',
|
|
role: 'contador' as 'contador' | 'visor' | 'auxiliar' | 'supervisor' | 'cliente',
|
|
tenantId: '',
|
|
});
|
|
|
|
const createUsuario = useCreateUsuarioGlobal();
|
|
|
|
const isGlobal = isGlobalAdminRfc(currentUser?.tenantRfc, currentUser?.role, currentUser?.platformRoles);
|
|
|
|
useEffect(() => {
|
|
if (!isGlobal) return;
|
|
getTenants().then(setTenants).catch(console.error);
|
|
}, [isGlobal]);
|
|
|
|
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 handleCreate = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!createFormData.tenantId) {
|
|
alert('Selecciona una empresa');
|
|
return;
|
|
}
|
|
try {
|
|
await createUsuario.mutateAsync({
|
|
email: createFormData.email,
|
|
nombre: createFormData.nombre,
|
|
role: createFormData.role,
|
|
tenantId: createFormData.tenantId,
|
|
});
|
|
setCreateFormData({ email: '', nombre: '', role: 'contador', tenantId: '' });
|
|
setShowCreateForm(false);
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.error || err.message || 'Error al crear 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">
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} defaultValue="usuarios" className="space-y-4">
|
|
<TabsList>
|
|
<TabsTrigger value="usuarios">Usuarios</TabsTrigger>
|
|
<TabsTrigger value="invitaciones-trial">Invitaciones Trial</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="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 + Acción */}
|
|
<div className="flex items-center justify-between">
|
|
<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>
|
|
<Button onClick={() => setShowCreateForm(true)}>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Agregar Usuario
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Formulario de creación */}
|
|
{showCreateForm && (
|
|
<Card>
|
|
<CardHeader className="py-3">
|
|
<CardTitle className="text-base">Nuevo Usuario</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleCreate} className="space-y-4">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="create-nombre">Nombre</Label>
|
|
<Input
|
|
id="create-nombre"
|
|
value={createFormData.nombre}
|
|
onChange={(e) => setCreateFormData({ ...createFormData, nombre: e.target.value })}
|
|
placeholder="Juan Pérez"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="create-email">Email</Label>
|
|
<Input
|
|
id="create-email"
|
|
type="email"
|
|
value={createFormData.email}
|
|
onChange={(e) => setCreateFormData({ ...createFormData, email: e.target.value })}
|
|
placeholder="juan@empresa.com"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="create-role">Rol</Label>
|
|
<Select
|
|
value={createFormData.role}
|
|
onValueChange={(v) => setCreateFormData({ ...createFormData, role: v as any })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="contador">Contador</SelectItem>
|
|
<SelectItem value="visor">Visor</SelectItem>
|
|
<SelectItem value="auxiliar">Auxiliar</SelectItem>
|
|
<SelectItem value="supervisor">Supervisor</SelectItem>
|
|
<SelectItem value="cliente">Cliente</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="create-tenant">Empresa</Label>
|
|
<Select
|
|
value={createFormData.tenantId}
|
|
onValueChange={(v) => setCreateFormData({ ...createFormData, tenantId: v })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Selecciona empresa" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{tenants.map(t => (
|
|
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 justify-end">
|
|
<Button type="button" variant="outline" onClick={() => setShowCreateForm(false)}>
|
|
Cancelar
|
|
</Button>
|
|
<Button type="submit" disabled={createUsuario.isPending}>
|
|
{createUsuario.isPending ? 'Creando...' : 'Crear Usuario'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 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>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="invitaciones-trial">
|
|
<InvitacionesTrialTab />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</DashboardShell>
|
|
);
|
|
}
|