/* 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»). Si la persona ya había comentado, puede
elegir uno de sus comentarios como el porqué («_nota_de» = su «cuando»; la entrada guarda «ref»): no se repite en el
hilo, el comentario elegido lleva la etiqueta «→ Hecho». Si comentó después del último cambio de columna, ese viene
ya elegido. El orden es el del historial, no la hora (hay horas en UTC y en hora local). */
(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) => '';
// Íconos de estado de las columnas (con relleno; colores de los tokens).
const ESTADO_SVG = {
'por-hacer': '',
'en-curso': '',
'bloqueado': '',
'hecho': ''
};
// ---- 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 = [], porClave = {};
(t.historial || []).forEach((x, i) => {
const c = String(x.cambio || '');
const mv = c.match(/estado: ([a-z-]+) → ([a-z-]+)/);
// movió citando uno de sus comentarios: la etiqueta va en ese comentario, sin repetir el texto
if (x.ref && !x.nota) {
const o = porClave[x.ref + '|' + x.quien];
if (o && mv) (o.movs = o.movs || []).push({ a: mv[2], quien: x.quien, cuando: x.cuando });
return;
}
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); }
}
// el porqué pegado igual que un comentario suyo de antes (como se hacía antes del 28-oct en la tarde): una sola vez
if (mv && texto.trim()) {
const igual = out.slice().reverse().find(o => o.quien === x.quien && !o.a && o.texto === texto.trim());
if (igual) { (igual.movs = igual.movs || []).push({ a: mv[2], quien: x.quien, cuando: x.cuando }); return; }
}
if (texto.trim()) {
const o = { quien: x.quien, cuando: x.cuando, texto: texto.trim(), de: mv && mv[1], a: mv && mv[2], i };
out.push(o); porClave[x.cuando + '|' + x.quien] = o;
}
});
return out;
}
// posición en el historial del último cambio de columna (-1 si nunca se movió)
const ultimoMovimiento = t => { const h = t.historial || []; for (let i = h.length - 1; i >= 0; i--) if (/estado: [a-z-]+ → /.test(String(h[i].cambio || ''))) return i; return -1; };
// 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] + '' + nombre + '0' }));
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] + '' + n + '' });
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') + 'Espera: ' + esc(bl.map(b => corto(b.titulo.split(':')[0], 42)).join(' · ')) + '' }));
// 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') + '' + esc(x.titulo || x.url) + '' }); 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') + '' + nCom + '' });
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] + '' + (k === 'hecho' && filtro.hechas === 'recientes' ? 'Hecho, 14 días' : n) + '' })));
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] + '' + (k === 'hecho' && filtro.hechas === 'recientes' ? 'Hecho, 14 días' : n) + '' })));
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 pq = null;
const regla = mismaCol ? null : reglaMover(t.estado, estado);
if (regla) {
pq = await porQue(t, estado, regla);
if (pq === null) { pintar(); return; }
}
const extra = {};
if (pq && pq.nota) extra._nota = pq.nota;
if (pq && pq.ref) extra._nota_de = pq.ref;
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) }, extra)));
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] + '.' + (pq ? ' 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)) + '»
' + esc(NOMBRE_ESTADO[t.estado] || t.estado) + ' → ' + esc(NOMBRE_ESTADO[estado] || estado) + '' }));
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;
// 28-oct (Leo): si ya comentaste en este pendiente, elige uno de tus comentarios en vez de repetirlo
const yo = norm(user), desde = ultimoMovimiento(t);
const mios = comentarios(t).filter(c => norm(c.quien) === yo).slice(-5).reverse();
let elegido = null;
const ops = el('div', { class: 'pq-ops', role: 'radiogroup', 'aria-label': 'Tus comentarios' });
const radios = [];
function elegir(c) {
elegido = c;
radios.forEach(r => { r.inp.checked = r.c === c; r.lab.classList.toggle('on', r.c === c); });
txt.placeholder = c ? 'Agrega algo más (opcional)' : regla.ph;
txt.setAttribute('aria-required', c ? 'false' : 'true');
listoPara();
}
if (mios.length) {
cuerpo.append(lab);
mios.forEach(c => {
const l = el('label', { class: 'pq-op' });
const inp = el('input', { type: 'radio', name: 'kb-pq-op-' + t.id });
l.append(inp, el('span', { class: 'pq-op-c' }, el('span', { class: 'pq-op-f', text: 'Tu comentario · ' + fmt(c.cuando) }), el('span', { class: 'pq-op-t', text: c.texto })));
inp.addEventListener('change', () => elegir(c));
radios.push({ inp, lab: l, c }); ops.append(l);
});
const lOtro = el('label', { class: 'pq-op' });
const inpOtro = el('input', { type: 'radio', name: 'kb-pq-op-' + t.id });
lOtro.append(inpOtro, el('span', { class: 'pq-op-c' }, el('span', { class: 'pq-op-t', text: 'Escribir otro motivo' })));
inpOtro.addEventListener('change', () => { elegir(null); txt.focus(); });
radios.push({ inp: inpOtro, lab: lOtro, c: null }); ops.append(lOtro);
cuerpo.append(ops, txt);
} else cuerpo.append(lab, txt);
const ayuda = el('div', { class: 'd-nota', text: mios.length ? 'El comentario que elijas queda marcado con este cambio; no se repite.' : 'Queda en los comentarios del pendiente, con tu nombre.' });
cuerpo.append(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) });
function listoPara() { ok.disabled = !elegido && txt.value.trim().length < 5; }
txt.addEventListener('input', listoPara);
// si comentaste después del último cambio de columna, ese comentario ya viene elegido
if (mios.length && mios[0].i > desde) elegir(mios[0]); else if (mios.length) elegir(null); else 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 = { nota: txt.value.trim(), ref: elegido ? elegido.cuando : '' }; 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(() => (elegido ? ok : 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') + 'Comentarios' }));
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) }));
(c.movs || []).forEach(m => cab.append(el('span', { class: 'cm-mv e-' + m.a, title: 'Movido con este comentario como porqué · ' + fmt(m.cuando),
text: '→ ' + (NOMBRE_ESTADO[m.a] || m.a) + (norm(m.quien) !== norm(c.quien) ? ' · ' + nombreCorto(norm(m.quien)) : '') })));
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' });
// el botón no depende de los eventos de teclado (autocompletado, dictado, pegar): revisa el texto al hacer clic
const listoPara = () => { btn.disabled = !user; };
txt.disabled = !user; listoPara();
btn.addEventListener('click', async () => {
const nota = txt.value.trim();
if (!nota) { msg.className = 'msg err'; msg.textContent = 'Escribe el comentario primero.'; txt.focus(); 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] + '' + n + '' });
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: 'Creado 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)) + ' · ' + esc(x.quien) + ' · ' + 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; } };
})();