usuarios en cliente

This commit is contained in:
2026-03-11 11:36:11 -06:00
parent 12cbbd1b80
commit 1a301409de
9 changed files with 648 additions and 5 deletions

View File

@@ -0,0 +1,11 @@
with open("/app/src/routes/usuarios/+page.svelte", "r") as f:
content = f.read()
content = content.replace(
"{#if (user as any).can_manage_users && user.role !== 'CLIENT_ADMIN'}",
"{#if user.can_manage_users && user.role !== 'CLIENT_ADMIN'}"
)
with open("/app/src/routes/usuarios/+page.svelte", "w") as f:
f.write(content)
print("Listo")

View File

@@ -18,7 +18,7 @@
function handleLogout() {
isMenuOpen = false;
auth.logout(); // El store maneja la redirección automática
auth.logout(); // El store maneja la redirección automática
}
</script>
@@ -49,6 +49,13 @@
>
Mis Tickets
</a>
<a
href="/usuarios"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Usuarios
</a>
<a
href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
@@ -133,6 +140,13 @@
>
Mis Tickets
</a>
<a
href="/usuarios"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Usuarios
</a>
<a
href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"

View File

@@ -0,0 +1,548 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
// ---- Tipos ----
interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: string;
is_active: boolean;
created_at: string;
tenant_id: string;
can_manage_users?: boolean;
}
// ---- Estado ----
let users: User[] = [];
let isLoading = true;
let showModal = false;
let showDeleteConfirm = false;
let modalMode: 'create' | 'edit' = 'create';
let selectedUser: User | null = null;
let searchQuery = '';
let filterRole = '';
let filterStatus = '';
let form = {
first_name: '',
last_name: '',
email: '',
password: '',
role: 'CLIENT_USER',
is_active: true,
can_manage_users: false
};
let isSaving = false;
let isDeleting = false;
// ---- Permisos ----
$: currentRole = $auth.user?.role ?? '';
$: isClientAdmin = currentRole === 'CLIENT_ADMIN';
$: canManageUsers = isClientAdmin;
// ---- Utilidades ----
function apiHeaders(): Record<string, string> {
const h: Record<string, string> = {
'X-App': 'client',
'X-Tenant-Slug': $auth.user?.tenant_slug || $auth.user?.tenant_id || '',
};
if ($auth.token) h['Authorization'] = `Bearer ${$auth.token}`;
return h;
}
// ---- Carga de usuarios ----
async function loadUsers() {
isLoading = true;
try {
const res = await fetch('/api/v1/users/', {
credentials: 'include',
headers: apiHeaders()
});
if (!res.ok) throw new Error((await res.json()).detail || 'Error al cargar usuarios');
users = await res.json();
} catch (e: any) {
toast.error(e.message);
} finally {
isLoading = false;
}
}
onMount(async () => {
if (!$auth.isAuthenticated) { goto('/login'); return; }
await loadUsers();
});
// ---- Filtros reactivos ----
$: filteredUsers = users.filter(u => {
const q = searchQuery.toLowerCase();
const matchSearch = !q ||
u.first_name?.toLowerCase().includes(q) ||
u.last_name?.toLowerCase().includes(q) ||
u.email?.toLowerCase().includes(q);
const matchRole = !filterRole || u.role === filterRole;
const matchStatus = filterStatus === '' ? true :
filterStatus === 'active' ? u.is_active : !u.is_active;
return matchSearch && matchRole && matchStatus;
});
// ---- Modal ----
function openCreate() {
modalMode = 'create';
form = { first_name: '', last_name: '', email: '', password: '', role: 'CLIENT_USER', is_active: true, can_manage_users: false };
showModal = true;
}
function openEdit(user: User) {
modalMode = 'edit';
selectedUser = user;
form = {
first_name: user.first_name || '',
last_name: user.last_name || '',
email: user.email,
password: '',
role: user.role,
is_active: user.is_active,
can_manage_users: user.can_manage_users || false
};
showModal = true;
}
function closeModal() {
showModal = false;
selectedUser = null;
}
// ---- CRUD ----
async function saveUser() {
isSaving = true;
try {
const payload: any = { ...form };
if (modalMode === 'edit' && !payload.password) delete payload.password;
const url = modalMode === 'create'
? '/api/v1/users/'
: `/api/v1/users/${selectedUser?.id}`;
const method = modalMode === 'create' ? 'POST' : 'PUT';
const res = await fetch(url, {
method,
credentials: 'include',
headers: { ...apiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error((await res.json()).detail || 'Error al guardar');
toast.success(modalMode === 'create' ? 'Usuario creado correctamente' : 'Usuario actualizado');
closeModal();
await loadUsers();
} catch (e: any) {
toast.error(e.message);
} finally {
isSaving = false;
}
}
async function toggleActive(user: User) {
try {
const endpoint = user.is_active
? `/api/v1/users/${user.id}`
: `/api/v1/users/${user.id}/activate`;
const method = user.is_active ? 'DELETE' : 'PATCH';
const res = await fetch(endpoint, {
method,
credentials: 'include',
headers: apiHeaders()
});
if (!res.ok) throw new Error((await res.json()).detail || 'Error');
toast.success(user.is_active ? 'Usuario desactivado' : 'Usuario activado');
await loadUsers();
} catch (e: any) {
toast.error(e.message);
}
}
async function deleteUser() {
if (!selectedUser) return;
isDeleting = true;
try {
const res = await fetch(`/api/v1/users/${selectedUser.id}`, {
method: 'DELETE',
credentials: 'include',
headers: apiHeaders()
});
if (!res.ok) throw new Error((await res.json()).detail || 'Error al eliminar');
toast.success('Usuario eliminado');
showDeleteConfirm = false;
selectedUser = null;
await loadUsers();
} catch (e: any) {
toast.error(e.message);
} finally {
isDeleting = false;
}
}
function confirmDelete(user: User) {
selectedUser = user;
showDeleteConfirm = true;
}
// ---- Helpers visuales ----
function roleLabel(role: string): string {
return role === 'CLIENT_ADMIN' ? 'Administrador' : 'Usuario';
}
function roleBadgeClass(role: string): string {
return role === 'CLIENT_ADMIN'
? 'bg-violet-100 text-violet-700 ring-1 ring-violet-200'
: 'bg-sky-50 text-sky-700 ring-1 ring-sky-200';
}
function initials(u: User): string {
const f = u.first_name?.[0] || '';
const l = u.last_name?.[0] || '';
return (f + l).toUpperCase() || u.email[0].toUpperCase();
}
function avatarColor(email: string): string {
const colors = [
'bg-rose-400', 'bg-orange-400', 'bg-amber-400',
'bg-emerald-400', 'bg-teal-400', 'bg-cyan-400',
'bg-blue-400', 'bg-violet-400', 'bg-pink-400'
];
let hash = 0;
for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0);
return colors[Math.abs(hash) % colors.length];
}
</script>
<svelte:head>
<title>Usuarios - ServiceManager</title>
</svelte:head>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Encabezado -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div>
<h1 class="text-2xl font-bold text-gray-900">Usuarios</h1>
<p class="text-sm text-gray-500 mt-1">
Miembros de tu organización· {users.length} en total
</p>
</div>
{#if isClientAdmin}
<button
type="button"
on:click={openCreate}
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Nuevo usuario
</button>
{/if}
</div>
<!-- Filtros -->
<div class="flex flex-col sm:flex-row gap-3 mb-6">
<div class="relative flex-1">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="text"
placeholder="Buscar por nombre o email..."
bind:value={searchQuery}
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
/>
</div>
<select bind:value={filterRole} class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Roles </option>
<option value="CLIENT_ADMIN">Administrador</option>
<option value="CLIENT_USER">Usuario</option>
</select>
<select bind:value={filterStatus} class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Estado </option>
<option value="active">Activos</option>
<option value="inactive">Inactivos</option>
</select>
</div>
<!-- Tabla -->
{#if isLoading}
<div class="flex items-center justify-center py-24">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{:else if filteredUsers.length === 0}
<div class="text-center py-20 bg-white rounded-xl border border-gray-100">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<p class="text-gray-500 font-medium">No se encontraron usuarios</p>
<p class="text-sm text-gray-400 mt-1">Intenta ajustar los filtros de búsqueda</p>
</div>
{:else}
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 bg-gray-50">
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Usuario</th>
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Rol</th>
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider hidden sm:table-cell">Miembro desde</th>
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Estado</th>
{#if canManageUsers}
<th class="px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">Acciones</th>
{/if}
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{#each filteredUsers as user (user.id)}
<tr class="hover:bg-gray-50/50 transition-colors group">
<!-- Avatar + info -->
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full {avatarColor(user.email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{initials(user)}
</div>
<div class="min-w-0">
<p class="font-medium text-gray-900 truncate">
{user.first_name || ''} {user.last_name || ''}
{#if user.id === $auth.user?.id}
<span class="ml-1 text-xs text-gray-400">(tú)</span>
{/if}
</p>
<p class="text-xs text-gray-400 truncate">{user.email}</p>
</div>
</div>
</td>
<!-- Rol -->
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {roleBadgeClass(user.role)}">
{roleLabel(user.role)}
</span>
{#if user.can_manage_users && user.role !== 'CLIENT_ADMIN'}
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-700 ring-1 ring-amber-200">
Gestión
</span>
{/if}
</td>
<!-- Fecha -->
<td class="px-6 py-4 text-gray-500 hidden sm:table-cell">
{user.created_at ? new Date(user.created_at).toLocaleDateString('es-MX', { year: 'numeric', month: 'short', day: 'numeric' }) : '—'}
</td>
<!-- Estado -->
<td class="px-6 py-4">
{#if user.is_active}
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-700">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
Activo
</span>
{:else}
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-gray-400">
<span class="w-1.5 h-1.5 rounded-full bg-gray-300"></span>
Inactivo
</span>
{/if}
</td>
<!-- Acciones -->
{#if canManageUsers}
<td class="px-6 py-4">
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<!-- Editar -->
<button
type="button"
on:click={() => openEdit(user)}
class="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-colors"
title="Editar usuario"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<!-- Activar/Desactivar -->
<button
type="button"
on:click={() => toggleActive(user)}
class="p-1.5 rounded-lg transition-colors {user.is_active
? 'text-gray-400 hover:text-amber-600 hover:bg-amber-50'
: 'text-gray-400 hover:text-emerald-600 hover:bg-emerald-50'}"
title={user.is_active ? 'Desactivar' : 'Activar'}
disabled={user.id === $auth.user?.id}
>
{#if user.is_active}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{/if}
</button>
<!-- Eliminar (solo CLIENT_ADMIN) -->
{#if isClientAdmin && user.id !== $auth.user?.id}
<button
type="button"
on:click={() => confirmDelete(user)}
class="p-1.5 rounded-lg text-gray-400 hover:text-rose-600 hover:bg-rose-50 transition-colors"
title="Eliminar usuario"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
{/if}
</div>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Modal crear/editar -->
{#if showModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={closeModal}></div>
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 z-10">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-gray-900">
{modalMode === 'create' ? 'Nuevo usuario' : 'Editar usuario'}
</h2>
<button type="button" on:click={closeModal} class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form on:submit|preventDefault={saveUser} class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre</label>
<input type="text" bind:value={form.first_name} required
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Apellido</label>
<input type="text" bind:value={form.last_name}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" bind:value={form.email} required
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{modalMode === 'create' ? 'Contraseña' : 'Nueva contraseña (dejar vacío para no cambiar)'}
</label>
<input type="password" bind:value={form.password} required={modalMode === 'create'}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Rol</label>
<select bind:value={form.role}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white">
<option value="CLIENT_USER">Usuario</option>
<option value="CLIENT_ADMIN">Administrador</option>
</select>
</div>
<!-- Permiso de gestión (solo si el rol es CLIENT_USER) -->
{#if form.role === 'CLIENT_USER'}
<div class="flex items-start gap-3 p-3 bg-amber-50 rounded-lg border border-amber-100">
<input
type="checkbox"
id="can_manage_users"
bind:checked={form.can_manage_users}
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label for="can_manage_users" class="text-sm text-gray-700 cursor-pointer">
<span class="font-medium">Permitir gestión de usuarios</span>
<p class="text-xs text-gray-500 mt-0.5">Podrá crear y editar usuarios, pero no eliminarlos</p>
</label>
</div>
{/if}
{#if modalMode === 'edit'}
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-100">
<input
type="checkbox"
id="is_active"
bind:checked={form.is_active}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label for="is_active" class="text-sm font-medium text-gray-700 cursor-pointer">Usuario activo</label>
</div>
{/if}
<div class="flex justify-end gap-3 pt-2">
<button type="button" on:click={closeModal}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
Cancelar
</button>
<button type="submit" disabled={isSaving}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-60 transition-colors">
{isSaving ? 'Guardando...' : modalMode === 'create' ? 'Crear usuario' : 'Guardar cambios'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Confirmacióclsn eliminar -->
{#if showDeleteConfirm && selectedUser}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={() => showDeleteConfirm = false}></div>
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6 z-10">
<div class="w-12 h-12 rounded-full bg-rose-100 flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-rose-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
</div>
<h3 class="text-center font-semibold text-gray-900 mb-1">Eliminar usuario</h3>
<p class="text-center text-sm text-gray-500 mb-6">
¿Seguro que quieres eliminar a <strong>{selectedUser.first_name} {selectedUser.last_name}</strong>? Esta acción no se puede deshacer.
</p>
<div class="flex gap-3">
<button type="button" on:click={() => showDeleteConfirm = false}
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
Cancelar
</button>
<button type="button" on:click={deleteUser} disabled={isDeleting}
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-rose-600 rounded-lg hover:bg-rose-700 disabled:opacity-60 transition-colors">
{isDeleting ? 'Eliminando...' : 'Eliminar'}
</button>
</div>
</div>
</div>
{/if}