Permiso vistas implementados
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { CLIENT_ROUTES } from '$lib/routes-registry';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { navPerms } from '$lib/stores/navPermissions';
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
@@ -8,24 +10,29 @@
|
||||
|
||||
let isMenuOpen = false;
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
onMount(async () => {
|
||||
await auth.init();
|
||||
if ($auth.isAuthenticated) {
|
||||
await navPerms.load();
|
||||
}
|
||||
});
|
||||
|
||||
$: visibleRoutes = CLIENT_ROUTES.filter(r => $navPerms[r.key] === true);
|
||||
|
||||
function toggleMenu() {
|
||||
isMenuOpen = !isMenuOpen;
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
isMenuOpen = false;
|
||||
auth.logout(); // El store maneja la redirección automática
|
||||
navPerms.clear();
|
||||
auth.logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- Logo -->
|
||||
{#if showLogo}
|
||||
<div class="flex items-center">
|
||||
<a href="/" class="flex items-center space-x-2">
|
||||
@@ -40,22 +47,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation -->
|
||||
{#if showNavigation && $auth.isAuthenticated}
|
||||
<nav class="hidden md:flex space-x-8">
|
||||
<a
|
||||
href="/tickets"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Mis Tickets
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/usuarios"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Usuarios
|
||||
</a>
|
||||
{#each visibleRoutes as route}
|
||||
<a
|
||||
href={route.path}
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
{route.label}
|
||||
</a>
|
||||
{/each}
|
||||
<a
|
||||
href="/tickets/new"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
@@ -65,7 +66,6 @@
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<!-- User menu -->
|
||||
<div class="flex items-center space-x-4">
|
||||
{#if $auth.isAuthenticated}
|
||||
<div class="relative">
|
||||
@@ -98,17 +98,13 @@
|
||||
<a
|
||||
href="/profile"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
on:click={() => (isMenuOpen = false)}
|
||||
on:click={() => (isMenuOpen = false)}>Mi Perfil</a
|
||||
>
|
||||
Mi Perfil
|
||||
</a>
|
||||
<a
|
||||
href="/organization"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
on:click={() => (isMenuOpen = false)}
|
||||
on:click={() => (isMenuOpen = false)}>Mi Organización</a
|
||||
>
|
||||
Mi Organización
|
||||
</a>
|
||||
<button
|
||||
on:click={handleLogout}
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
@@ -130,23 +126,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile navigation -->
|
||||
{#if showNavigation && $auth.isAuthenticated}
|
||||
<div class="md:hidden border-t border-gray-200 py-2">
|
||||
<nav class="flex space-x-4">
|
||||
<a
|
||||
href="/tickets"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Mis Tickets
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/usuarios"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Usuarios
|
||||
</a>
|
||||
{#each visibleRoutes as route}
|
||||
<a
|
||||
href={route.path}
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
{route.label}
|
||||
</a>
|
||||
{/each}
|
||||
<a
|
||||
href="/tickets/new"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
@@ -159,7 +149,6 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Backdrop for mobile menu -->
|
||||
{#if isMenuOpen}
|
||||
<div class="fixed inset-0 z-40 md:hidden" on:click={() => (isMenuOpen = false)} />
|
||||
{/if}
|
||||
|
||||
11
frontend-client/src/lib/routes-registry.ts
Normal file
11
frontend-client/src/lib/routes-registry.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface RouteDefinition {
|
||||
key: string;
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export const CLIENT_ROUTES: RouteDefinition[] = [
|
||||
{ key: 'nav:tickets', label: 'Mis Tickets', path: '/tickets' },
|
||||
{ key: 'nav:organization', label: 'Organización', path: '/organization' },
|
||||
{ key: 'nav:usuarios', label: 'Usuarios', path: '/usuarios' },
|
||||
]
|
||||
@@ -1,7 +1,6 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { auth } from './auth.js';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { auth } from './auth.js';
|
||||
|
||||
// Types
|
||||
export interface Category {
|
||||
@@ -60,15 +59,15 @@ function createAppStore() {
|
||||
// Load categories
|
||||
loadCategories: async () => {
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
|
||||
try {
|
||||
const categories = await apiCall('/categories/');
|
||||
update(state => ({ ...state, categories, isLoading: false }));
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load categories'
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load categories'
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
36
frontend-client/src/lib/stores/navPermissions.ts
Normal file
36
frontend-client/src/lib/stores/navPermissions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
function createNavPermissionsStore() {
|
||||
const { subscribe, set } = writable<Record<string, boolean>>({});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
load: async () => {
|
||||
try {
|
||||
const authState = get(auth);
|
||||
const slug = authState.user?.tenant_slug || authState.user?.tenant_id || '';
|
||||
const headers: Record<string, string> = {
|
||||
'X-App': 'client',
|
||||
'X-Tenant-Slug': slug,
|
||||
};
|
||||
if (authState.token) {
|
||||
headers['Authorization'] = `Bearer ${authState.token}`;
|
||||
}
|
||||
const res = await fetch('/api/v1/permissions/my', {
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
if (res.ok) {
|
||||
const perms = await res.json();
|
||||
set(perms);
|
||||
}
|
||||
} catch (e) {
|
||||
set({});
|
||||
}
|
||||
},
|
||||
clear: () => set({})
|
||||
};
|
||||
}
|
||||
|
||||
export const navPerms = createNavPermissionsStore();
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let profile: any = null;
|
||||
let isLoading = true;
|
||||
@@ -90,9 +90,8 @@
|
||||
|
||||
function cancelEdit() {
|
||||
for (const key of Object.keys(form)) {
|
||||
(form as any)[key] = (profile?.[key] !== undefined && profile?.[key] !== null)
|
||||
? profile[key]
|
||||
: '';
|
||||
(form as any)[key] =
|
||||
profile?.[key] !== undefined && profile?.[key] !== null ? profile[key] : '';
|
||||
}
|
||||
isEditing = false;
|
||||
}
|
||||
@@ -114,11 +113,7 @@
|
||||
<p class="text-gray-600 mt-1">Información empresarial de tu organización</p>
|
||||
</div>
|
||||
{#if !isEditing && !isLoading}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary px-4 py-2"
|
||||
on:click={() => (isEditing = true)}
|
||||
>
|
||||
<button type="button" class="btn-primary px-4 py-2" on:click={() => (isEditing = true)}>
|
||||
Editar información
|
||||
</button>
|
||||
{/if}
|
||||
@@ -126,12 +121,11 @@
|
||||
|
||||
{#if isLoading}
|
||||
<div class="text-center py-16">
|
||||
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
|
||||
<div class="spinner w-8 h-8 mx-auto mb-4" />
|
||||
<p class="text-gray-500">Cargando información de la organización...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form on:submit|preventDefault={saveProfile} class="space-y-8">
|
||||
|
||||
<!-- Información general -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-200 px-6 py-4">
|
||||
@@ -139,7 +133,6 @@
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
|
||||
<div>
|
||||
<label class="form-label">Razón social</label>
|
||||
{#if isEditing}
|
||||
@@ -161,7 +154,14 @@
|
||||
<div>
|
||||
<label class="form-label">RFC</label>
|
||||
{#if isEditing}
|
||||
<input type="text" class="form-input" style="text-transform:uppercase" bind:value={form.rfc} maxlength="13" placeholder="XAXX010101000" />
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
style="text-transform:uppercase"
|
||||
bind:value={form.rfc}
|
||||
maxlength="13"
|
||||
placeholder="XAXX010101000"
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-sm font-mono text-gray-900 mt-1">{val('rfc') || '—'}</p>
|
||||
{/if}
|
||||
@@ -194,18 +194,25 @@
|
||||
<div>
|
||||
<label class="form-label">Sitio web</label>
|
||||
{#if isEditing}
|
||||
<input type="url" class="form-input" bind:value={form.website} placeholder="https://..." />
|
||||
<input
|
||||
type="url"
|
||||
class="form-input"
|
||||
bind:value={form.website}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
{:else if val('website')}
|
||||
<p class="text-sm mt-1">
|
||||
<a
|
||||
href={val('website')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:underline">{val('website')}</a
|
||||
>
|
||||
</p>
|
||||
{:else}
|
||||
{#if val('website')}
|
||||
<p class="text-sm mt-1">
|
||||
<a href={val('website')} target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:underline">{val('website')}</a>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-400 mt-1">—</p>
|
||||
{/if}
|
||||
<p class="text-sm text-gray-400 mt-1">—</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,7 +224,6 @@
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
|
||||
<div>
|
||||
<label class="form-label">País</label>
|
||||
{#if isEditing}
|
||||
@@ -248,7 +254,12 @@
|
||||
<div>
|
||||
<label class="form-label">Código postal</label>
|
||||
{#if isEditing}
|
||||
<input type="text" class="form-input" bind:value={form.postal_code} maxlength="10" />
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
bind:value={form.postal_code}
|
||||
maxlength="10"
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('postal_code') || '—'}</p>
|
||||
{/if}
|
||||
@@ -262,7 +273,6 @@
|
||||
<p class="text-sm text-gray-900 mt-1">{val('address') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,7 +284,6 @@
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
|
||||
<div>
|
||||
<label class="form-label">Teléfono principal</label>
|
||||
{#if isEditing}
|
||||
@@ -296,12 +305,16 @@
|
||||
<div>
|
||||
<label class="form-label">Horario de atención</label>
|
||||
{#if isEditing}
|
||||
<input type="text" class="form-input" bind:value={form.business_hours} placeholder="Lun-Vie 9:00-18:00" />
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
bind:value={form.business_hours}
|
||||
placeholder="Lun-Vie 9:00-18:00"
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('business_hours') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -318,13 +331,11 @@
|
||||
rows="4"
|
||||
bind:value={form.notes}
|
||||
placeholder="Información adicional sobre la organización..."
|
||||
></textarea>
|
||||
/>
|
||||
{:else if val('notes')}
|
||||
<p class="text-sm text-gray-900 whitespace-pre-wrap">{val('notes')}</p>
|
||||
{:else}
|
||||
{#if val('notes')}
|
||||
<p class="text-sm text-gray-900 whitespace-pre-wrap">{val('notes')}</p>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-400">Sin notas</p>
|
||||
{/if}
|
||||
<p class="text-sm text-gray-400">Sin notas</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,13 +347,9 @@
|
||||
type="button"
|
||||
class="btn-secondary px-5 py-2"
|
||||
on:click={cancelEdit}
|
||||
disabled={isSaving}
|
||||
>Cancelar</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary px-5 py-2"
|
||||
disabled={isSaving}
|
||||
disabled={isSaving}>Cancelar</button
|
||||
>
|
||||
<button type="submit" class="btn-primary px-5 py-2" disabled={isSaving}>
|
||||
{isSaving ? 'Guardando...' : 'Guardar cambios'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -992,19 +992,48 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if $auth.user?.is_two_factor_enabled}
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
>
|
||||
<svg
|
||||
class="w-3.5 h-3.5 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/></svg
|
||||
>
|
||||
Habilitado
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-red-600 hover:text-red-800 font-medium"
|
||||
on:click={() => { show2faDisable = !show2faDisable; disableTotpCode = ''; }}
|
||||
disabled={is2faLoading}
|
||||
>Deshabilitar</button>
|
||||
on:click={() => {
|
||||
show2faDisable = !show2faDisable;
|
||||
disableTotpCode = '';
|
||||
}}
|
||||
disabled={is2faLoading}>Deshabilitar</button
|
||||
>
|
||||
{:else}
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
||||
<svg class="w-3.5 h-3.5 mr-1" 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>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600"
|
||||
>
|
||||
<svg
|
||||
class="w-3.5 h-3.5 mr-1"
|
||||
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
|
||||
>
|
||||
Deshabilitado
|
||||
</span>
|
||||
<button
|
||||
@@ -1021,11 +1050,21 @@
|
||||
|
||||
{#if show2faSetup && qrUri}
|
||||
<div class="p-5 border-t border-gray-200 space-y-4">
|
||||
<p class="text-sm font-medium text-gray-700">1. Escanea este código QR en Google Authenticator, Authy o cualquier app TOTP:</p>
|
||||
<p class="text-sm font-medium text-gray-700">
|
||||
1. Escanea este código QR en Google Authenticator, Authy o cualquier app TOTP:
|
||||
</p>
|
||||
<div class="flex justify-center bg-white p-4 border border-gray-200 rounded">
|
||||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data={encodeURIComponent(qrUri)}" alt="Código QR 2FA" class="w-44 h-44" />
|
||||
<img
|
||||
src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data={encodeURIComponent(
|
||||
qrUri
|
||||
)}"
|
||||
alt="Código QR 2FA"
|
||||
class="w-44 h-44"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-700 mt-3">2. Ingresa el código de 6 dígitos para confirmar:</p>
|
||||
<p class="text-sm font-medium text-gray-700 mt-3">
|
||||
2. Ingresa el código de 6 dígitos para confirmar:
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
@@ -1045,32 +1084,45 @@
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-500 hover:text-gray-700 text-sm font-medium"
|
||||
on:click={() => { show2faSetup = false; }}
|
||||
>Cancelar</button>
|
||||
on:click={() => {
|
||||
show2faSetup = false;
|
||||
}}>Cancelar</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showBackupCodes && backupCodes.length > 0}
|
||||
<div class="p-5 border-t border-green-200 bg-green-50">
|
||||
<h4 class="font-medium text-green-900 mb-2">✅ 2FA activado — Guarda tus códigos de respaldo</h4>
|
||||
<p class="text-sm text-green-700 mb-3">Estos códigos son de un solo uso. Guárdalos en un lugar seguro.</p>
|
||||
<h4 class="font-medium text-green-900 mb-2">
|
||||
✅ 2FA activado — Guarda tus códigos de respaldo
|
||||
</h4>
|
||||
<p class="text-sm text-green-700 mb-3">
|
||||
Estos códigos son de un solo uso. Guárdalos en un lugar seguro.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||
{#each backupCodes as code}
|
||||
<span class="bg-white border border-green-200 px-3 py-1.5 rounded text-center">{code}</span>
|
||||
<span class="bg-white border border-green-200 px-3 py-1.5 rounded text-center"
|
||||
>{code}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 text-sm text-green-700 underline"
|
||||
on:click={() => { showBackupCodes = false; backupCodes = []; }}
|
||||
>He guardado mis códigos</button>
|
||||
on:click={() => {
|
||||
showBackupCodes = false;
|
||||
backupCodes = [];
|
||||
}}>He guardado mis códigos</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if show2faDisable}
|
||||
<div class="p-5 border-t border-red-200 bg-red-50">
|
||||
<p class="text-sm font-medium text-red-800 mb-3">Ingresa el código de tu app autenticadora para deshabilitar 2FA:</p>
|
||||
<p class="text-sm font-medium text-red-800 mb-3">
|
||||
Ingresa el código de tu app autenticadora para deshabilitar 2FA:
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
@@ -1090,8 +1142,10 @@
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-500 hover:text-gray-700 text-sm"
|
||||
on:click={() => { show2faDisable = false; }}
|
||||
>Cancelar</button>
|
||||
on:click={() => {
|
||||
show2faDisable = false;
|
||||
}}>Cancelar</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,47 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { tickets } from '$lib/stores/tickets.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import TicketCard from '$lib/components/TicketCard.svelte';
|
||||
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { tickets } from '$lib/stores/tickets.js';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let searchQuery = '';
|
||||
let statusFilter = '';
|
||||
let priorityFilter = '';
|
||||
let filteredTickets: any[] = [];
|
||||
|
||||
onMount(() => {
|
||||
// Redirect if not authenticated
|
||||
|
||||
onMount(async () => {
|
||||
await auth.init();
|
||||
if (!$auth.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load tickets
|
||||
tickets.loadTickets();
|
||||
});
|
||||
|
||||
|
||||
// Filter tickets based on search and filters
|
||||
$: {
|
||||
filteredTickets = $tickets.tickets.filter(ticket => {
|
||||
const matchesSearch = !searchQuery ||
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
ticket.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
ticket.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
ticket.id.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
|
||||
const matchesStatus = !statusFilter || ticket.status === statusFilter;
|
||||
const matchesPriority = !priorityFilter || ticket.priority === priorityFilter;
|
||||
|
||||
|
||||
return matchesSearch && matchesStatus && matchesPriority;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Get status counts
|
||||
$: statusCounts = $tickets.tickets.reduce((acc, ticket) => {
|
||||
acc[ticket.status] = (acc[ticket.status] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
|
||||
function clearFilters() {
|
||||
searchQuery = '';
|
||||
statusFilter = '';
|
||||
@@ -58,29 +57,39 @@
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Mis Tickets</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Gestiona y da seguimiento a todos tus tickets de soporte
|
||||
</p>
|
||||
<p class="text-gray-600 mt-2">Gestiona y da seguimiento a todos tus tickets de soporte</p>
|
||||
</div>
|
||||
<a
|
||||
href="/tickets/new"
|
||||
class="btn-primary px-4 py-2 inline-flex items-center space-x-2"
|
||||
>
|
||||
<a href="/tickets/new" class="btn-primary px-4 py-2 inline-flex items-center space-x-2">
|
||||
<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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
<span>Crear Ticket</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
<svg
|
||||
class="w-4 h-4 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
@@ -90,13 +99,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
class="w-4 h-4 text-yellow-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
@@ -108,13 +127,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
class="w-4 h-4 text-orange-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
@@ -126,13 +155,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="w-4 h-4 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
@@ -145,7 +184,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-8">
|
||||
<div class="card-content">
|
||||
@@ -160,14 +199,10 @@
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label for="status-filter" class="form-label">Estado</label>
|
||||
<select
|
||||
id="status-filter"
|
||||
class="form-input"
|
||||
bind:value={statusFilter}
|
||||
>
|
||||
<select id="status-filter" class="form-input" bind:value={statusFilter}>
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="NEW">Nuevo</option>
|
||||
<option value="IN_PROGRESS">En Progreso</option>
|
||||
@@ -177,14 +212,10 @@
|
||||
<option value="REOPENED">Reabierto</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label for="priority-filter" class="form-label">Prioridad</label>
|
||||
<select
|
||||
id="priority-filter"
|
||||
class="form-input"
|
||||
bind:value={priorityFilter}
|
||||
>
|
||||
<select id="priority-filter" class="form-input" bind:value={priorityFilter}>
|
||||
<option value="">Todas las prioridades</option>
|
||||
<option value="LOW">Baja</option>
|
||||
<option value="MEDIUM">Media</option>
|
||||
@@ -192,39 +223,38 @@
|
||||
<option value="URGENT">Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
on:click={clearFilters}
|
||||
class="btn-secondary px-4 py-2 w-full"
|
||||
>
|
||||
<button on:click={clearFilters} class="btn-secondary px-4 py-2 w-full">
|
||||
Limpiar Filtros
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Tickets List -->
|
||||
<div class="space-y-6">
|
||||
{#if $tickets.isLoading}
|
||||
<div class="text-center py-12">
|
||||
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
|
||||
<div class="spinner w-8 h-8 mx-auto mb-4" />
|
||||
<p class="text-gray-600">Cargando tickets...</p>
|
||||
</div>
|
||||
{:else if $tickets.error}
|
||||
<div class="text-center py-12">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar tickets</h3>
|
||||
<p class="text-gray-600 mb-4">{$tickets.error}</p>
|
||||
<button
|
||||
on:click={() => tickets.loadTickets()}
|
||||
class="btn-primary px-4 py-2"
|
||||
>
|
||||
<button on:click={() => tickets.loadTickets()} class="btn-primary px-4 py-2">
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
@@ -232,25 +262,26 @@
|
||||
<div class="text-center py-12">
|
||||
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||
{$tickets.tickets.length === 0 ? 'No tienes tickets' : 'No se encontraron tickets'}
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
{$tickets.tickets.length === 0
|
||||
{$tickets.tickets.length === 0
|
||||
? 'Crea tu primer ticket para comenzar'
|
||||
: 'Intenta ajustar los filtros de búsqueda'}
|
||||
</p>
|
||||
{#if $tickets.tickets.length === 0}
|
||||
<a href="/tickets/new" class="btn-primary px-4 py-2">
|
||||
Crear Ticket
|
||||
</a>
|
||||
<a href="/tickets/new" class="btn-primary px-4 py-2"> Crear Ticket </a>
|
||||
{:else}
|
||||
<button on:click={clearFilters} class="btn-secondary px-4 py-2">
|
||||
Limpiar Filtros
|
||||
</button>
|
||||
<button on:click={clearFilters} class="btn-secondary px-4 py-2"> Limpiar Filtros </button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -259,7 +290,7 @@
|
||||
<TicketCard {ticket} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
{#if filteredTickets.length !== $tickets.tickets.length}
|
||||
<div class="text-center py-4 text-sm text-gray-500">
|
||||
Mostrando {filteredTickets.length} de {$tickets.tickets.length} tickets
|
||||
@@ -267,4 +298,4 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { CLIENT_ROUTES } from '$lib/routes-registry';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// ---- Tipos ----
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -17,7 +17,6 @@
|
||||
can_manage_users?: boolean;
|
||||
}
|
||||
|
||||
// ---- Estado ----
|
||||
let users: User[] = [];
|
||||
let isLoading = true;
|
||||
let showModal = false;
|
||||
@@ -27,6 +26,8 @@
|
||||
let searchQuery = '';
|
||||
let filterRole = '';
|
||||
let filterStatus = '';
|
||||
let navPermissions: Record<string, boolean> = {};
|
||||
let isLoadingPerms = false;
|
||||
|
||||
let form = {
|
||||
first_name: '',
|
||||
@@ -41,22 +42,47 @@
|
||||
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 || '',
|
||||
'X-Tenant-Slug': $auth.user?.tenant_slug || $auth.user?.tenant_id || ''
|
||||
};
|
||||
if ($auth.token) h['Authorization'] = `Bearer ${$auth.token}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
// ---- Carga de usuarios ----
|
||||
function initNavPermissions() {
|
||||
navPermissions = {};
|
||||
CLIENT_ROUTES.forEach(r => {
|
||||
navPermissions[r.key] = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadUserNavPermissions(userId: string) {
|
||||
isLoadingPerms = true;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/permissions/user/${userId}`, {
|
||||
credentials: 'include',
|
||||
headers: apiHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error('Error cargando permisos');
|
||||
const perms = await res.json();
|
||||
const updated: Record<string, boolean> = {};
|
||||
CLIENT_ROUTES.forEach(r => {
|
||||
updated[r.key] = perms[r.key] ?? false;
|
||||
});
|
||||
navPermissions = updated;
|
||||
} catch (e) {
|
||||
initNavPermissions();
|
||||
} finally {
|
||||
isLoadingPerms = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
isLoading = true;
|
||||
try {
|
||||
@@ -74,31 +100,42 @@
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!$auth.isAuthenticated) { goto('/login'); return; }
|
||||
if (!$auth.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
await loadUsers();
|
||||
});
|
||||
|
||||
// ---- Filtros reactivos ----
|
||||
$: filteredUsers = users.filter(u => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchSearch = !q ||
|
||||
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;
|
||||
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 };
|
||||
form = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'CLIENT_USER',
|
||||
is_active: true,
|
||||
can_manage_users: false
|
||||
};
|
||||
initNavPermissions();
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEdit(user: User) {
|
||||
async function openEdit(user: User) {
|
||||
modalMode = 'edit';
|
||||
selectedUser = user;
|
||||
form = {
|
||||
@@ -110,6 +147,8 @@
|
||||
is_active: user.is_active,
|
||||
can_manage_users: user.can_manage_users || false
|
||||
};
|
||||
initNavPermissions();
|
||||
await loadUserNavPermissions(user.id);
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
@@ -118,16 +157,13 @@
|
||||
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 url = modalMode === 'create' ? '/api/v1/users/' : `/api/v1/users/${selectedUser!.id}`;
|
||||
const method = modalMode === 'create' ? 'POST' : 'PUT';
|
||||
|
||||
const res = await fetch(url, {
|
||||
@@ -138,7 +174,26 @@
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error((await res.json()).detail || 'Error al guardar');
|
||||
toast.success(modalMode === 'create' ? 'Usuario creado correctamente' : 'Usuario actualizado');
|
||||
const savedUser = await res.json();
|
||||
const userId = modalMode === 'create' ? savedUser.id : selectedUser!.id;
|
||||
|
||||
// Guardar permisos de navegación
|
||||
if (userId) {
|
||||
try {
|
||||
await fetch(`/api/v1/permissions/user/${userId}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { ...apiHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(navPermissions)
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error('Usuario guardado pero error al guardar permisos de navegación');
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(
|
||||
modalMode === 'create' ? 'Usuario creado correctamente' : 'Usuario actualizado'
|
||||
);
|
||||
closeModal();
|
||||
await loadUsers();
|
||||
} catch (e: any) {
|
||||
@@ -193,7 +248,6 @@
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
// ---- Helpers visuales ----
|
||||
function roleLabel(role: string): string {
|
||||
return role === 'CLIENT_ADMIN' ? 'Administrador' : 'Usuario';
|
||||
}
|
||||
@@ -212,9 +266,15 @@
|
||||
|
||||
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'
|
||||
'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);
|
||||
@@ -227,13 +287,11 @@
|
||||
</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
|
||||
Miembros de tu organización · {users.length} en total
|
||||
</p>
|
||||
</div>
|
||||
{#if isClientAdmin}
|
||||
@@ -243,79 +301,111 @@
|
||||
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"/>
|
||||
<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
|
||||
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"
|
||||
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 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>
|
||||
<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>
|
||||
<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 class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</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>
|
||||
<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>
|
||||
<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">
|
||||
<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 || ''}
|
||||
{user.first_name || ''}
|
||||
{user.last_name || ''}
|
||||
{#if user.id === $auth.user?.id}
|
||||
<span class="ml-1 text-xs text-gray-400">(tú)</span>
|
||||
{/if}
|
||||
@@ -324,45 +414,42 @@
|
||||
</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)}">
|
||||
<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' }) : '—'}
|
||||
{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
|
||||
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" />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 class="w-1.5 h-1.5 rounded-full bg-gray-300" />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 -->
|
||||
<div
|
||||
class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => openEdit(user)}
|
||||
@@ -370,11 +457,14 @@
|
||||
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"/>
|
||||
<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)}
|
||||
@@ -386,16 +476,24 @@
|
||||
>
|
||||
{#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"/>
|
||||
<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"/>
|
||||
<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"
|
||||
@@ -404,11 +502,15 @@
|
||||
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"/>
|
||||
<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}
|
||||
@@ -423,59 +525,86 @@
|
||||
<!-- 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="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={closeModal} />
|
||||
<div
|
||||
class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 z-10 max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<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">
|
||||
<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"/>
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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)'}
|
||||
{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" />
|
||||
<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">
|
||||
<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
|
||||
@@ -486,7 +615,9 @@
|
||||
/>
|
||||
<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>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
Podrá crear y editar usuarios, pero no eliminarlos
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -499,50 +630,109 @@
|
||||
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>
|
||||
<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>
|
||||
<!-- Sección de vistas -->
|
||||
<div class="pt-4 border-t border-gray-200">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-sm font-semibold text-gray-900">Vistas disponibles</h4>
|
||||
{#if isLoadingPerms}
|
||||
<span class="text-xs text-gray-400">Cargando...</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each CLIENT_ROUTES as route}
|
||||
<label
|
||||
class="flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer hover:bg-gray-50 transition-colors
|
||||
{navPermissions[route.key]
|
||||
? 'border-blue-200 bg-blue-50'
|
||||
: 'border-gray-100 bg-gray-50'}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={navPermissions[route.key]}
|
||||
disabled={isLoadingPerms}
|
||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-700">{route.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-2">
|
||||
Las vistas desactivadas no aparecerán en el menú del usuario.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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 -->
|
||||
<!-- Confirmar 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="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
on:click={() => (showDeleteConfirm = false)}
|
||||
/>
|
||||
<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"/>
|
||||
<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.
|
||||
¿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">
|
||||
<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">
|
||||
<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}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user