Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SfRSvDd335NPYEbBrDFGE
183 lines
14 KiB
JavaScript
183 lines
14 KiB
JavaScript
/* 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);
|
||
})();
|