hbox-docs/docs/tableros/kanban.js

574 lines
40 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» (?vista=persona 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= (solo admin). Cada cambio guarda quién y cuándo en el historial.
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. */
(function () {
const PISTAS = { ingenieria: 'Ingeniería', produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', campo: 'Campo' };
const ESTADOS = [['por-hacer', 'Por hacer'], ['en-curso', 'En curso'], ['bloqueado', 'Bloqueado'], ['hecho', 'Hecho']];
const VERSIONES = ['—', 'V5.5', 'V5.6', 'V5.7', 'v6'];
// 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'
};
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';
idsResp(t).forEach(id => g.append(avatar(id)));
libresResp(t).forEach(x => g.append(avatar(null, x)));
g.title = 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) });
function montar(root, opts) {
opts = opts || {};
let tareas = [], admin = false, user = '', porId = {};
const q0 = new URLSearchParams(location.search);
const filtro = { pista: opts.pista || '', responsable: '', version: '', buscar: '', hechas: 'recientes', mios: q0.get('mios') === '1' };
const vistasOn = !!opts.vistas;
let vista = vistasOn && (q0.get('vista') === 'persona' || (!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 + u.hash);
}
// 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 === 'persona') u.set('vista', 'persona');
if (opts.pista) u.set('pista', opts.pista);
Object.entries(extra || {}).forEach(([k, v]) => v && u.set(k, v));
const q = u.toString();
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']].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 === 'persona' ? 'persona' : null); pintar(); });
selVista.append(b);
});
const modo = opts.filtros === undefined ? true : opts.filtros;
if (modo) {
if (vistasOn) barra.append(selVista);
barra.append(btnMios);
if (modo !== 'min') { 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);
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 (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 c = el('div', { class: 'card p-' + t.pista + ' e-' + t.estado + (t.vencida ? ' vencida' : ''), draggable: 'true', 'data-id': t.id });
c.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', t.id); e.dataTransfer.effectAllowed = 'move'; c.classList.add('arrastrando'); });
c.addEventListener('dragend', () => c.classList.remove('arrastrando'));
// 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 }));
if (sup.childNodes.length) 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 f = fechaDe(t);
if (f) pie.append(el('span', { class: 'fecha ' + f.cls, text: f.texto, title: 'Fecha límite ' + t.fecha_limite }));
c.append(pie);
// "Mover a…": teléfonos y quien no arrastra
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)));
kb.hidden = vista === 'persona';
per.hidden = vista !== 'persona';
if (vista === 'persona') pintarPersonas(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); }
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);
}
}
}
async function recargar() {
try {
const j = await api('/api/tareas');
tareas = j.tareas || []; admin = !!j.admin; user = j.user || '';
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;
const mismaCol = t.estado === estado;
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', { estado, orden: ids.indexOf(id) }));
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] + '.', true);
await recargar();
} catch (e) { t.estado = prev.estado; t.orden = prev.orden; pintar(); mensaje('No se pudo mover: ' + e.message, false); }
}
// ---------- 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) {
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: '—' };
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));
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(', ');
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, pedido 570, bloqueante' });
cuerpo.append(campo('Título', fTit), campo('Descripción', fDesc));
const g1 = el('div', { class: 'dos-col' }); g1.append(campo('Pista', fPista), campo('Estado', fEst), campo('Fecha límite', fFecha), campo('Versión de tarjeta', fVer), campo('Etiquetas (separadas por coma)', fEtq)); cuerpo.append(g1);
const cResp = campo('Responsable (obligatorio; puede ser más de uno)', fResp); cResp.append(fLibre); cuerpo.append(cResp);
// 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 && admin) {
const del = el('button', { type: 'button', class: 'peligro izq', text: 'Borrar' });
del.addEventListener('click', async () => {
if (!confirm('¿Borrar el pendiente «' + t.titulo + '»? No se puede deshacer. Si ya no aplica, mejor muévelo a Hecho.')) return;
try { await api('/api/tarea?id=' + encodeURIComponent(t.id), { method: 'DELETE' }); dlg.close(); mensaje('Pendiente borrado.', true); recargar(); }
catch (e) { m.className = 'msg err'; m.textContent = e.message; }
});
pie.append(del);
} else if (!nuevo) pie.append(el('span', { class: 'izq vacio', text: 'Solo el administrador borra; 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: fEst.value, responsable: leerResp(),
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) };
if (!d.titulo) { m.className = 'msg err'; m.textContent = 'Escribe el título.'; 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 (JSON.stringify(d[k]) !== JSON.stringify(t[k] == null ? (Array.isArray(d[k]) ? [] : (k === 'fecha_limite' ? null : '')) : t[k])) cambios[k] = d[k]; });
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(() => 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, cargarEquipo, usarEquipo, nombresResp, idsResp, norm, nombreCorto, avatar, avatares, icono, fechaDe, fechaCorta, get equipo() { return EQUIPO; } };
})();