- Quitado Invitaciones Trial del sidebar (4 layouts) - Agregado tab Invitaciones Trial dentro de /admin/usuarios - Componente reutilizable invitaciones-trial-tab.tsx - Agregada nueva opcion Tareas en el sidebar principal
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>
|
|
);
|
|
}
|