feat(core): gestión de Usuarios y Roles/permisos (pantallas)

- Roles y permisos: crear/eliminar roles por compañía y editar sus permisos
  (checkboxes agrupados por módulo) vía rolesAPI + rolePermissionsAPI + permissionsAPI.
- Usuarios: lista usuarios de la compañía activa y permite asignar/quitar roles
  vía usersAPI + userRolesAPI. Reusa los clientes API ya existentes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-07-17 13:31:11 -06:00
parent 387b3c0e78
commit cd3f8c62a4
2 changed files with 382 additions and 29 deletions

View File

@@ -1,26 +1,230 @@
<script lang="ts">
import { Shield } from 'lucide-svelte';
import { onMount, onDestroy } from 'svelte';
import { Shield, Plus, Trash2, ChevronRight, ChevronDown } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import { companyStore } from '$lib/stores/company.svelte';
import { rolesAPI, type CompanyRole } from '$lib/api/dashboard/admin/roles';
import { permissionsAPI, type Permission } from '$lib/api/dashboard/admin/permissions';
import { rolePermissionsAPI } from '$lib/api/dashboard/admin/role-permissions';
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let roles = $state<CompanyRole[]>([]);
let catalog = $state<Permission[]>([]);
let loading = $state(true);
let creating = $state(false);
let name = $state('');
let code = $state('');
let description = $state('');
let expandedRoleId = $state<number | null>(null);
// roleId → Set de permission_id asignados
let rolePerms = $state<Record<number, Set<number>>>({});
let savingPerm = $state<string | null>(null);
// Catálogo agrupado por módulo
const byModule = $derived.by(() => {
const m: Record<string, Permission[]> = {};
for (const p of catalog) (m[p.module] ??= []).push(p);
return m;
});
function slugify(s: string): string {
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}
async function load() {
if (!companyId) {
loading = false;
return;
}
loading = true;
try {
const [rRes, pRes] = await Promise.all([
rolesAPI.list(companyId, { page_size: 100 }),
permissionsAPI.list({ page_size: 1000 })
]);
roles = rRes.data?.items ?? [];
catalog = pRes.items ?? [];
} catch {
toast.error('No se pudieron cargar roles/permisos');
} finally {
loading = false;
}
}
function onCompanyChanged() {
expandedRoleId = null;
rolePerms = {};
void load();
}
onMount(() => {
void load();
if (typeof window !== 'undefined') window.addEventListener('companyChanged', onCompanyChanged);
});
onDestroy(() => {
if (typeof window !== 'undefined') window.removeEventListener('companyChanged', onCompanyChanged);
});
async function createRole() {
if (!companyId) return;
const n = name.trim();
if (n.length < 2) {
toast.error('El nombre del rol es obligatorio');
return;
}
const c = (code.trim() || slugify(n));
creating = true;
try {
const res = await rolesAPI.create(companyId, { name: n, code: c, description: description.trim() || undefined });
if (res.error) {
toast.error(res.error);
return;
}
toast.success('Rol creado');
name = '';
code = '';
description = '';
await load();
} finally {
creating = false;
}
}
async function delRole(r: CompanyRole) {
if (!companyId) return;
if (!confirm(`¿Eliminar el rol "${r.name}"?`)) return;
const res = await rolesAPI.delete(r.id, companyId);
if (res.error) {
toast.error(res.error);
return;
}
toast.success('Rol eliminado');
await load();
}
async function toggleExpand(r: CompanyRole) {
if (expandedRoleId === r.id) {
expandedRoleId = null;
return;
}
expandedRoleId = r.id;
if (!rolePerms[r.id] && companyId) {
try {
const resp = await rolePermissionsAPI.listByRole(r.id, companyId);
rolePerms = { ...rolePerms, [r.id]: new Set((resp.permissions ?? []).map((p) => p.permission_id)) };
} catch {
rolePerms = { ...rolePerms, [r.id]: new Set() };
}
}
}
async function togglePerm(roleId: number, perm: Permission, checked: boolean) {
if (!companyId) return;
savingPerm = `${roleId}:${perm.id}`;
try {
if (checked) {
await rolePermissionsAPI.assign(roleId, companyId, { permission_id: perm.id });
rolePerms[roleId]?.add(perm.id);
} else {
await rolePermissionsAPI.remove(roleId, perm.id, companyId);
rolePerms[roleId]?.delete(perm.id);
}
rolePerms = { ...rolePerms };
} catch (e) {
toast.error('No se pudo actualizar el permiso');
} finally {
savingPerm = null;
}
}
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2">
<Shield class="h-6 w-6" />
Roles y permisos
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Shield class="h-6 w-6" /> Roles y permisos
</h1>
<p class="text-muted-foreground text-sm mt-1">
Gestión de roles y control de acceso.
<p class="mt-1 text-sm text-muted-foreground">
Define roles (carriles) por compañía y qué puede hacer cada uno. Los usuarios se asignan en Usuarios.
</p>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Roles del sistema</Card.Title>
<Card.Description>Implementa aquí la gestión de roles y permisos de tu proyecto.</Card.Description>
</Card.Header>
<Card.Content>
<p class="text-sm text-muted-foreground">Sección en construcción.</p>
</Card.Content>
</Card.Root>
{#if !companyId}
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa para gestionar sus roles.</Card.Content></Card.Root>
{:else}
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nuevo rol</Card.Title>
</Card.Header>
<Card.Content>
<div class="grid gap-4 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre *</span><input class={inputCls} bind:value={name} placeholder="Ventas" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Código</span><input class="font-mono {inputCls}" bind:value={code} placeholder="(auto)" title="Se genera del nombre si lo dejas vacío" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={description} /></label>
</div>
<div class="mt-4 flex justify-end"><Button onclick={createRole} disabled={creating}>{creating ? 'Creando…' : 'Crear rol'}</Button></div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Roles ({roles.length})</Card.Title>
<Card.Description>Haz clic en un rol para ver y editar sus permisos.</Card.Description>
</Card.Header>
<Card.Content class="space-y-2">
{#if loading}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if roles.length === 0}
<p class="text-sm text-muted-foreground">No hay roles. Crea uno arriba.</p>
{:else}
{#each roles as r (r.id)}
<div class="rounded-md border">
<div class="flex items-center gap-2 px-3 py-2">
<button type="button" class="flex flex-1 items-center gap-2 text-left" onclick={() => toggleExpand(r)}>
{#if expandedRoleId === r.id}<ChevronDown class="h-4 w-4" />{:else}<ChevronRight class="h-4 w-4" />{/if}
<span class="font-medium">{r.name}</span>
<span class="font-mono text-xs text-muted-foreground">{r.code}</span>
</button>
<Button variant="ghost" size="sm" onclick={() => delRole(r)}><Trash2 class="h-4 w-4 text-destructive" /></Button>
</div>
{#if expandedRoleId === r.id}
<div class="border-t p-3">
{#if !rolePerms[r.id]}
<p class="text-sm text-muted-foreground">Cargando permisos…</p>
{:else}
<div class="space-y-4">
{#each Object.entries(byModule) as [mod, perms] (mod)}
<div>
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{mod}</p>
<div class="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
{#each perms as p (p.id)}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
class="h-4 w-4 rounded border"
checked={rolePerms[r.id].has(p.id)}
disabled={savingPerm === `${r.id}:${p.id}`}
onchange={(e) => togglePerm(r.id, p, (e.currentTarget as HTMLInputElement).checked)}
/>
<span title={p.code}>{p.description || p.code}</span>
</label>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/each}
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -1,23 +1,172 @@
<script lang="ts">
import { Users } from 'lucide-svelte';
import { onMount, onDestroy } from 'svelte';
import { Users, X } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import { toast } from 'svelte-sonner';
import { companyStore } from '$lib/stores/company.svelte';
import { usersAPI, type User } from '$lib/api/dashboard/users';
import { rolesAPI, type CompanyRole } from '$lib/api/dashboard/admin/roles';
import { userRolesAPI, type UserRole } from '$lib/api/dashboard/admin/user-roles';
const inputCls =
'rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let users = $state<User[]>([]);
let roles = $state<CompanyRole[]>([]);
let assignments = $state<UserRole[]>([]);
let loading = $state(true);
let busy = $state<string | null>(null);
// user_id → asignaciones de rol
const rolesByUser = $derived.by(() => {
const m: Record<string, UserRole[]> = {};
for (const a of assignments) (m[String(a.user_id)] ??= []).push(a);
return m;
});
function fullName(u: User): string {
const n = `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim();
return n || u.username || u.email || u.id;
}
async function load() {
if (!companyId) {
loading = false;
return;
}
loading = true;
try {
const [uRes, rRes, arRes] = await Promise.all([
usersAPI.list(companyId, { page_size: 100 }).catch(() => ({ users: [] as User[] })),
rolesAPI.list(companyId, { page_size: 100 }),
userRolesAPI.list(companyId, { page_size: 500 }).catch(() => ({ items: [] as UserRole[] } as any))
]);
users = uRes.users ?? [];
roles = rRes.data?.items ?? [];
assignments = arRes.items ?? [];
} catch {
toast.error('No se pudieron cargar los usuarios');
} finally {
loading = false;
}
}
function onCompanyChanged() {
void load();
}
onMount(() => {
void load();
if (typeof window !== 'undefined') window.addEventListener('companyChanged', onCompanyChanged);
});
onDestroy(() => {
if (typeof window !== 'undefined') window.removeEventListener('companyChanged', onCompanyChanged);
});
async function assignRole(user: User, roleIdRaw: string) {
if (!companyId) return;
const roleId = Number(roleIdRaw);
if (!roleId) return;
if (rolesByUser[String(user.id)]?.some((a) => a.company_role_id === roleId)) {
toast.info('El usuario ya tiene ese rol');
return;
}
busy = `assign:${user.id}`;
try {
await userRolesAPI.assign(companyId, { user_id: String(user.id), company_role_id: roleId });
toast.success('Rol asignado');
await load();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo asignar el rol');
} finally {
busy = null;
}
}
async function removeRole(a: UserRole) {
if (!companyId) return;
busy = `remove:${a.id}`;
try {
await userRolesAPI.remove(a.id, companyId);
toast.success('Rol removido');
await load();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo remover el rol');
} finally {
busy = null;
}
}
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2">
<Users class="h-6 w-6" />
Usuarios
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Users class="h-6 w-6" /> Usuarios
</h1>
<p class="text-muted-foreground text-sm mt-1">Gestión de usuarios y accesos.</p>
<p class="mt-1 text-sm text-muted-foreground">
Usuarios de la compañía activa y sus roles. Para dar de alta usuarios nuevos usa Workspace → Usuarios (invitaciones).
</p>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Usuarios del sistema</Card.Title>
<Card.Description>Implementa aquí la gestión de usuarios de tu proyecto.</Card.Description>
</Card.Header>
<Card.Content>
<p class="text-sm text-muted-foreground">Sección en construcción.</p>
</Card.Content>
</Card.Root>
{#if !companyId}
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa.</Card.Content></Card.Root>
{:else}
<Card.Root>
<Card.Header>
<Card.Title>Usuarios ({users.length})</Card.Title>
<Card.Description>Asigna o quita roles (los roles se definen en Roles y permisos).</Card.Description>
</Card.Header>
<Card.Content>
{#if loading}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if users.length === 0}
<p class="text-sm text-muted-foreground">No hay usuarios en esta compañía todavía.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left text-muted-foreground">
<tr class="border-b">
<th class="py-2 pr-4 font-medium">Usuario</th>
<th class="py-2 pr-4 font-medium">Email</th>
<th class="py-2 pr-4 font-medium">Roles</th>
<th class="py-2 pr-4 font-medium">Asignar rol</th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
<tr class="border-b last:border-0 align-top">
<td class="py-2 pr-4">{fullName(u)}</td>
<td class="py-2 pr-4 text-muted-foreground">{u.email ?? '—'}</td>
<td class="py-2 pr-4">
<div class="flex flex-wrap gap-1">
{#each rolesByUser[String(u.id)] ?? [] as a (a.id)}
<span class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
{a.company_role?.name ?? a.company_role?.code ?? `rol ${a.company_role_id}`}
<button type="button" class="hover:text-destructive" disabled={busy === `remove:${a.id}`} onclick={() => removeRole(a)} title="Quitar rol"><X class="h-3 w-3" /></button>
</span>
{:else}
<span class="text-xs text-muted-foreground">sin rol</span>
{/each}
</div>
</td>
<td class="py-2 pr-4">
<select
class={inputCls}
disabled={busy === `assign:${u.id}` || roles.length === 0}
onchange={(e) => { const el = e.currentTarget as HTMLSelectElement; assignRole(u, el.value); el.value = ''; }}
>
<option value="">+ rol…</option>
{#each roles as r (r.id)}<option value={r.id}>{r.name}</option>{/each}
</select>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>