feat(admin/usuarios): agregar usuario globalmente desde admin

El admin global ahora puede crear usuarios directamente desde
/admin/usuarios sin depender de que un owner los invite.

Backend:
- Nuevo endpoint POST /usuarios/global (controller + service)
- Valida límite de usuarios del plan del tenant destino
- Si el email ya existe, agrega membership al tenant destino
- Si no existe, crea user con temp password + membership
- Schema Zod: email, nombre, role, tenantId, supervisorUserId?

Frontend:
- Botón 'Agregar Usuario' en /admin/usuarios
- Formulario con: nombre, email, rol, empresa
- Hook useCreateUsuarioGlobal con invalidación de queries
This commit is contained in:
Horux Dev
2026-05-17 14:32:45 +00:00
parent e8aaf9ff15
commit 0b704e0e27
6 changed files with 239 additions and 11 deletions

View File

@@ -3,11 +3,11 @@
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 { 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 } from 'lucide-react';
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase, Plus } from 'lucide-react';
import { cn } from '@horux/shared-ui';
// Mapa de roles + fallback defensivo. El fork despacho introduce roles
@@ -43,6 +43,15 @@ export default function AdminUsuariosPage() {
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
const [filterTenant, setFilterTenant] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState('');
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);
@@ -86,6 +95,26 @@ export default function AdminUsuariosPage() {
}
};
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 ||
@@ -152,18 +181,104 @@ export default function AdminUsuariosPage() {
</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>
{/* 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>

View File

@@ -26,6 +26,11 @@ export async function getAllUsuarios(): Promise<UserListItem[]> {
return response.data;
}
export async function createUsuarioGlobal(data: UserInvite & { tenantId: string }): Promise<UserListItem> {
const response = await apiClient.post<UserListItem>('/usuarios/global', data);
return response.data;
}
export async function updateUsuarioGlobal(id: string, data: UserUpdate): Promise<UserListItem> {
const response = await apiClient.patch<UserListItem>(`/usuarios/global/${id}`, data);
return response.data;

View File

@@ -47,6 +47,17 @@ export function useAllUsuarios() {
});
}
export function useCreateUsuarioGlobal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UserInvite & { tenantId: string }) => usuariosApi.createUsuarioGlobal(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['usuarios', 'global'] });
queryClient.invalidateQueries({ queryKey: ['usuarios'] });
},
});
}
export function useUpdateUsuarioGlobal() {
const queryClient = useQueryClient();
return useMutation({