esp/paginas/index.php

791 lines
37 KiB
PHP

<?php
$title = 'Dashboard Dispositivos';
ob_start();
?>
<!-- Header con título y acciones -->
<div class="mb-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-slate-900">Dispositivos</h1>
<p class="text-slate-600 mt-1">Gestiona tus dispositivos ESP conectados</p>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer select-none bg-white px-3 py-2 rounded-lg border border-slate-200 shadow-sm">
<input type="checkbox" id="hideOfflineDevices" class="w-4 h-4 text-emerald-600 rounded focus:ring-emerald-500">
<span class="text-sm font-medium text-slate-700">Ocultar Inactivos (offline)</span>
</label>
<button id="pageCreateDevice" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 shadow-sm transition-all" type="button">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Nuevo dispositivo
</button>
<button id="pageCreateSector" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-lg bg-white text-slate-700 hover:bg-slate-50 border border-slate-200 shadow-sm transition-all" type="button">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
Gestionar sectores
</button>
</div>
</div>
</div>
<!-- Lista de dispositivos -->
<div id="lista-dispositivos"></div>
<!-- Modal Crear Dispositivo -->
<div id="modalCrearDispositivo" class="fixed inset-0 z-50 hidden">
<div data-modal-overlay class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="relative mx-auto mt-20 max-w-lg rounded-xl bg-white shadow-2xl">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-gradient-to-r from-emerald-50 to-emerald-100">
<h5 class="m-0 font-bold text-slate-900 text-lg">Crear nuevo dispositivo</h5>
<button type="button" class="text-slate-400 hover:text-slate-600 transition-colors" data-modal-close aria-label="Cerrar">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="p-6">
<form id="form-add" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div><input name="chipid" placeholder="ChipID" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 transition-all" required></div>
<div><input name="tag" placeholder="Tag" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 transition-all" required></div>
<div><input name="nombre" placeholder="Modelo" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 transition-all"></div>
<div><input name="ciudad" placeholder="Ciudad" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 transition-all"></div>
<div class="md:col-span-2">
<select name="sector" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 transition-all" required>
<option value="">Seleccione un sector</option>
</select>
</div>
<div class="md:col-span-1"><button class="w-full inline-flex items-center justify-center px-4 py-2.5 text-sm font-semibold rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 shadow-sm transition-all" type="submit">Agregar</button></div>
</form>
</div>
</div>
</div>
<!-- Modal Gestión de Sectores -->
<div id="modalGestionSectores" class="fixed inset-0 z-50 hidden">
<div data-modal-overlay class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="relative mx-auto mt-20 max-w-lg rounded-xl bg-white shadow-2xl">
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h5 class="m-0 font-semibold">🏷️ Gestión de Sectores</h5>
<button type="button" class="text-gray-500 hover:text-gray-700" data-modal-close aria-label="Cerrar">✕</button>
</div>
<form id="form-sector">
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
<div class="md:col-span-2">
<input id="input-nuevo-sector" name="sector" placeholder="Nombre del sector" class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-200" required>
</div>
</div>
<ul id="lista-sectores" class="list-group divide-y divide-gray-100"></ul>
</div>
<div class="px-4 py-3 border-t border-gray-200 flex items-center justify-end gap-2">
<button type="button" class="inline-flex items-center px-4 py-2 text-sm font-medium rounded border border-gray-300 text-gray-700 hover:bg-gray-100" data-modal-close>Cancelar</button>
<button class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-emerald-600 text-white hover:bg-emerald-700" type="submit">Crear sector</button>
</div>
</form>
</div>
</div>
<!-- Modal Confirmar Eliminación de Sector (Tailwind) -->
<div id="modalConfirmEliminarSector" class="fixed inset-0 z-50 hidden">
<div data-modal-overlay class="absolute inset-0 bg-black/40"></div>
<div class="relative mx-auto mt-24 max-w-sm rounded-md bg-white shadow-lg">
<div class="px-5 py-4 border-b border-gray-200">
<h5 class="m-0 font-semibold text-gray-900">Confirmar eliminación</h5>
</div>
<div class="px-5 py-4 text-sm text-gray-700">
¿Eliminar el sector <span class="font-semibold" id="sectorAEliminar"></span>? Esta acción no se puede deshacer.
</div>
<div class="px-5 py-3 border-t border-gray-200 flex items-center justify-end gap-2">
<button type="button" class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded border border-gray-300 text-gray-700 hover:bg-gray-100" data-modal-close>Cancelar</button>
<button type="button" id="confirmDeleteSectorBtn" class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded bg-red-600 text-white hover:bg-red-700">Eliminar</button>
</div>
</div>
</div>
<!-- Modal Editar Dispositivo (Tailwind) -->
<div id="modalEditarDispositivo" class="fixed inset-0 z-50 hidden">
<div data-modal-overlay class="absolute inset-0 bg-black/40"></div>
<div class="relative mx-auto mt-20 max-w-lg rounded-md bg-white shadow-lg">
<form id="form-edit">
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h5 class="m-0 font-semibold">✏️ Editar dispositivo</h5>
<button type="button" class="text-gray-500 hover:text-gray-700" data-modal-close aria-label="Cerrar">✕</button>
</div>
<div class="p-4 grid grid-cols-2 gap-3">
<input type="hidden" name="chipid">
<div><input name="tag" placeholder="Tag" class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-200" required></div>
<div><input name="nombre" placeholder="Modelo" class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-200"></div>
<div><input name="ciudad" placeholder="Ciudad" class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-200"></div>
<div>
<select name="sector" class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-200" required>
<option value="">Seleccione un sector</option>
</select>
</div>
</div>
<div class="px-4 py-3 border-t border-gray-200 text-right">
<button class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-blue-600 text-white hover:bg-blue-700" type="submit">Guardar cambios</button>
</div>
</form>
</div>
</div>
<script>
// Estados actuales por chipid (solo en RAM, provistos por API)
const puertosDispositivos = {}; // chipid -> { pin: { estado, modo, disp } }
let latestFirmwareVersion = null; // Versión más reciente del firmware
// Cargar versión de firmware del manifest
function loadLatestFirmwareVersion() {
fetch('/assets/flash/esp8266-manifest.json')
.then(r => r.json())
.then(data => {
latestFirmwareVersion = data.version || null;
console.log('Versión de firmware más reciente:', latestFirmwareVersion);
})
.catch(err => {
console.error('Error cargando versión de firmware:', err);
});
}
// Comparar versiones de firmware
function compareFirmwareVersion(deviceVersion) {
if (!latestFirmwareVersion || !deviceVersion) return 'unknown';
return deviceVersion === latestFirmwareVersion ? 'updated' : 'outdated';
}
// Íconos: se proveen globalmente desde layout.php (window.loadIcons / window.applyIcons)
// Fallback: si los SVG no cargaron aún, mostrar el texto también en XS
function ensureActionIcons(context) {
try {
const root = document.querySelector(context);
if (!root) return;
// Columna Acción (sm+)
root.querySelectorAll('#lista-dispositivos td:nth-last-child(2) a, #lista-dispositivos td:nth-last-child(2) button')
.forEach(btn => {
const icon = btn.querySelector('.icon');
const label = btn.querySelector('span:not(.icon)');
if (!(icon && icon.querySelector('svg')) && label) label.classList.remove('hidden');
});
// Toolbar móvil (XS) dentro de la primera celda
root.querySelectorAll('#lista-dispositivos td .sm\\:hidden a, #lista-dispositivos td .sm\\:hidden button')
.forEach(btn => {
const icon = btn.querySelector('.icon');
let srLabel = btn.querySelector('span.sr-only');
if (!(icon && icon.querySelector('svg'))) {
// Si no hay SVG, mostrar texto corto en lugar de sr-only
if (srLabel) srLabel.classList.remove('sr-only');
// Si no hay etiqueta, crear una abreviada
if (!srLabel) {
const abbr = document.createElement('span');
abbr.textContent = btn.getAttribute('aria-label') || 'Acción';
abbr.className = 'ml-1';
btn.appendChild(abbr);
}
}
});
} catch {}
}
// Botones de acciones del header
document.addEventListener('DOMContentLoaded', function(){
const btnCreateDevice = document.getElementById('pageCreateDevice');
if (btnCreateDevice) btnCreateDevice.addEventListener('click', function(){
if (window.openModal) window.openModal('modalCrearDispositivo');
});
const btnCreateSector = document.getElementById('pageCreateSector');
if (btnCreateSector) btnCreateSector.addEventListener('click', function(){
if (window.openModal) window.openModal('modalGestionSectores');
});
// Checkbox para ocultar inactivos
const hideOfflineCheckbox = document.getElementById('hideOfflineDevices');
if (hideOfflineCheckbox) {
// Restaurar estado desde localStorage
try {
const savedState = localStorage.getItem('devices_hideOffline');
if (savedState !== null) {
hideOfflineCheckbox.checked = savedState === 'true';
}
} catch(e) {}
// Event listener para filtrar dispositivos
hideOfflineCheckbox.addEventListener('change', function() {
// Guardar preferencia
try {
localStorage.setItem('devices_hideOffline', this.checked);
} catch(e) {}
// Aplicar filtro a las tarjetas existentes
filterDevices();
});
}
});
// Helper: aplica clases Tailwind destacadas al badge de estado (responsivo)
function applyEstadoClasses(badge, estado) {
if (!badge) return;
// Remover clases previas del badge
badge.classList.remove('bg-emerald-600', 'bg-gray-500', 'bg-amber-600', 'text-white', 'shadow-sm');
// Aplicar fondo al card según estado
const card = badge.closest('.device-card');
if (card) {
card.classList.remove('bg-white', 'bg-emerald-50', 'bg-gray-100');
if (estado === 'online') {
card.classList.add('bg-emerald-50');
} else if (estado === 'offline') {
card.classList.add('bg-gray-100');
} else {
card.classList.add('bg-white');
}
}
// Asegurar estructura interna del badge
let dot = badge.querySelector('.estado-dot');
if (!dot) {
dot = document.createElement('span');
dot.className = 'estado-dot inline-block w-3 h-3 md:w-2.5 md:h-2.5 rounded-full mr-1 ring-2 ring-white/60 shrink-0';
badge.insertBefore(dot, badge.firstChild);
}
let textSpan = badge.querySelector('.estado-text');
if (!textSpan) {
textSpan = document.createElement('span');
textSpan.className = 'estado-text';
badge.appendChild(textSpan);
}
// Colores del dot
const dotOnline = 'bg-emerald-200';
const dotOffline = 'bg-gray-500';
const dotUnknown = 'bg-amber-200';
dot.classList.remove('bg-emerald-200', 'bg-gray-500', 'bg-amber-200');
dot.classList.add(estado === 'online' ? dotOnline : (estado === 'offline' ? dotOffline : dotUnknown));
// Texto visible solo en sm+
textSpan.textContent = estado === 'online' ? 'Online' : (estado === 'offline' ? 'Offline' : 'Desconocido');
}
// Estados actuales por chipid para filtrado
const estadosDispositivos = {};
// Estados de pines en tiempo real
const pinesEstados = {};
// Helper: retorna clases y texto para estado al construir filas (evita repetición)
function estadoUI(estado) {
const st = String(estado || '').toLowerCase();
const badgeCls = (st === 'online')
? 'bg-emerald-600 text-white shadow-sm'
: (st === 'offline')
? 'bg-gray-500 text-white shadow-sm'
: 'bg-amber-600 text-white shadow-sm';
const dotCls = (st === 'online') ? 'bg-emerald-200' : (st === 'offline') ? 'bg-gray-500' : 'bg-amber-200';
const estadoTxt = (st === 'online') ? 'Online' : (st === 'offline' ? 'Offline' : 'Desconocido');
return { badgeCls, dotCls, estadoTxt };
}
function updateDispositivoEstado(chipid, estado) {
estadosDispositivos[chipid] = estado; // Guardar estado
const cards = document.querySelectorAll(`.device-card[data-chipid="${chipid}"]`);
cards.forEach(card => {
const badge = card.querySelector('.badge-estado');
if (badge) applyEstadoClasses(badge, String(estado || '').toLowerCase());
});
// Reaplicar filtro después de actualizar estado
filterDevices();
}
function updateDispositivoIP(chipid, ip) {
const cards = document.querySelectorAll(`.device-card[data-chipid="${chipid}"]`);
cards.forEach(card => {
const ipEl = card.querySelector('.device-ip');
if (ipEl) ipEl.textContent = ip || '-';
});
}
function updateDispositivoPines(chipid, pines) {
if (!pinesEstados[chipid]) pinesEstados[chipid] = {};
Object.assign(pinesEstados[chipid], pines);
renderPortsPreview(chipid); // Re-renderizar pines
}
// Generar badge de firmware
function getFirmwareBadge(deviceFirmware) {
const status = compareFirmwareVersion(deviceFirmware);
if (status === 'unknown') return '';
if (status === 'updated') {
return `<span class="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded bg-emerald-100 text-emerald-700 border border-emerald-300" title="Firmware actualizado: ${deviceFirmware}">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span>FW OK</span>
</span>`;
} else {
return `<span class="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded bg-amber-100 text-amber-700 border border-amber-300" title="Firmware desactualizado: ${deviceFirmware} (Disponible: ${latestFirmwareVersion})">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<span>Actualizar</span>
</span>`;
}
}
function cargarDispositivos() {
api.get('/api/get_dispositivos', { _: Date.now() }).then(data => {
let html = '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">';
for (const chipid in data) {
const d = data[chipid];
const chip = d.chipid || chipid;
const estado = d.online ? 'online' : 'offline';
estadosDispositivos[chip] = estado;
if (d.pines && typeof d.pines === 'object') {
pinesEstados[chip] = Object.assign(pinesEstados[chip] || {}, d.pines);
}
const { badgeCls, dotCls, estadoTxt } = estadoUI(estado);
const cardBgClass = (estado === 'online') ? 'bg-emerald-50' : (estado === 'offline') ? 'bg-gray-100' : 'bg-white';
html += `
<div class="device-card ${cardBgClass} rounded-xl shadow-sm border-2 border-slate-200 hover:shadow-lg hover:border-emerald-300 transition-all duration-300 p-5 relative overflow-hidden" data-chipid="${d.chipid}">
<!-- Barra lateral de estado -->
<div class="absolute left-0 top-0 bottom-0 w-1 ${estado === 'online' ? 'bg-emerald-500' : 'bg-slate-300'}"></div>
<!-- Header con Tag y Estado -->
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="text-lg font-semibold text-gray-900">${d.tag || 'Sin tag'}</h3>
${d.firmware ? getFirmwareBadge(d.firmware) : ''}
</div>
<p class="text-xs text-gray-500 mt-1"><code class="bg-gray-100 px-1.5 py-0.5 rounded">${d.chipid}</code></p>
</div>
<span class="badge-estado inline-flex items-center px-2.5 py-1 text-xs font-semibold rounded-full transition ${badgeCls}" title="${estadoTxt}">
<span class="estado-dot inline-block w-2.5 h-2.5 rounded-full mr-1.5 ring-2 ring-white/60 ${dotCls}"></span>
<span class="estado-text">${estadoTxt}</span>
</span>
</div>
<!-- Info del dispositivo -->
<div class="space-y-2 mb-4 text-sm">
<div class="flex items-center text-gray-600">
<span class="font-medium w-20">Modelo:</span>
<span class="text-gray-900">${d.nombre || '-'}</span>
</div>
<div class="flex items-center text-gray-600">
<span class="font-medium w-20">Ciudad:</span>
<span class="text-gray-900">${d.ciudad || '-'}</span>
</div>
<div class="flex items-center text-gray-600">
<span class="font-medium w-20">Sector:</span>
<span class="text-gray-900">${d.sector || '-'}</span>
</div>
<div class="flex items-center text-gray-600">
<span class="font-medium w-20">IP:</span>
<span class="text-gray-900 device-ip">${d.ip || '-'}</span>
</div>
</div>
<!-- Resumen de Puertos -->
<div class="mb-4 pb-3 border-b border-slate-200">
<div class="text-xs font-semibold text-slate-600 mb-2 uppercase">Estados</div>
<div id="ports-preview-${d.chipid}" class="flex flex-wrap gap-1.5">
<span class="text-xs text-slate-400">Cargando...</span>
</div>
</div>
<!-- Botones de acción -->
<div class="grid grid-cols-2 gap-2">
<a href="/dashboard?chipid=${d.chipid}" class="inline-flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium rounded-md border border-blue-600 text-blue-700 hover:bg-blue-50 transition">
<span class="icon icon-dashboard" aria-hidden="true"></span>
<span>Dashboard</span>
</a>
<button class="editar-dispositivo inline-flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium rounded-md border border-blue-600 text-blue-700 hover:bg-blue-50 transition" data-chipid="${d.chipid}">
<span class="icon icon-edit" aria-hidden="true"></span>
<span>Editar</span>
</button>
<a href="/puertos?chipid=${d.chipid}" class="inline-flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium rounded-md border border-amber-600 text-amber-700 hover:bg-amber-50 transition">
<span class="icon icon-puzzle" aria-hidden="true"></span>
<span>Puertos</span>
</a>
<button class="reiniciar-dispositivo inline-flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium rounded-md border border-red-600 text-red-700 hover:bg-red-50 transition" data-chipid="${d.chipid}">
<span class="icon icon-reboot" aria-hidden="true"></span>
<span>Reiniciar</span>
</button>
</div>
</div>`;
}
html += '</div>';
const listaEl = document.getElementById('lista-dispositivos');
if (listaEl) listaEl.innerHTML = html;
try { if (window.applyIcons) window.applyIcons('#lista-dispositivos'); } catch {}
ensureActionIcons('#lista-dispositivos');
// Aplicar filtro de ocultar offline si está activado
filterDevices();
// Cargar estados de puertos para cada dispositivo
for (const chipid in data) {
loadDevicePorts(chipid);
}
}).catch(err => {
console.error('Error cargando dispositivos:', err);
mostrarToast('Error al cargar dispositivos', { type: 'danger' });
});
}
// Función para filtrar dispositivos offline
function filterDevices() {
const hideOffline = document.getElementById('hideOfflineDevices')?.checked || false;
const deviceCards = document.querySelectorAll('.device-card');
deviceCards.forEach(card => {
const chipid = card.dataset.chipid;
const estado = estadosDispositivos[chipid] || 'offline'; // Usar estado actual o offline por defecto
if (hideOffline && estado === 'offline') {
card.style.display = 'none';
} else {
card.style.display = '';
}
});
}
// Cargar estados de puertos de un dispositivo
function loadDevicePorts(chipid) {
api.get('/api/get_puertos', { chipid: chipid })
.then(data => {
if (data && !data.error) {
puertosDispositivos[chipid] = data;
renderPortsPreview(chipid);
}
})
.catch(err => {
console.error(`Error cargando puertos de ${chipid}:`, err);
});
}
// Renderizar preview de puertos en la tarjeta
function renderPortsPreview(chipid) {
const container = document.getElementById(`ports-preview-${chipid}`);
if (!container) return;
const ports = puertosDispositivos[chipid];
if (!ports) {
container.innerHTML = '<span class="text-xs text-slate-400">Sin datos</span>';
return;
}
let html = '';
let count = 0;
const maxShow = 6; // Máximo de puertos a mostrar
for (let pin in ports) {
if (pin === 'update') continue;
if (count >= maxShow) break;
const p = ports[pin];
if (p.modo === 'OUTPUT' && p.disp) {
const state = (pinesEstados[chipid] && pinesEstados[chipid][pin]) || 'OFF';
const isOn = (String(state).toUpperCase() === 'ON');
html += `
<div class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium ${isOn ? 'bg-emerald-100 text-emerald-700 border border-emerald-300' : 'bg-red-50 text-red-700 border border-red-200'}" title="${p.disp} - ${pin} (${isOn ? 'ON' : 'OFF'})">
<div class="w-1.5 h-1.5 rounded-full ${isOn ? 'bg-emerald-500' : 'bg-red-500'}"></div>
<span class="truncate max-w-[70px]">${p.disp}</span>
<span class="font-mono text-[10px] opacity-75">${pin}</span>
</div>`;
count++;
}
}
if (html === '') {
html = '<span class="text-xs text-slate-400">Sin salidas</span>';
}
container.innerHTML = html;
}
document.addEventListener('click', function(e) {
const editBtn = e.target.closest('.editar-dispositivo');
if (!editBtn) return;
const chipid = editBtn.dataset.chipid;
api.get('/api/get_dispositivos').then(data => {
const d = data[chipid];
if (!d) return alert("Dispositivo no encontrado");
const form = document.getElementById('form-edit');
if (form) {
const fields = {
chipid: d.chipid || '',
tag: d.tag || '',
nombre: d.nombre || '',
ciudad: d.ciudad || '',
sector: d.sector || ''
};
Object.keys(fields).forEach(key => {
const input = form.querySelector(`[name="${key}"]`);
if (input) input.value = fields[key];
});
window.openModal('modalEditarDispositivo');
}
}).catch(err => {
console.error('Error cargando dispositivo:', err);
mostrarToast('Error al cargar datos del dispositivo', { type: 'danger' });
});
});
const formEdit = document.getElementById('form-edit');
if (formEdit) {
formEdit.addEventListener('submit', function (e) {
e.preventDefault();
const formData = new FormData(this);
const editado = {};
formData.forEach((value, key) => editado[key] = value);
console.log('Enviando datos al servidor:', editado);
// Mostrar indicador de carga
const submitBtn = this.querySelector('button[type="submit"]');
const originalBtnText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="inline-block animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full"></span> Procesando...';
api.postJSON('/api/edit_dispositivo', editado)
.then(resp => {
console.log('Respuesta del servidor:', resp);
if (resp && resp.success) {
window.closeModal('modalEditarDispositivo');
cargarDispositivos();
mostrarToast('Dispositivo actualizado correctamente', { type: 'success' });
} else {
const errorMsg = resp && resp.msg ? resp.msg : 'Error desconocido al editar el dispositivo';
console.error('Error en la respuesta:', errorMsg);
mostrarToast(errorMsg, { type: 'danger' });
}
})
.catch(err => {
console.error('Error en la petición:', err);
const errorMsg = err.data?.msg || err.data?.message || err.message || 'Error al comunicarse con el servidor';
mostrarToast(errorMsg, { type: 'danger' });
})
.finally(() => {
// Restaurar el botón
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnText;
});
});
}
// ==================== Sectores ====================
// Cargar sectores al iniciar
function cargarSectores() {
api.get('/api/get_sectores').then(data => {
let html = '';
data.forEach(sector => {
html += `
<li class="flex items-center justify-between px-3 py-2">
<span class="sector-nombre text-sm text-gray-800">${sector}</span>
<div class="flex items-center gap-2">
<button class="editar-sector inline-flex items-center px-2.5 py-1 text-xs font-medium rounded border border-amber-600 text-amber-700 hover:bg-amber-50" data-sector="${sector}">✏️ <span class="hidden sm:inline">Editar</span></button>
<button class="eliminar-sector inline-flex items-center px-2.5 py-1 text-xs font-medium rounded border border-red-600 text-red-700 hover:bg-red-50" data-sector="${sector}">🗑️ <span class="hidden sm:inline">Eliminar</span></button>
</div>
</li>`;
});
const listaEl = document.getElementById('lista-sectores');
if (listaEl) listaEl.innerHTML = html;
}).catch(err => {
console.error('Error cargando sectores:', err);
mostrarToast('Error al cargar sectores', { type: 'danger' });
});
}
// Manejo de la edición de sectores
document.addEventListener('click', function(e) {
const editBtn = e.target.closest('.editar-sector');
if (!editBtn) return;
const sector = editBtn.dataset.sector;
const li = editBtn.closest('li');
const span = li.querySelector('.sector-nombre');
const nombreOriginal = span.textContent;
// Cambiar el nombre a un campo de texto editable
span.innerHTML = `<input type="text" class="w-full px-2 py-1 border border-gray-300 rounded text-sm focus:outline-none focus:ring focus:ring-blue-200" value="${nombreOriginal}" />`;
// Cambiar el texto del botón de editar a guardar
editBtn.innerHTML = '💾 <span class="hidden sm:inline">Guardar</span>';
editBtn.classList.remove('border-amber-600', 'text-amber-700', 'hover:bg-amber-50');
editBtn.classList.add('border-emerald-600', 'text-emerald-700', 'hover:bg-emerald-50');
// Cambiar el manejador para guardar
const saveHandler = function(evt) {
evt.stopPropagation();
const input = li.querySelector('input');
const nuevoNombre = input ? input.value : '';
if (nuevoNombre !== nombreOriginal) {
if (nuevoNombre.trim() === "") {
mostrarToast("El nombre del sector no puede estar vacío.", { type: 'danger' });
return;
}
api.postJSON('/api/update_sector', {
old_sector: nombreOriginal,
new_sector: nuevoNombre
}).then(resp => {
console.log(resp);
if (resp.success) {
cargarSectores();
mostrarToast('Sector actualizado correctamente', { type: 'success' });
} else {
mostrarToast(resp.msg || 'Error al actualizar sector', { type: 'danger' });
}
}).catch(err => {
console.error("Error en la actualización del sector:", err);
mostrarToast("Hubo un error al intentar actualizar el sector.", { type: 'danger' });
});
} else {
cargarSectores();
}
editBtn.removeEventListener('click', saveHandler);
};
editBtn.addEventListener('click', saveHandler);
});
// Manejo de la eliminación de sectores con modal de confirmación
let sectorPendienteEliminar = null;
document.addEventListener('click', function(e) {
const deleteBtn = e.target.closest('.eliminar-sector');
if (!deleteBtn) return;
sectorPendienteEliminar = deleteBtn.dataset.sector;
const sectorNameEl = document.getElementById('sectorAEliminar');
if (sectorNameEl) sectorNameEl.textContent = sectorPendienteEliminar;
if (window.openModal) window.openModal('modalConfirmEliminarSector');
});
const confirmDelBtn = document.getElementById('confirmDeleteSectorBtn');
if (confirmDelBtn) {
confirmDelBtn.addEventListener('click', function(){
if (!sectorPendienteEliminar) return;
api.postJSON('/api/delete_sector', { sector: sectorPendienteEliminar })
.then(resp => {
if (resp.success) {
mostrarToast('Sector eliminado', { type: 'success' });
cargarSectores();
} else {
mostrarToast(resp.msg || 'No se pudo eliminar', { type: 'danger' });
}
})
.catch(err => {
console.error('Error eliminando sector:', err);
mostrarToast('Error al eliminar sector', { type: 'danger' });
})
.finally(() => {
if (window.closeModal) window.closeModal('modalConfirmEliminarSector');
sectorPendienteEliminar = null;
});
});
}
// Agregar un sector
const formSector = document.getElementById('form-sector');
if (formSector) {
formSector.addEventListener('submit', function (e) {
e.preventDefault();
const formData = new FormData(this);
const datos = {};
formData.forEach((value, key) => datos[key] = value);
api.postJSON('/api/add_sector', datos)
.then(resp => {
if (resp.success) {
this.reset();
cargarSectores();
mostrarToast('Sector agregado correctamente', { type: 'success' });
} else {
mostrarToast(resp.msg || 'Error al agregar sector', { type: 'danger' });
}
})
.catch(err => {
console.error('Error:', err);
mostrarToast('Error al agregar sector', { type: 'danger' });
});
});
}
// Cargar sectores en los combos de los modales (Crear y Editar Dispositivo)
function cargarSectoresEnSelectModal() {
api.get('/api/get_sectores').then(data => {
let html = '<option value="">Seleccione un sector</option>';
data.forEach(sector => {
html += `<option value="${sector}">${sector}</option>`;
});
const selects = document.querySelectorAll('#modalEditarDispositivo select[name="sector"], #modalCrearDispositivo select[name="sector"]');
selects.forEach(select => select.innerHTML = html);
}).catch(err => {
console.error('Error cargando sectores:', err);
});
}
// Al abrir el modal de creación (legacy bootstrap hook removido). Usar hook global.
document.addEventListener('click', function(e) {
const rebootBtn = e.target.closest('.reiniciar-dispositivo');
if (!rebootBtn) return;
const chipid = rebootBtn.dataset.chipid;
if (!chipid) return mostrarToast('No se encontró el ChipID.');
if (!confirm(`¿Confirmas reiniciar el dispositivo ${chipid}?`)) return;
const topic = `dispositivo/${chipid}/comando/reboot`;
const payload = '1';
const publisher = window.mqttRealtime
? window.mqttRealtime.publish(topic, payload)
: api.postJSON('/api/mqtt_publish', { topic, payload });
publisher
.then(resp => {
mostrarToast(`Comando de reinicio enviado al dispositivo ${chipid}.`);
rebootBtn.disabled = true;
})
.catch(err => {
mostrarToast('Error al enviar el comando de reinicio.', { type: 'danger' });
console.error(err);
});
});
// Inicialización
document.addEventListener('DOMContentLoaded', function() {
loadLatestFirmwareVersion();
cargarDispositivos();
cargarSectores();
cargarSectoresEnSelectModal();
if (window.mqttRealtime) {
window.mqttRealtime.onMessage(function(evt) {
if (evt.type === 'state') updateDispositivoEstado(evt.chipid, evt.estado);
if (evt.type === 'ip') updateDispositivoIP(evt.chipid, evt.ip);
if (evt.type === 'pin') updateDispositivoPines(evt.chipid, { [evt.alias]: evt.valor });
});
}
});
</script>
<?php
$content = ob_get_clean();
// Si no se está renderizando como parcial, incluir el layout principal
if (!defined('RENDER_PARTIAL')) {
include __DIR__ . '/../layouts/layout.php';
}