/* Tablero kanban HBOX — módulo compartido (kanban.html, ingenieria.html, produccion.html).
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)
compacto: false, // tarjetas más pequeñas (tableros que solo muestran un extracto)
onCargado: (tareas, meta) => {} // callback después de cada carga
});
kb.recargar();
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. */
(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'];
const RESPONSABLES = ['leo', 'michelle', 'aldair', 'jesús', 'alan', 'daniel', 'claude'];
const BADGE = { 'V5.5': 'badge-v55', 'V5.6': 'badge-v56', 'V5.7': 'badge-v57', 'v6': 'badge-v6' };
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 + '-' + ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'][+m - 1] + (y !== hoyISO().slice(0, 4) ? '-' + y.slice(2) : ''); };
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 filtro = { pista: opts.pista || '', responsable: '', version: '', buscar: '', hechas: 'recientes' };
root.classList.add('kanban-root');
// ---------- barra de filtros ----------
const barra = el('div', { class: 'kb-barra' });
const selPista = el('select', { title: 'Pista' }); selPista.append(el('option', { value: '', text: 'Todas las pistas' }));
Object.entries(PISTAS).forEach(([k, v]) => selPista.append(el('option', { value: k, text: v })));
const selResp = el('select', { title: 'Responsable' }); selResp.append(el('option', { value: '', text: 'Todos los responsables' }));
const selVer = el('select', { title: 'Versión de tarjeta' }); selVer.append(el('option', { value: '', text: 'Todas las versiones' }));
VERSIONES.filter(v => v !== '—').forEach(v => selVer.append(el('option', { value: v, text: v })));
const selHechas = el('select', { title: 'Hechas' });
[['recientes', 'Hechas: últimos 14 días'], ['todas', 'Hechas: todas'], ['ninguna', 'Hechas: ocultar']].forEach(([v, t]) => selHechas.append(el('option', { value: v, text: t })));
const buscar = el('input', { type: 'search', placeholder: 'Buscar…', style: 'min-width:140px;flex:1' });
const btnNuevo = el('button', { text: '+ Nuevo pendiente' });
const cnt = el('span', { class: 'cnt' });
if (opts.filtros !== false) {
if (!opts.pista) barra.append(selPista);
barra.append(selResp, selVer, selHechas, buscar, btnNuevo, cnt);
root.append(barra);
}
const aviso = el('div', { class: 'msg' }); 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', null, el('span', { text: nombre }), el('span', { class: 'n', text: '0' })));
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);
// ---------- filtrado y pintado ----------
function visibles() {
const q = filtro.buscar.trim().toLowerCase();
const corte = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10);
return tareas.filter(t => {
if (filtro.pista && t.pista !== filtro.pista) return false;
if (filtro.responsable && !String(t.responsable || '').toLowerCase().includes(filtro.responsable)) 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, (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'); }
function tarjeta(t) {
const c = el('div', { class: 'card p-' + t.pista + (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'));
const tit = el('div', { class: 'tit', text: t.titulo }); tit.addEventListener('click', () => abrir(t)); c.append(tit);
const meta = el('div', { class: 'meta' });
if (!opts.pista) meta.append(el('span', { class: 'chip p-' + t.pista, text: PISTAS[t.pista] || t.pista }));
if (t.version_tarjeta && t.version_tarjeta !== '—') meta.append(el('span', { class: BADGE[t.version_tarjeta] || 'chip', text: t.version_tarjeta }));
if (t.responsable) meta.append(el('span', { text: '👤 ' + t.responsable }));
if (t.fecha_limite) meta.append(el('span', { class: 'fecha' + (t.vencida ? ' mal' : ''), text: (t.vencida ? '⚠ venció ' : '📅 ') + fechaCorta(t.fecha_limite) }));
(t.etiquetas || []).slice(0, 3).forEach(x => meta.append(el('span', { class: 'chip', text: x })));
c.append(meta);
const bl = bloqueosAbiertos(t);
if (bl.length) c.append(el('div', { class: 'bloq', text: '⛓ espera: ' + bl.map(b => b.titulo.length > 38 ? b.titulo.slice(0, 37) + '…' : b.titulo).join(' · ') }));
if ((t.enlaces || []).length) {
const e = el('div', { class: 'enl' });
t.enlaces.slice(0, 3).forEach(x => e.append(el('a', { href: x.url, text: '🔗 ' + (x.titulo || x.url) })));
c.append(e);
}
// "Mover a…": teléfonos y quien no arrastra
const mv = el('div', { class: 'mover' });
const sel = el('select');
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();
ESTADOS.forEach(([k]) => {
const c = cols[k]; c.querySelectorAll('.card').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));
c.querySelector('.n').textContent = lst.length;
lst.forEach(t => c.append(tarjeta(t)));
});
// responsables presentes en los datos (más los conocidos)
selResp.querySelectorAll('option:not(:first-child)').forEach(o => o.remove());
const set = new Set(RESPONSABLES);
tareas.forEach(t => String(t.responsable || '').split(/[,/]+/).map(s => s.trim().toLowerCase()).filter(Boolean).forEach(s => set.add(s)));
[...set].sort().forEach(r => selResp.append(el('option', { value: r, text: r })));
selResp.value = filtro.responsable;
const venc = vis.filter(t => t.vencida).length;
cnt.textContent = vis.length + ' pendiente(s)' + (venc ? ' · ' + venc + ' vencido(s)' : '') + (vis.length !== tareas.length ? ' · de ' + tareas.length : '');
}
async function recargar() {
try {
const j = await api('/api/tareas');
tareas = j.tareas || []; admin = !!j.admin; user = j.user || '';
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('«' + t.titulo.slice(0, 40) + (t.titulo.length > 40 ? '…' : '') + '» → ' + 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');
document.body.append(dlg);
function campo(lbl, input) { const d = el('div'); d.append(el('label', { text: lbl }), input); return d; }
function abrir(t) {
const nuevo = !t;
t = t || { titulo: '', descripcion: '', pista: opts.pista || 'produccion', estado: '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 ' + t.id }));
const cerrar = el('button', { type: 'button', text: '✕', title: '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;
const fResp = el('input', { type: 'text', value: t.responsable || '', list: 'kb-resp', placeholder: 'leo, michelle, aldair, jesús…' });
let dl = document.getElementById('kb-resp'); if (!dl) { dl = el('datalist', { id: 'kb-resp' }); document.body.append(dl); }
dl.innerHTML = ''; RESPONSABLES.forEach(r => dl.append(el('option', { value: r })));
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('Responsable', fResp), campo('Fecha límite', fFecha), campo('Versión de tarjeta', fVer), campo('Etiquetas (separadas por coma)', fEtq)); cuerpo.append(g1);
// 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', text: '✕' }); 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', text: '+ enlace', style: 'padding:6px 10px;font-size:13px' }); addEnl.addEventListener('click', () => filaEnlace({}));
cuerpo.append(campo('Enlaces (documentos, actas, 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('Bloqueado por (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 && 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: fResp.value.trim(),
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; }
guardar.disabled = true;
try {
if (nuevo) 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) await api('/api/tarea?id=' + encodeURIComponent(t.id), json('PATCH', cambios));
}
dlg.close(); mensaje(nuevo ? 'Pendiente creado.' : 'Cambios guardados.', true); 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(); });
btnNuevo.addEventListener('click', () => abrir(null));
const q = new URLSearchParams(location.search);
if (q.get('pista') && !opts.pista) { selPista.value = q.get('pista'); filtro.pista = q.get('pista'); }
if (q.get('responsable')) { filtro.responsable = q.get('responsable').toLowerCase(); }
if (q.get('id')) setTimeout(() => { const t = porId[q.get('id')]; if (t) abrir(t); }, 600);
recargar();
return { recargar, abrir, get tareas() { return tareas; }, get admin() { return admin; } };
}
window.Kanban = { montar, PISTAS, ESTADOS, VERSIONES, RESPONSABLES };
})();