hbox-docs/docs/tableros/kanban.js
Leopoldo Rodríguez 62c037f198 Pendientes: comentarios en cada tarjeta y «¿por qué?» al moverla
Pedido de Leo (28-oct, 12:57 y 16:12). El detalle tiene «Comentarios» (hilo + caja; cualquiera con sesión comenta, aunque
no sea el campeón) y la tarjeta dice cuántos. Al mover pregunta «¿por qué?»: a Hecho (la evidencia, D-27-6), al reabrir
desde Hecho, a Bloqueado y de En curso a Por hacer; «Cancelar» o Esc la deja donde estaba. Vale para arrastrar, la franja,
las flechas, «Mover a…» y los botones del detalle. En el teléfono el detalle cabe en la pantalla.
Backend: _nota hasta 1000 caracteres y guardada aparte en historial[-1].nota. Pruebas: tests-comentarios.sh (16) +
1027 (55) + 1028 Compras (58) + 1023 (35) + campeón (25) + tests-actas-documentos.sh (15, antes tests-1028.sh, nombre que
pasó a Compras). Desplegado (DESPLIEGUE A13). Lote 2026-10-28_comentarios-pendientes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SfRSvDd335NPYEbBrDFGE
2026-10-28 16:23:43 -06:00

919 lines
64 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 pendientes HBOX — módulo compartido (tableros/pendientes, ingenieria, produccion, equipo).
Vocabulario: la tarjeta del tablero se llama "pendiente" en pantalla para no confundirla con la
tarjeta EinarBoard. Columnas fijas: Por hacer · En curso · Bloqueado · Hecho.
Uso: const kb = Kanban.montar(document.getElementById('kb'), {
pista: 'ingenieria', // opcional: fija la pista (oculta su filtro y la precarga en "Nuevo")
filtros: true, // barra de filtros (responsable / versión / buscar / pista / solo los míos)
compacto: false, // tarjetas sin ligas ni etiquetas (la página de inicio)
botonNuevoEn: elemento, // opcional: dónde va el botón «Nuevo pendiente» (p. ej. el encabezado)
onCargado: (tareas, meta) => {}, // callback después de cada carga
// agregados el 21-oct (página de inicio y vista por persona):
vistas: true, // botones «Tablero · Por persona · Por sitio» (?vista=persona|sitio en la dirección)
limite: 4, // máximo de pendientes por columna; el resto, «N más» → enlaceMas
limitePersona: 3, // máximo por celda en la vista por persona
enlaceMas: '/tableros/pendientes/', // a dónde llevan los «N más» (sin él, se despliegan ahí)
nuevoPorColumna: true, // el «+ Nuevo» al pie de cada columna
barraEn: elemento, // los botones de la barra van dentro de este elemento (filtros: 'min')
onPintado: (visibles, info) => {} // después de cada pintado: lo que se ve con los filtros
});
kb.recargar();
filtros: true = barra completa · 'min' = solo vistas, «Solo los míos» y «Nuevo» · false = sin barra.
Servidor: GET /api/tareas · POST /api/tareas · PATCH /api/tarea?id= · POST /api/tareas/orden ·
DELETE /api/tarea?id= (administradores y TAREAS_BORRAN: «puede_borrar» y «borran» en GET /api/tareas;
el pendiente pasa a la papelera del servidor y sigue en la Actividad). Cada cambio guarda quién y cuándo.
Equipo (14-oct): los responsables son miembros de /api/equipo (GET /api/tareas ya trae el mapa
id → nombre). El pendiente exige responsable; se puede escribir texto libre si la persona aún no
está dada de alta (el servidor avisa). Kanban.nombresResp(t, equipo) sirve a otras páginas.
Estilos: tableros/tablero.css (todo bajo .tb). Sin emoji: íconos de línea (Kanban.icono) y
avatares con iniciales (Kanban.avatar), con el mismo color por persona en todo el sitio.
Campeón (27-oct, Leo): cada pendiente tiene UNA persona que lo mueve entre columnas (campo «campeon», uno de los
responsables; sin él, el primero). Su avatar va primero, con anillo. Solo el campeón, los administradores y
«mueven_siempre» de GET /api/tareas lo cambian de columna; el servidor lo vuelve a revisar. Para mover: arrastrar
(al empezar aparece arriba una franja fija con las cuatro columnas, para no tener que bajar con el mouse),
las flechas ← → de la tarjeta o los botones de columna del detalle.
Comentarios (28-oct, Leo): el detalle tiene «Comentarios» (hilo + caja; PATCH solo con «_nota», cualquiera con
sesión) y la tarjeta muestra cuántos tiene. Al mover se pide «¿por qué?» cuando hace falta (reglaMover): a Hecho
(la evidencia, D-27-6), al reabrir algo hecho, a Bloqueado y de En curso a Por hacer. «Cancelar» la deja donde estaba.
La nota va en «_nota» del mismo PATCH y queda en el historial («nota»). */
(function () {
const PISTAS = { ingenieria: 'Ingeniería', produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', campo: 'Campo', gestion: 'Gestión' };
const ESTADOS = [['por-hacer', 'Por hacer'], ['en-curso', 'En curso'], ['bloqueado', 'Bloqueado'], ['hecho', 'Hecho']];
const VERSIONES = ['—', 'V5.5', 'V5.6', 'V5.7', 'v6'];
// Sitio de cada pendiente (22-oct): [id, corto, chip del filtro, nombre]. Sin sitio = Querétaro.
// El backend manda su lista en GET /api/tareas (sitios); esta es la de respaldo.
let SITIOS = [['icave', 'ICAVE', 'ICAVE', 'ICAVE · Veracruz'], ['timsa', 'TIMSA', 'TIMSA', 'TIMSA · Manzanillo'], ['eit', 'EIT', 'EIT', 'EIT · Ensenada'],
['lzc', 'LCT', 'LCT', 'LCT · Lázaro Cárdenas'], ['qro', 'QRO', 'Querétaro', 'Querétaro · fabricación e ingeniería']];
const sitiosDe = t => (t && t.sitios && t.sitios.length) ? t.sitios : ['qro'];
const SIT = id => SITIOS.find(x => x[0] === id) || [id, id, id, id];
// en la dirección también se aceptan los nombres de siempre (?sitio=lct, ?sitio=ensenada)
const ALIAS_SITIO = { lct: 'lzc', veracruz: 'icave', manzanillo: 'timsa', ensenada: 'eit', queretaro: 'qro', 'querétaro': 'qro', oficina: 'qro' };
// Lista de respaldo si /api/equipo no responde; lo normal es que el equipo venga del servidor.
const RESPONSABLES = ['leo', 'adriana', 'michelle', 'jesus', 'alan', 'daniel', 'aldair', 'gibran', 'claude'];
const BADGE = { 'V5.5': 'badge-v55', 'V5.6': 'badge-v56', 'V5.7': 'badge-v57', 'v6': 'badge-v6' };
const MESES = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'];
// Un color por persona, el mismo en todo el sitio (sistema visual del 20-oct).
const AVATAR = {
leo: ['LR', '#3A5A9A'], adriana: ['AD', '#8A4F7D'], michelle: ['MM', '#2E7D74'], jesus: ['JS', '#9A6A2E'],
aldair: ['AL', '#5B5FA8'], alan: ['AB', '#57682B'], daniel: ['DM', '#A0503F'], gibran: ['GB', '#36708E'],
produccion: ['PR', '#5A6680'], claude: ['CL', '#4A5468']
};
const TONOS = ['#3A5A9A', '#8A4F7D', '#2E7D74', '#9A6A2E', '#5B5FA8', '#57682B', '#A0503F', '#36708E', '#5A6680', '#6B4F8A'];
// Íconos de línea (24×24, trazo redondo).
const ICONOS = {
liga: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1',
aviso: 'M12 3.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17zM12 8v5M12 16.2v.3',
mas: 'M12 5v14M5 12h14',
cerrar: 'M6 6l12 12M18 6 6 18',
persona: 'M12 5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7zM5 20c.8-3.8 3.6-6 7-6s6.2 2.2 7 6',
fecha: 'M6 5h12a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zM8 3v4M16 3v4M4 10h16',
espera: 'M12 3.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17zM12 7.5V12l3 2',
ok: 'M5 12.5l4.5 4.5L19 7.5',
equipo: 'M9 5.3a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4zM3.5 19.5c.6-3.2 2.8-5 5.5-5s4.9 1.8 5.5 5M16.8 7a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM15.8 14.6c2.3 0 4.2 1.6 4.7 4.4',
descargar: 'M12 4v11M7.5 10.5 12 15l4.5-4.5M5 20h14',
documento: 'M14 3.5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5zM14 3.5v5h5M9 13h6M9 16.5h4',
flecha: 'M5 12h14M13 6l6 6-6 6',
unidad: 'M5 7.5 12 4l7 3.5v9L12 20l-7-3.5zM5 7.5l7 3.5 7-3.5M12 11v9',
actualizar: 'M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4',
imprimir: 'M7 8.5V4h10v4.5M7 17H5a1.5 1.5 0 0 1-1.5-1.5v-5A1.5 1.5 0 0 1 5 9h14a1.5 1.5 0 0 1 1.5 1.5v5A1.5 1.5 0 0 1 19 17h-2M7 14h10v6H7z',
columnas: 'M5 4.5h14a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 18V6A1.5 1.5 0 0 1 5 4.5zM9.3 4.5v15M14.7 4.5v15',
sitio: 'M12 20.5s-6.5-5.4-6.5-10.3a6.5 6.5 0 0 1 13 0c0 4.9-6.5 10.3-6.5 10.3zM12 7.7a2.4 2.4 0 1 0 0 4.8 2.4 2.4 0 0 0 0-4.8',
comentario: 'M5 5.5h14A1.5 1.5 0 0 1 20.5 7v8.5A1.5 1.5 0 0 1 19 17h-8.5L6 20.5V17H5a1.5 1.5 0 0 1-1.5-1.5V7A1.5 1.5 0 0 1 5 5.5zM8 10h8M8 13h5'
};
const icono = (n, cls) => '<svg class="i' + (cls ? ' ' + cls : '') + '" viewBox="0 0 24 24" aria-hidden="true"><path d="' + (ICONOS[n] || '') + '"/></svg>';
// Íconos de estado de las columnas (con relleno; colores de los tokens).
const ESTADO_SVG = {
'por-hacer': '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z" style="fill:none;stroke:var(--hg-texto-3);stroke-width:2;stroke-dasharray:2.6 2.6"/></svg>',
'en-curso': '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z" style="fill:none;stroke:var(--hg-curso);stroke-width:2"/><path d="M12 4a8 8 0 0 1 0 16z" style="fill:var(--hg-curso)"/></svg>',
'bloqueado': '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z" style="fill:none;stroke:var(--hg-bloqueado);stroke-width:2"/><path d="M6.8 17.2 17.2 6.8" style="fill:none;stroke:var(--hg-bloqueado);stroke-width:2.2;stroke-linecap:round"/></svg>',
'hecho': '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z" style="fill:var(--hg-hecho-icono);stroke:var(--hg-hecho-icono);stroke-width:2"/><path d="M8.3 12.3l2.5 2.5 4.9-5.1" style="fill:none;stroke:var(--hg-fondo);stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round"/></svg>'
};
// ---- equipo: id → {nombre, activo, area}. 'Jesús' ≡ 'jesus' (sin acentos, minúsculas).
const norm = s => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim().replace(/[^a-z0-9_\-.]/g, '');
let EQUIPO = {};
let equipoProm = null;
// junta los datos de cada persona sin borrar lo que ya se sabía (p. ej. el rol)
function fundir(mapa) { Object.keys(mapa || {}).forEach(id => { EQUIPO[id] = Object.assign({}, EQUIPO[id] || {}, mapa[id]); }); return EQUIPO; }
function cargarEquipo() {
if (!equipoProm) equipoProm = fetch('/api/equipo?todos=1', { cache: 'no-store' }).then(r => r.json()).then(j => {
const m = {}; (j.miembros || []).forEach(x => m[x.id] = { nombre: x.nombre, activo: x.activo !== false, area: x.area, rol: x.rol || '', tiene_login: x.tiene_login });
return m;
}).catch(() => ({})).then(m => { if (!Object.keys(m).length && !Object.keys(EQUIPO).length) RESPONSABLES.forEach(id => m[id] = { nombre: id, activo: true }); return fundir(m); });
return equipoProm;
}
function usarEquipo(mapa) { if (mapa && Object.keys(mapa).length) fundir(mapa); if (!Object.keys(EQUIPO).length) RESPONSABLES.forEach(id => EQUIPO[id] = { nombre: id, activo: true }); return EQUIPO; }
const partesResp = t => String((t && t.responsable) || '').split(/[,;\/]+/).map(x => x.trim()).filter(Boolean);
// nombres a mostrar: cada parte del campo responsable → nombre del miembro, o el texto tal cual
function nombresResp(t, eq) {
eq = eq || EQUIPO;
return partesResp(t).map(p => { const a = norm(p.replace(/\s+/g, '')), b = norm(p); return (eq[a] || eq[b] || {}).nombre || p; });
}
function idsResp(t, eq) {
eq = eq || EQUIPO;
if (Array.isArray(t.responsable_ids)) return t.responsable_ids;
return partesResp(t).map(p => { const a = norm(p.replace(/\s+/g, '')), b = norm(p); return eq[a] ? a : (eq[b] ? b : null); }).filter(Boolean);
}
const libresResp = (t, eq) => { eq = eq || EQUIPO; return partesResp(t).filter(p => !eq[norm(p.replace(/\s+/g, ''))] && !eq[norm(p)]); };
// nombre corto para frases («Leo movió…»): el primer nombre, con los apodos de la casa
const APODOS = { leopoldo: 'Leo' };
function nombreCorto(id) {
const n = String((EQUIPO[id] || {}).nombre || id || '').trim().split(/\s+/)[0];
return APODOS[norm(n)] || (EQUIPO[id] ? n : n.charAt(0).toUpperCase() + n.slice(1));
}
// ---- avatares: iniciales sobre el color de la persona
function iniciales(nombre) {
const w = String(nombre || '?').trim().split(/\s+/).filter(Boolean);
if (w.length >= 2) return (w[0][0] + w[1][0]).toUpperCase();
return (w[0] || '?').slice(0, 2).toUpperCase();
}
function tono(id) { let h = 0; for (const c of String(id)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return TONOS[h % TONOS.length]; }
// avatar(id) para un miembro; avatar(null, 'texto libre') para alguien que no está en Equipo
function avatar(id, libre) {
const s = document.createElement('span');
if (id) {
const m = EQUIPO[id] || {};
const a = AVATAR[id] || [iniciales(m.nombre || id), tono(id)];
s.className = 'av'; s.textContent = a[0]; s.style.background = a[1]; s.title = m.nombre || id;
} else {
s.className = 'av libre'; s.textContent = iniciales(libre); s.title = (libre || '') + ' (sin registrar en Equipo)';
}
return s;
}
function avatares(t) {
const g = document.createElement('span'); g.className = 'avs';
const ids = idsResp(t);
// con varios responsables, el campeón va primero y con anillo
const c = ids.length > 1 ? ((t.campeon && ids.includes(t.campeon)) ? t.campeon : ids[0]) : '';
(c ? [c].concat(ids.filter(x => x !== c)) : ids).forEach(id => {
const a = avatar(id);
if (id === c) { a.classList.add('campeon'); a.title = 'Campeón: ' + a.title; }
g.append(a);
});
libresResp(t).forEach(x => g.append(avatar(null, x)));
g.title = (c ? 'Campeón: ' + ((EQUIPO[c] || {}).nombre || c) + ' · ' : '') + nombresResp(t).join(', ');
return g;
}
const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { if (p === 'html') e.innerHTML = a[p]; else if (p === 'text') e.textContent = a[p]; else e.setAttribute(p, a[p]); } k.forEach(c => c != null && e.append(c)); return e; };
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; };
const hoyISO = () => { const d = new Date(); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 10); };
const fmt = iso => (iso || '').replace('T', ' ').slice(0, 16);
const fechaCorta = f => { if (!f) return ''; const [y, m, d] = f.split('-'); return (+d) + ' ' + MESES[+m - 1] + (y !== hoyISO().slice(0, 4) ? ' ' + y : ''); };
// texto y clase de la fecha límite de un pendiente
function fechaDe(t) {
if (!t.fecha_limite) return null;
if (t.vencida) return { texto: 'Venció ' + fechaCorta(t.fecha_limite), cls: 'mal' };
if (t.estado !== 'hecho' && t.fecha_limite === hoyISO()) return { texto: 'Hoy', cls: 'hoy' };
return { texto: fechaCorta(t.fecha_limite), cls: '' };
}
async function api(url, opts) {
const r = await fetch(url, Object.assign({ cache: 'no-store' }, opts || {}));
let j = null; try { j = await r.json(); } catch (e) {}
if (!r.ok || !j || !j.ok) throw new Error((j && j.error) || ('HTTP ' + r.status));
return j;
}
const json = (method, body) => ({ method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
// ---- comentarios (28-oct): salen del historial. Desde hoy el servidor guarda la nota aparte («nota»); en lo de
// antes, una entrada sin «campo: antes → después» es un comentario (las notas de los lotes, el cotizador…) y en
// «cambios — nota» la nota va después del último «→».
const NOMBRE_ESTADO = Object.fromEntries(ESTADOS);
const NO_ES_COMENTARIO = /^(creado en |borró el pendiente|restaur)/i;
function comentarios(t) {
const out = [];
(t.historial || []).forEach(x => {
const c = String(x.cambio || '');
const mv = c.match(/estado: ([a-z-]+) → ([a-z-]+)/);
let texto = x.nota || '';
if (!texto) {
if (!c || NO_ES_COMENTARIO.test(c)) return;
const flecha = c.lastIndexOf('→');
if (flecha < 0) texto = c;
else { const k = c.indexOf(' — ', flecha); if (k < 0) return; texto = c.slice(k + 3); }
}
if (texto.trim()) out.push({ quien: x.quien, cuando: x.cuando, texto: texto.trim(), de: mv && mv[1], a: mv && mv[2] });
});
return out;
}
// cuándo pide el «¿por qué?» un cambio de columna (null = se mueve sin preguntar)
function reglaMover(desde, hacia) {
if (desde === hacia) return null;
if (hacia === 'hecho') return { titulo: 'Pasar a Hecho', pregunta: '¿Cuál es la evidencia de que quedó hecho?',
ph: 'La liga, el acta o el archivo que lo demuestra (D-27-6). Ejemplo: acta de prueba en sitio de HBOX5-E15, o /formularios/…' };
if (desde === 'hecho') return { titulo: 'Reabrir', pregunta: '¿Qué faltó? ¿Por qué se reabre?',
ph: 'Qué no quedó terminado y qué falta para cerrarlo' };
if (hacia === 'bloqueado') return { titulo: 'Pasar a Bloqueado', pregunta: '¿Qué lo bloquea y quién lo destraba?',
ph: 'Ejemplo: espera el pago a JLC; lo destraba Adriana' };
if (desde === 'en-curso' && hacia === 'por-hacer') return { titulo: 'Regresar a Por hacer', pregunta: '¿Por qué regresa a Por hacer?',
ph: 'Qué cambió para que deje de estar en curso' };
return null;
}
function montar(root, opts) {
opts = opts || {};
let tareas = [], admin = false, user = '', porId = {}, puedeBorrar = false, borran = [], muevenSiempre = null;
// campeón (27-oct): quién mueve cada pendiente entre columnas
const campeonDe = t => { const ids = idsResp(t); return (t.campeon && ids.includes(t.campeon)) ? t.campeon : (ids[0] || ''); };
function puedeMover(t) {
if (!muevenSiempre) return true; // servidor de antes del 27-oct: cualquiera mueve
const u = norm(user), c = campeonDe(t);
return !c || (!!u && (u === c || admin || muevenSiempre.includes(u)));
}
const quienMueve = t => { const c = campeonDe(t); return (EQUIPO[c] || {}).nombre || c; };
const q0 = new URLSearchParams(location.search);
const filtro = { pista: opts.pista || '', responsable: '', version: '', buscar: '', hechas: 'recientes', mios: q0.get('mios') === '1',
sitios: new Set((q0.get('sitio') || '').split(',').map(x => x.trim().toLowerCase()).filter(Boolean).map(x => ALIAS_SITIO[x] || x)) };
const vistasOn = !!opts.vistas;
let vista = !vistasOn ? 'tablero' : (['persona', 'sitio'].includes(q0.get('vista')) ? q0.get('vista') : (!q0.get('vista') && opts.vista === 'persona' ? 'persona' : 'tablero'));
const desplegadas = new Set(); // celdas de la vista por persona que ya muestran todo
root.classList.add('kanban-root');
const contenedor = root.closest('.tb') || root;
// los filtros que vale la pena compartir viajan en la dirección (?mios=1, ?vista=persona)
function ponURL(k, v) {
if (opts.url === false) return;
const u = new URL(location.href);
if (v) u.searchParams.set(k, v); else u.searchParams.delete(k);
history.replaceState(null, '', u.pathname + u.search.replace(/%2C/gi, ',') + u.hash); // ?sitio=eit,timsa legible
}
// dirección del tablero completo con lo que se está viendo (para los «N más» de la página de inicio)
function enlaceCompleto(extra) {
const u = new URLSearchParams();
if (filtro.mios) u.set('mios', '1');
if (vista !== 'tablero') u.set('vista', vista);
if (filtro.sitios.size) u.set('sitio', [...filtro.sitios].join(','));
if (opts.pista) u.set('pista', opts.pista);
Object.entries(extra || {}).forEach(([k, v]) => v && u.set(k, v));
const q = u.toString().replace(/%2C/gi, ',');
return (opts.enlaceMas || '/tableros/pendientes/') + (q ? '?' + q : '');
}
// ---------- barra de filtros ----------
const barra = el('div', { class: 'kb-barra' });
const selPista = el('select', { title: 'Pista', 'aria-label': 'Pista' }); selPista.append(el('option', { value: '', text: 'Pista: todas' }));
Object.entries(PISTAS).forEach(([k, v]) => selPista.append(el('option', { value: k, text: v })));
const selResp = el('select', { title: 'Responsable', 'aria-label': 'Responsable' }); selResp.append(el('option', { value: '', text: 'Responsable: todos' }));
const selVer = el('select', { title: 'Versión de tarjeta', 'aria-label': 'Versión de tarjeta' }); selVer.append(el('option', { value: '', text: 'Tarjeta: todas' }));
VERSIONES.filter(v => v !== '—').forEach(v => selVer.append(el('option', { value: v, text: v })));
const selHechas = el('select', { title: 'Hechos', 'aria-label': 'Hechos' });
[['recientes', 'Hechos: 14 días'], ['todas', 'Hechos: todos'], ['ninguna', 'Hechos: ocultar']].forEach(([v, t]) => selHechas.append(el('option', { value: v, text: t })));
const buscar = el('input', { type: 'search', placeholder: 'Buscar en pendientes', 'aria-label': 'Buscar en pendientes' });
const btnMios = el('button', { type: 'button', class: 'kb-solo', 'aria-pressed': String(filtro.mios), html: icono('persona') + 'Solo los míos' });
const btnNuevo = el('button', { type: 'button', class: 'kb-nuevo', html: icono('mas') + 'Nuevo pendiente' });
const selVista = el('div', { class: 'kb-vistas', role: 'group', 'aria-label': 'Vista' });
[['tablero', 'Tablero', 'columnas'], ['persona', 'Por persona', 'equipo'], ['sitio', 'Por sitio', 'sitio']].forEach(([k, n, ic]) => {
const b = el('button', { type: 'button', 'data-vista': k, 'aria-pressed': String(vista === k), html: icono(ic) + n });
b.addEventListener('click', () => { if (vista === k) return; vista = k; ponURL('vista', k === 'tablero' ? null : k); pintar(); });
selVista.append(b);
});
// filtro por sitio (22-oct): varios a la vez; viaja en la dirección (?sitio=eit,timsa)
const grpSitios = el('div', { class: 'kb-sitios', role: 'group', 'aria-label': 'Sitio' });
function pintarChipsSitio() {
grpSitios.innerHTML = '';
grpSitios.append(el('span', { class: 'kb-sitios-i', title: 'Sitio', html: icono('sitio') }));
const todos = el('button', { type: 'button', 'aria-pressed': String(!filtro.sitios.size), text: 'Todos', title: 'Todos los sitios' });
todos.addEventListener('click', () => { filtro.sitios.clear(); ponURL('sitio', null); pintar(); });
grpSitios.append(todos);
SITIOS.forEach(([id, , chip, nombre]) => {
const b = el('button', { type: 'button', 'data-sitio': id, title: nombre, 'aria-pressed': String(filtro.sitios.has(id)), text: chip });
b.addEventListener('click', () => { if (filtro.sitios.has(id)) filtro.sitios.delete(id); else filtro.sitios.add(id); ponURL('sitio', filtro.sitios.size ? [...filtro.sitios].join(',') : null); pintar(); });
grpSitios.append(b);
});
}
const modo = opts.filtros === undefined ? true : opts.filtros;
if (modo) {
if (vistasOn) barra.append(selVista);
barra.append(btnMios);
if (modo !== 'min') { barra.append(grpSitios); if (!opts.pista) barra.append(selPista); barra.append(selResp, selVer, selHechas, buscar); }
if (opts.botonNuevoEn) opts.botonNuevoEn.append(btnNuevo); else barra.append(btnNuevo);
if (opts.barraEn) {
// la página ya puso ahí sus propios elementos (p. ej. una liga): las vistas y «Solo los míos»
// van antes, «Nuevo pendiente» al final
const nodos = [...barra.childNodes];
nodos.filter(n => n !== btnNuevo).reverse().forEach(n => opts.barraEn.prepend(n));
if (nodos.includes(btnNuevo)) opts.barraEn.append(btnNuevo);
} else root.append(barra);
}
const aviso = el('div', { class: 'msg', role: 'status' }); root.append(aviso);
function mensaje(txt, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = txt; clearTimeout(aviso._t); aviso._t = setTimeout(() => { aviso.className = 'msg'; }, bien ? 3500 : 8000); }
// ---------- columnas ----------
const kb = el('div', { class: 'kb' + (opts.compacto ? ' compacto' : '') });
if (!('draggable' in document.createElement('div')) || matchMedia('(pointer:coarse)').matches) kb.classList.add('movil');
const cols = {};
ESTADOS.forEach(([k, nombre]) => {
const c = el('div', { class: 'col e-' + k, 'data-estado': k });
c.append(el('h3', { html: ESTADO_SVG[k] + '<span>' + nombre + '</span><span class="n">0</span>' }));
if (k !== 'hecho' && opts.nuevoPorColumna !== false) {
const mas = el('button', { type: 'button', class: 'kb-mas', html: icono('mas') + 'Nuevo' });
mas.addEventListener('click', () => abrir(null, k));
c.append(mas);
}
c.addEventListener('dragover', e => { e.preventDefault(); c.classList.add('sobre'); });
c.addEventListener('dragleave', () => c.classList.remove('sobre'));
c.addEventListener('drop', e => {
e.preventDefault(); c.classList.remove('sobre');
const id = e.dataTransfer.getData('text/plain'); if (!id) return;
// posición: antes de la primera tarjeta cuyo centro queda debajo del cursor
const cards = [...c.querySelectorAll('.card')].filter(x => x.dataset.id !== id);
let idx = cards.length;
for (let i = 0; i < cards.length; i++) { const r = cards[i].getBoundingClientRect(); if (e.clientY < r.top + r.height / 2) { idx = i; break; } }
mover(id, k, idx);
});
cols[k] = c; kb.append(c);
});
root.append(kb);
// franja para soltar (27-oct): al arrastrar aparece fija arriba de la pantalla con las cuatro columnas, así no hay
// que arrastrar y bajar a la vez cuando la columna de destino quedó lejos
const dock = el('div', { class: 'kb-dock', hidden: '' });
ESTADOS.forEach(([k, n]) => {
const z = el('div', { class: 'kb-dock-z e-' + k, 'data-estado': k, html: ESTADO_SVG[k] + '<span>' + n + '</span>' });
z.addEventListener('dragover', e => { e.preventDefault(); z.classList.add('sobre'); });
z.addEventListener('dragleave', () => z.classList.remove('sobre'));
z.addEventListener('drop', e => {
e.preventDefault(); z.classList.remove('sobre'); dock.hidden = true;
const id = e.dataTransfer.getData('text/plain'); if (id) mover(id, k, 0);
});
dock.append(z);
});
root.append(dock);
const per = el('div', { class: 'kb-personas', hidden: '' });
root.append(per);
// ---------- filtrado y pintado ----------
function visibles() {
const q = filtro.buscar.trim().toLowerCase();
const corte = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10);
const yo = norm(user);
return tareas.filter(t => {
if (filtro.pista && t.pista !== filtro.pista) return false;
if (filtro.mios && yo && !idsResp(t).includes(yo) && !String(t.responsable || '').toLowerCase().includes(yo)) return false;
if (filtro.responsable) {
const f = filtro.responsable;
if (EQUIPO[f]) { if (!idsResp(t).includes(f)) return false; }
else if (!String(t.responsable || '').toLowerCase().includes(f)) return false;
}
if (filtro.version && t.version_tarjeta !== filtro.version) return false;
if (filtro.sitios.size && !sitiosDe(t).some(x => filtro.sitios.has(x))) return false;
if (t.estado === 'hecho') {
if (filtro.hechas === 'ninguna') return false;
if (filtro.hechas === 'recientes' && (t.modificado_en || '').slice(0, 10) < corte) return false;
}
if (q) { const txt = [t.titulo, t.descripcion, t.responsable, nombresResp(t).join(' '), (t.etiquetas || []).join(' '), t.id].join(' ').toLowerCase(); if (!txt.includes(q)) return false; }
return true;
});
}
function bloqueosAbiertos(t) { return (t.bloqueada_por || []).map(id => porId[id]).filter(b => b && b.estado !== 'hecho'); }
const corto = (s, n) => s.length > n ? s.slice(0, n - 1) + '…' : s;
function tarjeta(t) {
const mueve = puedeMover(t);
const c = el('div', { class: 'card p-' + t.pista + ' e-' + t.estado + (t.vencida ? ' vencida' : '') + (mueve ? '' : ' fija'), draggable: mueve ? 'true' : 'false', 'data-id': t.id });
if (mueve) {
c.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', t.id); e.dataTransfer.effectAllowed = 'move'; c.classList.add('arrastrando');
// la franja se muestra un instante después: cambiar la página en el mismo dragstart cancela el arrastre en Chrome
setTimeout(() => { dock.dataset.desde = t.estado; dock.hidden = false; }, 0);
});
c.addEventListener('dragend', () => { c.classList.remove('arrastrando'); dock.hidden = true; dock.querySelectorAll('.sobre').forEach(z => z.classList.remove('sobre')); });
}
// pista y versión de tarjeta
const sup = el('div', { class: 'sup' });
if (!opts.pista) sup.append(el('span', { class: 'pista-p' }), el('span', { text: PISTAS[t.pista] || t.pista }));
if (t.version_tarjeta && t.version_tarjeta !== '—') sup.append(el('span', { class: (BADGE[t.version_tarjeta] || 'chip') + ' ver-t', text: t.version_tarjeta }));
const sb = el('span', { class: 'sits' });
sitiosDe(t).forEach(id => sb.append(el('span', { class: 'sit-b s-' + id, title: SIT(id)[3], text: SIT(id)[1] })));
sup.append(sb);
c.append(sup);
// título (abre el detalle)
const tit = el('div', { class: 'tit', text: t.titulo, role: 'button', tabindex: '0' });
tit.addEventListener('click', () => abrir(t));
tit.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); abrir(t); } });
c.append(tit);
// de qué depende
const bl = bloqueosAbiertos(t);
if (bl.length) c.append(el('div', { class: 'nota', html: icono('aviso') + '<span>Espera: ' + esc(bl.map(b => corto(b.titulo.split(':')[0], 42)).join(' · ')) + '</span>' }));
// ligas
if (!opts.compacto && (t.enlaces || []).length) {
const e = el('div', { class: 'enl' });
t.enlaces.slice(0, 2).forEach(x => { const a = el('a', { href: x.url, html: icono('liga') + '<span>' + esc(x.titulo || x.url) + '</span>' }); a.addEventListener('click', ev => ev.stopPropagation()); e.append(a); });
if (t.enlaces.length > 2) e.append(el('span', { class: 'vacio', text: '+' + (t.enlaces.length - 2) + ' más' }));
c.append(e);
}
if (!opts.compacto && (t.etiquetas || []).length) { const g = el('div', { class: 'etq' }); t.etiquetas.slice(0, 3).forEach(x => g.append(el('span', { class: 'chip', text: x }))); c.append(g); }
// responsables y fecha
const pie = el('div', { class: 'pie' });
pie.append(t.responsable ? avatares(t) : el('span', { class: 'vacio', text: 'Sin responsable' }));
const der = el('span', { class: 'der' });
const nCom = opts.compacto ? 0 : comentarios(t).length;
if (nCom) {
const cm = el('button', { type: 'button', class: 'cm-n', title: nCom + (nCom === 1 ? ' comentario' : ' comentarios'),
'aria-label': nCom + (nCom === 1 ? ' comentario: abrir' : ' comentarios: abrir'), html: icono('comentario') + '<span>' + nCom + '</span>' });
cm.addEventListener('click', ev => { ev.stopPropagation(); abrir(t, null, true); });
der.append(cm);
}
const f = fechaDe(t);
if (f) der.append(el('span', { class: 'fecha ' + f.cls, text: f.texto, title: 'Fecha límite ' + t.fecha_limite }));
// flechas ← → (27-oct): una columna a la izquierda o a la derecha, de un clic (solo el campeón o un administrador)
if (mueve) {
const i = ESTADOS.findIndex(e => e[0] === t.estado);
const fl = el('span', { class: 'flechas' });
[[-1, 'izq'], [1, 'der']].forEach(([d, cls]) => {
const dest = ESTADOS[i + d];
const b = el('button', { type: 'button', class: 'fl ' + cls, html: icono('flecha') });
if (dest) {
b.title = 'Mover a ' + dest[1]; b.setAttribute('aria-label', 'Mover a ' + dest[1]);
b.addEventListener('click', ev => { ev.stopPropagation(); mover(t.id, dest[0], 0); });
} else { b.disabled = true; b.setAttribute('aria-hidden', 'true'); b.tabIndex = -1; }
fl.append(b);
});
der.append(fl);
} else {
der.append(el('span', { class: 'candado', title: 'Lo mueve ' + quienMueve(t) + ' (campeón) o un administrador', html: icono('persona') }));
}
pie.append(der);
c.append(pie);
// "Mover a…": teléfonos y quien no arrastra
if (mueve) {
const mv = el('div', { class: 'mover' });
const sel = el('select', { 'aria-label': 'Mover a otra columna' });
sel.append(el('option', { value: '', text: 'Mover a…' }));
ESTADOS.filter(([k]) => k !== t.estado).forEach(([k, n]) => sel.append(el('option', { value: k, text: n })));
sel.addEventListener('change', () => { if (sel.value) mover(t.id, sel.value, 0); });
mv.append(sel); c.append(mv);
}
return c;
}
function pintar() {
porId = {}; tareas.forEach(t => porId[t.id] = t);
const vis = visibles();
selVista.querySelectorAll('button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.vista === vista)));
pintarChipsSitio();
kb.hidden = vista !== 'tablero';
per.hidden = vista === 'tablero';
if (vista === 'persona') pintarPersonas(vis);
else if (vista === 'sitio') pintarSitios(vis);
else ESTADOS.forEach(([k]) => {
const c = cols[k]; c.querySelectorAll('.card, .kb-mas-n').forEach(x => x.remove());
const lst = vis.filter(t => t.estado === k).sort((a, b) => (a.orden - b.orden) || (a.creado_en < b.creado_en ? -1 : 1));
// con tope (página de inicio), en Hecho van primero los más recientes
if (opts.limite && k === 'hecho') lst.sort((a, b) => String(b.modificado_en || '').localeCompare(String(a.modificado_en || '')));
c.querySelector('.n').textContent = lst.length;
const mas = c.querySelector('.kb-mas');
const pon = x => mas ? c.insertBefore(x, mas) : c.append(x);
const tope = !opts.limite ? Infinity : (typeof opts.limite === 'object' ? (opts.limite[k] || opts.limite.otros || 4) : opts.limite);
lst.slice(0, tope).forEach(t => pon(tarjeta(t)));
if (lst.length > tope) pon(el('a', { class: 'kb-mas-n', href: enlaceCompleto(), text: (lst.length - tope) + ' más' }));
});
// responsables presentes en los datos (más los conocidos)
selResp.querySelectorAll('option:not(:first-child)').forEach(o => o.remove());
Object.entries(EQUIPO).filter(([, m]) => m.activo !== false).sort((a, b) => a[1].nombre.localeCompare(b[1].nombre))
.forEach(([id, m]) => selResp.append(el('option', { value: id, text: m.nombre })));
const libres = new Set(); tareas.forEach(t => libresResp(t).forEach(x => libres.add(x.toLowerCase())));
[...libres].sort().forEach(x => selResp.append(el('option', { value: x, text: x + ' (sin registrar)' })));
selResp.value = filtro.responsable;
btnMios.setAttribute('aria-pressed', String(filtro.mios));
btnMios.disabled = !user;
btnMios.title = user ? 'Solo los pendientes de ' + ((EQUIPO[norm(user)] || {}).nombre || user) : 'No se sabe quién eres (falta X-Remote-User)';
if (opts.onPintado) opts.onPintado(vis, { vista, filtro, user, admin });
}
// ---------- vista por persona: una fila por responsable, una columna por estado ----------
function filtrosActivos() { return !!((filtro.pista && !opts.pista) || filtro.responsable || filtro.version || filtro.buscar.trim() || filtro.mios || filtro.sitios.size); }
function itemPersona(t) {
const b = el('button', { type: 'button', class: 'kb-p-item' + (t.vencida ? ' vencida' : '') + (t.estado === 'hecho' ? ' e-hecho' : ''), title: t.titulo });
b.append(el('span', { class: 'kb-p-tit', text: t.titulo }));
const f = fechaDe(t);
if (f) b.append(el('span', { class: 'fecha ' + f.cls, text: f.texto }));
b.addEventListener('click', () => abrir(t));
return b;
}
function pintarPersonas(vis) {
per.innerHTML = '';
const grupos = new Map();
const agrega = (clave, t, libre) => { if (!grupos.has(clave)) grupos.set(clave, { clave, libre, tareas: [] }); grupos.get(clave).tareas.push(t); };
vis.forEach(t => {
const ids = idsResp(t), libres = libresResp(t);
ids.forEach(id => agrega(id, t));
libres.forEach(x => agrega('libre:' + norm(x), t, x));
if (!ids.length && !libres.length) agrega('_nadie', t, 'Sin responsable');
});
const abiertos = g => g.tareas.filter(t => t.estado !== 'hecho').length;
const nombre = g => g.libre || (EQUIPO[g.clave] || {}).nombre || g.clave;
const filas = [...grupos.values()].sort((a, b) => (abiertos(b) - abiertos(a)) || nombre(a).localeCompare(nombre(b)));
const cab = el('div', { class: 'kb-p-fila kb-p-cab', 'aria-hidden': 'true' });
cab.append(el('span', { text: 'Persona' }));
ESTADOS.forEach(([k, n]) => cab.append(el('span', { html: ESTADO_SVG[k] + '<span>' + (k === 'hecho' && filtro.hechas === 'recientes' ? 'Hecho, 14 días' : n) + '</span>' })));
per.append(cab);
if (!filas.length) per.append(el('p', { class: 'vacio', text: 'Ningún pendiente con estos filtros.' }));
const N = opts.limitePersona || 3;
filas.forEach(g => {
const fila = el('div', { class: 'kb-p-fila' });
const quien = el('div', { class: 'kb-p-quien' });
quien.append(g.libre ? avatar(null, g.libre) : avatar(g.clave));
const m = EQUIPO[g.clave] || {};
const ab = abiertos(g), bl = g.tareas.filter(t => t.estado === 'bloqueado').length, ve = g.tareas.filter(t => t.vencida).length;
const carga = ab ? ab + (ab === 1 ? ' abierto' : ' abiertos') + (bl ? ', ' + bl + (bl === 1 ? ' bloqueado' : ' bloqueados') : '') + (ve ? ', ' + ve + (ve === 1 ? ' vencido' : ' vencidos') : '') : 'Nada abierto';
const txt = el('span', { class: 'kb-p-nom' });
txt.append(el('b', { text: nombre(g) }));
if (m.rol) txt.append(el('span', { class: 'kb-p-rol', text: m.rol }));
txt.append(el('span', { class: 'kb-p-carga' + (ve ? ' mal' : (bl ? ' aviso' : '')), text: carga }));
quien.append(txt);
fila.append(quien);
ESTADOS.forEach(([k, n]) => {
const celda = el('div', { class: 'kb-p-celda e-' + k, 'data-col': n });
let lst = g.tareas.filter(t => t.estado === k).sort((a, b) => (a.orden - b.orden) || (a.creado_en < b.creado_en ? -1 : 1));
if (k === 'hecho') lst = lst.sort((a, b) => String(b.modificado_en || '').localeCompare(String(a.modificado_en || '')));
const clave = g.clave + '|' + k;
const todo = desplegadas.has(clave);
(todo ? lst : lst.slice(0, N)).forEach(t => celda.append(itemPersona(t)));
if (!todo && lst.length > N) {
if (opts.enlaceMas) celda.append(el('a', { class: 'kb-mas-n', href: enlaceCompleto(g.libre ? {} : { responsable: g.clave }), text: (lst.length - N) + ' más' }));
else {
const b = el('button', { type: 'button', class: 'kb-mas-n', text: (lst.length - N) + ' más' });
b.addEventListener('click', () => { desplegadas.add(clave); pintar(); });
celda.append(b);
}
}
if (!lst.length) celda.classList.add('vacia');
fila.append(celda);
});
per.append(fila);
});
// quienes no tienen nada abierto (solo sin filtros: con filtros sería engañoso)
if (!filtrosActivos()) {
const con = new Set(filas.filter(g => abiertos(g)).map(g => g.clave));
const libres = Object.entries(EQUIPO).filter(([id, m]) => m.activo !== false && !con.has(id) && m.nombre)
.sort((a, b) => a[1].nombre.localeCompare(b[1].nombre));
if (libres.length) {
const f = el('div', { class: 'kb-p-libres' });
f.append(el('span', { class: 'kb-p-libres-t', text: 'Sin pendientes abiertos' }));
libres.forEach(([id]) => f.append(el('span', { class: 'persona' }, avatar(id), document.createTextNode(nombreCorto(id)))));
per.append(f);
}
}
}
// ---------- vista por sitio (22-oct): una fila por sitio, una columna por estado ----------
function pintarSitios(vis) {
per.innerHTML = '';
const cab = el('div', { class: 'kb-p-fila kb-p-cab', 'aria-hidden': 'true' });
cab.append(el('span', { text: 'Sitio' }));
ESTADOS.forEach(([k, n]) => cab.append(el('span', { html: ESTADO_SVG[k] + '<span>' + (k === 'hecho' && filtro.hechas === 'recientes' ? 'Hecho, 14 días' : n) + '</span>' })));
per.append(cab);
const N = opts.limitePersona || 3;
const lista = filtro.sitios.size ? SITIOS.filter(x => filtro.sitios.has(x[0])) : SITIOS;
lista.forEach(([id, corto, , nombre]) => {
const ts = vis.filter(t => sitiosDe(t).includes(id));
const fila = el('div', { class: 'kb-p-fila kb-s-fila s-' + id });
const quien = el('div', { class: 'kb-p-quien' });
quien.append(el('span', { class: 'kb-s-pin', html: icono('sitio') }));
const ab = ts.filter(t => t.estado !== 'hecho').length, bl = ts.filter(t => t.estado === 'bloqueado').length, ve = ts.filter(t => t.vencida).length;
const txt = el('span', { class: 'kb-p-nom' });
txt.append(el('b', { text: nombre.split(' · ')[0] }));
if (nombre.includes(' · ')) txt.append(el('span', { class: 'kb-p-rol', text: nombre.split(' · ').slice(1).join(' · ') }));
txt.append(el('span', { class: 'kb-p-carga' + (bl ? ' mal' : (ve ? ' aviso' : '')), text: ab ? ab + (ab === 1 ? ' abierto' : ' abiertos') + (bl ? ', ' + bl + (bl === 1 ? ' bloqueado' : ' bloqueados') : '') + (ve ? ', ' + ve + (ve === 1 ? ' vencido' : ' vencidos') : '') : 'Nada abierto' }));
quien.append(txt); fila.append(quien);
ESTADOS.forEach(([k, n]) => {
const celda = el('div', { class: 'kb-p-celda e-' + k, 'data-col': n });
let lst = ts.filter(t => t.estado === k).sort((a, b) => (a.orden - b.orden) || (a.creado_en < b.creado_en ? -1 : 1));
if (k === 'hecho') lst = lst.sort((a, b) => String(b.modificado_en || '').localeCompare(String(a.modificado_en || '')));
const clave = 'sitio:' + id + '|' + k;
const todo = desplegadas.has(clave);
(todo ? lst : lst.slice(0, N)).forEach(t => celda.append(itemPersona(t)));
if (!todo && lst.length > N) {
if (opts.enlaceMas) celda.append(el('a', { class: 'kb-mas-n', href: enlaceCompleto({ sitio: id }), text: (lst.length - N) + ' más' }));
else { const b = el('button', { type: 'button', class: 'kb-mas-n', text: (lst.length - N) + ' más' }); b.addEventListener('click', () => { desplegadas.add(clave); pintar(); }); celda.append(b); }
}
if (!lst.length) celda.classList.add('vacia');
fila.append(celda);
});
per.append(fila);
});
}
async function recargar() {
try {
const j = await api('/api/tareas');
tareas = j.tareas || []; admin = !!j.admin; user = j.user || '';
puedeBorrar = j.puede_borrar === undefined ? admin : !!j.puede_borrar; // servidor de antes del 22-oct: solo admin
borran = Array.isArray(j.borran) ? j.borran : [];
muevenSiempre = Array.isArray(j.mueven_siempre) ? j.mueven_siempre.map(norm) : null; // null: servidor de antes del 27-oct
if (Array.isArray(j.sitios) && j.sitios.length) SITIOS = j.sitios.map(x => [x.id, x.corto || x.id, x.id === 'qro' ? 'Querétaro' : (x.corto || x.id), x.nombre || x.id]);
usarEquipo(j.equipo);
pintar();
if (opts.onCargado) opts.onCargado(tareas, { admin, user });
} catch (e) { mensaje('No se pudo cargar el tablero: ' + e.message, false); }
}
// ---------- mover (arrastre o "Mover a…") ----------
async function mover(id, estado, idx) {
const t = porId[id]; if (!t) return;
if (t.estado !== estado && !puedeMover(t)) {
mensaje('Solo ' + quienMueve(t) + ', campeón de «' + corto(t.titulo, 41) + '», o un administrador lo cambia de columna.', false);
return;
}
const mismaCol = t.estado === estado;
// 28-oct: algunos cambios de columna piden «¿por qué?» antes de moverse; «Cancelar» la deja donde estaba
let nota = '';
const regla = mismaCol ? null : reglaMover(t.estado, estado);
if (regla) {
nota = await porQue(t, estado, regla);
if (nota === null) { pintar(); return; }
}
const columna = tareas.filter(x => x.estado === estado && x.id !== id).sort((a, b) => a.orden - b.orden);
columna.splice(Math.max(0, Math.min(idx, columna.length)), 0, t);
const ids = columna.map(x => x.id);
// pintado optimista
const prev = { estado: t.estado, orden: t.orden };
t.estado = estado; columna.forEach((x, i) => x.orden = i); pintar();
try {
if (!mismaCol) await api('/api/tarea?id=' + encodeURIComponent(id), json('PATCH', Object.assign({ estado, orden: ids.indexOf(id) }, nota ? { _nota: nota } : {})));
await api('/api/tareas/orden', json('POST', { estado, ids }));
if (!mismaCol) mensaje('«' + corto(t.titulo, 41) + '» pasó a ' + ESTADOS.find(e => e[0] === estado)[1] + '.' + (nota ? ' El porqué quedó en sus comentarios.' : ''), true);
await recargar();
} catch (e) { t.estado = prev.estado; t.orden = prev.orden; pintar(); mensaje('No se pudo mover: ' + e.message, false); }
}
// ---------- «¿por qué?» al mover (28-oct) ----------
const dlgPq = el('dialog', { class: 'kb-dlg kb-porque', 'aria-label': 'Por qué se mueve' });
contenedor.append(dlgPq);
function porQue(t, estado, regla) {
return new Promise(listo => {
dlgPq.innerHTML = '';
let valor = null;
const cab = el('div', { class: 'dcab' });
cab.append(el('h2', { text: regla.titulo }));
const x = el('button', { type: 'button', title: 'Cancelar', 'aria-label': 'Cancelar', html: icono('cerrar') });
x.addEventListener('click', () => dlgPq.close());
cab.append(x); dlgPq.append(cab);
const cuerpo = el('div', { class: 'dcuerpo' });
cuerpo.append(el('p', { class: 'pq-que', html: '«' + esc(corto(t.titulo, 90)) + '»<br><span>' + esc(NOMBRE_ESTADO[t.estado] || t.estado) + ' → <b>' + esc(NOMBRE_ESTADO[estado] || estado) + '</b></span>' }));
const lab = el('label', { text: regla.pregunta });
const txt = el('textarea', { rows: '3', maxlength: '1000', placeholder: regla.ph, 'aria-required': 'true' });
lab.setAttribute('for', 'kb-pq-' + t.id); txt.id = 'kb-pq-' + t.id;
const ayuda = el('div', { class: 'd-nota', text: 'Queda en los comentarios del pendiente, con tu nombre.' });
cuerpo.append(lab, txt, ayuda); dlgPq.append(cuerpo);
const pie = el('div', { class: 'dpie' });
const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' });
const ok = el('button', { type: 'button', text: 'Mover a ' + (NOMBRE_ESTADO[estado] || estado) });
const listoPara = () => { ok.disabled = txt.value.trim().length < 5; };
txt.addEventListener('input', listoPara); listoPara();
txt.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !ok.disabled) ok.click(); });
cancelar.addEventListener('click', () => dlgPq.close());
ok.addEventListener('click', () => { valor = txt.value.trim(); dlgPq.close(); });
pie.append(cancelar, ok); dlgPq.append(pie);
dlgPq.addEventListener('close', function fin() { dlgPq.removeEventListener('close', fin); listo(valor); });
if (typeof dlgPq.showModal === 'function') dlgPq.showModal(); else dlgPq.setAttribute('open', '');
setTimeout(() => txt.focus(), 50);
});
}
// ---------- comentarios en el detalle (28-oct) ----------
function seccionComentarios(t) {
const caja = el('div', { class: 'd-coment' });
caja.append(el('label', { html: icono('comentario') + '<span>Comentarios</span>' }));
const lista = el('div', { class: 'cm-lista' });
let todos = comentarios(t), verTodo = false;
const N = 4;
function item(c) {
const d = el('div', { class: 'cm' });
const id = norm(c.quien);
d.append(EQUIPO[id] ? avatar(id) : avatar(null, c.quien || '?'));
const cuerpo = el('div', { class: 'cm-c' });
const cab = el('div', { class: 'cm-cab' });
cab.append(el('b', { text: EQUIPO[id] ? nombreCorto(id) : (c.quien || '') }), el('span', { class: 'cm-f', text: fmt(c.cuando) }));
if (c.a) cab.append(el('span', { class: 'cm-mv e-' + c.a, text: '→ ' + (NOMBRE_ESTADO[c.a] || c.a) }));
cuerpo.append(cab, el('div', { class: 'cm-t', text: c.texto }));
d.append(cuerpo);
return d;
}
function pinta() {
lista.innerHTML = '';
if (!todos.length) { lista.append(el('div', { class: 'vacio', text: 'Sin comentarios todavía.' })); return; }
if (todos.length > N && !verTodo) {
const b = el('button', { type: 'button', class: 'cm-mas', text: 'Ver ' + (todos.length - N) + (todos.length - N === 1 ? ' anterior' : ' anteriores') });
b.addEventListener('click', () => { verTodo = true; pinta(); });
lista.append(b);
}
(verTodo ? todos : todos.slice(-N)).forEach(c => lista.append(item(c)));
}
pinta();
caja.append(lista);
const nuevoC = el('div', { class: 'cm-nuevo' });
const txt = el('textarea', { rows: '2', maxlength: '1000', placeholder: user ? 'Escribe un comentario: avance, por qué se movió, qué falta…' : 'Entra con tu usuario para comentar',
'aria-label': 'Nuevo comentario' });
const btn = el('button', { type: 'button', text: 'Comentar' });
const msg = el('div', { class: 'msg' });
const listoPara = () => { btn.disabled = !user || !txt.value.trim(); };
txt.disabled = !user; txt.addEventListener('input', listoPara); listoPara();
btn.addEventListener('click', async () => {
const nota = txt.value.trim(); if (!nota) return;
btn.disabled = true;
try {
const r = await api('/api/tarea?id=' + encodeURIComponent(t.id), json('PATCH', { _nota: nota }));
if (r.tarea) { t.historial = r.tarea.historial; t.modificado_por = r.tarea.modificado_por; t.modificado_en = r.tarea.modificado_en; }
todos = comentarios(t); verTodo = false; pinta();
txt.value = ''; msg.className = 'msg'; pintar();
lista.scrollTop = lista.scrollHeight;
} catch (e) { msg.className = 'msg err'; msg.textContent = 'No se guardó el comentario: ' + e.message; }
listoPara();
});
nuevoC.append(txt, btn);
caja.append(nuevoC, msg);
return caja;
}
// ---------- diálogo: alta y detalle ----------
const dlg = el('dialog', { class: 'kb-dlg', 'aria-label': 'Pendiente' });
contenedor.append(dlg);
function campo(lbl, input) { const d = el('div'); d.append(el('label', { text: lbl }), input); return d; }
function abrir(t, estadoInicial, aComentarios) {
const nuevo = !t;
t = t || { titulo: '', descripcion: '', pista: opts.pista || 'produccion', estado: estadoInicial || 'por-hacer', responsable: '', fecha_limite: null, enlaces: [], bloqueada_por: [], etiquetas: [], version_tarjeta: '—',
sitios: filtro.sitios.size ? [...filtro.sitios] : ['qro'] };
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' });
cab.append(el('h2', { text: nuevo ? 'Nuevo pendiente' : 'Pendiente' }));
if (!nuevo) cab.append(el('span', { class: 'dcab-id', text: t.id }));
const cerrar = el('button', { type: 'button', title: 'Cerrar', 'aria-label': 'Cerrar', html: icono('cerrar') }); cerrar.addEventListener('click', () => dlg.close()); cab.append(cerrar);
dlg.append(cab);
const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo);
const fTit = el('input', { type: 'text', value: t.titulo, maxlength: '200', placeholder: 'Qué hay que hacer, en una línea' });
const fDesc = el('textarea', { rows: '3', placeholder: 'Detalle, criterio de terminado, notas' }); fDesc.value = t.descripcion || '';
const fPista = el('select'); Object.entries(PISTAS).forEach(([k, v]) => fPista.append(el('option', { value: k, text: v }))); fPista.value = t.pista;
const fEst = el('select'); ESTADOS.forEach(([k, v]) => fEst.append(el('option', { value: k, text: v }))); fEst.value = t.estado;
// responsable: chips de miembros activos (varios) + texto libre opcional. Obligatorio.
const fResp = el('div', { class: 'chips' });
const yaIds = idsResp(t);
Object.entries(EQUIPO).filter(([id, m]) => m.activo !== false || yaIds.includes(id)).sort((a, b) => a[1].nombre.localeCompare(b[1].nombre)).forEach(([id, m]) => {
const l = el('label', { class: 'chip-sel' + (yaIds.includes(id) ? ' on' : '') });
const ck = el('input', { type: 'checkbox', value: id }); ck.checked = yaIds.includes(id);
ck.addEventListener('change', () => { l.classList.toggle('on', ck.checked); pintarCamp(); });
l.append(ck, avatar(id), document.createTextNode(m.nombre + (m.activo === false ? ' (inactivo)' : ''))); fResp.append(l);
});
const fLibre = el('input', { type: 'text', value: libresResp(t).join(', '), placeholder: 'Otro (texto libre, solo si la persona aún no está en Equipo)', style: 'margin-top:8px' });
const leerResp = () => [...fResp.querySelectorAll('input:checked')].map(c => c.value).concat(fLibre.value.split(',').map(x => x.trim()).filter(Boolean)).join(', ');
// campeón (27-oct): uno de los responsables marcados; es quien mueve el pendiente entre columnas
const pm = nuevo || puedeMover(t);
const fCamp = el('select', { 'aria-label': 'Campeón' });
function pintarCamp() {
const marcados = [...fResp.querySelectorAll('input:checked')].map(c => c.value);
const antes = fCamp.value || (nuevo ? '' : campeonDe(t));
fCamp.innerHTML = '';
if (!marcados.length) fCamp.append(el('option', { value: '', text: 'Primero marca a los responsables' }));
marcados.forEach(id => fCamp.append(el('option', { value: id, text: (EQUIPO[id] || {}).nombre || id })));
fCamp.value = marcados.includes(antes) ? antes : (marcados[0] || '');
}
pintarCamp();
fCamp.disabled = !pm;
const fFecha = el('input', { type: 'date', value: t.fecha_limite || '' });
const fVer = el('select'); VERSIONES.forEach(v => fVer.append(el('option', { value: v, text: v === '—' ? 'No aplica' : v }))); fVer.value = t.version_tarjeta || '—';
const fEtq = el('input', { type: 'text', value: (t.etiquetas || []).join(', '), placeholder: 'layout, crisis, quick-win' });
// sitio: dónde pasa el trabajo (uno o varios); nuevo = el del filtro o Querétaro
const ySit = sitiosDe(t);
const fSit = el('div', { class: 'chips' });
SITIOS.forEach(([id, , , nombre]) => {
const l = el('label', { class: 'chip-sel' + (ySit.includes(id) ? ' on' : '') });
const ck = el('input', { type: 'checkbox', value: id }); ck.checked = ySit.includes(id);
ck.addEventListener('change', () => l.classList.toggle('on', ck.checked));
l.append(ck, el('span', { class: 'kb-s-pin', html: icono('sitio') }), document.createTextNode(nombre)); fSit.append(l);
});
// columna (27-oct): botones que mueven el pendiente de inmediato (solo el campeón o un administrador)
if (!nuevo) {
const mvD = el('div', { class: 'd-mover', role: 'group', 'aria-label': 'Columna' });
ESTADOS.forEach(([k, n]) => {
const b = el('button', { type: 'button', 'aria-pressed': String(t.estado === k), html: ESTADO_SVG[k] + '<span>' + n + '</span>' });
b.disabled = !pm && t.estado !== k;
b.addEventListener('click', () => { if (t.estado === k) return; dlg.close(); mover(t.id, k, 0); });
mvD.append(b);
});
const cMv = campo('Columna', mvD);
cMv.append(el('div', { class: 'd-nota', text: pm ? 'Se mueve al hacer clic (lo demás se guarda con «Guardar cambios»).'
: 'Solo ' + quienMueve(t) + ', campeón de este pendiente, o un administrador lo cambia de columna o de campeón.' }));
cuerpo.append(cMv);
}
const secCom = nuevo ? null : seccionComentarios(t);
if (secCom) cuerpo.append(secCom);
cuerpo.append(campo('Título', fTit), campo('Descripción', fDesc));
const g1 = el('div', { class: 'dos-col' }); g1.append(campo('Pista', fPista));
if (nuevo) g1.append(campo('Estado', fEst));
g1.append(campo('Fecha límite', fFecha), campo('Versión de tarjeta', fVer), campo('Etiquetas (separadas por coma)', fEtq)); cuerpo.append(g1);
cuerpo.append(campo('Sitio (dónde pasa; puede ser más de uno)', fSit));
const cResp = campo('Responsable (obligatorio; puede ser más de uno)', fResp); cResp.append(fLibre); cuerpo.append(cResp);
cuerpo.append(campo('Campeón (uno solo: mueve el pendiente entre columnas y responde por él)', fCamp));
// enlaces
const enlBox = el('div', { class: 'enlaces-ed' });
function filaEnlace(e) {
const f = el('div', { class: 'fila' });
const a = el('input', { type: 'text', value: e.titulo || '', placeholder: 'Título' });
const b = el('input', { type: 'text', value: e.url || '', placeholder: '/hardware/… o https://…' });
const q = el('button', { type: 'button', class: 'sec', title: 'Quitar la liga', 'aria-label': 'Quitar la liga', html: icono('cerrar') }); q.addEventListener('click', () => f.remove());
f.append(a, b, q); enlBox.append(f);
}
(t.enlaces || []).forEach(filaEnlace);
const addEnl = el('button', { type: 'button', class: 'sec', html: icono('mas') + 'Liga', style: 'padding:6px 10px;font-size:13px' }); addEnl.addEventListener('click', () => filaEnlace({}));
cuerpo.append(campo('Documentos, actas o expediente de unidad', enlBox), addEnl);
// bloqueada por (otros pendientes abiertos)
const lst = el('div', { class: 'lista-chk' });
const otros = tareas.filter(x => x.id !== t.id && (x.estado !== 'hecho' || (t.bloqueada_por || []).includes(x.id)));
if (!otros.length) lst.append(el('span', { class: 'vacio', text: 'No hay otros pendientes.' }));
otros.forEach(x => { const l = el('label'); const ck = el('input', { type: 'checkbox', value: x.id }); ck.checked = (t.bloqueada_por || []).includes(x.id); l.append(ck, document.createTextNode(x.titulo + ' (' + (PISTAS[x.pista] || x.pista) + ')')); lst.append(l); });
cuerpo.append(campo('Espera a que terminen', lst));
if (!nuevo) {
const h = el('div', { class: 'hist' });
h.append(el('div', { html: '<b>Creado</b> por ' + esc(t.creado_por) + ' el ' + esc(fmt(t.creado_en)) + ' · última modificación ' + esc(t.modificado_por) + ' el ' + esc(fmt(t.modificado_en)) }));
(t.historial || []).slice().reverse().forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · <b>' + esc(x.quien) + '</b> · ' + esc(x.cambio) })));
cuerpo.append(h);
}
const m = el('div', { class: 'msg' }); cuerpo.append(m);
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
if (!nuevo && puedeBorrar) {
const del = el('button', { type: 'button', class: 'peligro izq', text: 'Borrar' });
del.addEventListener('click', async () => {
if (!confirm('¿Borrar el pendiente «' + t.titulo + '»? Sale del tablero; queda en la Actividad y Leo lo puede recuperar. Si ya no aplica, mejor muévelo a Hecho.')) return;
try {
const r = await api('/api/tarea?id=' + encodeURIComponent(t.id), { method: 'DELETE' });
const n = (r.dependencias || []).length;
dlg.close();
mensaje('Pendiente borrado.' + (n === 1 ? ' Un pendiente ya no lo espera.' : n > 1 ? ' ' + n + ' pendientes ya no lo esperan.' : ''), true);
recargar();
} catch (e) { m.className = 'msg err'; m.textContent = e.message; }
});
pie.append(del);
} else if (!nuevo) {
const nombres = borran.map(nombreCorto), quienes = nombres.length > 1 ? nombres.slice(0, -1).join(', ') + ' y ' + nombres[nombres.length - 1] : (nombres[0] || 'un administrador');
pie.append(el('span', { class: 'izq vacio', text: 'Solo ' + quienes + ' borran pendientes; si ya no aplica, muévelo a Hecho.' }));
}
const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' }); cancelar.addEventListener('click', () => dlg.close());
const guardar = el('button', { type: 'button', text: nuevo ? 'Crear pendiente' : 'Guardar cambios' });
guardar.addEventListener('click', async () => {
const d = { titulo: fTit.value.trim(), descripcion: fDesc.value.trim(), pista: fPista.value, estado: nuevo ? fEst.value : t.estado, responsable: leerResp(), campeon: fCamp.value,
fecha_limite: fFecha.value || null, version_tarjeta: fVer.value, etiquetas: fEtq.value.split(',').map(s => s.trim()).filter(Boolean),
enlaces: [...enlBox.querySelectorAll('.fila')].map(f => ({ titulo: f.children[0].value.trim(), url: f.children[1].value.trim() })).filter(e => e.url),
bloqueada_por: [...lst.querySelectorAll('input:checked')].map(c => c.value),
sitios: [...fSit.querySelectorAll('input:checked')].map(c => c.value) };
if (!d.titulo) { m.className = 'msg err'; m.textContent = 'Escribe el título.'; return; }
if (!d.sitios.length) { m.className = 'msg err'; m.textContent = 'Elige al menos un sitio (Querétaro si es de fabricación o ingeniería).'; fSit.scrollIntoView({ block: 'center' }); return; }
if (!d.responsable) { m.className = 'msg err'; m.textContent = 'Elige al menos un responsable del equipo (o escribe uno en texto libre).'; fResp.scrollIntoView({ block: 'center' }); return; }
guardar.disabled = true;
try {
let r;
if (nuevo) r = await api('/api/tareas', json('POST', d));
else {
const cambios = {}; Object.keys(d).forEach(k => {
if (k === 'sitios') { if ([...d.sitios].sort().join() !== [...sitiosDe(t)].sort().join()) cambios.sitios = d.sitios; return; }
if (JSON.stringify(d[k]) !== JSON.stringify(t[k] == null ? (Array.isArray(d[k]) ? [] : (k === 'fecha_limite' ? null : '')) : t[k])) cambios[k] = d[k];
});
if (cambios.campeon === '') delete cambios.campeon; // solo responsables en texto libre: el servidor decide
if (cambios.responsable && d.campeon) cambios.campeon = d.campeon; // que reordenar responsables no cambie al campeón
if (Object.keys(cambios).length) r = await api('/api/tarea?id=' + encodeURIComponent(t.id), json('PATCH', cambios));
}
dlg.close(); mensaje((nuevo ? 'Pendiente creado.' : 'Cambios guardados.') + (r && r.aviso ? ' Aviso: ' + r.aviso : ''), !(r && r.aviso)); recargar();
} catch (e) { m.className = 'msg err'; m.textContent = e.message; }
guardar.disabled = false;
});
pie.append(cancelar, guardar);
if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
setTimeout(() => {
// solo se desplaza el cuerpo del detalle (scrollIntoView movía también la ventana y escondía el encabezado en el teléfono)
if (aComentarios && secCom) { cuerpo.scrollTop = Math.max(0, secCom.offsetTop - cuerpo.offsetTop - 8); const x = secCom.querySelector('textarea'); if (x && !x.disabled) x.focus({ preventScroll: true }); }
else fTit.focus();
}, 50);
}
// ---------- eventos de filtros ----------
selPista.addEventListener('change', () => { filtro.pista = selPista.value; pintar(); });
selResp.addEventListener('change', () => { filtro.responsable = selResp.value; pintar(); });
selVer.addEventListener('change', () => { filtro.version = selVer.value; pintar(); });
selHechas.addEventListener('change', () => { filtro.hechas = selHechas.value; pintar(); });
buscar.addEventListener('input', () => { filtro.buscar = buscar.value; pintar(); });
btnMios.addEventListener('click', () => { filtro.mios = !filtro.mios; ponURL('mios', filtro.mios ? '1' : null); pintar(); });
btnNuevo.addEventListener('click', () => abrir(null));
if (q0.get('pista') && !opts.pista) { selPista.value = q0.get('pista'); filtro.pista = q0.get('pista'); }
if (q0.get('responsable')) { filtro.responsable = q0.get('responsable').toLowerCase(); }
if (q0.get('id')) setTimeout(() => { const t = porId[q0.get('id')]; if (t) abrir(t); }, 600);
if (vistasOn) cargarEquipo().then(() => { if (vista === 'persona' && tareas.length) pintar(); });
recargar();
return { recargar, abrir, pintar, get tareas() { return tareas; }, get admin() { return admin; }, get user() { return user; }, get vista() { return vista; } };
}
window.Kanban = { montar, PISTAS, ESTADOS, VERSIONES, RESPONSABLES, get SITIOS() { return SITIOS; }, sitiosDe, cargarEquipo, usarEquipo, nombresResp, idsResp, norm, nombreCorto, avatar, avatares, icono, fechaDe, fechaCorta, get equipo() { return EQUIPO; } };
})();