Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { useAuditLog } from '@/lib/hooks/use-audit-log';
import { ChevronLeft, ChevronRight, Search, X, FileWarning, ShieldAlert } from 'lucide-react';
const ACTION_GROUPS = [
{ value: '', label: 'Todas las acciones' },
{ value: 'user.', label: 'Usuarios (login, logout, password)' },
{ value: 'subscription.', label: 'Suscripciones (crear, cancelar, cambiar, reactivar)' },
{ value: 'trial.', label: 'Trials' },
{ value: 'price.', label: 'Precios' },
{ value: 'invoice.', label: 'Facturación' },
{ value: 'payment.', label: 'Pagos' },
{ value: 'tenant.', label: 'Tenants' },
{ value: 'fiel.', label: 'FIEL' },
];
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
'user.login': { label: 'Login', color: 'bg-blue-50 text-blue-700' },
'user.logout': { label: 'Logout', color: 'bg-slate-50 text-slate-700' },
'user.password_changed': { label: 'Cambio password', color: 'bg-amber-50 text-amber-700' },
'trial.started': { label: 'Trial iniciado', color: 'bg-sky-50 text-sky-700' },
'subscription.created': { label: 'Suscripción creada',color: 'bg-green-50 text-green-700' },
'subscription.cancelled': { label: 'Suscripción cancelada', color: 'bg-orange-50 text-orange-700' },
'subscription.reactivated': { label: 'Reactivada', color: 'bg-teal-50 text-teal-700' },
'subscription.plan_changed': { label: 'Cambio de plan', color: 'bg-indigo-50 text-indigo-700' },
'price.updated': { label: 'Precio editado', color: 'bg-purple-50 text-purple-700' },
'invoice.emitted_auto': { label: 'Factura auto', color: 'bg-emerald-50 text-emerald-700' },
'invoice.emitted_manual': { label: 'Factura manual', color: 'bg-emerald-50 text-emerald-700' },
'payment.marked_paid_manually': { label: 'Pago marcado manual', color: 'bg-lime-50 text-lime-700' },
};
function formatDateTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('es-MX', { dateStyle: 'short', timeStyle: 'medium' });
}
function ActionBadge({ action }: { action: string }) {
const cfg = ACTION_LABELS[action] || { label: action, color: 'bg-muted text-muted-foreground' };
return <span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${cfg.color}`}>{cfg.label}</span>;
}
export default function AuditLogPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const [filters, setFilters] = useState({
action: '',
tenantId: '',
userId: '',
from: '',
to: '',
page: 1,
limit: 50,
});
const [expandedId, setExpandedId] = useState<string | null>(null);
const { data, isLoading } = useAuditLog(filters);
if (!isGlobalAdmin) {
return (
<>
<Header title="Audit Log" />
<main className="p-6">
<Card>
<CardContent className="py-12 text-center">
<ShieldAlert className="h-12 w-12 text-muted-foreground/40 mx-auto mb-3" />
<p className="font-semibold">Acceso restringido</p>
<p className="text-sm text-muted-foreground mt-1">
Solo el administrador global puede consultar el audit log.
</p>
</CardContent>
</Card>
</main>
</>
);
}
const clearFilters = () => setFilters({ action: '', tenantId: '', userId: '', from: '', to: '', page: 1, limit: 50 });
return (
<>
<Header title="Audit Log" />
<main className="p-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileWarning className="h-5 w-5" />
Filtros
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<div className="space-y-1">
<Label>Acción</Label>
<Select value={filters.action || 'all'} onValueChange={v => setFilters(f => ({ ...f, action: v === 'all' ? '' : v, page: 1 }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ACTION_GROUPS.map(g => (
<SelectItem key={g.value || 'all'} value={g.value || 'all'}>{g.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Tenant ID</Label>
<Input value={filters.tenantId} onChange={e => setFilters(f => ({ ...f, tenantId: e.target.value, page: 1 }))} placeholder="UUID del tenant" />
</div>
<div className="space-y-1">
<Label>User ID</Label>
<Input value={filters.userId} onChange={e => setFilters(f => ({ ...f, userId: e.target.value, page: 1 }))} placeholder="UUID del usuario" />
</div>
<div className="space-y-1">
<Label>Desde</Label>
<Input type="datetime-local" value={filters.from} onChange={e => setFilters(f => ({ ...f, from: e.target.value, page: 1 }))} />
</div>
<div className="space-y-1">
<Label>Hasta</Label>
<Input type="datetime-local" value={filters.to} onChange={e => setFilters(f => ({ ...f, to: e.target.value, page: 1 }))} />
</div>
</div>
<div className="flex gap-2 mt-4">
<Button variant="outline" size="sm" onClick={clearFilters}>
<X className="h-4 w-4 mr-1" />
Limpiar filtros
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Eventos {data?.total !== undefined && <span className="text-sm font-normal text-muted-foreground">({data.total.toLocaleString('es-MX')} totales)</span>}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-center py-8 text-muted-foreground">Cargando...</p>
) : !data || data.data.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay eventos con estos filtros.</p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">Fecha</th>
<th className="py-2 pr-4 font-medium">Acción</th>
<th className="py-2 pr-4 font-medium">Usuario</th>
<th className="py-2 pr-4 font-medium">Tenant</th>
<th className="py-2 pr-4 font-medium">Entidad</th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{data.data.map(row => (
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-3 pr-4 text-xs text-muted-foreground whitespace-nowrap">{formatDateTime(row.createdAt)}</td>
<td className="py-3 pr-4"><ActionBadge action={row.action} /></td>
<td className="py-3 pr-4">
{row.user ? (
<div className="text-xs">
<div className="font-medium">{row.user.nombre}</div>
<div className="text-muted-foreground">{row.user.email}</div>
</div>
) : <span className="text-muted-foreground text-xs">Sistema</span>}
</td>
<td className="py-3 pr-4">
{row.tenant ? (
<div className="text-xs">
<div className="font-medium">{row.tenant.nombre}</div>
<div className="text-muted-foreground font-mono">{row.tenant.rfc}</div>
</div>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="py-3 pr-4 text-xs text-muted-foreground">
{row.entityType ? `${row.entityType}${row.entityId ? ` ${row.entityId.slice(0, 8)}` : ''}` : '—'}
</td>
<td className="py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => setExpandedId(expandedId === row.id ? null : row.id)}>
{expandedId === row.id ? 'Ocultar' : 'Ver detalle'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{expandedId && (() => {
const row = data.data.find(r => r.id === expandedId);
if (!row) return null;
return (
<Card className="mt-4 bg-muted/20">
<CardContent className="pt-4">
<p className="text-xs text-muted-foreground mb-2">Metadata del evento <code className="font-mono">{row.id}</code></p>
<pre className="text-xs whitespace-pre-wrap break-all bg-background p-3 rounded border">{JSON.stringify(row.metadata, null, 2)}</pre>
</CardContent>
</Card>
);
})()}
{data.totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t mt-4">
<p className="text-sm text-muted-foreground">Página {data.page} de {data.totalPages}</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={data.page <= 1} onClick={() => setFilters(f => ({ ...f, page: f.page - 1 }))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" disabled={data.page >= data.totalPages} onClick={() => setFilters(f => ({ ...f, page: f.page + 1 }))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,264 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared';
import { useStaff, useSearchUsers, useGrantRole, useRevokeRole } from '@/lib/hooks/use-platform-staff';
import { ShieldAlert, Shield, ShieldCheck, HeadphonesIcon, TrendingUp, DollarSign, UserPlus, X, Loader2, Search, Cpu } from 'lucide-react';
const ROLE_META: Record<PlatformRole, { label: string; desc: string; icon: any; color: string }> = {
platform_admin: { label: 'Admin', desc: 'Todo: gestión staff, precios, clientes, facturas', icon: ShieldCheck, color: 'bg-red-100 text-red-700 border-red-200' },
platform_ti: { label: 'TI', desc: 'Equipo de TI. Mismos permisos que Admin (diferencia solo en trazabilidad)', icon: Cpu, color: 'bg-slate-100 text-slate-700 border-slate-200' },
platform_support: { label: 'Support', desc: 'Ver tenants, resolver tickets', icon: HeadphonesIcon, color: 'bg-blue-100 text-blue-700 border-blue-200' },
platform_sales: { label: 'Sales', desc: 'Crear/editar clientes, ver suscripciones', icon: TrendingUp, color: 'bg-green-100 text-green-700 border-green-200' },
platform_finance: { label: 'Finance', desc: 'Pagos, facturas manuales, editar precios', icon: DollarSign, color: 'bg-amber-100 text-amber-700 border-amber-200' },
};
const ALL_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti', 'platform_support', 'platform_sales', 'platform_finance'];
export default function StaffPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const { data: staff = [], isLoading } = useStaff();
const grantRole = useGrantRole();
const revokeRole = useRevokeRole();
const [addOpen, setAddOpen] = useState(false);
const [searchQ, setSearchQ] = useState('');
const { data: candidates = [] } = useSearchUsers(searchQ);
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
const [pickedRole, setPickedRole] = useState<PlatformRole>('platform_support');
if (!isGlobalAdmin) {
return (
<>
<Header title="Gestión de Staff" />
<main className="p-6">
<Card>
<CardContent className="py-12 text-center">
<ShieldAlert className="h-12 w-12 text-muted-foreground/40 mx-auto mb-3" />
<p className="font-semibold">Acceso restringido</p>
<p className="text-sm text-muted-foreground mt-1">Solo platform_admin puede gestionar staff.</p>
</CardContent>
</Card>
</main>
</>
);
}
const handleGrant = async () => {
if (!pickedUserId) return;
try {
await grantRole.mutateAsync({ userId: pickedUserId, role: pickedRole });
setAddOpen(false);
setSearchQ('');
setPickedUserId(null);
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al asignar rol');
}
};
const handleRevoke = async (userId: string, role: PlatformRole, userEmail: string) => {
if (!confirm(`¿Quitar el rol "${ROLE_META[role].label}" a ${userEmail}?`)) return;
try {
await revokeRole.mutateAsync({ userId, role });
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al quitar rol');
}
};
return (
<>
<Header title="Gestión de Staff" />
<main className="p-6 space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">
Staff interno de Horux 360 con poderes transversales. <code className="font-mono text-xs">platform_admin</code> implica todos los otros roles.
</p>
</div>
<Button onClick={() => setAddOpen(true)}>
<UserPlus className="h-4 w-4 mr-1" />
Agregar staff
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Equipo ({staff.length})</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-center py-8 text-muted-foreground">Cargando...</p>
) : staff.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">
Todavía no hay staff. Agrega al primer miembro con el botón arriba.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">Usuario</th>
<th className="py-2 pr-4 font-medium">Tenant origen</th>
<th className="py-2 pr-4 font-medium">Roles</th>
<th className="py-2 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{staff.map(s => (
<tr key={s.id} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-3 pr-4">
<div className="font-medium">{s.nombre}</div>
<div className="text-xs text-muted-foreground">{s.email}</div>
</td>
<td className="py-3 pr-4 text-xs">
{s.tenant ? (
<div>
<div>{s.tenant.nombre}</div>
<div className="font-mono text-muted-foreground">{s.tenant.rfc}</div>
</div>
) : <span className="text-muted-foreground"></span>}
</td>
<td className="py-3 pr-4">
<div className="flex flex-wrap gap-1.5">
{s.roles.map(r => {
const meta = ROLE_META[r];
const Icon = meta.icon;
return (
<span key={r} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border ${meta.color}`}>
<Icon className="h-3 w-3" />
{meta.label}
<button
type="button"
onClick={() => handleRevoke(s.id, r, s.email)}
className="hover:opacity-60 ml-1"
title="Quitar"
>
<X className="h-3 w-3" />
</button>
</span>
);
})}
</div>
</td>
<td className="py-3 text-right">
<Button size="sm" variant="outline" onClick={() => { setPickedUserId(s.id); setSearchQ(s.email); setAddOpen(true); }}>
Agregar rol
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Shield className="h-4 w-4" />
Descripción de roles
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 md:grid-cols-2">
{ALL_ROLES.map(r => {
const m = ROLE_META[r];
const Icon = m.icon;
return (
<div key={r} className={`rounded border p-3 ${m.color}`}>
<div className="flex items-center gap-2 font-medium mb-1">
<Icon className="h-4 w-4" />
{m.label}
</div>
<p className="text-xs opacity-80">{m.desc}</p>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Add staff dialog */}
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Agregar rol de staff</DialogTitle>
<DialogDescription>
Busca al usuario por email o nombre y asígnale un rol de plataforma.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Buscar usuario</Label>
<div className="relative">
<Search className="h-4 w-4 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQ}
onChange={e => { setSearchQ(e.target.value); setPickedUserId(null); }}
placeholder="email o nombre (min 2 caracteres)"
className="pl-8"
/>
</div>
{searchQ.length >= 2 && candidates.length > 0 && !pickedUserId && (
<div className="border rounded mt-1 max-h-40 overflow-auto">
{candidates.map(c => (
<button
type="button"
key={c.id}
onClick={() => { setPickedUserId(c.id); setSearchQ(c.email); }}
className="w-full text-left px-3 py-2 hover:bg-muted text-sm border-b last:border-0"
>
<div className="font-medium">{c.nombre}</div>
<div className="text-xs text-muted-foreground">{c.email} · {c.tenant?.rfc || '—'}</div>
</button>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label>Rol a asignar</Label>
<div className="grid gap-2">
{ALL_ROLES.map(r => {
const m = ROLE_META[r];
const Icon = m.icon;
return (
<button
type="button"
key={r}
onClick={() => setPickedRole(r)}
className={`text-left rounded border-2 p-3 transition-all ${pickedRole === r ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'}`}
>
<div className="flex items-center gap-2 font-medium text-sm mb-0.5">
<Icon className="h-4 w-4" />
{m.label}
</div>
<p className="text-xs text-muted-foreground">{m.desc}</p>
</button>
);
})}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)}>Cancelar</Button>
<Button onClick={handleGrant} disabled={!pickedUserId || grantRole.isPending}>
{grantRole.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
Asignar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
</>
);
}

View File

@@ -0,0 +1,311 @@
'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 { 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('');
useEffect(() => {
getTenants().then(setTenants).catch(console.error);
}, []);
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>
);
}