/* 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 más pequeñas (tableros que solo muestran un extracto) botonNuevoEn: elemento, // opcional: dónde va el botón «Nuevo pendiente» (p. ej. el encabezado) 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. 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' }; 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; 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, tiene_login: x.tiene_login }); return m; }).catch(() => ({})).then(m => { if (!Object.keys(m).length) RESPONSABLES.forEach(id => m[id] = { nombre: id, activo: true }); EQUIPO = Object.assign(EQUIPO, m); return EQUIPO; }); return equipoProm; } function usarEquipo(mapa) { if (mapa && Object.keys(mapa).length) EQUIPO = Object.assign(EQUIPO, 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)]); }; // ---- 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' }; root.classList.add('kanban-root'); const contenedor = root.closest('.tb') || root; // ---------- 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: últimos 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' }); if (opts.filtros !== false) { barra.append(btnMios); if (!opts.pista) barra.append(selPista); barra.append(selResp, selVer, selHechas, buscar); if (opts.botonNuevoEn) opts.botonNuevoEn.append(btnNuevo); else barra.append(btnNuevo); 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') { 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); // ---------- 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') + 'Espera: ' + esc(bl.map(b => corto(b.titulo.split(':')[0], 42)).join(' · ')) + '' })); // ligas if ((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 ((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(); 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; const mas = c.querySelector('.kb-mas'); lst.forEach(t => mas ? c.insertBefore(tarjeta(t), mas) : c.append(tarjeta(t))); }); // 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)'; } 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: '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: 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; pintar(); const u = new URL(location.href); if (filtro.mios) u.searchParams.set('mios', '1'); else u.searchParams.delete('mios'); history.replaceState(null, '', u.pathname + u.search + u.hash); }); 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); recargar(); return { recargar, abrir, get tareas() { return tareas; }, get admin() { return admin; }, get user() { return user; } }; } window.Kanban = { montar, PISTAS, ESTADOS, VERSIONES, RESPONSABLES, cargarEquipo, usarEquipo, nombresResp, idsResp, norm, avatar, avatares, icono, fechaDe, get equipo() { return EQUIPO; } }; })();