227 lines
18 KiB
HTML
227 lines
18 KiB
HTML
<!doctype html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Equipo y accesos — HBOX</title>
|
|
<link rel="stylesheet" href="tablero.css">
|
|
<style>
|
|
table.t td.num{text-align:right;font-variant-numeric:tabular-nums}
|
|
table.t th.num{text-align:right}
|
|
table.t tr.inactivo td{opacity:.55}
|
|
table.t td .nom{font-weight:700;cursor:pointer;color:var(--naranja)}
|
|
table.t td .nom:hover{text-decoration:underline}
|
|
table.t td .id{font-size:11.5px;color:var(--tinta2);display:block}
|
|
table.t td button{padding:5px 9px;font-size:12.5px;margin:1px 2px 1px 0}
|
|
.area{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:99px;background:var(--pend-bg);color:var(--tinta2)}
|
|
.pass-una-vez{background:var(--aviso-bg);border:1px solid var(--aviso);border-radius:10px;padding:12px 14px;margin-top:10px;font-size:14.5px;color:#7a4a00}
|
|
.pass-una-vez code{font-size:17px;font-weight:700;letter-spacing:.04em;user-select:all}
|
|
.mis-pend .card{cursor:default}
|
|
@media(max-width:760px){#cont table.t{min-width:760px}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<header class="top">
|
|
<div><h1>Equipo y accesos</h1><div class="meta" id="meta">Cargando…</div></div>
|
|
<nav>
|
|
<a href="../">← Documentación</a>
|
|
<a href="kanban.html">Pendientes</a>
|
|
<a href="ingenieria.html">Ingeniería</a>
|
|
<a href="produccion.html">Producción</a>
|
|
<a class="activo" href="equipo.html">Equipo</a>
|
|
<a href="../cuenta.html">Mi cuenta</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<details class="como">
|
|
<summary>Cómo se usa</summary>
|
|
<ol>
|
|
<li>Aquí está <b>quién es quién</b>: nombre, rol, área, si tiene acceso al sitio y cuántos pendientes abiertos y vencidos trae cada quien.</li>
|
|
<li><b>Todo pendiente tiene responsable.</b> Al crearlo en el tablero se elige de esta lista; puede haber más de uno.</li>
|
|
<li>Un miembro <b>sin acceso al sitio</b> también puede ser responsable (Adriana hoy): el pendiente se le asigna igual y alguien con acceso lo mueve por ella.</li>
|
|
<li>Toca un nombre para ver sus pendientes abiertos y abrir el tablero filtrado.</li>
|
|
<li>Los <b>accesos los crea Leo aquí</b> (botón «Nuevo usuario / miembro»), ya no con <code>htpasswd</code> en el servidor. La contraseña inicial se muestra <b>una sola vez</b>: pásala en persona o por mensaje directo; la persona la cambia en <a href="../cuenta.html">Mi cuenta</a>.</li>
|
|
<li>«Desactivar» oculta al miembro de las listas sin borrar nada; «Quitar acceso» le cierra la entrada al sitio y lo desactiva. Nadie se borra: el historial se queda.</li>
|
|
<li>Si no tienes acceso o no apareces, pídeselo a Leo.</li>
|
|
</ol>
|
|
</details>
|
|
|
|
<div class="msg" id="aviso"></div>
|
|
<div id="acciones" style="display:none;margin:0 0 12px"><button id="bNuevo">+ Nuevo usuario / miembro</button></div>
|
|
<div id="cont" class="tblwrap"></div>
|
|
<section class="bloque mis-pend" id="detalle" style="display:none"></section>
|
|
</div>
|
|
<dialog id="dlg"></dialog>
|
|
<script src="kanban.js"></script>
|
|
<script>
|
|
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 fmt = iso => (iso || '').replace('T', ' ').slice(0, 16);
|
|
const $ = id => document.getElementById(id);
|
|
const AREA = { direccion: 'Dirección', operaciones: 'Operaciones', ingenieria: 'Ingeniería', layout: 'Layout', produccion: 'Producción', pruebas: 'Pruebas', fabricacion: 'Fabricación', software: 'Software', campo: 'Campo', asistente: 'Asistente' };
|
|
const PISTAS = Kanban.PISTAS, ESTADOS = Object.fromEntries(Kanban.ESTADOS);
|
|
let MIEMBROS = [], ADMIN = false, USER = '', PASS_MIN = 8, AREAS = Object.keys(AREA), HTPASSWD_OK = true;
|
|
const dlg = $('dlg'), aviso = $('aviso');
|
|
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.innerHTML = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => aviso.className = 'msg', bien ? 5000 : 10000); }
|
|
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 = (m, b) => ({ method: m, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) });
|
|
function campo(l, i, ayuda) { const d = el('div'); d.append(el('label', { text: l }), i); if (ayuda) d.append(el('div', { style: 'font-size:12.5px;color:var(--tinta2);margin-top:3px', text: ayuda })); return d; }
|
|
|
|
function pintar() {
|
|
const cont = $('cont'); cont.innerHTML = '';
|
|
if (!MIEMBROS.length) { cont.append(el('div', { class: 'vacio', text: 'Todavía no hay miembros. Corre la siembra o crea el primero.' })); return; }
|
|
const t = el('table', { class: 't' });
|
|
t.innerHTML = '<tr><th>Nombre</th><th>Rol</th><th>Área</th><th>Acceso</th><th>Admin</th><th class="num">Abiertos</th><th class="num">Vencidos</th><th>Activo</th>' + (ADMIN ? '<th></th>' : '') + '</tr>';
|
|
MIEMBROS.forEach(m => {
|
|
const tr = el('tr', { class: m.activo === false ? 'inactivo' : '' });
|
|
const nom = el('span', { class: 'nom', text: m.nombre }); nom.addEventListener('click', () => detalle(m));
|
|
tr.append(el('td', null, nom, el('span', { class: 'id', text: m.id + (m.correo ? ' · ' + m.correo : '') + (m.telefono ? ' · ' + m.telefono : '') })));
|
|
tr.append(el('td', { text: m.rol || '—' }));
|
|
tr.append(el('td', null, el('span', { class: 'area', text: AREA[m.area] || m.area })));
|
|
tr.append(el('td', { html: m.tiene_login ? '<span class="pill ok">sí</span>' : '<span class="pill pend">no</span>' }));
|
|
tr.append(el('td', { html: m.es_admin ? '<span class="pill info">admin</span>' : '' }));
|
|
const p = m.pendientes || {};
|
|
tr.append(el('td', { class: 'num', html: p.abiertos ? '<a href="kanban.html?responsable=' + encodeURIComponent(m.id) + '"><b>' + p.abiertos + '</b></a>' : '0' }));
|
|
tr.append(el('td', { class: 'num', html: p.vencidas ? '<span style="color:var(--falla);font-weight:700">' + p.vencidas + '</span>' : '0' }));
|
|
tr.append(el('td', { html: m.activo === false ? '<span class="pill falla">inactivo</span>' : '<span class="pill ok">activo</span>' }));
|
|
if (ADMIN) {
|
|
const acc = el('td', { style: 'white-space:nowrap' });
|
|
const be = el('button', { class: 'sec', text: 'Editar' }); be.addEventListener('click', () => editar(m)); acc.append(be);
|
|
if (m.activo !== false) { const bd = el('button', { class: 'sec', text: 'Desactivar' }); bd.addEventListener('click', () => desactivar(m)); acc.append(bd); }
|
|
else { const ba = el('button', { class: 'sec', text: 'Reactivar' }); ba.addEventListener('click', () => reactivar(m)); acc.append(ba); }
|
|
if (m.tiene_login && m.id.toLowerCase() !== USER.toLowerCase()) { const bq = el('button', { class: 'peligro', text: 'Quitar acceso' }); bq.addEventListener('click', () => quitarAcceso(m)); acc.append(bq); }
|
|
if (!m.tiene_login && m.activo !== false) { const bc = el('button', { text: 'Crear acceso' }); bc.addEventListener('click', () => nuevo(m)); acc.append(bc); }
|
|
tr.append(acc);
|
|
}
|
|
t.append(tr);
|
|
});
|
|
cont.append(t);
|
|
}
|
|
async function cargar() {
|
|
$('meta').textContent = 'Actualizando…';
|
|
try {
|
|
const j = await api('/api/equipo?todos=1');
|
|
MIEMBROS = j.miembros || []; ADMIN = !!j.admin; USER = j.user || ''; PASS_MIN = j.pass_min || 8; AREAS = j.areas || AREAS; HTPASSWD_OK = j.htpasswd_ok !== false;
|
|
Kanban.usarEquipo(Object.fromEntries(MIEMBROS.map(m => [m.id, { nombre: m.nombre, activo: m.activo !== false, area: m.area }])));
|
|
pintar();
|
|
$('acciones').style.display = ADMIN ? '' : 'none';
|
|
const act = MIEMBROS.filter(m => m.activo !== false), con = act.filter(m => m.tiene_login).length;
|
|
$('meta').textContent = act.length + ' miembros activos · ' + con + ' con acceso al sitio · ' + (USER ? USER + (ADMIN ? ' (admin)' : '') : '');
|
|
if (ADMIN && !HTPASSWD_OK) mensaje('El servicio no encuentra el archivo de usuarios (HTPASSWD_FILE): se pueden editar miembros, pero no crear ni quitar accesos. Avisa a Leo.', false);
|
|
if (ADMIN && (j.logins_sin_miembro || []).length) mensaje('Cuentas con acceso que no están en el equipo: <b>' + j.logins_sin_miembro.map(esc).join(', ') + '</b>. Dales de alta como miembro (Nuevo usuario / miembro, sin marcar «crear acceso») para que puedan ser responsables.', false);
|
|
const q = new URLSearchParams(location.search).get('id'); if (q) { const m = MIEMBROS.find(x => x.id === q); if (m) detalle(m); }
|
|
} catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo cargar el equipo: ' + e.message })); $('meta').textContent = 'Error'; }
|
|
}
|
|
|
|
// ---------- pendientes de un miembro ----------
|
|
async function detalle(m) {
|
|
const sec = $('detalle'); sec.style.display = ''; sec.innerHTML = '';
|
|
sec.append(el('h2', { text: 'Pendientes de ' + m.nombre }), el('p', { class: 'sub', html: esc(m.rol || '') + ' · ' + esc(AREA[m.area] || m.area) + ' · <a href="kanban.html?responsable=' + encodeURIComponent(m.id) + '">Abrir en el tablero →</a>' }));
|
|
const lista = el('div', { text: 'Cargando…' }); sec.append(lista);
|
|
sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
try {
|
|
const j = await api('/api/tareas?responsable=' + encodeURIComponent(m.id));
|
|
const abiertas = (j.tareas || []).filter(t => t.estado !== 'hecho');
|
|
lista.innerHTML = '';
|
|
if (!abiertas.length) { lista.append(el('div', { class: 'vacio', text: 'Sin pendientes abiertos.' })); return; }
|
|
abiertas.forEach(t => {
|
|
const c = el('div', { class: 'card p-' + t.pista + (t.vencida ? ' vencida' : '') });
|
|
c.append(el('div', { class: 'tit', html: '<a href="kanban.html?id=' + encodeURIComponent(t.id) + '" style="color:inherit;text-decoration:none">' + esc(t.titulo) + '</a>' }));
|
|
c.append(el('div', { class: 'meta', html: '<span class="chip p-' + t.pista + '">' + esc(PISTAS[t.pista] || t.pista) + '</span><span class="chip">' + esc(ESTADOS[t.estado] || t.estado) + '</span>'
|
|
+ (t.fecha_limite ? '<span class="fecha' + (t.vencida ? ' mal' : '') + '">' + (t.vencida ? '⚠ venció ' : '📅 ') + esc(t.fecha_limite) + '</span>' : '')
|
|
+ ((t.responsable_ids || []).length > 1 ? '<span>👥 ' + esc(Kanban.nombresResp(t).join(', ')) + '</span>' : '') }));
|
|
lista.append(c);
|
|
});
|
|
} catch (e) { lista.textContent = 'No se pudieron leer los pendientes: ' + e.message; }
|
|
}
|
|
|
|
// ---------- diálogos (admin) ----------
|
|
function abrirDialogo(titulo, cuerpoFn, botones) {
|
|
dlg.innerHTML = '';
|
|
const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: titulo }));
|
|
const x = el('button', { type: 'button', text: '✕' }); x.addEventListener('click', () => dlg.close()); cab.append(x); dlg.append(cab);
|
|
const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo);
|
|
const m = el('div', { class: 'msg' });
|
|
cuerpoFn(cuerpo, m); cuerpo.append(m);
|
|
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
|
|
const cancelar = el('button', { type: 'button', class: 'sec', text: botones.length ? 'Cancelar' : 'Cerrar' }); cancelar.addEventListener('click', () => dlg.close()); pie.append(cancelar);
|
|
botones.forEach(([txt, fn]) => { const b = el('button', { type: 'button', text: txt }); b.addEventListener('click', async () => { b.disabled = true; try { await fn(m, b, pie); } catch (e) { m.className = 'msg err'; m.textContent = e.message; } b.disabled = false; }); pie.append(b); });
|
|
if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', '');
|
|
}
|
|
function formMiembro(c, m, conAcceso) {
|
|
m = m || {}; const f = {};
|
|
f.id = el('input', { type: 'text', maxlength: '30', value: m.id || '', placeholder: 'adriana (minúsculas, sin acentos; será el usuario del sitio)', autocapitalize: 'none' }); if (m.id) f.id.disabled = true;
|
|
f.nombre = el('input', { type: 'text', maxlength: '80', value: m.nombre || '', placeholder: 'Nombre y apellido' });
|
|
f.rol = el('input', { type: 'text', maxlength: '120', value: m.rol || '', placeholder: 'Técnico de pruebas · Layout de PCB · Operaciones' });
|
|
f.area = el('select'); AREAS.forEach(a => f.area.append(el('option', { value: a, text: AREA[a] || a }))); f.area.value = m.area || 'produccion';
|
|
f.correo = el('input', { type: 'email', maxlength: '120', value: m.correo || '' });
|
|
f.telefono = el('input', { type: 'tel', maxlength: '40', value: m.telefono || '' });
|
|
const g = el('div', { class: 'dos-col' }); g.append(campo('Usuario (id)', f.id), campo('Nombre', f.nombre), campo('Rol', f.rol), campo('Área', f.area), campo('Correo', f.correo), campo('Teléfono', f.telefono)); c.append(g);
|
|
if (conAcceso) {
|
|
f.acceso = el('input', { type: 'checkbox', style: 'width:auto' }); f.acceso.checked = !m.tiene_login && HTPASSWD_OK; f.acceso.disabled = !HTPASSWD_OK;
|
|
const l = el('label', { class: 'chip-sel', style: 'margin-top:12px' }); l.append(f.acceso, document.createTextNode(' Crear acceso al sitio (usuario y contraseña)')); c.append(l);
|
|
f.pass = el('input', { type: 'text', autocomplete: 'off', placeholder: 'mínimo ' + PASS_MIN + ' caracteres; se muestra una sola vez' });
|
|
const cp = campo('Contraseña inicial', f.pass, 'La persona la cambia en Mi cuenta la primera vez que entre.'); c.append(cp);
|
|
const gen = el('button', { type: 'button', class: 'sec', text: 'Generar una', style: 'padding:6px 10px;font-size:13px' });
|
|
gen.addEventListener('click', () => { const A = 'abcdefghjkmnpqrstuvwxyz23456789'; let p = ''; const r = new Uint32Array(12); crypto.getRandomValues(r); r.forEach(v => p += A[v % A.length]); f.pass.value = p.slice(0, 4) + '-' + p.slice(4, 8) + '-' + p.slice(8, 12); });
|
|
cp.append(gen);
|
|
const sync = () => { cp.style.display = f.acceso.checked ? '' : 'none'; }; f.acceso.addEventListener('change', sync); sync();
|
|
if (!HTPASSWD_OK) c.append(el('div', { class: 'err', text: 'No se pueden crear accesos: el servicio no encuentra el archivo de usuarios.' }));
|
|
}
|
|
return f;
|
|
}
|
|
const leerMiembro = f => ({ nombre: f.nombre.value.trim(), rol: f.rol.value.trim(), area: f.area.value, correo: f.correo.value.trim(), telefono: f.telefono.value.trim() });
|
|
function nuevo(base) {
|
|
let f;
|
|
abrirDialogo(base ? 'Crear acceso para ' + base.nombre : 'Nuevo usuario / miembro', c => { f = formMiembro(c, base, true); if (base) { f.acceso.checked = true; f.acceso.dispatchEvent(new Event('change')); } },
|
|
[['Guardar', async (m, b, pie) => {
|
|
const id = Kanban.norm(f.id.value); const d = leerMiembro(f);
|
|
if (!id) throw new Error('Escribe el usuario (id).');
|
|
if (!d.nombre) throw new Error('Escribe el nombre.');
|
|
if (f.acceso.checked) {
|
|
if (f.pass.value.length < PASS_MIN) throw new Error('La contraseña inicial debe tener al menos ' + PASS_MIN + ' caracteres.');
|
|
const j = await api('/api/usuarios', json('POST', Object.assign({ usuario: id, password_inicial: f.pass.value }, d)));
|
|
const pw = f.pass.value;
|
|
dlg.querySelector('.dcuerpo').innerHTML = '';
|
|
dlg.querySelector('.dcuerpo').append(el('div', { class: 'pass-una-vez', html: '✓ Acceso creado para <b>' + esc(j.usuario) + '</b>.<br>Contraseña inicial (no se vuelve a mostrar): <code>' + esc(pw) + '</code><br><small>Pásasela en persona o por mensaje directo y dile que la cambie en Mi cuenta.</small>' }));
|
|
pie.innerHTML = ''; const ok = el('button', { type: 'button', text: 'Listo' }); ok.addEventListener('click', () => dlg.close()); pie.append(ok);
|
|
cargar(); return;
|
|
}
|
|
if (base) throw new Error('Marca «Crear acceso al sitio» para darle acceso.');
|
|
await api('/api/equipo', json('POST', Object.assign({ id }, d)));
|
|
dlg.close(); mensaje('Miembro ' + esc(d.nombre) + ' creado (sin acceso al sitio).', true); cargar();
|
|
}]]);
|
|
}
|
|
function editar(m) {
|
|
let f;
|
|
abrirDialogo('Editar ' + m.nombre, c => {
|
|
f = formMiembro(c, m, false);
|
|
const h = el('div', { class: 'hist' });
|
|
h.append(el('div', { html: '<b>Creado</b> por ' + esc(m.creado_por) + ' el ' + esc(fmt(m.creado_en)) + (m.tiene_login ? ' · tiene acceso al sitio' : ' · sin acceso al sitio') }));
|
|
(m.historial || []).slice().reverse().slice(0, 10).forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · <b>' + esc(x.quien) + '</b> · ' + esc(x.cambio) })));
|
|
c.append(h);
|
|
}, [['Guardar', async () => {
|
|
const d = leerMiembro(f); const cambios = {};
|
|
Object.keys(d).forEach(k => { if (d[k] !== (m[k] || '')) cambios[k] = d[k]; });
|
|
if (Object.keys(cambios).length) await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', cambios));
|
|
dlg.close(); mensaje('Guardado.', true); cargar();
|
|
}]]);
|
|
}
|
|
async function desactivar(m) {
|
|
if (!confirm('¿Desactivar a ' + m.nombre + '? Deja de aparecer en las listas de responsables; sus pendientes y su historial se quedan.' + (m.tiene_login ? ' Su acceso al sitio NO se quita (usa «Quitar acceso» para eso).' : ''))) return;
|
|
try { await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', { activo: false })); mensaje(m.nombre + ' desactivado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
|
|
}
|
|
async function reactivar(m) {
|
|
try { await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', { activo: true })); mensaje(m.nombre + ' reactivado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
|
|
}
|
|
async function quitarAcceso(m) {
|
|
if (!confirm('¿Quitar el acceso al sitio a ' + m.nombre + ' (' + m.id + ')? Ya no podrá entrar y quedará como inactivo. Sus actas y pendientes se conservan.')) return;
|
|
try { await api('/api/usuario?u=' + encodeURIComponent(m.id), { method: 'DELETE' }); mensaje('Acceso de ' + m.id + ' retirado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
|
|
}
|
|
$('bNuevo').addEventListener('click', () => nuevo(null));
|
|
cargar();
|
|
</script>
|
|
</body>
|
|
</html>
|