hbox-docs/docs/tableros/incidencias-tablero.js

183 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 => '<div class="kpi ' + k[2] + '"><div class="n">' + k[1] + '</div><div class="l">' + k[0] + '</div></div>').join('');
$('cnt').textContent = DATOS.incidencias.length + ' en esta vista';
const L = $('lista');
if (!DATOS.incidencias.length) { L.innerHTML = '<p class="vacio">Nada por aquí. Si algo falla, repórtalo con el botón de arriba o desde el pie de cualquier página.</p>'; 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 = '<div class="inc__sup"><span class="inc__id">' + esc(x.id) + '</span>'
+ '<span class="pill e-' + esc(x.estado) + '">' + esc(ESTADO[x.estado] || x.estado) + '</span>'
+ '<span class="pill ' + esc(x.gravedad) + '">' + esc(x.gravedad) + '</span>'
+ '<span>' + esc(TIPOS[x.tipo] || x.tipo) + '</span>'
+ (x.ocurrencias > 1 ? '<span class="pill aviso" title="Veces que se repitió">×' + x.ocurrencias + '</span>' : '')
+ (x.publica ? '<span class="pill info">en la entrada</span>' : '')
+ (x.comentarios ? '<span>· ' + x.comentarios + ' comentario' + (x.comentarios > 1 ? 's' : '') + '</span>' : '') + '</div>'
+ '<div class="inc__tit" role="button" tabindex="0">' + esc(x.titulo) + '</div>'
+ '<div class="inc__meta">' + esc(ORIGEN[x.origen] || x.origen) + ' · ' + esc(quien) + ' · ' + esc(fecha(x.creado_en))
+ (x.pagina ? ' · <a href="' + esc(x.pagina) + '">' + esc(x.pagina.length > 50 ? x.pagina.slice(0, 49) + '…' : x.pagina) + '</a>' : '')
+ (x.asignado ? ' · atiende <b>' + esc(x.asignado) + '</b>' : '')
+ (x.tarea_id ? ' · pendiente <a href="/tableros/pendientes/?id=' + encodeURIComponent(x.tarea_id) + '">' + esc(x.tarea_id) + '</a>' : '')
+ (x.duplicado_de ? ' · duplicada de <a href="?id=' + esc(x.duplicado_de) + '">' + esc(x.duplicado_de) + '</a>' : '')
+ '</div><div class="inc__det" hidden></div>';
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 = '<p class="vacio">Cargando…</p>';
try {
const j = await api('/api/incidencia?id=' + encodeURIComponent(id));
detalle(det, j.incidencia, j.puede_editar);
} catch (e) { det.innerHTML = '<div class="err">' + esc(e.message) + '</div>'; }
}
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 ? '<p class="inc__desc">' + esc(r.descripcion) + '</p>' : '')
+ (ctx ? '<details><summary>Datos técnicos</summary><div class="inc__ctx">' + esc(ctx) + '</div></details>' : '')
+ '<ul class="inc__com">' + (r.comentarios || []).map(c => '<li class="' + (c.quien === 'claude' || c.quien === 'vigia' ? 'bot' : '') + '"><div class="q">'
+ esc(c.quien) + ' · ' + esc(fecha(c.cuando)) + '</div><div class="t">' + esc(c.texto) + '</div></li>').join('') + '</ul>';
if (ed) {
h += '<div class="inc__ed">'
+ '<div><label>Estado</label><select data-c="estado">' + Object.keys(ESTADO).map(e => '<option value="' + e + '"' + (e === r.estado ? ' selected' : '') + '>' + ESTADO[e] + '</option>').join('') + '</select></div>'
+ '<div><label>Gravedad</label><select data-c="gravedad">' + ['alta', 'media', 'baja'].map(e => '<option' + (e === r.gravedad ? ' selected' : '') + '>' + e + '</option>').join('') + '</select></div>'
+ '<div><label>Tipo</label><select data-c="tipo">' + Object.keys(TIPOS).map(e => '<option value="' + e + '"' + (e === r.tipo ? ' selected' : '') + '>' + esc(TIPOS[e]) + '</option>').join('') + '</select></div>'
+ '<div><label>Atiende</label><input type="text" data-c="asignado" value="' + esc(r.asignado) + '" placeholder="claude, gibranlp…"></div>'
+ '<div><label>Pendiente del tablero</label><input type="text" data-c="tarea_id" value="' + esc(r.tarea_id) + '" placeholder="t-…"></div>'
+ '<div><label>Duplicada de</label><input type="text" data-c="duplicado_de" value="' + esc(r.duplicado_de) + '" placeholder="INC-0001"></div>'
+ '</div>'
+ '<div class="inc__fila"><label class="inc__pub"><input type="checkbox" data-c="publica"' + (r.publica ? ' checked' : '') + '> Mostrar en la página de entrada</label>'
+ '<input type="text" data-c="resumen_publico" value="' + esc(r.resumen_publico) + '" placeholder="Texto corto para la entrada (sin datos internos)" style="flex:1;min-width:220px"></div>';
}
h += '<div class="inc__fila"><textarea placeholder="Comentar (lo ve quien reportó)"></textarea>'
+ '<button class="b-guardar">' + (ed ? 'Guardar' : 'Comentar') + '</button>'
+ (!ed && yoReporte && !['resuelta', 'descartada'].includes(r.estado) ? '<button class="sec b-resuelta">Ya quedó</button>' : '')
+ (!ed && yoReporte && ['resuelta', 'descartada'].includes(r.estado) ? '<button class="sec b-reabrir">Sigue pasando</button>' : '')
+ '</div>';
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 '<tr><td>' + (lado === 'servidor' ? 'servidor' : 'navegador') + '</td><td class="mono">' + esc(que) + '</td><td>' + g.veces + '</td><td>' + esc(g.usuarios.join(', ')) + '</td><td>' + esc(fecha(g.ultimo)) + '</td><td class="mono">' + esc(e.pagina || '') + '</td></tr>';
};
const filas = j.servidor.map(g => fila(g, 'servidor')).concat(j.cliente.map(g => fila(g, 'cliente')));
$('errores').innerHTML = filas.length ? '<div class="tblwrap"><table class="t"><thead><tr><th>Dónde</th><th>Qué</th><th>Veces</th><th>Quién</th><th>Última</th><th>Página</th></tr></thead><tbody>' + filas.join('') + '</tbody></table></div>' : '<p class="vacio">Sin errores registrados.</p>';
} 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]) =>
'<tr><td><b>' + esc(u) + '</b></td><td>' + p.ok + '</td><td>' + (p.fallas ? '<span class="pill falla">' + p.fallas + '</span>' : '0') + '</td><td>'
+ esc(Object.entries(p.motivos).map(([m, n]) => (MOTIVO[m] || m) + ' ×' + n).join(', ')) + '</td><td>'
+ esc(Object.entries(p.pistas).map(([m, n]) => (PISTA[m] || m) + ' ×' + n).join(', ')) + '</td><td>' + esc(fecha(p.ultimo_ok)) + '</td><td>' + esc(fecha(p.ultima_falla)) + '</td></tr>');
$('entradas').innerHTML = filas.length ? '<div class="tblwrap"><table class="t"><thead><tr><th>Usuario</th><th>Entró</th><th>Fallas</th><th>Por qué</th><th>Pistas</th><th>Última entrada</th><th>Última falla</th></tr></thead><tbody>' + filas.join('') + '</tbody></table></div>' : '<p class="vacio">Nadie ha entrado por la página nueva todavía.</p>';
} 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);
})();