/* Tablero de Incidencias del sitio (27-oct-2026). Servidor (backend/incidencias.py): GET /api/incidencias?estado=&tipo=&origen=&q= · GET /api/incidencia?id= · PATCH /api/incidencia?id= (comentario; estado, gravedad, asignado, publica, resumen_publico, tarea_id, duplicado_de) · GET /api/incidencias/errores (quien atiende) · GET /api/accesos/intentos (administradores, backend/acceso.py). ?id=INC-0003 abre esa incidencia. Reportar = el formulario del pie (js/incidencias.js). */ (function () { const $ = id => document.getElementById(id); const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }; const MES = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']; const fecha = iso => { const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}:\d{2}))?/.exec(iso || ''); return m ? (+m[3]) + ' ' + MES[+m[2] - 1] + (m[4] ? ', ' + m[4] : '') : ''; }; const ESTADO = { nueva: 'Nueva', 'en-revision': 'En revisión', 'en-curso': 'En curso', esperando: 'Esperando respuesta', resuelta: 'Resuelta', descartada: 'Descartada' }; const ORIGEN = { sitio: 'reportada en el sitio', entrada: 'desde la entrada', vigia: 'detectada por el vigía', agente: 'abierta por el agente' }; const MOTIVO = { 'usuario-no-existe': 'usuario que no existe', 'contrasena-incorrecta': 'contraseña incorrecta', bloqueado: 'bloqueado por intentos', 'sin-datos': 'sin datos' }; const PISTA = { 'bloq-mayus': 'Bloq Mayús', 'mayuscula-inicial': 'mayúscula inicial', 'espacios-alrededor': 'espacio de más' }; let DATOS = null, abiertas = new Set(), ED = false, YO = '', TIPOS = {}; const params = new URLSearchParams(location.search); if (params.get('id')) abiertas.add(params.get('id')); async function api(url, op) { const r = await fetch(url, Object.assign({ credentials: 'same-origin', cache: 'no-store' }, op || {})); const j = await r.json().catch(() => ({ ok: false, error: 'HTTP ' + r.status })); if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status)); return j; } const json = (method, body) => ({ method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); function aviso(t, bien) { const a = $('aviso'); a.className = 'msg ' + (bien ? 'ok' : 'err'); a.textContent = t; } async function cargar() { const q = new URLSearchParams({ estado: $('fEstado').value, tipo: $('fTipo').value, origen: $('fOrigen').value, q: $('fBuscar').value.trim() }); try { DATOS = await api('/api/incidencias?' + q); ED = DATOS.puede_editar; YO = DATOS.yo; if (!$('fTipo').options[1]) DATOS.tipos.forEach(t => { TIPOS[t.id] = t.nombre; $('fTipo').append(new Option(t.nombre, t.id)); }); DATOS.tipos.forEach(t => { TIPOS[t.id] = t.nombre; }); pintar(); } catch (e) { aviso('No pude cargar las incidencias: ' + e.message); $('meta').textContent = ''; } } function pintar() { const c = DATOS.conteo; $('meta').textContent = c.abiertas + ' abiertas · ' + (c.por_estado.resuelta || 0) + ' resueltas · ' + (YO ? YO + (ED ? ' (atiendes incidencias)' : '') : ''); const vig = DATOS.incidencias.filter(x => x.origen === 'vigia' && ['nueva', 'en-revision', 'en-curso', 'esperando'].includes(x.estado)).length; $('kpis').innerHTML = [ ['Abiertas', c.abiertas, c.abiertas ? 'mal' : 'bien'], ['Sin revisar', c.por_estado.nueva || 0, (c.por_estado.nueva || 0) ? 'mal' : ''], ['No pueden entrar', c.por_tipo.acceso || 0, (c.por_tipo.acceso || 0) ? 'mal' : ''], ['Errores (abiertas)', c.por_tipo.error || 0, ''], ['Del vigía (en esta vista)', vig, ''] ].map(k => '
' + k[1] + '
' + k[0] + '
').join(''); $('cnt').textContent = DATOS.incidencias.length + ' en esta vista'; const L = $('lista'); if (!DATOS.incidencias.length) { L.innerHTML = '

Nada por aquí. Si algo falla, repórtalo con el botón de arriba o desde el pie de cualquier página.

'; return; } L.innerHTML = ''; DATOS.incidencias.forEach(x => L.append(tarjeta(x))); } function tarjeta(x) { const cerrada = x.estado === 'resuelta' || x.estado === 'descartada'; const d = document.createElement('article'); d.className = 'inc g-' + (x.gravedad || 'media') + (cerrada ? ' cerrada' : ''); d.id = x.id; const quien = x.reportado_por || (x.nombre ? x.nombre + ' (sin sesión)' : (x.origen === 'vigia' ? 'vigía' : '—')); d.innerHTML = '
' + esc(x.id) + '' + '' + esc(ESTADO[x.estado] || x.estado) + '' + '' + esc(x.gravedad) + '' + '' + esc(TIPOS[x.tipo] || x.tipo) + '' + (x.ocurrencias > 1 ? '×' + x.ocurrencias + '' : '') + (x.publica ? 'en la entrada' : '') + (x.comentarios ? '· ' + x.comentarios + ' comentario' + (x.comentarios > 1 ? 's' : '') + '' : '') + '
' + '
' + esc(x.titulo) + '
' + '
' + esc(ORIGEN[x.origen] || x.origen) + ' · ' + esc(quien) + ' · ' + esc(fecha(x.creado_en)) + (x.pagina ? ' · ' + esc(x.pagina.length > 50 ? x.pagina.slice(0, 49) + '…' : x.pagina) + '' : '') + (x.asignado ? ' · atiende ' + esc(x.asignado) + '' : '') + (x.tarea_id ? ' · pendiente ' + esc(x.tarea_id) + '' : '') + (x.duplicado_de ? ' · duplicada de ' + esc(x.duplicado_de) + '' : '') + '
'; const tit = d.querySelector('.inc__tit'); const abrir = () => alternar(d, x.id); tit.addEventListener('click', abrir); tit.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); abrir(); } }); if (abiertas.has(x.id)) setTimeout(() => { alternar(d, x.id, true); d.scrollIntoView({ block: 'start' }); }, 0); return d; } async function alternar(card, id, forzar) { const det = card.querySelector('.inc__det'); if (!det.hidden && !forzar) { det.hidden = true; abiertas.delete(id); return; } abiertas.add(id); det.hidden = false; det.innerHTML = '

Cargando…

'; try { const j = await api('/api/incidencia?id=' + encodeURIComponent(id)); detalle(det, j.incidencia, j.puede_editar); } catch (e) { det.innerHTML = '
' + esc(e.message) + '
'; } } function contexto(r) { const c = r.contexto || {}, out = []; if (r.navegador) out.push('Navegador: ' + r.navegador); ['pantalla', 'idioma', 'hora_local', 'referer', 'ip'].forEach(k => { if (c[k]) out.push(k + ': ' + c[k]); }); (c.errores || []).forEach(e => out.push('error JS: ' + (e.mensaje || '') + ' @ ' + (e.fuente || '') + ' (' + (e.cuando || '') + ')')); (c.llamadas || []).forEach(e => out.push('API: ' + (e.metodo || '') + ' ' + (e.url || '') + ' → ' + (e.estado || '') + ' (' + (e.cuando || '') + ')')); (r.evidencia || []).forEach(e => out.push('evidencia: ' + e)); if (r.contacto) out.push('Contacto: ' + r.contacto); return out.join('\n'); } function detalle(det, r, ed) { const ctx = contexto(r); const yoReporte = (r.reportado_por || '').toLowerCase() === (YO || '').toLowerCase(); let h = (r.descripcion ? '

' + esc(r.descripcion) + '

' : '') + (ctx ? '
Datos técnicos
' + esc(ctx) + '
' : '') + ''; if (ed) { h += '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
'; } h += '
' + '' + (!ed && yoReporte && !['resuelta', 'descartada'].includes(r.estado) ? '' : '') + (!ed && yoReporte && ['resuelta', 'descartada'].includes(r.estado) ? '' : '') + '
'; det.innerHTML = h; const enviar = async (extra) => { const cuerpo = Object.assign({}, extra || {}); const t = det.querySelector('textarea').value.trim(); if (t) cuerpo.comentario = t; if (ed) det.querySelectorAll('[data-c]').forEach(i => { const k = i.dataset.c, v = i.type === 'checkbox' ? i.checked : i.value; if (k === 'publica' ? v !== !!r.publica : String(v) !== String(r[k] == null ? '' : r[k])) cuerpo[k] = v; }); if (!Object.keys(cuerpo).length) return; try { await api('/api/incidencia?id=' + encodeURIComponent(r.id), json('PATCH', cuerpo)); aviso(r.id + ' actualizada.', true); await cargar(); } catch (e) { aviso(e.message); } }; det.querySelector('.b-guardar').addEventListener('click', () => enviar()); const br = det.querySelector('.b-resuelta'); if (br) br.addEventListener('click', () => enviar({ estado: 'resuelta' })); const bo = det.querySelector('.b-reabrir'); if (bo) bo.addEventListener('click', () => enviar({ estado: 'nueva' })); } async function errores() { if (!ED) return; try { const j = await api('/api/incidencias/errores?dias=2'); $('bErrores').hidden = false; const fila = (g, lado) => { const e = g.ejemplo || {}; const que = lado === 'servidor' ? (e.tipo + ' ' + (e.metodo || '') + ' ' + (e.ruta || '') + ' — ' + (e.error || '')) : ((e.tipo || '') + ': ' + (e.mensaje || '') + (e.fuente ? ' @ ' + e.fuente + ':' + e.linea : '')); return '' + (lado === 'servidor' ? 'servidor' : 'navegador') + '' + esc(que) + '' + g.veces + '' + esc(g.usuarios.join(', ')) + '' + esc(fecha(g.ultimo)) + '' + esc(e.pagina || '') + ''; }; const filas = j.servidor.map(g => fila(g, 'servidor')).concat(j.cliente.map(g => fila(g, 'cliente'))); $('errores').innerHTML = filas.length ? '
' + filas.join('') + '
DóndeQuéVecesQuiénÚltimaPágina
' : '

Sin errores registrados.

'; } catch (e) { /* sin permiso o sin servidor */ } try { const j = await api('/api/accesos/intentos?dias=7'); $('bEntradas').hidden = false; const filas = Object.entries(j.por_usuario).sort((a, b) => b[1].fallas - a[1].fallas).map(([u, p]) => '' + esc(u) + '' + p.ok + '' + (p.fallas ? '' + p.fallas + '' : '0') + '' + esc(Object.entries(p.motivos).map(([m, n]) => (MOTIVO[m] || m) + ' ×' + n).join(', ')) + '' + esc(Object.entries(p.pistas).map(([m, n]) => (PISTA[m] || m) + ' ×' + n).join(', ')) + '' + esc(fecha(p.ultimo_ok)) + '' + esc(fecha(p.ultima_falla)) + ''); $('entradas').innerHTML = filas.length ? '
' + filas.join('') + '
UsuarioEntróFallasPor quéPistasÚltima entradaÚltima falla
' : '

Nadie ha entrado por la página nueva todavía.

'; } catch (e) { /* solo administradores */ } } $('bReportar').addEventListener('click', () => { const b = document.getElementById('hg-inc-abrir'); if (b) { const s = document.getElementById('problemas-del-sitio'); s.scrollIntoView({ behavior: 'smooth' }); const f = document.getElementById('hg-inc-f'); if (f && f.hidden) b.click(); } }); let t = null; ['fEstado', 'fTipo', 'fOrigen'].forEach(id => $(id).addEventListener('change', cargar)); $('fBuscar').addEventListener('input', () => { clearTimeout(t); t = setTimeout(cargar, 300); }); if (params.get('id')) $('fEstado').value = 'todas'; cargar().then(errores); })();