Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/admin/usuarios/page.tsx
Horux Dev 9f11a0ba39 feat: facturación primer pago, fixes SAT/MP, autocompletado RFCs/conceptos
Backend:
- Notificación email al admin cuando llega primer pago aprobado (sin factura auto)
- Endpoints GET /pagos-sin-factura y POST /emitir-factura-pago para admin global
- Fix vinculación org Facturapi Horux 360 (69f23a5a242e0af47a41fa0d)
- Fix webhook MP: validación defensiva de x-signature header
- Fix autocompleto RFCs: eliminado filtro por contribuyenteId
- Fix autocompleto conceptos: eliminado filtro por contribuyenteId
- SAT fixes: anti-bot CSF scraper, request reuse, date range fix, stale job thresholds
- SAT sync request reuse across jobs para evitar agotar cuota diaria
- Typo fix MP_ACCESS_TOKEN en .env
- Trial invitations system backend

Frontend:
- Nueva página /admin/facturas-pendientes con tabla y emisión manual
- Métrica 'Facturas pendientes' en /clientes (clickable)
- Navegación onboarding FIEL/CSD corregida
- Sidebar themes sincronizados
- Fix SAT portal migration scraper (NetIQ)
- Trial invitation acceptance pages
2026-05-09 21:56:42 +00:00

316 lines
14 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 } from '@horux/shared-ui';
import { useAllUsuarios, 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 { cn } from '@horux/shared-ui';
// 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 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 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">
<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 */}
<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>
{/* 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>
</DashboardShell>
);
}