Files
Monitor-de-Viales/configurador_monitor_v11.html
yamaray fcefd22334 Subir archivos a "/"
Primera version estable para monitorear viales en diferentes planta. Cada planta se configura con el archivo configurador:monitor
2026-06-04 17:43:08 +00:00

1081 lines
36 KiB
HTML

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Configurador JSON v11 - Monitor de viales</title>
<style>
:root {
--bg: #0f172a;
--bg2: #12243a;
--panel: #172033;
--panel2: #111827;
--line: #334155;
--line2: #38bdf8;
--text: #e5edf7;
--muted: #94a3b8;
--accent: #f59e0b;
--ok: #22c55e;
--bad: #ef4444;
--input: #0b1220;
--button: #1e3a5f;
--button2: #1f2937;
}
body.light {
--bg: #eef3f8;
--bg2: #d8e6f2;
--panel: #ffffff;
--panel2: #f8fafc;
--line: #cbd5e1;
--line2: #0284c7;
--text: #0f172a;
--muted: #475569;
--accent: #b45309;
--ok: #15803d;
--bad: #b91c1c;
--input: #ffffff;
--button: #2563eb;
--button2: #e2e8f0;
}
body[data-style="salvia"] {
--bg: #101b18;
--bg2: #182721;
--panel: #17241f;
--panel2: #1d3029;
--line: #36564c;
--line2: #7bc4a8;
--text: #edf8f2;
--muted: #9ebaae;
--accent: #8fd6b8;
--button: #21463b;
--button2: #1a3029;
}
body.light[data-style="salvia"] {
--bg: #eef7f1;
--bg2: #dfeee6;
--panel: #fbfffc;
--panel2: #eef8f2;
--line: #bdd6ca;
--line2: #4f9d7e;
--text: #13251f;
--muted: #547067;
--accent: #2f8f6f;
--button: #2f8f6f;
--button2: #e3f2ea;
}
body[data-style="bruma"] {
--bg: #101821;
--bg2: #192735;
--panel: #172331;
--panel2: #1d3041;
--line: #344f63;
--line2: #92b7c9;
--text: #edf5f8;
--muted: #9fb2bd;
--accent: #a8cfe0;
--button: #243f52;
--button2: #1b2d3a;
}
body.light[data-style="bruma"] {
--bg: #f1f6f8;
--bg2: #ddeaf0;
--panel: #ffffff;
--panel2: #f3f8fb;
--line: #c5d6df;
--line2: #6d9ab0;
--text: #14232d;
--muted: #5c707b;
--accent: #477f98;
--button: #477f98;
--button2: #e7f1f6;
}
body[data-style="calma"] {
--bg: #181716;
--bg2: #27231f;
--panel: #22201d;
--panel2: #302b26;
--line: #574b40;
--line2: #d2b48c;
--text: #f7f0e8;
--muted: #c4b6a6;
--accent: #e6c79c;
--button: #49392b;
--button2: #2e261f;
}
body.light[data-style="calma"] {
--bg: #f7f4ef;
--bg2: #ebe3d7;
--panel: #fffdf9;
--panel2: #f7f0e6;
--line: #d8c8b5;
--line2: #a47c54;
--text: #2a241e;
--muted: #756757;
--accent: #9a6a3d;
--button: #9a6a3d;
--button2: #efe4d5;
}
body[data-style="selva"] {
--bg: #071611;
--bg2: #0f2a1f;
--panel: #10251d;
--panel2: #17372a;
--line: #285f49;
--line2: #34d399;
--text: #edfff6;
--muted: #a5cdbd;
--accent: #5ee0aa;
--button: #14533f;
--button2: #0e2f25;
}
body.light[data-style="selva"] {
--bg: #ecfdf3;
--bg2: #d4f5e2;
--panel: #fbfffc;
--panel2: #eaf9f0;
--line: #a7d8bf;
--line2: #10b981;
--text: #0f281f;
--muted: #4d6f61;
--accent: #047857;
--button: #047857;
--button2: #d8f3e5;
}
body[data-style="aurora"] {
--bg: #1b1118;
--bg2: #2a1724;
--panel: #241620;
--panel2: #341d2c;
--line: #614258;
--line2: #f0a6c8;
--text: #fff1f7;
--muted: #d2a9bc;
--accent: #f2b6d0;
--button: #553044;
--button2: #33202c;
}
body.light[data-style="aurora"] {
--bg: #fff1f6;
--bg2: #f8dce8;
--panel: #fffafd;
--panel2: #fbedf3;
--line: #e5b9cc;
--line2: #d96c9f;
--text: #321d29;
--muted: #7a5a69;
--accent: #b83273;
--button: #b83273;
--button2: #f5d8e5;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", Roboto, Arial, sans-serif;
background: linear-gradient(135deg, var(--bg), var(--bg2));
color: var(--text);
}
.page {
width: min(1500px, calc(100% - 28px));
margin: 0 auto;
padding: 16px 0 24px;
}
.topbar, .panel {
border: 1px solid var(--line);
border-radius: 14px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: 0 14px 34px rgba(0,0,0,0.28);
}
.topbar {
display: grid;
grid-template-columns: minmax(280px, 1fr) auto;
gap: 14px;
align-items: center;
padding: 16px;
margin-bottom: 14px;
position: relative;
z-index: 50;
}
h1 {
margin: 0;
font-size: 1.35rem;
letter-spacing: 0.02em;
}
.subtitle {
margin-top: 4px;
color: var(--muted);
font-size: 0.88rem;
}
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
}
button {
height: 36px;
border: 1px solid var(--line);
border-radius: 9px;
padding: 0 12px;
background: var(--button);
color: var(--text);
font-weight: 800;
cursor: pointer;
white-space: nowrap;
}
button:hover { border-color: var(--line2); }
button.secondary { background: var(--button2); }
body.light button.secondary { color: #0f172a; }
button.danger { background: rgba(127,29,29,0.75); }
button.small { height: 30px; padding: 0 9px; font-size: 0.78rem; }
.iconBtn {
width: 38px;
min-width: 38px;
padding: 0;
display: inline-grid;
place-items: center;
border-radius: 10px;
font-size: 1rem;
}
.styleTools { position: relative; display: inline-flex; gap: 8px; }
.styleMenu {
position: absolute;
top: 43px;
left: 0;
z-index: 5000;
width: 200px;
display: none;
gap: 7px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 14px;
background: color-mix(in srgb, var(--panel) 96%, transparent);
box-shadow: 0 16px 34px rgba(0,0,0,0.24);
}
.styleMenu.open { display: grid; }
.styleOption { width: 100%; text-align: left; }
.styleOption.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 18%, var(--button));
}
input, select, textarea {
width: 100%;
min-width: 0;
height: 36px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0 10px;
background: var(--input);
color: var(--text);
outline: none;
font: inherit;
}
input:focus, select:focus, textarea:focus {
border-color: var(--line2);
box-shadow: 0 0 0 3px rgba(56,189,248,0.14);
}
textarea {
height: 88px;
padding: 9px 10px;
resize: vertical;
line-height: 1.45;
}
label {
display: block;
margin-bottom: 5px;
color: var(--muted);
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.03em;
}
.grid {
display: grid;
grid-template-columns: 310px minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.panel { padding: 14px; }
.panelTitle {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
}
.panelTitle h2 {
margin: 0;
color: var(--accent);
font-size: 1rem;
letter-spacing: 0.08em;
}
.plantList {
display: grid;
gap: 8px;
}
.plantBtn {
width: 100%;
height: auto;
min-height: 48px;
text-align: left;
background: color-mix(in srgb, var(--panel2) 72%, transparent);
}
.plantBtn.active {
border-color: var(--accent);
background: rgba(245,158,11,0.14);
}
.plantBtn span:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 24px;
padding: 0 8px;
border-radius: 999px;
background: rgba(56,189,248,0.14);
color: #bae6fd;
font-size: 0.78rem;
font-weight: 900;
}
body.light .badge { color: #075985; }
.formGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.wide2 { grid-column: span 2; }
.wide4 { grid-column: 1 / -1; }
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 8px;
overflow: hidden;
border-radius: 10px;
}
th, td {
border-bottom: 1px solid var(--line);
padding: 8px;
vertical-align: middle;
background: color-mix(in srgb, var(--panel2) 70%, transparent);
}
th {
color: var(--muted);
font-size: 0.74rem;
text-align: left;
letter-spacing: 0.04em;
}
td.actions {
width: 90px;
text-align: right;
}
.sectionHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 14px;
margin-bottom: 6px;
}
.sectionHead h3 {
margin: 0;
font-size: 0.9rem;
letter-spacing: 0.06em;
color: #cbd5e1;
}
.status {
min-height: 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
margin-top: 12px;
border: 1px solid var(--line);
border-radius: 10px;
color: var(--muted);
background: color-mix(in srgb, var(--panel2) 72%, transparent);
font-size: 0.86rem;
}
.status.ok { color: #bbf7d0; border-color: rgba(34,197,94,0.45); }
.status.bad { color: #fecaca; border-color: rgba(239,68,68,0.55); }
.jsonPreview {
height: 360px;
font-family: Consolas, "Courier New", monospace;
font-size: 0.82rem;
white-space: pre;
}
.fileInput { display: none; }
@media (max-width: 1100px) {
.topbar, .grid, .formGrid { grid-template-columns: 1fr; }
.wide2, .wide4 { grid-column: auto; }
.toolbar { justify-content: flex-start; }
}
</style>
</head>
<body>
<input id="fileInput" class="fileInput" type="file" accept="application/json,.json" />
<div class="page">
<header class="topbar">
<div>
<h1>Configurador JSON v11</h1>
<div class="subtitle">Editor de plantas, viales, tótems, tablas y tarjetas para el monitor.</div>
</div>
<div class="toolbar">
<div class="styleTools">
<button id="styleBtn" class="secondary iconBtn" type="button" title="Cambiar estilo" aria-label="Cambiar estilo"></button>
<div id="styleMenu" class="styleMenu" aria-label="Selector de estilo">
<button class="styleOption" type="button" data-style-option="base">Azul sereno</button>
<button class="styleOption" type="button" data-style-option="salvia">Salvia técnica</button>
<button class="styleOption" type="button" data-style-option="bruma">Bruma gris</button>
<button class="styleOption" type="button" data-style-option="calma">Calma cálida</button>
<button class="styleOption" type="button" data-style-option="selva">Selva tropical</button>
<button class="styleOption" type="button" data-style-option="aurora">Aurora rosa</button>
</div>
<button id="themeBtn" class="secondary iconBtn" type="button" title="Cambiar claro/oscuro" aria-label="Cambiar claro/oscuro"></button>
</div>
<button id="loadFileBtn" type="button">Cargar configuración JSON</button>
<button id="downloadBtn" type="button">Exportar configuración JSON</button>
<button id="copyBtn" class="secondary" type="button">Copiar JSON</button>
<button id="resetBtn" class="danger" type="button">Restaurar base</button>
</div>
</header>
<main class="grid">
<aside class="panel">
<div class="panelTitle">
<h2>PLANTAS</h2>
<button id="addPlantBtn" class="small" type="button">Añadir</button>
</div>
<div id="plantList" class="plantList"></div>
</aside>
<section class="panel">
<div class="panelTitle">
<h2>DATOS DE PLANTA</h2>
<button id="deletePlantBtn" class="small danger" type="button">Eliminar planta</button>
</div>
<div class="formGrid">
<div>
<label for="plantName">Nombre planta</label>
<input id="plantName" autocomplete="off" />
</div>
<div class="wide2">
<label for="plantGuid">IdCentroProveedor / GUID</label>
<input id="plantGuid" autocomplete="off" />
</div>
<div>
<label for="plantVials">Viales</label>
<input id="plantVials" type="number" min="1" step="1" />
</div>
<div>
<label for="supervisorName">Nombre supervisor</label>
<input id="supervisorName" autocomplete="off" />
</div>
<div class="wide2">
<label for="websocketUrl">WebSocket supervisor</label>
<input id="websocketUrl" autocomplete="off" />
</div>
<div>
<label for="initialPlant">Planta inicial</label>
<select id="initialPlant"></select>
</div>
<div class="wide4">
<label for="plantTables">Tablas de esta planta, una por línea</label>
<textarea id="plantTables" spellcheck="false"></textarea>
</div>
</div>
<div class="sectionHead">
<h3>TÓTEMS DE LA PLANTA</h3>
<button id="addTotemBtn" class="small" type="button">Añadir tótem</button>
</div>
<table>
<thead>
<tr>
<th style="width: 16%;">ID</th>
<th style="width: 16%;">Nombre</th>
<th style="width: 20%;">IP</th>
<th>URL pantalla</th>
<th style="width: 92px;"></th>
</tr>
</thead>
<tbody id="totemRows"></tbody>
</table>
<div class="sectionHead">
<h3>TARJETAS INICIALES</h3>
</div>
<table>
<thead>
<tr>
<th style="width: 20%;">Tarjeta</th>
<th style="width: 14%;">Vial</th>
<th>Tótem 1</th>
<th>Tótem 2</th>
<th style="width: 18%;">Pantalla</th>
</tr>
</thead>
<tbody id="cardRows"></tbody>
</table>
<div class="sectionHead">
<h3>JSON GENERADO</h3>
<button id="refreshPreviewBtn" class="small secondary" type="button">Actualizar vista</button>
</div>
<textarea id="jsonPreview" class="jsonPreview" spellcheck="false" readonly></textarea>
<div id="status" class="status">Listo.</div>
</section>
</main>
</div>
<script>
const BASE_CONFIG = {
"version": 10,
"plantaInicial": "02C9381D-D4A0-4503-B9A4-890BFABD9608",
"plantas": [
{
"nombre": "CANTABRIA",
"idCentroProveedor": "02C9381D-D4A0-4503-B9A4-890BFABD9608",
"supervisor": {
"nombre": "Supervisor LOCAL",
"websocketUrl": "ws://127.0.0.1:25009/"
},
"viales": 4,
"totems": [
{ "id": "totem_1", "nombre": "entrada", "ip": "192.168.0.29", "url": "http://192.168.0.29:1880/ui?monitor=1" },
{ "id": "totem_2", "nombre": "salida", "ip": "127.0.0.1:25054", "url": "http://127.0.0.1:25054/ui?monitor=1" },
{ "id": "totem_3", "nombre": "entrada", "ip": "127.0.0.1:25059", "url": "http://127.0.0.1:25059/ui?monitor=1" },
{ "id": "totem_4", "nombre": "salida", "ip": "192.168.0.29", "url": "http://192.168.0.29:1880/ui?monitor=1" },
{ "id": "totem_5", "nombre": "entrada", "ip": "192.168.0.29", "url": "http://192.168.0.29:1880/ui?monitor=1" }
],
"tablas": ["Matriculas", "Identificadores", "MatriculasIdentificadores", "UbicacionesInternas"]
},
{
"nombre": "ZARAGOZA",
"idCentroProveedor": "02C9381D-D4A0-4503-B9A4-890BFABD9608",
"supervisor": {
"nombre": "Supervisor",
"websocketUrl": "ws://127.0.0.1:25009/"
},
"viales": 3,
"totems": [
{ "id": "totem_1", "nombre": "entrada", "ip": "192.168.0.29", "url": "http://192.168.0.29:1880/ui?monitor=1" },
{ "id": "totem_2", "nombre": "salida", "ip": "127.0.0.1:25054", "url": "http://127.0.0.1:25054/ui?monitor=1" },
{ "id": "totem_3", "nombre": "entrada", "ip": "127.0.0.1:25059", "url": "http://127.0.0.1:25059/ui?monitor=1" },
{ "id": "totem_4", "nombre": "salida", "ip": "192.168.0.29", "url": "http://192.168.0.29:1880/ui?monitor=1" }
],
"tablas": ["Matriculas", "Identificadores", "MatriculasIdentificadores", "UbicacionesInternas"]
}
],
"pantalla": {
"tarjetas": [
{ "id": "card_1", "vialNumero": 1, "totemId": "totem_1", "totemScreenEnabled": true },
{ "id": "card_2", "vialNumero": 2, "totemId": "totem_2", "totemScreenEnabled": true },
{ "id": "card_3", "vialNumero": 3, "totemIds": ["totem_3", "totem_4"], "totemScreenEnabled": true }
]
}
};
const $ = selector => document.querySelector(selector);
const els = {
fileInput: $('#fileInput'),
styleBtn: $('#styleBtn'),
styleMenu: $('#styleMenu'),
themeBtn: $('#themeBtn'),
loadFileBtn: $('#loadFileBtn'),
downloadBtn: $('#downloadBtn'),
copyBtn: $('#copyBtn'),
resetBtn: $('#resetBtn'),
addPlantBtn: $('#addPlantBtn'),
deletePlantBtn: $('#deletePlantBtn'),
plantList: $('#plantList'),
plantName: $('#plantName'),
plantGuid: $('#plantGuid'),
plantVials: $('#plantVials'),
supervisorName: $('#supervisorName'),
websocketUrl: $('#websocketUrl'),
initialPlant: $('#initialPlant'),
plantTables: $('#plantTables'),
addTotemBtn: $('#addTotemBtn'),
totemRows: $('#totemRows'),
cardRows: $('#cardRows'),
jsonPreview: $('#jsonPreview'),
refreshPreviewBtn: $('#refreshPreviewBtn'),
status: $('#status')
};
let config = clone(BASE_CONFIG);
let selectedPlantIndex = 0;
let theme = localStorage.getItem('configuradorMonitorV11.theme') || 'dark';
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function setStatus(message, type = '') {
els.status.textContent = message;
els.status.className = 'status' + (type ? ' ' + type : '');
}
const VISUAL_STYLES = new Set(['base', 'salvia', 'bruma', 'calma', 'selva', 'aurora']);
function applyVisualStyle(style) {
const next = VISUAL_STYLES.has(style) ? style : 'base';
document.body.dataset.style = next;
localStorage.setItem('configuradorMonitorV11.visualStyle', next);
document.querySelectorAll('[data-style-option]').forEach(btn => {
const active = btn.dataset.styleOption === next;
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
function applyTheme(nextTheme) {
theme = nextTheme === 'light' ? 'light' : 'dark';
document.body.classList.toggle('light', theme === 'light');
els.themeBtn.textContent = theme === 'light' ? '☾' : '☼';
els.themeBtn.title = theme === 'light' ? 'Cambiar a modo oscuro' : 'Cambiar a modo claro';
els.themeBtn.setAttribute('aria-label', els.themeBtn.title);
localStorage.setItem('configuradorMonitorV11.theme', theme);
}
function plantKey(plant, index) {
return `${plant.nombre || 'PLANTA'}__${plant.idCentroProveedor || ''}__${index}`;
}
function selectedPlant() {
return config.plantas[selectedPlantIndex] || null;
}
function normalizeConfig(raw) {
const cfg = raw && typeof raw === 'object' ? raw : {};
const plants = Array.isArray(cfg.plantas) ? cfg.plantas : [];
return {
version: 10,
plantaInicial: String(cfg.plantaInicial || plants[0]?.idCentroProveedor || '').trim(),
plantas: plants.map((plant, index) => ({
nombre: String(plant.nombre || `PLANTA ${index + 1}`).trim(),
idCentroProveedor: String(plant.idCentroProveedor || '').trim(),
supervisor: {
nombre: String(plant.supervisor?.nombre || '').trim(),
websocketUrl: String(plant.supervisor?.websocketUrl || '').trim()
},
viales: Math.max(1, Number(plant.viales) || 1),
totems: Array.isArray(plant.totems) ? plant.totems.map((totem, totemIndex) => ({
id: String(totem.id || `totem_${totemIndex + 1}`).trim(),
nombre: String(totem.nombre || '').trim(),
ip: String(totem.ip || '').trim(),
url: String(totem.url || '').trim()
})) : [],
tablas: Array.isArray(plant.tablas) ? plant.tablas.map(String).filter(Boolean) : []
})),
pantalla: {
tarjetas: Array.isArray(cfg.pantalla?.tarjetas) ? cfg.pantalla.tarjetas.map((card, index) => ({
id: String(card.id || `card_${index + 1}`).trim(),
vialNumero: String(card.vialNumero ?? '').trim() === '' ? '' : Math.max(1, Number(card.vialNumero) || 1),
totemScreenEnabled: card.totemScreenEnabled !== false,
...(Array.isArray(card.totemIds)
? { totemIds: card.totemIds.map(String).filter(Boolean) }
: { totemId: String(card.totemId || '').trim() })
})) : clone(BASE_CONFIG.pantalla.tarjetas)
}
};
}
function buildDerivedConfig() {
const cfg = normalizeConfig(config);
const firstPlant = cfg.plantas[0];
cfg.websocketUrls = cfg.plantas
.filter(plant => plant.supervisor.websocketUrl)
.map(plant => ({ nombre: plant.supervisor.nombre || plant.nombre, url: plant.supervisor.websocketUrl }));
cfg.centrosProveedor = cfg.plantas
.filter(plant => plant.idCentroProveedor)
.map(plant => ({ nombre: plant.nombre, id: plant.idCentroProveedor }));
cfg.totemUrls = firstPlant
? firstPlant.totems.filter(totem => totem.url).map(totem => ({
nombre: `${totem.id}${totem.nombre ? ' - ' + totem.nombre : ''}`,
url: totem.url
}))
: [];
cfg.tablas = firstPlant ? [...firstPlant.tablas] : [];
return cfg;
}
function renderPlantList() {
els.plantList.innerHTML = '';
config.plantas.forEach((plant, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'plantBtn' + (index === selectedPlantIndex ? ' active' : '');
button.innerHTML = `<span>${plant.nombre || 'Sin nombre'}</span><span class="badge">${plant.viales || 1}</span>`;
button.addEventListener('click', () => {
savePlantForm();
selectedPlantIndex = index;
renderAll();
});
els.plantList.appendChild(button);
});
}
function renderInitialPlantSelect() {
const current = config.plantaInicial || config.plantas[0]?.idCentroProveedor || '';
els.initialPlant.innerHTML = '';
config.plantas.forEach((plant, index) => {
const option = document.createElement('option');
option.value = String(index);
option.textContent = `${plant.nombre || 'PLANTA'} - ${plant.idCentroProveedor || 'sin GUID'}`;
els.initialPlant.appendChild(option);
});
const match = config.plantas.findIndex(plant => plant.idCentroProveedor === current);
els.initialPlant.value = String(match >= 0 ? match : 0);
}
function renderPlantForm() {
const plant = selectedPlant();
if (!plant) return;
els.plantName.value = plant.nombre || '';
els.plantGuid.value = plant.idCentroProveedor || '';
els.plantVials.value = plant.viales || 1;
els.supervisorName.value = plant.supervisor?.nombre || '';
els.websocketUrl.value = plant.supervisor?.websocketUrl || '';
els.plantTables.value = (plant.tablas || []).join('\n');
renderInitialPlantSelect();
renderTotems();
renderCards();
}
function savePlantForm() {
const plant = selectedPlant();
if (!plant) return;
plant.nombre = els.plantName.value.trim();
plant.idCentroProveedor = els.plantGuid.value.trim();
plant.viales = Math.max(1, Number(els.plantVials.value) || 1);
plant.supervisor = {
nombre: els.supervisorName.value.trim(),
websocketUrl: els.websocketUrl.value.trim()
};
plant.tablas = els.plantTables.value.split(/\r?\n/).map(x => x.trim()).filter(Boolean);
const initial = config.plantas[Number(els.initialPlant.value)] || config.plantas[0];
config.plantaInicial = initial ? initial.idCentroProveedor : '';
}
function renderTotems() {
const plant = selectedPlant();
els.totemRows.innerHTML = '';
(plant.totems || []).forEach((totem, index) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input data-totem-field="id" data-index="${index}" value="${escapeAttr(totem.id)}" /></td>
<td><input data-totem-field="nombre" data-index="${index}" value="${escapeAttr(totem.nombre)}" /></td>
<td><input data-totem-field="ip" data-index="${index}" value="${escapeAttr(totem.ip)}" /></td>
<td><input data-totem-field="url" data-index="${index}" value="${escapeAttr(totem.url)}" /></td>
<td class="actions"><button class="small danger" type="button" data-delete-totem="${index}">Eliminar</button></td>
`;
els.totemRows.appendChild(tr);
});
els.totemRows.querySelectorAll('input').forEach(input => input.addEventListener('input', updateTotemFromInput));
els.totemRows.querySelectorAll('[data-delete-totem]').forEach(button => {
button.addEventListener('click', () => {
plant.totems.splice(Number(button.dataset.deleteTotem), 1);
renderTotems();
renderCards();
updatePreview();
});
});
}
function updateTotemFromInput(event) {
const plant = selectedPlant();
const index = Number(event.target.dataset.index);
const field = event.target.dataset.totemField;
if (!plant || !plant.totems[index]) return;
plant.totems[index][field] = event.target.value.trim();
renderCards();
updatePreview();
}
function renderCards() {
const plant = selectedPlant();
const totems = plant ? plant.totems : [];
els.cardRows.innerHTML = '';
config.pantalla.tarjetas.forEach((card, index) => {
const ids = Array.isArray(card.totemIds) ? card.totemIds : [card.totemId || ''];
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(card.id || `card_${index + 1}`)}</td>
<td>${vialSelectHtml(index, card.vialNumero, plant?.viales || 1)}</td>
<td>${totemSelectHtml(index, 0, ids[0] || '', totems)}</td>
<td>${index === 2 ? totemSelectHtml(index, 1, ids[1] || '', totems) : ''}</td>
<td>${screenPowerSelectHtml(index, card.totemScreenEnabled !== false)}</td>
`;
els.cardRows.appendChild(tr);
});
els.cardRows.querySelectorAll('select').forEach(select => select.addEventListener('change', updateCardFromSelect));
}
function vialSelectHtml(cardIndex, value, max) {
const selectedValue = String(value ?? '').trim();
const options = [`<option value=""${selectedValue === '' ? ' selected' : ''}></option>`];
for (let i = 1; i <= Math.max(1, Number(max) || 1); i += 1) {
options.push(`<option value="${i}"${selectedValue !== '' && Number(selectedValue) === i ? ' selected' : ''}>${i}</option>`);
}
return `<select data-card="${cardIndex}" data-card-field="vialNumero">${options.join('')}</select>`;
}
function totemSelectHtml(cardIndex, slot, value, totems) {
const options = ['<option value=""></option>'].concat(totems.map(totem => (
`<option value="${escapeAttr(totem.id)}"${totem.id === value ? ' selected' : ''}>${escapeHtml(totem.id)}</option>`
)));
return `<select data-card="${cardIndex}" data-card-field="totem" data-slot="${slot}">${options.join('')}</select>`;
}
function screenPowerSelectHtml(cardIndex, enabled) {
return `<select data-card="${cardIndex}" data-card-field="totemScreenEnabled">
<option value="true"${enabled ? ' selected' : ''}>ON</option>
<option value="false"${!enabled ? ' selected' : ''}>OFF</option>
</select>`;
}
function updateCardFromSelect(event) {
const card = config.pantalla.tarjetas[Number(event.target.dataset.card)];
if (!card) return;
if (event.target.dataset.cardField === 'vialNumero') {
card.vialNumero = event.target.value === '' ? '' : Number(event.target.value);
} else if (event.target.dataset.cardField === 'totemScreenEnabled') {
card.totemScreenEnabled = event.target.value === 'true';
} else {
const slot = Number(event.target.dataset.slot);
if (Number(event.target.dataset.card) === 2) {
const ids = Array.isArray(card.totemIds) ? [...card.totemIds] : [card.totemId || '', ''];
ids[slot] = event.target.value;
delete card.totemId;
card.totemIds = ids.filter(Boolean);
} else {
delete card.totemIds;
card.totemId = event.target.value;
}
}
updatePreview();
}
function validateConfig(cfg) {
const errors = [];
if (!cfg.plantas.length) errors.push('Debe existir al menos una planta.');
cfg.plantas.forEach(plant => {
if (!plant.nombre) errors.push('Hay una planta sin nombre.');
if (!plant.idCentroProveedor) errors.push(`La planta ${plant.nombre || '?'} no tiene GUID.`);
if (!plant.supervisor.websocketUrl) errors.push(`La planta ${plant.nombre || '?'} no tiene WebSocket.`);
if (!plant.totems.length) errors.push(`La planta ${plant.nombre || '?'} no tiene tótems.`);
});
const allTotems = new Set(cfg.plantas.flatMap(plant => plant.totems.map(totem => totem.id)));
cfg.pantalla.tarjetas.forEach(card => {
const ids = Array.isArray(card.totemIds) ? card.totemIds : [card.totemId];
ids.filter(Boolean).forEach(id => {
if (!allTotems.has(id)) errors.push(`${card.id} referencia un tótem inexistente: ${id}`);
});
});
return errors;
}
function updatePreview() {
savePlantForm();
const cfg = buildDerivedConfig();
const errors = validateConfig(cfg);
els.jsonPreview.value = JSON.stringify(cfg, null, 2);
setStatus(errors.length ? errors.join(' ') : 'JSON v11 válido para exportar.', errors.length ? 'bad' : 'ok');
return { cfg, errors };
}
function addPlant() {
savePlantForm();
config.plantas.push({
nombre: `PLANTA ${config.plantas.length + 1}`,
idCentroProveedor: '',
supervisor: { nombre: 'Supervisor', websocketUrl: 'ws://127.0.0.1:25009/' },
viales: 1,
totems: [],
tablas: ['Matriculas', 'Identificadores', 'MatriculasIdentificadores', 'UbicacionesInternas']
});
selectedPlantIndex = config.plantas.length - 1;
renderAll();
}
function deletePlant() {
if (config.plantas.length <= 1) {
setStatus('No se puede eliminar la última planta.', 'bad');
return;
}
const plant = selectedPlant();
if (!confirm(`¿Eliminar la planta ${plant?.nombre || selectedPlantIndex + 1}?`)) return;
config.plantas.splice(selectedPlantIndex, 1);
selectedPlantIndex = Math.max(0, selectedPlantIndex - 1);
renderAll();
}
function addTotem() {
const plant = selectedPlant();
const next = (plant.totems || []).length + 1;
plant.totems.push({ id: `totem_${next}`, nombre: '', ip: '', url: '' });
renderTotems();
renderCards();
updatePreview();
}
function downloadConfig() {
const { cfg, errors } = updatePreview();
if (errors.length && !confirm('Hay avisos de validación. ¿Descargar igualmente?')) return;
const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'monitor_config.json';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function copyConfig() {
const { cfg } = updatePreview();
await navigator.clipboard.writeText(JSON.stringify(cfg, null, 2));
setStatus('JSON copiado al portapapeles.', 'ok');
}
function loadFile(file) {
const reader = new FileReader();
reader.onload = () => {
try {
config = normalizeConfig(JSON.parse(String(reader.result || '{}')));
selectedPlantIndex = 0;
renderAll();
setStatus(`Archivo cargado: ${file.name}`, 'ok');
} catch (err) {
setStatus(`No se pudo cargar el JSON: ${err.message}`, 'bad');
}
};
reader.readAsText(file, 'utf-8');
}
function renderAll() {
config = normalizeConfig(config);
selectedPlantIndex = Math.min(selectedPlantIndex, config.plantas.length - 1);
renderPlantList();
renderPlantForm();
updatePreview();
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
}
function escapeAttr(value) {
return escapeHtml(value);
}
els.styleBtn.addEventListener('click', event => {
event.stopPropagation();
els.styleMenu.classList.toggle('open');
});
document.querySelectorAll('[data-style-option]').forEach(btn => {
btn.addEventListener('click', () => {
applyVisualStyle(btn.dataset.styleOption);
els.styleMenu.classList.remove('open');
});
});
document.addEventListener('click', event => {
if (!els.styleMenu.contains(event.target) && event.target !== els.styleBtn) els.styleMenu.classList.remove('open');
});
els.themeBtn.addEventListener('click', () => applyTheme(theme === 'light' ? 'dark' : 'light'));
els.loadFileBtn.addEventListener('click', () => els.fileInput.click());
els.fileInput.addEventListener('change', event => {
const file = event.target.files && event.target.files[0];
if (file) loadFile(file);
event.target.value = '';
});
els.downloadBtn.addEventListener('click', downloadConfig);
els.copyBtn.addEventListener('click', copyConfig);
els.resetBtn.addEventListener('click', () => {
if (!confirm('¿Restaurar la configuración base v11?')) return;
config = clone(BASE_CONFIG);
selectedPlantIndex = 0;
renderAll();
});
els.addPlantBtn.addEventListener('click', addPlant);
els.deletePlantBtn.addEventListener('click', deletePlant);
els.addTotemBtn.addEventListener('click', addTotem);
els.refreshPreviewBtn.addEventListener('click', updatePreview);
[els.plantName, els.plantGuid, els.plantVials, els.supervisorName, els.websocketUrl, els.initialPlant, els.plantTables]
.forEach(input => input.addEventListener('input', () => {
savePlantForm();
renderPlantList();
renderInitialPlantSelect();
renderCards();
updatePreview();
}));
els.initialPlant.addEventListener('change', updatePreview);
applyVisualStyle(localStorage.getItem('configuradorMonitorV11.visualStyle') || 'base');
applyTheme(theme);
renderAll();
</script>
</body>
</html>