Refactor: Migrar UI completa a Tailwind CSS v4 + shadcn-vue
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 58s

- Reemplazar CSS nativo con Tailwind CSS v4 y utilidades custom
- Crear librería de componentes UI basada en shadcn-vue (Radix Vue)
- Componentes UI: Button, Card, Input, Textarea, Badge, Dialog, Avatar, DropdownMenu
- Migrar todos los componentes existentes a Tailwind utilities
- Convertir EventCard.js (htm) a EventCard.vue (SFC)
- Implementar sistema de temas dark/light con clase .dark
- Mantener efectos glassmorphism via @utility custom
- Eliminar styles.css legacy
This commit is contained in:
2025-11-24 18:12:24 -06:00
parent 003fdf18a7
commit 96a8f95f9e
52 changed files with 2580 additions and 1418 deletions

View File

@@ -1,180 +1,17 @@
<template>
<header class="topbar">
<div class="title">RADIUS Nucleo</div>
<div class="actions">
<a class="icon-btn" href="https://inicio.nucleoriofrio.com">
🏠 Inicio
</a>
<button class="icon-btn" @click="toggleTheme">
<img class="icon" :src="theme === 'light' ? '/icons/moon.svg' : '/icons/sun.svg'" alt="theme">
</button>
<span class="chip"><span class="muted">Estado:</span> {{ statusText }}</span>
<button class="icon-btn" @click="openAddUser">
<img class="icon" src="/icons/user-plus.svg" alt="usuario"> Usuario
</button>
<button class="icon-btn" @click="openAddGuest">
<img class="icon" src="/icons/guest.svg" alt="invitado"> Invitado
</button>
<UserDropdown />
<div class="dropdown">
<button class="icon-btn" @click="toggleSettingsMenu">
<img class="icon" src="/icons/settings.svg" alt="config"> Configuración
</button>
<div v-if="showSettingsMenu" class="menu">
<button @click="openRawDb">ver rawDB</button>
<button @click="openVlanForm">crear VLAN</button>
<hr/>
<a class="icon-btn" href="/api/users.csv">Exportar usuarios CSV</a>
<a class="icon-btn" href="/api/devices.csv">Exportar dispositivos CSV</a>
<a class="icon-btn" href="/api/vlans.csv">Exportar VLANs CSV</a>
<button class="icon-btn" @click="openImport('users')">Importar usuarios CSV</button>
<button class="icon-btn" @click="openImport('devices')">Importar dispositivos CSV</button>
<button class="icon-btn" @click="openImport('vlans')">Importar VLANs CSV</button>
</div>
</div>
</div>
</header>
<section class="shell">
<!-- Sidebar: Eventos -->
<aside class="panel" :class="{ collapsed: sidebarCollapsed }">
<div class="panel-header">
<div class="panel-title">Eventos FreeRADIUS</div>
<div class="panel-actions">
<span class="chip">Página {{ reqPage+1 }} / {{ Math.max(1, Math.ceil(filteredRequestsAll.length / pageSize)) }}</span>
<button class="icon-btn" @click="reqPage=Math.max(0, reqPage-1)">Anterior</button>
<button class="icon-btn" @click="reqPage=Math.min(Math.ceil(filteredRequestsAll.length/pageSize)-1, reqPage+1)">Siguiente</button>
<button class="icon-btn" title="Filtrar" @click="showEventFilters = true"><img class="icon" src="/icons/filter.svg" alt="filtrar"></button>
<button class="icon-btn" title="Limpiar" @click="clearRequests"><img class="icon" src="/icons/clear.svg" alt="limpiar"></button>
<button class="icon-btn" title="Test" @click="selfTest"><img class="icon" src="/icons/test.svg" alt="test"></button>
<a class="icon-btn" title="Descargar" href="/api/requests.csv" target="_blank"><img class="icon" src="/icons/download.svg" alt="descargar"></a>
<button class="icon-btn" title="Copiar" @click="copyRequests"><img class="icon" src="/icons/copy.svg" alt="copiar"></button>
<button class="icon-btn collapse-btn" title="Colapsar" @click="sidebarCollapsed = !sidebarCollapsed">{{ sidebarCollapsed ? 'Expandir' : 'Colapsar' }}</button>
</div>
</div>
<div class="scroll">
<div v-if="loading.requests" class="muted">Cargando eventos</div>
<EventCard v-for="ev in pagedRequests" :key="ev.id" :ev="ev" />
</div>
</aside>
<!-- Main: Usuarios -->
<main class="panel" :class="{ collapsed: mainCollapsed }">
<div class="panel-header">
<div class="row">
<div class="panel-title">Usuarios y Dispositivos</div>
<span class="spacer"></span>
<span class="chip" v-if="layoutMode==='user'">Página {{ userPage+1 }} / {{ Math.max(1, Math.ceil(filteredUsersAll.length / pageSize)) }}</span>
<span class="chip" v-else>Página {{ devicePage+1 }} / {{ Math.max(1, Math.ceil(devicesAll.length / pageSize)) }}</span>
<button class="icon-btn" @click="layoutMode==='user' ? (userPage=Math.max(0,userPage-1)) : (devicePage=Math.max(0,devicePage-1))">Anterior</button>
<button class="icon-btn" @click="layoutMode==='user' ? (userPage=Math.min(Math.ceil(filteredUsersAll.length/pageSize)-1,userPage+1)) : (devicePage=Math.min(Math.ceil(devicesAll.length/pageSize)-1,devicePage+1))">Siguiente</button>
<button class="icon-btn" title="Vista usuarios" @click="layoutMode='user'">
<img class="icon" src="/icons/layout-users.svg" alt="usuarios"/> Usuarios
</button>
<button class="icon-btn" title="Vista dispositivos" @click="layoutMode='device'">
<img class="icon" src="/icons/layout-devices.svg" alt="dispositivos"/> Dispositivos
</button>
<button class="icon-btn" title="Filtrar" @click="showUserFilters = true"><img class="icon" src="/icons/filter.svg" alt="filtro"/></button>
<button class="icon-btn collapse-btn" title="Colapsar" @click="mainCollapsed = !mainCollapsed">{{ mainCollapsed ? 'Expandir' : 'Colapsar' }}</button>
</div>
</div>
<div class="scroll">
<div v-if="loading.users" class="muted">Cargando usuarios</div>
<template v-else>
<div v-if="layoutMode==='user'" class="grid">
<UserCard v-for="u in filteredUsers" :key="u.username" :item="u" :devicesById="devicesById"
:expanded="!!userExpanded[u.username]"
@toggle-expand="userExpanded[u.username] = !userExpanded[u.username]"
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser"
@disconnect="disconnectUser" @edit-device="openDeviceForm" />
</div>
<div v-else class="grid">
<DispositivoCard v-for="d in pagedDevices" :key="d.id" :device="d" :users="usersForDevice(d.id)" :devicesById="devicesById"
:expanded="!!deviceExpanded[d.id]"
@toggle-expand="deviceExpanded[d.id] = !deviceExpanded[d.id]"
@edit="openDeviceForm" @disconnect="disconnectDevice" />
</div>
</template>
</div>
</main>
</section>
<!-- Modals -->
<Modal :open="showEventFilters" title="Filtros de eventos" @close="showEventFilters=false">
<div class="row" style="margin:8px 0;">
<input v-model="eventFilters.text" placeholder="Buscar texto" class="toggle" style="flex:1;"/>
<select v-model="eventFilters.type" class="toggle">
<option value="">Todos</option>
<option value="authorize">authorize</option>
<option value="accounting">accounting</option>
<option value="post-auth">post-auth</option>
<option value="selftest">selftest</option>
<option value="coa-disconnect">coa-disconnect</option>
</select>
</div>
</Modal>
<Modal :open="showUserFilters" title="Filtros de usuarios" @close="showUserFilters=false">
<div class="row" style="margin:8px 0;">
<input v-model="userFilters.text" placeholder="Buscar usuario" class="toggle" style="flex:1;"/>
<select v-model="userFilters.status" class="toggle">
<option value="">Todos</option>
<option value="active">Activos</option>
<option value="disabled">Deshabilitados</option>
</select>
</div>
</Modal>
<Modal :open="showUserForm" :title="userFormMode==='edit' ? 'Editar usuario' : (userFormMode==='guest' ? 'Agregar invitado' : 'Agregar usuario')"
@close="showUserForm=false">
<UserForm :model-value="userFormModel" :mode="userFormMode" @submit="handleUserFormSubmit" @cancel="showUserForm=false" />
</Modal>
<Modal :open="showRawDb" :fullscreen="rawDbFullscreen" title="Raw DB Viewer" @close="closeRawDb">
<RawDbViewer @toggle-fullscreen="rawDbFullscreen = !rawDbFullscreen" />
</Modal>
<Modal :open="showVlan" title="Crear VLAN" @close="closeVlan">
<VlanForm @success="onVlanCreated" @cancel="closeVlan" />
</Modal>
<Modal :open="showDevice" title="Editar dispositivo" @close="closeDevice">
<DeviceForm :model="deviceFormModel" @success="onDeviceSaved" @cancel="closeDevice" />
</Modal>
<Modal :open="showImport" :title="'Importar ' + importKind + ' CSV'" @close="closeImport">
<div class="column" style="gap:8px;">
<div class="row" style="gap:8px; align-items:center; flex-wrap:wrap;">
<input id="csvFile" ref="importFile" type="file" accept=".csv,text/csv" @change="onImportFile" style="display:none;" />
<button class="icon-btn" @click="$refs.importFile.click()">Seleccionar archivo CSV</button>
<span class="muted" v-if="importFilename">{{ importFilename }}</span>
</div>
<textarea v-model="importText" rows="10" placeholder="Pega CSV aquí o selecciona un archivo" style="width:100%; background:transparent; border: 1px solid rgba(255,255,255,.12); color: inherit; padding:8px;"></textarea>
<div v-if="importError" class="muted" style="color:#ff6b6b;">{{ importError }}</div>
<div class="modal-footer">
<button class="icon-btn" @click="closeImport">Cancelar</button>
<button class="icon-btn" @click="submitImport">Importar</button>
</div>
</div>
</Modal>
<!-- Toast System -->
<Toast position="top-center" />
</template>
<script setup>
import { onMounted, reactive, ref, computed, watch } from 'vue';
import EventCard from './components/EventCard.js';
import UserCard from './components/UserCard.vue';
import DispositivoCard from './components/DispositivoCard.vue';
import Modal from './components/Modal.vue';
import UserForm from './components/UserForm.vue';
import RawDbViewer from './components/RawDbViewer.vue';
import VlanForm from './components/VlanForm.vue';
import DeviceForm from './components/DeviceForm.vue';
import Toast from './components/Toast.vue';
import UserDropdown from './components/auth/UserDropdown.vue';
import { createToastSystem, useToast } from './composables/useToast.js';
import { Button, Badge, Input, Card, CardHeader, CardTitle, CardActions, CardContent, Textarea } from '@/components/ui';
import EventCard from '@/components/EventCard.vue';
import UserCard from '@/components/UserCard.vue';
import DispositivoCard from '@/components/DispositivoCard.vue';
import Modal from '@/components/Modal.vue';
import UserForm from '@/components/UserForm.vue';
import RawDbViewer from '@/components/RawDbViewer.vue';
import VlanForm from '@/components/VlanForm.vue';
import DeviceForm from '@/components/DeviceForm.vue';
import Toast from '@/components/Toast.vue';
import UserDropdown from '@/components/auth/UserDropdown.vue';
import { createToastSystem, useToast } from '@/composables/useToast.js';
// Initialize toast system
createToastSystem();
@@ -186,11 +23,9 @@ const loading = reactive({ users: false, requests: false });
const devices = ref([]);
const userExpanded = reactive({});
const deviceExpanded = reactive({});
// formulario inline removido: se usa modal con UserForm
// Helper para detectar errores de autenticación
function isAuthError(error) {
// Si es un TypeError de fetch, probablemente es CORS (redirección de Authentik)
return error instanceof TypeError && error.message.includes('fetch');
}
@@ -214,17 +49,12 @@ async function fetchUsers() {
loading.users = true;
try {
const res = await fetch('/api/users');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const data = await res.json();
users.value = data.items || [];
} catch (error) {
if (isAuthError(error)) {
handleAuthError();
} else {
console.error('Error fetching users:', error);
}
if (isAuthError(error)) handleAuthError();
else console.error('Error fetching users:', error);
} finally { loading.users = false; }
}
@@ -232,34 +62,24 @@ async function fetchRequests() {
loading.requests = true;
try {
const res = await fetch('/api/requests');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const data = await res.json();
requests.value = data.items || [];
} catch (error) {
if (isAuthError(error)) {
handleAuthError();
} else {
console.error('Error fetching requests:', error);
}
if (isAuthError(error)) handleAuthError();
else console.error('Error fetching requests:', error);
} finally { loading.requests = false; }
}
async function fetchDevices() {
try {
const res = await fetch('/api/devices');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const data = await res.json();
devices.value = data.items || [];
} catch (error) {
if (isAuthError(error)) {
handleAuthError();
} else {
console.error('Error fetching devices:', error);
}
if (isAuthError(error)) handleAuthError();
else console.error('Error fetching devices:', error);
}
}
@@ -278,8 +98,6 @@ async function removeUser(u) {
await fetchUsers();
}
async function refreshRequests() { await fetchRequests(); }
async function clearRequests() {
await fetch('/api/requests', { method: 'DELETE' });
await fetchRequests();
@@ -327,7 +145,6 @@ function setupSse() {
}
onMounted(async () => {
// Load persisted expand state
try {
const ue = JSON.parse(localStorage.getItem('ui_userExpanded') || '{}');
Object.assign(userExpanded, ue && typeof ue === 'object' ? ue : {});
@@ -344,30 +161,19 @@ onMounted(async () => {
checkPWAStatus();
});
// PWA Detection and Toast
function checkPWAStatus() {
// Don't show if already dismissed
if (localStorage.getItem('pwa_toast_dismissed')) return;
// Check if running in standalone mode (PWA installed and active)
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
// If NOT in standalone mode, user is in browser
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
if (!isStandalone) {
// Check if PWA can be installed
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
// Show install prompt
setTimeout(() => {
toast.pwa('Instala RADIUS Nucleo como aplicación para una mejor experiencia', {
title: '📱 Instalar Aplicación',
position: 'top-center',
duration: 0, // Persistent
duration: 0,
persistent: true,
action: {
label: 'Instalar',
@@ -375,9 +181,7 @@ function checkPWAStatus() {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
localStorage.setItem('pwa_toast_dismissed', 'true');
}
if (outcome === 'accepted') localStorage.setItem('pwa_toast_dismissed', 'true');
deferredPrompt = null;
}
}
@@ -385,27 +189,6 @@ function checkPWAStatus() {
});
}, 2000);
});
// If already installed but not in standalone, prompt to open in app
if ('getInstalledRelatedApps' in navigator) {
navigator.getInstalledRelatedApps().then((apps) => {
if (apps.length > 0) {
setTimeout(() => {
toast.pwa('Abre RADIUS Nucleo en la aplicación para una mejor experiencia', {
title: '📱 Abrir en App',
position: 'top-center',
duration: 10000,
action: {
label: 'Entendido',
handler: () => {
localStorage.setItem('pwa_toast_dismissed', 'true');
}
}
});
}, 2000);
}
});
}
}
}
@@ -429,6 +212,7 @@ const filteredUsersAll = computed(() => {
return true;
});
});
const pageSize = 20;
const userPage = ref(0);
const filteredUsers = computed(() => filteredUsersAll.value.slice(userPage.value*pageSize, userPage.value*pageSize + pageSize));
@@ -444,19 +228,16 @@ function usersForDevice(id) {
return users.value.filter(u => Array.isArray(u.dispositivos_utilizados) && u.dispositivos_utilizados.includes(id));
}
// Devices pagination
const devicesAll = computed(() => devices.value);
const devicePage = ref(0);
const pagedDevices = computed(() => devicesAll.value.slice(devicePage.value*pageSize, devicePage.value*pageSize + pageSize));
watch([devicesAll, () => layoutMode.value], () => { devicePage.value = 0; });
// Requests pagination (sidebar)
const filteredRequestsAll = computed(() => filteredRequests.value);
const reqPage = ref(0);
const pagedRequests = computed(() => filteredRequestsAll.value.slice(reqPage.value*pageSize, reqPage.value*pageSize + pageSize));
watch(filteredRequestsAll, () => { reqPage.value = 0; });
// Persist expansion state
watch(userExpanded, (v) => {
try { localStorage.setItem('ui_userExpanded', JSON.stringify(v)); } catch {}
}, { deep: true });
@@ -475,11 +256,11 @@ function toggleTheme() {
applyTheme();
}
function applyTheme() {
document.documentElement.classList.toggle('light', theme.value === 'light');
document.documentElement.classList.toggle('dark', theme.value === 'dark');
}
const showUserForm = ref(false);
const userFormMode = ref('create'); // 'create' | 'edit' | 'guest'
const userFormMode = ref('create');
const userFormModel = ref({ username:'', password:'', vlan:'', disabled:false });
function openAddUser() {
@@ -507,19 +288,19 @@ function openDeviceForm(d) { deviceFormModel.value = d; showDevice.value = true;
function closeDevice() { showDevice.value = false; }
async function onDeviceSaved() { showDevice.value = false; await fetchDevices(); }
// Import CSV modal
const showImport = ref(false);
const importKind = ref('users');
const importText = ref('');
const importFilename = ref('');
const importError = ref('');
const importFile = ref(null);
function openImport(kind){ importKind.value = kind; importText.value=''; showImport.value = true; }
function closeImport(){ showImport.value = false; }
async function submitImport(){
importError.value = '';
const txt = (importText.value || '').trim();
if (!txt) { importError.value = 'El CSV está vacío.'; return; }
// Basic header validation
const headers = getCsvHeaders(txt);
if (!headers.length) { importError.value = 'No se encontraron columnas en el CSV.'; return; }
const need = (k)=>headers.includes(k);
@@ -567,7 +348,7 @@ function getCsvHeaders(text){
out.push(cur.trim().toLowerCase());
return out;
}
function openSettings() { showSettingsMenu.value = !showSettingsMenu.value; }
function openEditUser(u) {
userFormMode.value = 'edit';
userFormModel.value = { username: u.username, password: u.password || '', vlan: u.vlan || '', disabled: !!u.disabled };
@@ -586,3 +367,203 @@ async function handleUserFormSubmit(data) {
showUserForm.value = false;
}
</script>
<template>
<!-- Top Bar -->
<header class="sticky top-0 z-10 flex flex-wrap items-center gap-2.5 p-3 glass backdrop-blur-[14px] border-b border-pink-600/50 dark:border-pink-600/50 bg-gradient-to-r from-pink-600/20 via-transparent to-transparent">
<div class="text-base font-bold tracking-wide flex-1">RADIUS Nucleo</div>
<div class="flex flex-wrap items-center gap-2">
<Button as="a" href="https://inicio.nucleoriofrio.com" variant="ghost">
🏠 Inicio
</Button>
<Button variant="ghost" size="icon" @click="toggleTheme">
<img class="size-4 opacity-90" :src="theme === 'light' ? '/icons/moon.svg' : '/icons/sun.svg'" alt="theme">
</Button>
<Badge><span class="text-muted">Estado:</span> {{ statusText }}</Badge>
<Button @click="openAddUser">
<img class="size-4 opacity-90" src="/icons/user-plus.svg" alt="usuario"> Usuario
</Button>
<Button @click="openAddGuest">
<img class="size-4 opacity-90" src="/icons/guest.svg" alt="invitado"> Invitado
</Button>
<UserDropdown />
<div class="relative">
<Button @click="toggleSettingsMenu">
<img class="size-4 opacity-90" src="/icons/settings.svg" alt="config"> Configuración
</Button>
<div v-if="showSettingsMenu" class="absolute right-0 top-full mt-1.5 glass-card p-1.5 min-w-[200px] shadow-lg border border-pink-200 dark:border-pink-600/50 z-50 space-y-1">
<Button variant="ghost" class="w-full justify-start" @click="openRawDb">ver rawDB</Button>
<Button variant="ghost" class="w-full justify-start" @click="openVlanForm">crear VLAN</Button>
<hr class="border-border my-1" />
<Button as="a" variant="ghost" class="w-full justify-start" href="/api/users.csv">Exportar usuarios CSV</Button>
<Button as="a" variant="ghost" class="w-full justify-start" href="/api/devices.csv">Exportar dispositivos CSV</Button>
<Button as="a" variant="ghost" class="w-full justify-start" href="/api/vlans.csv">Exportar VLANs CSV</Button>
<Button variant="ghost" class="w-full justify-start" @click="openImport('users')">Importar usuarios CSV</Button>
<Button variant="ghost" class="w-full justify-start" @click="openImport('devices')">Importar dispositivos CSV</Button>
<Button variant="ghost" class="w-full justify-start" @click="openImport('vlans')">Importar VLANs CSV</Button>
</div>
</div>
</div>
</header>
<!-- Main Layout -->
<section class="h-[calc(100vh-54px)] grid grid-cols-[360px_1fr] gap-3 p-3 max-md:grid-cols-1 max-md:grid-rows-[auto_1fr]"
:class="{ 'grid-cols-[52px_1fr]': sidebarCollapsed && !mainCollapsed, 'grid-cols-[1fr_52px]': mainCollapsed && !sidebarCollapsed }">
<!-- Sidebar: Eventos -->
<aside>
<Card variant="panel" class="h-full border-pink-200 dark:border-pink-600/50" :class="{ 'panel-collapsed': sidebarCollapsed }">
<CardHeader>
<CardTitle>Eventos FreeRADIUS</CardTitle>
<CardActions>
<Badge>Página {{ reqPage+1 }} / {{ Math.max(1, Math.ceil(filteredRequestsAll.length / pageSize)) }}</Badge>
<Button size="sm" @click="reqPage=Math.max(0, reqPage-1)">Anterior</Button>
<Button size="sm" @click="reqPage=Math.min(Math.ceil(filteredRequestsAll.length/pageSize)-1, reqPage+1)">Siguiente</Button>
<Button size="sm" variant="ghost" title="Filtrar" @click="showEventFilters = true">
<img class="size-4 opacity-90" src="/icons/filter.svg" alt="filtrar">
</Button>
<Button size="sm" variant="ghost" title="Limpiar" @click="clearRequests">
<img class="size-4 opacity-90" src="/icons/clear.svg" alt="limpiar">
</Button>
<Button size="sm" variant="ghost" title="Test" @click="selfTest">
<img class="size-4 opacity-90" src="/icons/test.svg" alt="test">
</Button>
<Button as="a" size="sm" variant="ghost" title="Descargar" href="/api/requests.csv" target="_blank">
<img class="size-4 opacity-90" src="/icons/download.svg" alt="descargar">
</Button>
<Button size="sm" variant="ghost" title="Copiar" @click="copyRequests">
<img class="size-4 opacity-90" src="/icons/copy.svg" alt="copiar">
</Button>
<Button size="sm" variant="ghost" class="hidden max-md:inline-flex" title="Colapsar" @click="sidebarCollapsed = !sidebarCollapsed">
{{ sidebarCollapsed ? 'Expandir' : 'Colapsar' }}
</Button>
</CardActions>
</CardHeader>
<CardContent v-if="!sidebarCollapsed" class="flex-1 overflow-auto scroll-custom space-y-2">
<div v-if="loading.requests" class="text-muted">Cargando eventos</div>
<EventCard v-for="ev in pagedRequests" :key="ev.id" :ev="ev" />
</CardContent>
</Card>
</aside>
<!-- Main: Usuarios -->
<main>
<Card variant="panel" class="h-full border-pink-200 dark:border-pink-600/50" :class="{ 'panel-collapsed': mainCollapsed }">
<CardHeader>
<div class="flex flex-wrap items-center gap-2 w-full">
<CardTitle>Usuarios y Dispositivos</CardTitle>
<span class="flex-1"></span>
<Badge v-if="layoutMode==='user'">Página {{ userPage+1 }} / {{ Math.max(1, Math.ceil(filteredUsersAll.length / pageSize)) }}</Badge>
<Badge v-else>Página {{ devicePage+1 }} / {{ Math.max(1, Math.ceil(devicesAll.length / pageSize)) }}</Badge>
<Button size="sm" @click="layoutMode==='user' ? (userPage=Math.max(0,userPage-1)) : (devicePage=Math.max(0,devicePage-1))">Anterior</Button>
<Button size="sm" @click="layoutMode==='user' ? (userPage=Math.min(Math.ceil(filteredUsersAll.length/pageSize)-1,userPage+1)) : (devicePage=Math.min(Math.ceil(devicesAll.length/pageSize)-1,devicePage+1))">Siguiente</Button>
<Button size="sm" :variant="layoutMode==='user' ? 'default' : 'ghost'" title="Vista usuarios" @click="layoutMode='user'">
<img class="size-4 opacity-90" src="/icons/layout-users.svg" alt="usuarios"/> Usuarios
</Button>
<Button size="sm" :variant="layoutMode==='device' ? 'default' : 'ghost'" title="Vista dispositivos" @click="layoutMode='device'">
<img class="size-4 opacity-90" src="/icons/layout-devices.svg" alt="dispositivos"/> Dispositivos
</Button>
<Button size="sm" variant="ghost" title="Filtrar" @click="showUserFilters = true">
<img class="size-4 opacity-90" src="/icons/filter.svg" alt="filtro"/>
</Button>
<Button size="sm" variant="ghost" class="hidden max-md:inline-flex" title="Colapsar" @click="mainCollapsed = !mainCollapsed">
{{ mainCollapsed ? 'Expandir' : 'Colapsar' }}
</Button>
</div>
</CardHeader>
<CardContent v-if="!mainCollapsed" class="flex-1 overflow-auto scroll-custom">
<div v-if="loading.users" class="text-muted">Cargando usuarios</div>
<template v-else>
<div v-if="layoutMode==='user'" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2.5">
<UserCard v-for="u in filteredUsers" :key="u.username" :item="u" :devicesById="devicesById"
:expanded="!!userExpanded[u.username]"
@toggle-expand="userExpanded[u.username] = !userExpanded[u.username]"
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser"
@disconnect="disconnectUser" @edit-device="openDeviceForm" />
</div>
<div v-else class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2.5">
<DispositivoCard v-for="d in pagedDevices" :key="d.id" :device="d" :users="usersForDevice(d.id)" :devicesById="devicesById"
:expanded="!!deviceExpanded[d.id]"
@toggle-expand="deviceExpanded[d.id] = !deviceExpanded[d.id]"
@edit="openDeviceForm" @disconnect="disconnectDevice" />
</div>
</template>
</CardContent>
</Card>
</main>
</section>
<!-- Modals -->
<Modal :open="showEventFilters" title="Filtros de eventos" @close="showEventFilters=false">
<div class="flex flex-wrap gap-2 my-2">
<Input v-model="eventFilters.text" placeholder="Buscar texto" class="flex-1" />
<select v-model="eventFilters.type" class="glass glass-border rounded-md px-3 py-2">
<option value="">Todos</option>
<option value="authorize">authorize</option>
<option value="accounting">accounting</option>
<option value="post-auth">post-auth</option>
<option value="selftest">selftest</option>
<option value="coa-disconnect">coa-disconnect</option>
</select>
</div>
</Modal>
<Modal :open="showUserFilters" title="Filtros de usuarios" @close="showUserFilters=false">
<div class="flex flex-wrap gap-2 my-2">
<Input v-model="userFilters.text" placeholder="Buscar usuario" class="flex-1" />
<select v-model="userFilters.status" class="glass glass-border rounded-md px-3 py-2">
<option value="">Todos</option>
<option value="active">Activos</option>
<option value="disabled">Deshabilitados</option>
</select>
</div>
</Modal>
<Modal :open="showUserForm" :title="userFormMode==='edit' ? 'Editar usuario' : (userFormMode==='guest' ? 'Agregar invitado' : 'Agregar usuario')"
@close="showUserForm=false">
<UserForm :model-value="userFormModel" :mode="userFormMode" @submit="handleUserFormSubmit" @cancel="showUserForm=false" />
</Modal>
<Modal :open="showRawDb" :fullscreen="rawDbFullscreen" title="Raw DB Viewer" @close="closeRawDb">
<RawDbViewer @toggle-fullscreen="rawDbFullscreen = !rawDbFullscreen" />
</Modal>
<Modal :open="showVlan" title="Crear VLAN" @close="closeVlan">
<VlanForm @success="onVlanCreated" @cancel="closeVlan" />
</Modal>
<Modal :open="showDevice" title="Editar dispositivo" @close="closeDevice">
<DeviceForm :model="deviceFormModel" @success="onDeviceSaved" @cancel="closeDevice" />
</Modal>
<Modal :open="showImport" :title="'Importar ' + importKind + ' CSV'" @close="closeImport">
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<input ref="importFile" type="file" accept=".csv,text/csv" @change="onImportFile" class="hidden" />
<Button @click="importFile?.click()">Seleccionar archivo CSV</Button>
<span v-if="importFilename" class="text-muted text-sm">{{ importFilename }}</span>
</div>
<Textarea
v-model="importText"
:rows="10"
placeholder="Pega CSV aquí o selecciona un archivo"
/>
<div v-if="importError" class="text-sm text-red-400">{{ importError }}</div>
<div class="flex justify-end gap-2 mt-2">
<Button variant="ghost" @click="closeImport">Cancelar</Button>
<Button @click="submitImport">Importar</Button>
</div>
</div>
</Modal>
<!-- Toast System -->
<Toast position="top-center" />
</template>
<style>
/* Panel collapsed state */
.panel-collapsed .scroll-custom,
.panel-collapsed [class*="CardContent"] {
display: none;
}
</style>