205 lines
16 KiB
HTML
205 lines
16 KiB
HTML
<!doctype html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Sitio — HBOX</title>
|
|
<link rel="stylesheet" href="../tableros/tablero.css">
|
|
<style>
|
|
.ficha{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-bottom:14px}
|
|
.ficha .c{background:var(--tarjeta);border:1px solid var(--linea);border-radius:10px;padding:10px 12px}
|
|
.ficha .l{font-size:11px;color:var(--tinta2);text-transform:uppercase;letter-spacing:.05em}
|
|
.ficha .v{font-weight:600;font-size:15px}
|
|
.contacto{padding:6px 0;border-top:1px dashed var(--linea);font-size:14px}
|
|
.contacto:first-of-type{border-top:0}
|
|
.contacto .r{color:var(--tinta2);font-size:12.5px}
|
|
.linea{position:relative;padding-left:18px;border-left:2px solid var(--linea);margin-left:6px}
|
|
.ev{position:relative;padding:9px 0 9px 4px;border-bottom:1px solid var(--linea);font-size:14px}
|
|
.ev:last-child{border-bottom:0}
|
|
.ev::before{content:"";position:absolute;left:-25px;top:15px;width:11px;height:11px;border-radius:50%;background:var(--pend);border:2px solid var(--papel)}
|
|
.ev.instalacion-campo::before{background:var(--ok)} .ev.diagnostico-campo::before{background:var(--naranja)}
|
|
.ev.dano::before{background:var(--falla)} .ev.envio::before,.ev.recepcion::before{background:var(--info)}
|
|
.ev .f{color:var(--tinta2);font-size:12.5px}
|
|
.sitio-sel{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
|
|
.contactos-ed .fila{display:grid;grid-template-columns:1.2fr 1fr 1fr 1.2fr auto;gap:6px;margin-bottom:6px}
|
|
@media(max-width:700px){.contactos-ed .fila{grid-template-columns:1fr 1fr}}
|
|
.contactos-ed .fila button{padding:6px 9px;font-size:13px}
|
|
.por-texto{font-size:11px;color:var(--tinta2);border:1px dashed var(--linea);border-radius:99px;padding:0 7px}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<header class="top">
|
|
<div><h1 id="titulo">Sitio</h1><div class="meta" id="meta">Cargando…</div></div>
|
|
<nav>
|
|
<a href="../">← Documentación</a>
|
|
<a href="./">Sitios</a>
|
|
<a href="../formularios/instalacion.html">Nueva instalación</a>
|
|
<a href="../formularios/diagnostico.html">Diagnóstico</a>
|
|
<a href="../tableros/kanban.html?pista=campo">Pendientes de campo</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<details class="como">
|
|
<summary>Cómo se usa</summary>
|
|
<ol>
|
|
<li>Esta página junta todo lo que pasó en un sitio: unidades instaladas, envíos, recepciones, diagnósticos y daños. Sale de las actas; aquí no se captura nada de campo.</li>
|
|
<li>Para que una unidad aparezca, el acta debe traer el <b>sitio</b> elegido en el formulario (instalación, envío, recepción, diagnóstico, daño). Las actas viejas sin sitio se buscan por el nombre del puerto y se marcan «por texto».</li>
|
|
<li>Toca una serie para abrir su expediente completo. Rojo = unidad con marca de daño o baja.</li>
|
|
<li>Contactos y ubicación los edita solo el administrador (botón «Editar sitio»). Si falta un dato, díselo a Leo.</li>
|
|
<li>Para dar de alta otro sitio: <a href="./">lista de sitios</a> → «Nuevo sitio» (administrador).</li>
|
|
</ol>
|
|
</details>
|
|
|
|
<div class="sitio-sel"><label style="margin:0">Sitio</label><select id="sel"></select><button id="bEditar" class="sec" style="display:none">Editar sitio</button></div>
|
|
<div class="msg" id="aviso"></div>
|
|
<div id="cont"></div>
|
|
</div>
|
|
<dialog id="dlg"></dialog>
|
|
<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 NOMBRE = { 'instalacion-campo': 'Instalación', 'diagnostico-campo': 'Diagnóstico en campo', 'envio': '📦 Envío', 'recepcion': '📦 Recepción', 'dano': '⚡ Daño o incidente' };
|
|
let SITIOS = [], ADMIN = false, ACTUAL = null, DATOS = null;
|
|
const dlg = $('dlg'), aviso = $('aviso');
|
|
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => aviso.className = 'msg', bien ? 4000 : 9000); }
|
|
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) { const d = el('div'); d.append(el('label', { text: l }), i); return d; }
|
|
|
|
function render(j) {
|
|
const s = j.sitio, cont = $('cont'); cont.innerHTML = '';
|
|
$('titulo').textContent = s.nombre + (s.cliente ? ' — ' + s.cliente : '');
|
|
$('meta').textContent = (s.ubicacion || 'ubicación por confirmar') + ' · ' + j.total_unidades + ' unidad(es) · ' + j.total_actas + ' acta(s)' + (s.activo === false ? ' · INACTIVO' : '');
|
|
document.title = s.nombre + ' — sitio HBOX';
|
|
const ficha = el('div', { class: 'ficha' });
|
|
const c = (l, v) => ficha.append(el('div', { class: 'c' }, el('div', { class: 'l', text: l }), el('div', { class: 'v', html: v })));
|
|
c('Cliente', esc(s.cliente || '—')); c('Ubicación', esc(s.ubicacion || 'por confirmar'));
|
|
c('Unidades instaladas', String(j.unidades.filter(u => u.instalada).length) + ' <span style="font-weight:400;color:var(--tinta2);font-size:12.5px">con acta de instalación OK</span>');
|
|
c('Con daño o baja', '<span style="color:' + (j.danos.length ? 'var(--falla)' : 'inherit') + '">' + j.danos.length + '</span>');
|
|
cont.append(ficha);
|
|
if (s.notas) cont.append(el('p', { class: 'sub', style: 'margin:-6px 0 12px', text: s.notas }));
|
|
|
|
// contactos
|
|
const b0 = el('section', { class: 'bloque' });
|
|
b0.append(el('h2', { text: 'Contactos' }));
|
|
if (!(s.contactos || []).length) b0.append(el('div', { class: 'vacio', text: 'Sin contactos registrados. Pide a Leo que los agregue.' }));
|
|
(s.contactos || []).forEach(k => b0.append(el('div', { class: 'contacto', html: '<b>' + esc(k.nombre) + '</b>' + (k.rol ? ' <span class="r">· ' + esc(k.rol) + '</span>' : '') + (k.telefono ? ' · <a href="tel:' + esc(k.telefono) + '">' + esc(k.telefono) + '</a>' : '') + (k.correo ? ' · <a href="mailto:' + esc(k.correo) + '">' + esc(k.correo) + '</a>' : '') })));
|
|
cont.append(b0);
|
|
|
|
// unidades
|
|
const b1 = el('section', { class: 'bloque' });
|
|
b1.append(el('h2', { text: 'Unidades en este sitio' }), el('p', { class: 'sub', text: 'Toda serie con alguna acta que apunte a este sitio. «Instalada» = fecha de la última acta de instalación aprobada.' }));
|
|
if (!j.unidades.length) b1.append(el('div', { class: 'vacio', text: 'Todavía no hay unidades con actas en este sitio. Llena un acta de instalación o de envío con este sitio.' }));
|
|
else {
|
|
const t = el('table', { class: 't' }); t.innerHTML = '<tr><th>Unidad</th><th>Máquina</th><th>Instalada</th><th>Última acta</th><th>Actas</th><th>Estado</th></tr>';
|
|
j.unidades.forEach(u => {
|
|
const tr = el('tr');
|
|
tr.innerHTML = '<td><a href="../unidad.html?serie=' + encodeURIComponent(u.serie) + '"><b>' + esc(u.serie) + '</b></a>' + (u.coincidencia === 'texto' ? ' <span class="por-texto" title="el acta no trae el sitio; coincide por el nombre del puerto">por texto</span>' : '') + '</td>'
|
|
+ '<td>' + esc(u.maquina || '—') + '</td><td>' + (u.instalada ? '<span class="pill ok">' + esc(u.instalada) + '</span>' : '<span class="pill pend">sin acta</span>') + '</td>'
|
|
+ '<td>' + esc(NOMBRE[(u.ultima || {}).tipo] || (u.ultima || {}).tipo || '—') + (u.ultima && u.ultima.fecha ? ' · ' + esc(u.ultima.fecha) : '') + (u.ultima && u.ultima.accion ? '<div style="font-size:12px;color:var(--tinta2)">' + esc(u.ultima.accion) + '</div>' : '') + '</td>'
|
|
+ '<td>' + u.actas + '</td><td>' + (u.marca ? '<span class="pill ' + (u.marca === 'BAJA' ? 'falla' : 'aviso') + '">' + (u.marca === 'BAJA' ? 'BAJA' : 'DAÑADA') + '</span>' : '<span class="pill ok">sin marca</span>') + '</td>';
|
|
t.append(tr);
|
|
});
|
|
b1.append(el('div', { class: 'tblwrap' }, t));
|
|
}
|
|
cont.append(b1);
|
|
|
|
// daños abiertos
|
|
if (j.danos.length) {
|
|
const b2 = el('section', { class: 'bloque' });
|
|
b2.append(el('h2', { text: 'Daños abiertos' }), el('p', { class: 'sub', text: 'Unidades de este sitio con marca vigente. Se levanta con un acta de daño «Reparada».' }));
|
|
j.danos.forEach(u => { const a = u.marca_acta || {}; b2.append(el('div', { class: 'ev dano', style: 'padding-left:0', html: '<b><a href="../unidad.html?serie=' + encodeURIComponent(u.serie) + '">' + esc(u.serie) + '</a></b> · ' + esc(a.clasificacion || '') + (a.resumen ? ' — ' + esc(a.resumen) : '') + '<div class="f">' + esc(a.fecha || fmt(a.recibido)) + ' · ' + esc(a.tecnico || '') + (a.accion ? ' · ' + esc(a.accion) : '') + '</div>' })); });
|
|
cont.append(b2);
|
|
}
|
|
|
|
// actas
|
|
const b3 = el('section', { class: 'bloque' });
|
|
b3.append(el('h2', { text: 'Actas de instalación y campo' }), el('p', { class: 'sub', text: 'De la más reciente a la más vieja: instalaciones, diagnósticos, envíos, recepciones y daños.' }));
|
|
if (!j.actas.length) b3.append(el('div', { class: 'vacio', text: 'Sin actas.' }));
|
|
else {
|
|
const lt = el('div', { class: 'linea' });
|
|
j.actas.forEach(a => {
|
|
const r = String(a.resultado || '');
|
|
lt.append(el('div', { class: 'ev ' + a.tipo, html: '<b>' + esc(NOMBRE[a.tipo] || a.tipo) + '</b> — <a href="../unidad.html?serie=' + encodeURIComponent(a.serie) + '">' + esc(a.serie) + '</a>'
|
|
+ (a.maquina ? ' · ' + esc(a.maquina) : '') + ' <span class="pill ' + (r === 'FALLA' ? 'falla' : r ? 'ok' : 'pend') + '">' + esc(r || '—') + '</span>'
|
|
+ (a.coincidencia === 'texto' ? ' <span class="por-texto">por texto</span>' : '')
|
|
+ '<div class="f">' + esc(a.fecha || '') + ' · recibida ' + esc(fmt(a.recibido)) + ' · ' + esc(a.tecnico || '—') + (a.fotos ? ' · ' + a.fotos + ' foto(s)' : '') + '</div>'
|
|
+ (a.accion ? '<div>→ ' + esc(a.accion) + '</div>' : '') + (a.motivo ? '<div>Motivo: ' + esc(a.motivo) + '</div>' : '') + (a.resumen ? '<div>' + esc(a.resumen) + '</div>' : '') }));
|
|
});
|
|
b3.append(lt);
|
|
}
|
|
cont.append(b3);
|
|
}
|
|
|
|
async function cargar(id) {
|
|
$('cont').innerHTML = '<p class="vacio">Cargando…</p>';
|
|
try { DATOS = await api('/api/sitio/unidades?id=' + encodeURIComponent(id)); ACTUAL = DATOS.sitio; render(DATOS); $('bEditar').style.display = ADMIN ? '' : 'none'; }
|
|
catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo leer el sitio: ' + e.message })); $('meta').textContent = 'Error'; }
|
|
}
|
|
|
|
// ---------- alta / edición (admin) ----------
|
|
function formSitio(c, s) {
|
|
s = s || {}; const f = {};
|
|
f.id = el('input', { type: 'text', maxlength: '30', value: s.id || '', placeholder: 'icave (minúsculas, sin espacios)' }); if (s.id) f.id.disabled = true;
|
|
f.nombre = el('input', { type: 'text', maxlength: '80', value: s.nombre || '', placeholder: 'ICAVE' });
|
|
f.cliente = el('input', { type: 'text', maxlength: '120', value: s.cliente || '', placeholder: 'Hutchison Ports' });
|
|
f.ubicacion = el('input', { type: 'text', maxlength: '200', value: s.ubicacion || '', placeholder: 'Veracruz, Ver.' });
|
|
f.alias = el('input', { type: 'text', value: (s.alias || []).join(', '), placeholder: 'LCT, Lázaro Cárdenas (nombres con los que aparece en actas viejas)' });
|
|
f.notas = el('textarea', { rows: '2' }); f.notas.value = s.notas || '';
|
|
f.activo = el('select'); [['true', 'Activo'], ['false', 'Inactivo (ya no se muestra en los formularios)']].forEach(([v, t]) => f.activo.append(el('option', { value: v, text: t }))); f.activo.value = s.activo === false ? 'false' : 'true';
|
|
const g = el('div', { class: 'dos-col' }); g.append(campo('Id (slug)', f.id), campo('Nombre', f.nombre), campo('Cliente', f.cliente), campo('Ubicación', f.ubicacion)); c.append(g);
|
|
c.append(campo('Alias (separados por coma)', f.alias));
|
|
const ced = el('div', { class: 'contactos-ed' });
|
|
function fila(k) { const r = el('div', { class: 'fila' }); r.append(el('input', { type: 'text', value: k.nombre || '', placeholder: 'Nombre' }), el('input', { type: 'text', value: k.rol || '', placeholder: 'Rol' }), el('input', { type: 'text', value: k.telefono || '', placeholder: 'Teléfono' }), el('input', { type: 'text', value: k.correo || '', placeholder: 'Correo' })); const q = el('button', { type: 'button', class: 'sec', text: '✕' }); q.addEventListener('click', () => r.remove()); r.append(q); ced.append(r); }
|
|
(s.contactos || []).forEach(fila);
|
|
const add = el('button', { type: 'button', class: 'sec', text: '+ contacto', style: 'padding:6px 10px;font-size:13px' }); add.addEventListener('click', () => fila({}));
|
|
c.append(campo('Contactos', ced), add, campo('Notas', f.notas), campo('Estado', f.activo));
|
|
return () => ({ id: f.id.value.trim().toLowerCase(), nombre: f.nombre.value.trim(), cliente: f.cliente.value.trim(), ubicacion: f.ubicacion.value.trim(),
|
|
alias: f.alias.value.split(',').map(x => x.trim()).filter(Boolean), notas: f.notas.value.trim(), activo: f.activo.value === 'true',
|
|
contactos: [...ced.querySelectorAll('.fila')].map(r => ({ nombre: r.children[0].value.trim(), rol: r.children[1].value.trim(), telefono: r.children[2].value.trim(), correo: r.children[3].value.trim() })).filter(k => k.nombre) });
|
|
}
|
|
function dialogo(titulo, s) {
|
|
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 leer = formSitio(cuerpo, s);
|
|
const m = el('div', { class: 'msg' }); cuerpo.append(m);
|
|
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
|
|
const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' }); cancelar.addEventListener('click', () => dlg.close());
|
|
const guardar = el('button', { type: 'button', text: s ? 'Guardar' : 'Crear sitio' });
|
|
guardar.addEventListener('click', async () => {
|
|
const d = leer();
|
|
if (!d.nombre) { m.className = 'msg err'; m.textContent = 'Escribe el nombre.'; return; }
|
|
guardar.disabled = true;
|
|
try {
|
|
if (s) { delete d.id; await api('/api/sitio?id=' + encodeURIComponent(s.id), json('PATCH', d)); dlg.close(); mensaje('Sitio guardado.', true); await cargarLista(); cargar(s.id); }
|
|
else { const j = await api('/api/sitios', json('POST', d)); dlg.close(); mensaje('Sitio ' + j.sitio.nombre + ' creado.', true); await cargarLista(); history.replaceState(null, '', '?id=' + encodeURIComponent(j.sitio.id)); $('sel').value = j.sitio.id; cargar(j.sitio.id); }
|
|
} catch (e) { m.className = 'msg err'; m.textContent = e.message; }
|
|
guardar.disabled = false;
|
|
});
|
|
pie.append(cancelar, guardar);
|
|
if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', '');
|
|
}
|
|
$('bEditar').addEventListener('click', () => ACTUAL && dialogo('Editar sitio ' + ACTUAL.nombre, ACTUAL));
|
|
|
|
async function cargarLista() {
|
|
const j = await api('/api/sitios?todos=1'); SITIOS = j.sitios || []; ADMIN = !!j.admin;
|
|
const sel = $('sel'); sel.innerHTML = ''; sel.append(el('option', { value: '', text: '— elegir sitio —' }));
|
|
SITIOS.forEach(s => sel.append(el('option', { value: s.id, text: s.nombre + (s.activo === false ? ' (inactivo)' : '') })));
|
|
if (ADMIN) sel.append(el('option', { value: '__nuevo__', text: '+ Nuevo sitio…' }));
|
|
}
|
|
$('sel').addEventListener('change', () => { const v = $('sel').value; if (v === '__nuevo__') { $('sel').value = ACTUAL ? ACTUAL.id : ''; dialogo('Nuevo sitio', null); } else if (v) { history.replaceState(null, '', '?id=' + encodeURIComponent(v)); cargar(v); } });
|
|
(async () => {
|
|
try { await cargarLista(); } catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo leer la lista de sitios: ' + e.message })); return; }
|
|
const q = new URLSearchParams(location.search);
|
|
if (q.get('nuevo') === '1') { if (ADMIN) dialogo('Nuevo sitio', null); else mensaje('Solo el administrador da de alta sitios. Pide a Leo que lo cree.', false); }
|
|
const id = q.get('id') || (SITIOS[0] || {}).id;
|
|
if (id) { $('sel').value = id; cargar(id); } else { $('meta').textContent = 'No hay sitios dados de alta.'; $('cont').innerHTML = '<p class="vacio">Pide a Leo que cree el primer sitio.</p>'; }
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|