mejoras UI 4

This commit is contained in:
2025-09-26 18:14:15 -06:00
parent 689f80d59c
commit e10d8950d9
5 changed files with 153 additions and 32 deletions

View File

@@ -62,10 +62,19 @@
</div>
<div class="scroll">
<div v-if="loading.users" class="muted">Cargando usuarios</div>
<div v-else class="grid">
<UserCard v-for="u in filteredUsers" :key="u.username" :item="u" :mode="layoutMode"
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser" />
</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" />
</div>
<div v-else class="grid">
<DispositivoCard v-for="d in devices" :key="d.id" :device="d" :users="usersForDevice(d.id)" :devicesById="devicesById"
:expanded="!!deviceExpanded[d.id]"
@toggle-expand="deviceExpanded[d.id] = !deviceExpanded[d.id]" />
</div>
</template>
</div>
</main>
</section>
@@ -111,9 +120,10 @@
</template>
<script setup>
import { onMounted, reactive, ref, computed } from 'vue';
import { onMounted, reactive, ref, computed, watch } from 'vue';
import EventCard from './components/EventCard.js';
import UserCard from './components/UserCard.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';
@@ -122,6 +132,9 @@ import VlanForm from './components/VlanForm.vue';
const users = ref([]);
const requests = ref([]);
const loading = reactive({ users: false, requests: false });
const devices = ref([]);
const userExpanded = reactive({});
const deviceExpanded = reactive({});
// formulario inline removido: se usa modal con UserForm
const showEventFilters = ref(false);
@@ -153,6 +166,14 @@ async function fetchRequests() {
} finally { loading.requests = false; }
}
async function fetchDevices() {
try {
const res = await fetch('/api/devices');
const data = await res.json();
devices.value = data.items || [];
} catch {}
}
async function toggleDisable(u) {
await fetch(`/api/users/${encodeURIComponent(u.username)}`, {
method: 'PATCH',
@@ -181,17 +202,39 @@ async function selfTest() {
function setupSse() {
const ev = new EventSource('/api/events');
let refreshTimer = null;
function scheduleRefresh() {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(async () => {
await Promise.all([fetchUsers(), fetchDevices()]);
refreshTimer = null;
}, 1000);
}
ev.addEventListener('message', (e) => {
try {
const data = JSON.parse(e.data);
if (data && data.ts) requests.value.push(data);
const t = data && data.type;
if (t === 'authorize' || t === 'post-auth' || t === 'accounting' || t === 'coa-disconnect') {
scheduleRefresh();
}
} catch {}
});
ev.addEventListener('clear', () => { requests.value = []; });
}
onMounted(async () => {
// Load persisted expand state
try {
const ue = JSON.parse(localStorage.getItem('ui_userExpanded') || '{}');
Object.assign(userExpanded, ue && typeof ue === 'object' ? ue : {});
} catch {}
try {
const de = JSON.parse(localStorage.getItem('ui_deviceExpanded') || '{}');
Object.assign(deviceExpanded, de && typeof de === 'object' ? de : {});
} catch {}
await fetchUsers();
await fetchDevices();
await fetchRequests();
setupSse();
applyTheme();
@@ -218,6 +261,24 @@ const filteredUsers = computed(() => {
});
});
const devicesById = computed(() => {
const m = {};
for (const d of devices.value) m[d.id] = d;
return m;
});
function usersForDevice(id) {
return users.value.filter(u => Array.isArray(u.dispositivos_utilizados) && u.dispositivos_utilizados.includes(id));
}
// Persist expansion state
watch(userExpanded, (v) => {
try { localStorage.setItem('ui_userExpanded', JSON.stringify(v)); } catch {}
}, { deep: true });
watch(deviceExpanded, (v) => {
try { localStorage.setItem('ui_deviceExpanded', JSON.stringify(v)); } catch {}
}, { deep: true });
function copyRequests() {
const txt = JSON.stringify(requests.value, null, 2);
navigator.clipboard?.writeText(txt);

View File

@@ -0,0 +1,35 @@
<template>
<div class="card">
<div class="row">
<b>{{ device.nombre || device.mac }}</b>
<span class="chip">{{ device.mac }}</span>
<span v-if="connectedCount>0 || connected" class="chip" style="background: rgba(255,127,187,.2); border-color: rgba(255,127,187,.5);">Conectado</span>
<span class="spacer"></span>
<button v-if="!simple" class="icon-btn" @click="$emit('toggleExpand')">{{ expanded ? 'Contraer' : 'Expandir' }}</button>
</div>
<div v-if="expanded && users && users.length" style="margin-top:8px;">
<div class="grid">
<UserCard v-for="u in users" :key="u.username" :item="u" :devicesById="devicesById" :expandable="false" />
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import UserCard from './UserCard.vue';
const props = defineProps({
device: { type: Object, required: true },
users: { type: Array, default: () => [] },
devicesById: { type: Object, default: () => ({}) },
simple: { type: Boolean, default: false },
connected: { type: Boolean, default: false },
expanded: { type: Boolean, default: false }
});
const connectedCount = computed(() => {
if (!props.users || !props.users.length) return props.connected ? 1 : 0;
const id = props.device.id;
return props.users.filter(u => Array.isArray(u.dispositivos_conectados) && u.dispositivos_conectados.includes(id)).length;
});
</script>

View File

@@ -1,26 +0,0 @@
import { defineComponent, h } from 'vue';
import htm from 'htm';
const html = htm.bind(h);
export default defineComponent({
name: 'UserCard',
props: { item: { type: Object, required: true }, mode: { type: String, default: 'user' } },
emits: ['toggleDisable', 'remove', 'edit'],
setup(props, { emit }) {
function toggle() { emit('toggleDisable', props.item); }
function remove() { emit('remove', props.item); }
function edit() { emit('edit', props.item); }
return () => html`<div class="card">
<div class="row">
<b>${props.mode === 'user' ? props.item.username : (props.item.device || props.item.username)}</b>
<span class="chip">VLAN ${props.item.vlan}</span>
${props.item.disabled ? html`<span class="chip" style="color:#b33">deshabilitado</span>` : html`<span class="chip">activo</span>`}
<span class="spacer"></span>
<button class="icon-btn" onClick=${edit}>Editar</button>
<button class="icon-btn" onClick=${toggle}>${props.item.disabled ? 'Habilitar' : 'Deshabilitar'}</button>
<button class="icon-btn" onClick=${remove}>Eliminar</button>
</div>
<div class="muted" style="margin-top:6px; font-size:12px;">${props.item.devices ? props.item.devices.length : 0} dispositivos</div>
</div>`;
}
});

View File

@@ -0,0 +1,40 @@
<template>
<div class="card">
<div class="row">
<b>{{ item.username }}</b>
<span class="chip">VLAN {{ item.vlan }}</span>
<span class="chip" :style="item.disabled ? 'color:#b33' : ''">{{ item.disabled ? 'deshabilitado' : 'activo' }}</span>
<span class="spacer"></span>
<button class="icon-btn" @click="$emit('edit', item)">Editar</button>
<button class="icon-btn" @click="$emit('toggleDisable', item)">{{ item.disabled ? 'Habilitar' : 'Deshabilitar' }}</button>
<button class="icon-btn" @click="$emit('remove', item)">Eliminar</button>
<button v-if="expandable" class="icon-btn" @click="$emit('toggleExpand')">{{ expanded ? 'Contraer' : 'Expandir' }}</button>
</div>
<div v-if="expanded && deviceList.length" style="margin-top:8px;">
<div class="grid">
<DispositivoCard v-for="d in deviceList" :key="d.id" :device="d" :connected="isConnected(d.id)" simple />
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import DispositivoCard from './DispositivoCard.vue';
const props = defineProps({
item: { type: Object, required: true },
devicesById: { type: Object, default: () => ({}) },
expandable: { type: Boolean, default: true },
expanded: { type: Boolean, default: false }
});
const deviceList = computed(() => {
const ids = props.item.dispositivos_utilizados || [];
return ids.map(id => props.devicesById[id]).filter(Boolean);
});
function isConnected(id) {
return Array.isArray(props.item.dispositivos_conectados) && props.item.dispositivos_conectados.includes(id);
}
</script>