// ============================================================ // S02 CENTENARIO - CONTROL DE OBRA FASE II v5 // EMPOPASTO S.A. E.S.P. // ============================================================ // ============================================================ // SISTEMA DE ICONOS TRAZZO v3 — SVG lineales monocromáticos // Reemplaza emojis en interfaz estructural + botones de acción. // currentColor hereda el color del contenedor (naranja/carbón). // ============================================================ const TRZ_ICON = { // roles / componentes tecnico: '', sst: '', ambiental: '', social: '', contratista:'', supervision:'', // botones de acción del mapa ruta: '', gps: '', gpsActivo: '', cargando: '' }; // Devuelve el SVG del componente por id (con fallback técnico) function svgComp(id){ return TRZ_ICON[id] || TRZ_ICON.tecnico; } function svgCompMini(id){ return svgComp(id).replace('' }, proceso: { label: 'En proceso', color: '#007aff', bg: 'rgba(0,122,255,0.12)', icon: '' }, aprobado: { label: 'Aprobado', color: '#34c759', bg: 'rgba(52,199,89,0.12)', icon: '' } }; let datosObra = { estados: {}, notas: {}, archivos: {}, bitacoras: [], fotoPrincipal: {}, coordenadasCustom: {} // { puntoId: { lat: '...', lng: '...', alt: '...' } } }; // Variable temporal para el flujo de nombre de foto let _pendingFile = null; let _pendingPuntoId = null; let _pendingTipo = null; let _pendingPreviewUrl = null; // ============================================================ // SELECCIÓN DE ROL // ============================================================ function seleccionarRol(rol) { if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate([15]); // [F1-1] Migrar contraseñas legacy de texto plano a hash, una sola vez. // No esperamos al resultado: la migración es de fondo y el primer login // del contratista (si no hay hash) lo guía para crear contraseña. if (typeof CryptoUtils !== 'undefined' && typeof migrarContrasenasLegacy === 'function') { migrarContrasenasLegacy(); } rolActivo = rol; sessionStorage.setItem('s02_rol', rol); document.getElementById('rol-screen').style.display = 'none'; // [F2-1] Supervisión: el componente se ELIGE EXPLÍCITAMENTE en la pantalla // de login (ya no se adivina probando la contraseña contra todas las cuentas). _loginCompElegido = null; if (rol === 'supervision') { componenteActivo = null; // se define al elegir componente + contraseña correcta } else { componenteActivo = localStorage.getItem(COMPONENTE_STORAGE_KEY) || COMPONENTE_DEFAULT; } irALogin(rol, null); } function irALogin(rol, comp) { const loginScreen = document.getElementById('login-screen'); loginScreen.style.display = 'flex'; const grid = document.getElementById('login-comp-grid'); const inputEl = document.getElementById('login-input'); const btnEl = document.getElementById('login-btn'); if (rol === 'supervision') { document.getElementById('login-icon').innerHTML = TRZ_ICON.supervision; document.getElementById('login-rol-label').textContent = 'Supervisión'; document.getElementById('login-rol-label').style.color = '#6c3483'; btnEl.style.background = '#6c3483'; // [F2-1] Visión del autor: primero se elige el componente, luego se pide // SU contraseña. La primera vez global se crea la cuenta del Supervisor Técnico. renderLoginComponentes(); const hayAlguna = COMPONENTES_ORDEN.some(id => !!leerHashContrasena(COMPONENTES[id].passHashKey)); if (!hayAlguna) { // Primer ingreso global: solo Técnico, flujo de creación _loginCompElegido = 'tecnico'; if (grid) grid.style.display = 'none'; document.getElementById('login-rol-label').textContent = 'Primer ingreso · Supervisor Técnico'; inputEl.style.display = ''; inputEl.placeholder = 'Crea una contraseña (mín. 8, 1 mayúscula, 1 número)'; btnEl.style.display = ''; btnEl.textContent = 'Crear cuenta'; } else { if (grid) grid.style.display = 'grid'; inputEl.style.display = 'none'; btnEl.style.display = 'none'; btnEl.textContent = 'Ingresar'; } } else { document.getElementById('login-icon').innerHTML = TRZ_ICON.contratista; document.getElementById('login-rol-label').textContent = 'Operación'; document.getElementById('login-rol-label').style.color = '#0a3d62'; btnEl.style.background = '#0a3d62'; if (grid) grid.style.display = 'none'; inputEl.style.display = ''; btnEl.style.display = ''; const tieneHash = !!leerHashContrasena(PASS_HASH_KEY_CONTRATISTA); inputEl.placeholder = tieneHash ? '••••••' : 'Crea una contraseña (mín. 8, 1 mayúscula, 1 número)'; btnEl.textContent = tieneHash ? 'Ingresar' : 'Crear contraseña'; } inputEl.value = ''; document.getElementById('login-error').style.visibility = 'hidden'; if (rol !== 'supervision') setTimeout(() => inputEl.focus(), 300); } // [F2-1] Grilla de componentes en el login de supervisión function renderLoginComponentes() { const grid = document.getElementById('login-comp-grid'); if (!grid) return; grid.innerHTML = COMPONENTES_ORDEN.map(id => { const c = COMPONENTES[id]; const creada = !!leerHashContrasena(c.passHashKey); const elegido = _loginCompElegido === id; return ``; }).join(''); } // [F2-1] El supervisor elige explícitamente a qué componente entra function seleccionarCompLogin(compId) { const c = getComponente(compId); const inputEl = document.getElementById('login-input'); const btnEl = document.getElementById('login-btn'); const errorEl = document.getElementById('login-error'); errorEl.style.visibility = 'hidden'; if (!leerHashContrasena(c.passHashKey)) { // Visión del autor: las cuentas distintas a la inicial se crean DESDE ADENTRO // (menú del supervisor, botón "+"), no desde el login. errorEl.querySelector('span').textContent = `La cuenta ${c.label} aún no existe. Se crea desde el menú del Supervisor Técnico.`; errorEl.style.visibility = 'visible'; _loginCompElegido = null; renderLoginComponentes(); inputEl.style.display = 'none'; btnEl.style.display = 'none'; return; } _loginCompElegido = compId; renderLoginComponentes(); document.getElementById('login-rol-label').textContent = c.rolLabel; document.getElementById('login-rol-label').style.color = c.color; inputEl.style.display = ''; inputEl.placeholder = `Contraseña de ${c.label}`; inputEl.value = ''; btnEl.style.display = ''; btnEl.style.background = c.color; setTimeout(() => inputEl.focus(), 150); } function volverARoles() { // [CORRECCIÓN PDF #7] Bug pantalla negra al "cambiar perfil": // El login-screen quedaba con overlays/menús residuales pegados. // Solución: limpiar TODOS los overlays + recargar para garantizar estado limpio. // Cerrar cualquier overlay/menú que pudiera estar abierto const menuBg = document.getElementById('menu-bg'); if (menuBg) menuBg.classList.remove('activo'); const menuLateral = document.getElementById('menu-lateral'); if (menuLateral) menuLateral.classList.remove('activo'); const modalOverlay = document.getElementById('modal-overlay'); if (modalOverlay) modalOverlay.classList.remove('activo'); const passModal = document.getElementById('pass-modal'); if (passModal) passModal.classList.remove('activo'); const notifPanel = document.getElementById('notif-panel'); if (notifPanel) notifPanel.style.display = 'none'; // Limpiar sesión para que recargue limpio en pantalla de rol sessionStorage.removeItem('s02_rol'); borrarSesion(); // [F1-4] rolActivo = null; componenteActivo = null; // Reset visual inmediato (por si reload demora) const loginScreen = document.getElementById('login-screen'); const rolScreen = document.getElementById('rol-screen'); if (loginScreen) loginScreen.style.display = 'none'; if (rolScreen) rolScreen.style.display = 'flex'; // Recargar la página para asegurar estado limpio (Leaflet, listeners, datos cargados) location.reload(); } // ============================================================ // FASE III: GESTIÓN DE COMPONENTES // ============================================================ function aplicarTemaComponente(comp) { if (!comp) return; // Setear variables CSS dinámicas para el tema del componente const root = document.documentElement; root.style.setProperty('--comp-color', comp.color); root.style.setProperty('--comp-bg', comp.colorBg); root.style.setProperty('--comp-border', comp.colorBorder); } function renderMenuComponentes() { // Solo contratista puede cambiar de componente desde menú if (rolActivo !== 'contratista') return; const grupo = document.getElementById('menu-componentes'); const label = document.getElementById('menu-comp-label'); if (!grupo || !label) return; grupo.style.display = 'block'; label.style.display = 'block'; grupo.innerHTML = COMPONENTES_ORDEN.map(id => { const c = COMPONENTES[id]; const activo = id === componenteActivo; return ` `; }).join(''); actualizarBadgesComponentes(); } // ============================================================ // [F8-7] Notificaciones leídas: mapa {id: timestamp} con poda automática // a 30 días para que localStorage no crezca sin límite. // Migra automáticamente el formato viejo (array de ids). // ============================================================ const LEIDAS_KEY = 's02_notif_leidas'; const LEIDAS_TTL_MS = 30 * 24 * 60 * 60 * 1000; function obtenerLeidasMapa() { let raw = localStorage.getItem(LEIDAS_KEY); if (!raw) return {}; try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { // Migración del formato viejo: todas con timestamp "ahora" const mapa = {}; const ahora = Date.now(); parsed.forEach(id => { mapa[String(id)] = ahora; }); localStorage.setItem(LEIDAS_KEY, JSON.stringify(mapa)); return mapa; } return parsed || {}; } catch (e) { return {}; } } function guardarLeidasMapa(mapa) { // Poda: descartar ids leídos hace más de 30 días const limite = Date.now() - LEIDAS_TTL_MS; const podado = {}; Object.keys(mapa).forEach(id => { if (mapa[id] >= limite) podado[id] = mapa[id]; }); localStorage.setItem(LEIDAS_KEY, JSON.stringify(podado)); return podado; } function esNotaLeida(mapa, id) { return Object.prototype.hasOwnProperty.call(mapa, String(id)); } // [OBRA] Aislamiento por obra: ids de los puntos de la obra activa function _idsObraActiva() { try { return new Set((typeof PUNTOS !== 'undefined' && Array.isArray(PUNTOS) ? PUNTOS : []).map(p => String(p.id))); } catch (e) { return new Set(); } } function actualizarBadgesComponentes() { const leidas = obtenerLeidasMapa(); COMPONENTES_ORDEN.forEach(id => { let count = 0; Object.keys(datosObra.notas).forEach(puntoId => { if (!_idsObraActiva().has(String(puntoId))) return; (datosObra.notas[puntoId] || []).forEach(n => { if (rolActivo === 'supervision') { // [F4-3] Supervisor: contar RESPUESTAS del contratista sin leer por componente if (n.parent_id && (n.origen || 'contratista') === 'contratista' && (n.componente || 'tecnico') === id && !esNotaLeida(leidas, n.id)) count++; } else { if (n.origen === 'supervisor' && (n.componente || 'tecnico') === id && !esNotaLeida(leidas, n.id)) count++; } }); }); const el = document.getElementById(`comp-notif-${id}`); if (el) { el.textContent = count; el.classList.toggle('activo', count > 0); } // [F4-3] Badge también en la lista de cuentas del supervisor (menú lateral) const elSup = document.getElementById(`cuenta-notif-${id}`); if (elSup) { elSup.textContent = count; elSup.style.display = count > 0 ? 'inline-flex' : 'none'; } }); } function cambiarComponenteContratista(compId) { if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate([15]); if (compId === componenteActivo) { toggleMenu(); return; } componenteActivo = compId; localStorage.setItem(COMPONENTE_STORAGE_KEY, compId); const comp = getComponente(compId); aplicarTemaComponente(comp); // Actualizar badge en menú-top const badge = document.getElementById('menu-rol-badge'); if (badge) badge.innerHTML = `${TRZ_ICON.contratista} Operación · ${comp.label}`; // Re-renderizar menú con marca activa renderMenuComponentes(); // Refrescar notificaciones según nuevo componente if (typeof cargarNotificaciones === 'function') cargarNotificaciones(); // [F3-2] Sincronizar badges del menú lateral al instante actualizarBadgesComponentes(); // [F3-1] Si hay una ficha abierta, RE-ABRIRLA completa: notas, ARCHIVOS y // estado deben corresponder al nuevo componente (antes solo se refrescaban notas). if (_puntoAbiertoId) { const punto = PUNTOS.find(p => p.id === _puntoAbiertoId); if (punto) abrirFicha(punto); } toggleMenu(); toast(`${comp.icono} Componente: ${comp.label}`); } async function validarLogin() { // [CORRECCIÓN PDF #5] Normalización defensiva: quitar espacios invisibles, U+00A0, etc. const inputEl = document.getElementById('login-input'); const errorEl = document.getElementById('login-error'); const mostrarError = (msg) => { if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate([200]); errorEl.style.visibility = 'visible'; errorEl.querySelector('span').textContent = msg; }; const inputRaw = inputEl.value || ''; const input = inputRaw.replace(/[\u00A0\u200B-\u200D\uFEFF]/g, '').trim(); // [F1-5] Campo vacío: mensaje explícito en lugar de retorno silencioso if (!input) { mostrarError('Escribe tu contraseña'); inputEl.focus(); return; } const _authReal = (typeof SUPABASE_CONFIG !== 'undefined' && SUPABASE_CONFIG.usarAuthReal) || (new URLSearchParams(location.search).get('authreal') === '1'); if (_authReal) { return _validarLoginReal(input, mostrarError, inputEl); } if (rolActivo === 'supervision') { // [F2-1] El componente fue elegido explícitamente; se valida SOLO contra él. if (!_loginCompElegido) { mostrarError('Elige primero el componente'); return; } const comp = getComponente(_loginCompElegido); const scope = 'sup_' + comp.id; // [F1-2] Bloqueo exponencial por intentos fallidos const restante = obtenerBloqueoRestante(scope); if (restante > 0) { mostrarError(`Demasiados intentos. Espera ${Validaciones.formatearEspera(restante)}`); return; } const hashed = leerHashContrasena(comp.passHashKey); if (!hashed) { // Primer ingreso global: crear la cuenta del Supervisor Técnico pedirCrearCuentaSupervisorInicial(input); return; } const ok = await CryptoUtils.verifyPassword(input, hashed.hash, hashed.salt); if (ok) { limpiarIntentos(scope); componenteActivo = comp.id; localStorage.setItem(COMPONENTE_STORAGE_KEY, comp.id); guardarSesion('supervision', comp.id); // [F1-4] if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate([20, 30, 40]); document.getElementById('login-screen').style.display = 'none'; inicializarApp(); } else { registrarIntentoFallido(scope); // [F1-2] mostrarError('Contraseña incorrecta'); inputEl.value = ''; inputEl.focus(); } } else { // Contratista const scope = 'contratista'; const restante = obtenerBloqueoRestante(scope); if (restante > 0) { mostrarError(`Demasiados intentos. Espera ${Validaciones.formatearEspera(restante)}`); return; } const hashed = leerHashContrasena(PASS_HASH_KEY_CONTRATISTA); if (!hashed) { // [F1-1] Primer ingreso: forzar creación de contraseña (sin defaults en código) pedirCrearContrasenaContratista(input); return; } const ok = await CryptoUtils.verifyPassword(input, hashed.hash, hashed.salt); if (ok) { limpiarIntentos(scope); guardarSesion('contratista', componenteActivo || COMPONENTE_DEFAULT); // [F1-4] if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate([20, 30, 40]); document.getElementById('login-screen').style.display = 'none'; inicializarApp(); } else { registrarIntentoFallido(scope); // [F1-2] mostrarError('Código incorrecto'); inputEl.value = ''; inputEl.focus(); } } } // [F1-1] Helper para leer { hash, salt } de localStorage de forma robusta function leerHashContrasena(key) { const raw = localStorage.getItem(key); if (!raw) return null; try { const obj = JSON.parse(raw); if (obj && obj.hash && obj.salt) return obj; } catch (e) { /* ignore */ } return null; } // ============================================================ // [F1-2] BLOQUEO EXPONENCIAL POR INTENTOS FALLIDOS // 3 fallos → 30 s · 5 → 5 min · 10 → 1 h (por ámbito: contratista o sup_) // ============================================================ function _leerLock(scope) { try { return JSON.parse(localStorage.getItem(LOCK_KEY_PREFIX + scope)) || { fallos: 0, ultimo: 0 }; } catch (e) { return { fallos: 0, ultimo: 0 }; } } function registrarIntentoFallido(scope) { const l = _leerLock(scope); l.fallos += 1; l.ultimo = Date.now(); localStorage.setItem(LOCK_KEY_PREFIX + scope, JSON.stringify(l)); } function obtenerBloqueoRestante(scope) { const l = _leerLock(scope); const bloqueo = Validaciones.calcularBloqueoMs(l.fallos); if (!bloqueo) return 0; const restante = l.ultimo + bloqueo - Date.now(); return restante > 0 ? restante : 0; } function limpiarIntentos(scope) { localStorage.removeItem(LOCK_KEY_PREFIX + scope); } // ============================================================ // [F1-4] SESIÓN PERSISTENTE (24 h, con expiración explícita) // iOS descarga pestañas en segundo plano: sessionStorage moría con ellas. // ============================================================ function guardarSesion(rol, comp) { localStorage.setItem(SESION_KEY, JSON.stringify({ rol, comp, exp: Date.now() + SESION_DURACION_MS })); } function leerSesionValida() { try { const s = JSON.parse(localStorage.getItem(SESION_KEY)); if (s && s.rol && s.exp && Date.now() < s.exp) return s; } catch (e) { /* ignore */ } localStorage.removeItem(SESION_KEY); return null; } function borrarSesion() { localStorage.removeItem(SESION_KEY); } // [F1-4] Al cargar la página, si hay sesión válida se restaura sin pedir login. function restaurarSesion() { const s = leerSesionValida(); if (!s) return false; rolActivo = s.rol; sessionStorage.setItem('s02_rol', s.rol); componenteActivo = s.comp || COMPONENTE_DEFAULT; localStorage.setItem(COMPONENTE_STORAGE_KEY, componenteActivo); const rolScreen = document.getElementById('rol-screen'); const loginScreen = document.getElementById('login-screen'); if (rolScreen) rolScreen.style.display = 'none'; if (loginScreen) loginScreen.style.display = 'none'; if (typeof CryptoUtils !== 'undefined') migrarContrasenasLegacy(); inicializarApp(); return true; } document.addEventListener('DOMContentLoaded', () => { restaurarSesion(); }); // ============================================================ // MODAL DE ENTRADA GENÉRICO (reemplaza prompt() nativo) // Cierra: F1-3, F2-6, F10-2 (parcial), F4-4 // ============================================================ let _inputModalOnOk = null; function abrirInputModal({ titulo, info, tipo, placeholder, valor, okLabel, validar, onOk, permitirCancelar }) { const m = document.getElementById('input-modal'); if (!m) { console.error('input-modal no existe en el HTML'); return; } document.getElementById('input-modal-titulo').textContent = titulo || ''; document.getElementById('input-modal-info').textContent = info || ''; const campo = document.getElementById('input-modal-campo'); campo.type = tipo || 'text'; campo.placeholder = placeholder || ''; campo.value = valor || ''; document.getElementById('input-modal-error').style.visibility = 'hidden'; document.getElementById('input-modal-ok').textContent = okLabel || 'Aceptar'; document.getElementById('input-modal-cancelar').style.display = (permitirCancelar === false) ? 'none' : ''; _inputModalOnOk = () => { const v = campo.value; if (typeof validar === 'function') { const r = validar(v); if (!r.ok) { const err = document.getElementById('input-modal-error'); err.textContent = r.msg || 'Valor inválido'; err.style.visibility = 'visible'; return; } } m.classList.remove('activo'); _inputModalOnOk = null; if (typeof onOk === 'function') onOk(v); }; m.classList.add('activo'); setTimeout(() => campo.focus(), 200); } function confirmarInputModal() { if (_inputModalOnOk) _inputModalOnOk(); } function cerrarInputModal() { document.getElementById('input-modal').classList.remove('activo'); _inputModalOnOk = null; } // [F7-5] Copiar coordenadas al portapapeles function copiarCoordenadas(lat, lng) { const texto = `${lat}, ${lng}`; const okMsg = () => { vibrar([12]); toast('Coordenadas copiadas'); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(texto).then(okMsg).catch(() => toast('No se pudo copiar')); } else { const ta = document.createElement('textarea'); ta.value = texto; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); okMsg(); } catch (e) { toast('No se pudo copiar'); } document.body.removeChild(ta); } } // [F10-4] SHA-256 en hexadecimal para sellar bitácoras async function sha256Hex(texto) { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(texto)); return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); } // [F1-1] Primer ingreso del supervisor: crear cuenta Técnico inicial // (mismo patrón que el contratista; el resto de cuentas se crean // desde el menú "Agregar otra cuenta" una vez dentro). async function pedirCrearCuentaSupervisorInicial(intentoInicial) { const errorEl = document.getElementById('login-error'); const nueva = (intentoInicial || '').trim(); // [F2-8] Política: mín. 8 caracteres, 1 mayúscula, 1 número const pol = Validaciones.validarPoliticaPass(nueva); if (!pol.ok) { errorEl.style.visibility = 'visible'; errorEl.querySelector('span').textContent = `Contraseña débil: ${pol.msg}`; return; } // [F1-3/F2-6] Confirmación con modal propio (enmascarado), no prompt() nativo abrirInputModal({ titulo: 'Primer ingreso · Supervisor Técnico', info: 'Vamos a crear la cuenta del Supervisor Técnico (las demás se crean desde el menú una vez dentro). Confirma la contraseña:', tipo: 'password', placeholder: 'Repite la contraseña', okLabel: 'Confirmar', validar: (v) => (v.trim() === nueva) ? { ok: true } : { ok: false, msg: 'Las contraseñas no coinciden' }, onOk: async () => { // [F2-4] Nombre del supervisor: obligatorio y validado (nombre + apellido) abrirInputModal({ titulo: 'Tu nombre', info: 'Para la trazabilidad de obra, escribe tu nombre y apellido. Aparecerá en cada observación que registres.', tipo: 'text', placeholder: 'Ej: Juan Pérez', okLabel: 'Crear cuenta', permitirCancelar: false, validar: (v) => Validaciones.validarNombrePersona(v), onOk: async (nombre) => { const compTec = COMPONENTES.tecnico; const hashed = await CryptoUtils.hashPassword(nueva); localStorage.setItem(compTec.passHashKey, JSON.stringify(hashed)); guardarCuentaSupervisor('tecnico'); guardarNombreSupervisor('tecnico', Validaciones.validarNombrePersona(nombre).valor); componenteActivo = 'tecnico'; localStorage.setItem(COMPONENTE_STORAGE_KEY, 'tecnico'); guardarSesion('supervision', 'tecnico'); // [F1-4] if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate([20, 30, 40]); document.getElementById('login-screen').style.display = 'none'; inicializarApp(); } }); } }); } // [F1-1] Primer ingreso del contratista: pedir crear contraseña async function pedirCrearContrasenaContratista(intentoInicial) { const errorEl = document.getElementById('login-error'); const nueva = (intentoInicial || '').trim(); // [F2-8] Política: mín. 8 caracteres, 1 mayúscula, 1 número const pol = Validaciones.validarPoliticaPass(nueva); if (!pol.ok) { errorEl.style.visibility = 'visible'; errorEl.querySelector('span').textContent = `Contraseña débil: ${pol.msg}`; return; } // [F1-3] Confirmación y nombre con modal propio (no prompt nativo, campo obligatorio) abrirInputModal({ titulo: 'Primer ingreso', info: 'Vamos a crear tu contraseña para futuros accesos. Confírmala:', tipo: 'password', placeholder: 'Repite la contraseña', okLabel: 'Confirmar', validar: (v) => (v.trim() === nueva) ? { ok: true } : { ok: false, msg: 'Las contraseñas no coinciden' }, onOk: () => { abrirInputModal({ titulo: 'Tu nombre', info: 'Para la trazabilidad de obra, escribe tu nombre y apellido. Sin nombre no es posible entrar.', tipo: 'text', placeholder: 'Ej: Carlos Gómez', okLabel: 'Entrar', permitirCancelar: false, validar: (v) => Validaciones.validarNombrePersona(v), onOk: async (nombre) => { const hashed = await CryptoUtils.hashPassword(nueva); localStorage.setItem(PASS_HASH_KEY_CONTRATISTA, JSON.stringify(hashed)); localStorage.setItem(NOMBRE_CONTRATISTA_KEY, Validaciones.validarNombrePersona(nombre).valor); guardarSesion('contratista', componenteActivo || COMPONENTE_DEFAULT); // [F1-4] if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate([20, 30, 40]); document.getElementById('login-screen').style.display = 'none'; inicializarApp(); } }); } }); } // [F1-3] Nombre del contratista para trazabilidad (con fallback explícito) function obtenerNombreContratista() { return localStorage.getItem(NOMBRE_CONTRATISTA_KEY) || 'Operación S02'; } // [F1-1] Migración de contraseñas legacy (texto plano) a hash al cargar la app. // Si existe localStorage[s02_pass_contratista] o componente.passKey con texto // plano, lo hasheamos a la nueva clave y borramos el viejo. async function migrarContrasenasLegacy() { try { // Contratista const legacyContratista = localStorage.getItem(PASS_KEY_CONTRATISTA); if (legacyContratista && !localStorage.getItem(PASS_HASH_KEY_CONTRATISTA)) { const hashed = await CryptoUtils.hashPassword(legacyContratista); localStorage.setItem(PASS_HASH_KEY_CONTRATISTA, JSON.stringify(hashed)); localStorage.removeItem(PASS_KEY_CONTRATISTA); console.log('[Migración] Contraseña del contratista migrada a hash'); } // Cada componente de supervisor for (const id of COMPONENTES_ORDEN) { const comp = COMPONENTES[id]; const legacy = localStorage.getItem(comp.passKey); if (legacy && !localStorage.getItem(comp.passHashKey)) { const hashed = await CryptoUtils.hashPassword(legacy); localStorage.setItem(comp.passHashKey, JSON.stringify(hashed)); localStorage.removeItem(comp.passKey); console.log(`[Migración] Contraseña de ${comp.label} migrada a hash`); } } } catch (e) { console.warn('[Migración] Error migrando contraseñas:', e); } } // Lista de cuentas de supervisor activas (las que tienen contraseña creada) const CUENTAS_SUPERVISOR_KEY = 's02_cuentas_supervisor'; function obtenerCuentasSupervisorActivas() { // Por defecto, solo "tecnico" está activa (la primera). Las demás se crean con botón + const raw = localStorage.getItem(CUENTAS_SUPERVISOR_KEY); if (!raw) return ['tecnico']; try { const arr = JSON.parse(raw); return Array.isArray(arr) && arr.length > 0 ? arr : ['tecnico']; } catch(e) { return ['tecnico']; } } function guardarCuentaSupervisor(compId) { const cuentas = obtenerCuentasSupervisorActivas(); if (!cuentas.includes(compId)) { cuentas.push(compId); localStorage.setItem(CUENTAS_SUPERVISOR_KEY, JSON.stringify(cuentas)); } } // ============================================================ // INICIALIZACIÓN // ============================================================ function inicializarApp() { cargarDatos(); // Header según rol const header = document.getElementById('header-principal'); const menuTop = document.getElementById('menu-top'); if (rolActivo === 'supervision') { if (header) header.classList.add('modo-supervision'); if (menuTop) menuTop.classList.add('supervision'); const badge = document.getElementById('menu-rol-badge'); const comp = getComponente(componenteActivo); if (badge) badge.innerHTML = `${svgComp(comp.id)} ${comp.rolLabel}`; // Ocultar bitácora para supervisión const btnBit = document.getElementById('btn-bitacora'); if (btnBit) btnBit.style.display = 'none'; const menuBit = document.getElementById('menu-bitacora'); if (menuBit) menuBit.style.display = 'none'; // [F8-3] El supervisor SÍ ve "Recibidas": ahí llegan las RESPUESTAS del // contratista a sus observaciones (antes el tab estaba oculto y la // conversación se perdía). Se renombra para reflejar su contenido. const tabRecibidas = document.getElementById('notif-tab-recibidas'); if (tabRecibidas) { tabRecibidas.style.display = ''; tabRecibidas.childNodes[0].textContent = 'Respuestas '; } if (typeof cambiarTabNotif === 'function') cambiarTabNotif('recibidas'); // Mostrar campana de notificaciones también para supervisor const btnNotif = document.getElementById('btn-notif'); if (btnNotif) btnNotif.style.display = 'inline-block'; // Mostrar botón + para agregar cuenta const btnAdd = document.getElementById('btn-add-cuenta'); if (btnAdd) btnAdd.style.display = 'flex'; // Ocultar botón exportación (solo contratista) const fabExport = document.getElementById('fab-export-group'); if (fabExport) fabExport.classList.remove('visible'); // Aplicar tema de color del componente al header aplicarTemaComponente(comp); // Actualizar menú lateral reorganizado (supervisor) actualizarMenuLateralSupervisor(); } else { const badge = document.getElementById('menu-rol-badge'); const comp = getComponente(componenteActivo); if (badge) badge.innerHTML = `${TRZ_ICON.contratista} Operación · ${comp.label}`; // Mostrar campana para contratista const btnNotif = document.getElementById('btn-notif'); if (btnNotif) btnNotif.style.display = 'inline-block'; // Ocultar botón + (solo supervisor) const btnAdd = document.getElementById('btn-add-cuenta'); if (btnAdd) btnAdd.style.display = 'none'; // Mostrar botón exportación (solo contratista) const fabExport = document.getElementById('fab-export-group'); if (fabExport) fabExport.classList.add('visible'); // [CORRECCIÓN PDF #2 y #6] Ocultar "Proyectos" y "Bitácora de obra" del menú del contratista const menuProyectos = document.getElementById('menu-proyectos'); if (menuProyectos) menuProyectos.style.display = 'none'; const menuBitContr = document.getElementById('menu-bitacora'); if (menuBitContr) menuBitContr.style.display = 'none'; // Mostrar selector de componentes en menú lateral renderMenuComponentes(); aplicarTemaComponente(comp); actualizarMenuLateralSupervisor(); // limpia bloque supervisor } // Mapa map = L.map('map', { zoomControl: false, attributionControl: false }).setView(CENTRO_MAPA, ZOOM_INICIAL); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map); L.control.zoom({ position: 'topright' }).addTo(map); cargarMarcadores(); actualizarStats(); // Forzar centrado en los puntos cargados (por si CENTRO_MAPA falló por alguna razón) setTimeout(() => { if (marcadores.length > 0) centrarMapa(); }, 100); // [F5-2] Filtros COMBINABLES: una fila de TIPO + una de ESTADO que se // aplican a la vez ("Válvulas" + "Pendientes" = solo válvulas pendientes). document.querySelectorAll('.chip').forEach(btn => { btn.addEventListener('click', () => { const f = btn.dataset.filtro; const esTipo = ['todos', 'valvula', 'intervencion', 'camara'].includes(f); if (esTipo) { filtroActivo.tipo = f; document.querySelectorAll('.chip[data-grupo="tipo"]').forEach(b => b.classList.toggle('activo', b.dataset.filtro === f)); } else { // Estado: tocar el activo lo des-selecciona (vuelve a "todos los estados") filtroActivo.estado = (filtroActivo.estado === f) ? null : f; document.querySelectorAll('.chip[data-grupo="estado"]').forEach(b => b.classList.toggle('activo', b.dataset.filtro === filtroActivo.estado)); } // Si había ruta óptima activa, recalcular para el nuevo filtro if (typeof rutaOptimaActiva !== 'undefined' && rutaOptimaActiva) { limpiarRutaOptima(); const btnRuta = document.getElementById('btn-ruta'); if (btnRuta) { btnRuta.classList.remove('ruta-on'); btnRuta.innerHTML = TRZ_ICON.ruta; } } cargarMarcadores(); }); }); // Notificaciones if (rolActivo === 'contratista') { cargarNotificaciones(); } // Conectar Supabase if (typeof inicializarSupabase === 'function') { const ok = inicializarSupabase(); if (ok && typeof sincronizarDescarga === 'function') { sincronizarDescarga().then(async () => { // [F5-1] Si está habilitado, cargar puntos desde la tabla `puntos` // (multi-proyecto). Con fallback transparente a datos.js. if (typeof cargarPuntosRemotos === 'function') await cargarPuntosRemotos(); if (typeof activarRealtime === 'function') activarRealtime(); // [F8-1/F6-3] Drenar lo que quedó pendiente de sesiones anteriores if (typeof SyncQueue !== 'undefined') SyncQueue.drenar(); }); } } // [F8-1] Mostrar de inmediato cuántas operaciones esperan sincronización if (typeof SyncQueue !== 'undefined') SyncQueue.notificarPendientes(); // [F9-4] Ofrecer retomar la ruta óptima guardada (vigencia 12 h) try { const rutaRaw = localStorage.getItem(RUTA_GUARDADA_KEY); if (rutaRaw && rolActivo === 'contratista') { const r = JSON.parse(rutaRaw); const horas = (Date.now() - r.ts) / 3600000; if (horas < 12 && Array.isArray(r.ids) && r.ids.length >= 2) { setTimeout(() => { if (confirm(`Tienes una ruta óptima guardada de hace ${horas < 1 ? Math.round(horas * 60) + ' min' : horas.toFixed(1) + ' h'} (${r.ids.length} puntos).\n\n¿Retomarla?`)) { if (r.filtro) { filtroActivo = r.filtro; document.querySelectorAll('.chip[data-grupo="tipo"]').forEach(b => b.classList.toggle('activo', b.dataset.filtro === (filtroActivo.tipo || 'todos'))); document.querySelectorAll('.chip[data-grupo="estado"]').forEach(b => b.classList.toggle('activo', b.dataset.filtro === filtroActivo.estado)); cargarMarcadores(); } toggleRutaOptima(); } else { localStorage.removeItem(RUTA_GUARDADA_KEY); } }, 1200); } else if (horas >= 12) { localStorage.removeItem(RUTA_GUARDADA_KEY); } } } catch (e) { /* no crítico */ } // Polling cada 20s como fallback (por si Realtime se cae, wifi inestable, etc) // Solo el contratista necesita esto para alertarse de notas nuevas del supervisor if (rolActivo === 'contratista' && typeof iniciarPollingNotas === 'function') { iniciarPollingNotas(); } } // ============================================================ // DATOS (localStorage) // ============================================================ function guardarDatos() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(datosObra)); } catch (e) { console.warn('Error guardando:', e); } } function cargarDatos() { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { const saved = JSON.parse(raw); datosObra.estados = saved.estados || {}; datosObra.notas = saved.notas || {}; datosObra.archivos = saved.archivos || {}; datosObra.bitacoras = saved.bitacoras || []; datosObra.fotoPrincipal = saved.fotoPrincipal || {}; datosObra.coordenadasCustom = saved.coordenadasCustom || {}; // [CORRECCIÓN PDF #1] Migración v5→v6: fotoPrincipal pasa de {puntoId: id} a {puntoId: {comp: id}} migrarFotoPrincipalAPorComponente(); } } catch (e) { console.warn('Error cargando datos:', e); } } // [CORRECCIÓN PDF #1] Migración: si fotoPrincipal[puntoId] es string (formato viejo), // convertirlo a {tecnico: id} para no perder la referencia ya establecida. function migrarFotoPrincipalAPorComponente() { let migrado = false; Object.keys(datosObra.fotoPrincipal).forEach(puntoId => { const valor = datosObra.fotoPrincipal[puntoId]; if (typeof valor === 'string' || typeof valor === 'number') { // Formato viejo: era una referencia única → asumimos era para "tecnico" datosObra.fotoPrincipal[puntoId] = { tecnico: valor }; migrado = true; } }); if (migrado) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(datosObra)); } catch(e) {} } } // [CORRECCIÓN PDF #1] Helpers para foto principal por (puntoId, componente) function obtenerIdFotoPrincipal(puntoId, compId) { const reg = datosObra.fotoPrincipal[puntoId]; if (!reg) return null; if (typeof reg === 'string' || typeof reg === 'number') return reg; // fallback formato viejo return reg[compId] || null; } function setIdFotoPrincipal(puntoId, compId, archivoId) { if (!datosObra.fotoPrincipal[puntoId] || typeof datosObra.fotoPrincipal[puntoId] !== 'object') { datosObra.fotoPrincipal[puntoId] = {}; } if (archivoId === null) { delete datosObra.fotoPrincipal[puntoId][compId]; } else { datosObra.fotoPrincipal[puntoId][compId] = archivoId; } } // ============================================================ // MARCADORES EN MAPA // ============================================================ // [F5-2] Predicado único de visibilidad: lo usan el mapa Y la ruta óptima function puntoPasaFiltro(punto) { const estado = datosObra.estados[punto.id] || 'pendiente'; if (filtroActivo.tipo && filtroActivo.tipo !== 'todos' && punto.tipo !== filtroActivo.tipo) return false; if (filtroActivo.estado && estado !== filtroActivo.estado) return false; return true; } function cargarMarcadores() { marcadores.forEach(m => map.removeLayer(m)); marcadores = []; PUNTOS.forEach(punto => { const estado = datosObra.estados[punto.id] || 'pendiente'; if (!puntoPasaFiltro(punto)) return; const claseEstado = estado !== 'pendiente' ? ` estado-${estado}` : ''; const icon = L.divIcon({ className: 'custom-marker', html: `
${punto.etiqueta||punto.numero}
`, iconSize: [32, 32], iconAnchor: [16, 32], popupAnchor: [0, -32] }); // Si el contratista marcó coordenadas manuales, usar esas (también en vista supervisión) const coordsReales = obtenerCoordsReales(punto); const marker = L.marker(coordsReales, { icon }).addTo(map); marker.on('click', () => { vibrar([12]); abrirFicha(punto); }); marcadores.push(marker); }); // [F5-3] Si hay filtro activo, ajustar el mapa al área de los puntos visibles const hayFiltro = (filtroActivo.tipo && filtroActivo.tipo !== 'todos') || !!filtroActivo.estado; if (hayFiltro && marcadores.length > 0 && map) { map.fitBounds(L.featureGroup(marcadores).getBounds(), { padding: [50, 50], maxZoom: 17 }); } } // ============================================================ // ABRIR FICHA SEGÚN ROL // ============================================================ function abrirFicha(punto) { _puntoAbiertoId = punto.id; // [F3-1] if (rolActivo === 'supervision') { abrirFichaSupervision(punto); } else { abrirFichaContratista(punto); } } // ============================================================ // FICHA CONTRATISTA // ============================================================ function abrirFichaContratista(punto) { const estado = datosObra.estados[punto.id] || 'pendiente'; const compActivo = componenteActivo || COMPONENTE_DEFAULT; const comp = getComponente(compActivo); // Solo notas del componente activo const notas = (datosObra.notas[punto.id] || []).filter(n => (n.componente || 'tecnico') === compActivo); const archivos = datosObra.archivos[punto.id] || []; const tagEstado = estado === 'completado' ? 'tag-completado' : estado === 'proceso' ? 'tag-proceso' : 'tag-pendiente'; const labelEstado = estado === 'completado' ? 'Completado' : estado === 'proceso' ? 'En proceso' : 'Pendiente'; const tipoLabel = punto.tipo === 'valvula' ? 'Válvula nueva' : punto.tipo === 'intervencion' ? 'Intervención de red' : 'Cámara de regulación'; const coordsCustom = datosObra.coordenadasCustom[punto.id] || {}; const latVal = coordsCustom.lat || ''; const lngVal = coordsCustom.lng || ''; const altVal = coordsCustom.alt || ''; const cReal = obtenerCoordsReales(punto); const iconCamara = ''; const iconArchivo = ''; const html = `
${punto.id} · Punto ${punto.etiqueta||punto.numero}
${escapeHtml(punto.titulo)}
${escapeHtml(punto.direccion)}
${tipoLabel} ${labelEstado}
Archivos y fotos ${archivos.length}
${renderArchivos(punto.id)}
Notas y observaciones · ${comp.label} ${notas.length}
${renderNotas(punto.id, 'contratista')}
Estado de avance
Navegación
Coordenadas GPS · editables
Ingrese las coordenadas reales del punto
Referencia del mapa · ${punto.coords[0].toFixed(6)}, ${punto.coords[1].toFixed(6)}
`; document.getElementById('ficha-contenido').innerHTML = html; abrirModalFicha(); } // ============================================================ // FICHA SUPERVISIÓN // [3] Solo notas del supervisor (no ve notas del contratista) // [6] Sin bitácora // [8] Coordenadas después de foto principal // ============================================================ function abrirFichaSupervision(punto) { const estado = datosObra.estados[punto.id] || 'pendiente'; const notas = datosObra.notas[punto.id] || []; const compActivo = componenteActivo || COMPONENTE_DEFAULT; const notasSupervisor = notas.filter(n => n.origen === 'supervisor' && (n.componente || 'tecnico') === compActivo); const tagEstado = estado === 'completado' ? 'tag-completado' : estado === 'proceso' ? 'tag-proceso' : 'tag-pendiente'; const labelEstado = estado === 'completado' ? ` Completado` : estado === 'proceso' ? ` En proceso` : ` Pendiente`; const tipoLabel = punto.tipo === 'valvula' ? 'Válvula nueva' : punto.tipo === 'intervencion' ? 'Intervención de red' : 'Cámara de regulación'; // Foto principal const fotoPrincipal = obtenerFotoPrincipal(punto.id); const fotoPrincipalHtml = fotoPrincipal ? `
Foto principal ${punto.id}
Foto de referencia
` : `
El contratista aún no ha subido fotos para este punto.
`; // [5] Coordenadas del contratista (solo lectura) 3D const coordsCustom = datosObra.coordenadasCustom[punto.id] || {}; const latVal = coordsCustom.lat || ''; const lngVal = coordsCustom.lng || ''; const altVal = coordsCustom.alt || ''; const hayCoordsCustom = latVal && lngVal; const cReal = obtenerCoordsReales(punto); const hoy = new Date().toISOString().slice(0, 10); const compSup = getComponente(componenteActivo); const html = `
${punto.id} · Punto ${punto.etiqueta||punto.numero} · ${compSup.rolLabel.toUpperCase()}
${escapeHtml(punto.titulo)}
${escapeHtml(punto.direccion)}
${tipoLabel} ${labelEstado}
Foto de referencia
${fotoPrincipalHtml}
Coordenadas GPS del contratista
${hayCoordsCustom ? `
Mapa de referencia: ${punto.coords[0].toFixed(6)}, ${punto.coords[1].toFixed(6)}
` : `
El contratista aún no ha registrado coordenadas para este punto.
`}
Archivos del componente ${compSup.label} ${(datosObra.archivos[punto.id] || []).filter(a => !a.componente || a.componente === compActivo).length}
${renderArchivos(punto.id)}

Los archivos que subas quedan marcados como ${compSup.rolLabel} y solo se ven en este componente.

Historial fotográfico
Seleccione una fecha para ver las fotos del día.
Observaciones · ${compSup.label} ${notasSupervisor.length}
${renderNotas(punto.id, 'supervision')}
Navegación
`; document.getElementById('ficha-contenido').innerHTML = html; abrirModalFicha(); cargarFotosFecha(punto.id, hoy); } // ============================================================ // CALENDARIO DE FOTOS // ============================================================ function cargarFotosFecha(puntoId, fechaStr) { const archivos = datosObra.archivos[puntoId] || []; const compActivo = componenteActivo || COMPONENTE_DEFAULT; const fotos = archivos.filter(a => { if (!a.tipo || !a.tipo.startsWith('image/')) return false; // FASE IV: Filtrar por componente (legacy sin componente = visible a todos) if (a.componente && a.componente !== compActivo) return false; const fechaArchivo = new Date(a.fecha).toISOString().slice(0, 10); return fechaArchivo === fechaStr; }); const contenedor = document.getElementById(`carrusel-${puntoId}`); if (!contenedor) return; if (fotos.length === 0) { contenedor.innerHTML = '
No hay fotos para esta fecha.
'; return; } contenedor.innerHTML = fotos.map(f => { const hora = new Date(f.fecha).toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' }); const nombreCorto = f.nombre.length > 20 ? f.nombre.substring(0, 18) + '…' : f.nombre; return `
${escapeHtml(f.nombre)}
${hora} · ${escapeHtml(nombreCorto)}
`; }).join(''); } // ============================================================ // FOTO PRINCIPAL (primera foto = referencia) // ============================================================ function obtenerFotoPrincipal(puntoId) { let archivos = datosObra.archivos[puntoId] || []; // FASE IV: Filtrar archivos por componente activo (legacy sin componente = visible) const compActivo = componenteActivo || COMPONENTE_DEFAULT; archivos = archivos.filter(a => !a.componente || a.componente === compActivo); // [CORRECCIÓN PDF #1] Foto principal por (punto, componente) const principalId = obtenerIdFotoPrincipal(puntoId, compActivo); if (principalId) { const foto = archivos.find(a => String(a.id) === String(principalId)); if (foto) return foto; } const primeraFoto = archivos.find(a => a.tipo && a.tipo.startsWith('image/')); if (primeraFoto) { // Solo el contratista actualiza fotoPrincipal automáticamente if (rolActivo === 'contratista') { setIdFotoPrincipal(puntoId, compActivo, primeraFoto.id); guardarDatos(); } return primeraFoto; } return null; } // ============================================================ // [1][7] NOTIFICACIONES (para contratista) // ============================================================ function toggleNotificaciones() { const panel = document.getElementById('notif-panel'); panel.classList.toggle('activo'); if (panel.classList.contains('activo')) { marcarNotificacionesLeidas(); } else { // [F3-2] Al cerrar el panel, los badges por componente quedan en sync actualizarBadgesComponentes(); if (typeof cargarNotificaciones === 'function') cargarNotificaciones(); } } function cargarNotificaciones() { const compActivo = componenteActivo || COMPONENTE_DEFAULT; // TAB RECIBIDAS: notas del supervisor para este componente const notasRecibidas = []; // TAB MIS NOTAS: notas que YO escribí en este componente (no respuestas) const notasMias = []; Object.keys(datosObra.notas).forEach(puntoId => { if (!_idsObraActiva().has(String(puntoId))) return; (datosObra.notas[puntoId] || []).forEach(n => { if ((n.componente || 'tecnico') !== compActivo) return; if (n.parent_id) return; // omitir respuestas, son parte del hilo padre if (rolActivo === 'contratista') { if (n.origen === 'supervisor') { notasRecibidas.push({ ...n, puntoId }); } else { notasMias.push({ ...n, puntoId }); } } else { // [F8-3] Supervisor: "Recibidas" NO son las notas raíz del contratista // (esas son su libreta PRIVADA y no deben mostrarse al supervisor). // Sus "recibidas" son las RESPUESTAS del contratista a sus observaciones, // que se agregan más abajo. Aquí solo van sus propias notas raíz. if (n.origen === 'supervisor') { notasMias.push({ ...n, puntoId }); } } }); }); // [F8-3] Supervisor: construir "Recibidas" = respuestas del contratista a SUS // observaciones del componente activo (antes este tab estaba oculto y el // supervisor perdía la conversación). if (rolActivo !== 'contratista') { Object.keys(datosObra.notas).forEach(puntoId => { if (!_idsObraActiva().has(String(puntoId))) return; (datosObra.notas[puntoId] || []).forEach(n => { if ((n.componente || 'tecnico') !== compActivo) return; if (n.parent_id && (n.origen || 'contratista') === 'contratista') { const padre = (datosObra.notas[puntoId] || []).find(x => String(x.id) === String(n.parent_id)); notasRecibidas.push({ ...n, puntoId, _esRespuesta: true, _textoPadre: padre ? padre.texto : '' }); } }); }); } notasRecibidas.sort((a, b) => new Date(b.fecha) - new Date(a.fecha)); notasMias.sort((a, b) => new Date(b.fecha) - new Date(a.fecha)); // [OBS4] Separar observaciones del supervisor APROBADAS -> "Notas archivadas" const esAprobadaSup = (x) => x.origen === 'supervisor' && x.estado_revision === 'aprobado'; const notasArchivadas = []; const fuenteArch = rolActivo === 'contratista' ? notasRecibidas : notasMias; for (let i = fuenteArch.length - 1; i >= 0; i--) { if (esAprobadaSup(fuenteArch[i])) { notasArchivadas.push(fuenteArch[i]); fuenteArch.splice(i, 1); } } notasArchivadas.sort((a, b) => new Date(b.fecha) - new Date(a.fecha)); const leidasMapa = obtenerLeidasMapa(); // [F8-7] const leidas = { includes: (id) => esNotaLeida(leidasMapa, id) }; // shim de compatibilidad // [OBS3] Campana: contratista = observaciones recibidas sin leer; supervisor = respuestas del contratista sin leer notificacionesSinLeer = notasRecibidas.filter(n => !leidas.includes(String(n.id))).length; // Badge global const badge = document.getElementById('notif-count'); if (badge) { badge.textContent = notificacionesSinLeer; badge.classList.toggle('oculto', notificacionesSinLeer === 0); } // Conteos en tabs const tcR = document.getElementById('tab-count-recibidas'); const tcM = document.getElementById('tab-count-mias'); if (tcR) tcR.textContent = notasRecibidas.length; if (tcM) tcM.textContent = notasMias.length; // Badges menú lateral if (typeof actualizarBadgesComponentes === 'function') actualizarBadgesComponentes(); // Resumen diario solo si contratista mira recibidas if (rolActivo === 'contratista') { renderResumenDiario(notasRecibidas); } else { const r = document.getElementById('notif-resumen'); if (r) r.innerHTML = ''; } // Lista RECIBIDAS const lista = document.getElementById('notif-lista'); if (lista) { if (notasRecibidas.length === 0) { const origenLabel = rolActivo === 'contratista' ? 'supervisor' : 'contratista (respuestas)'; lista.innerHTML = `
Sin observaciones del ${origenLabel} todavía.
`; } else { lista.innerHTML = notasRecibidas.slice(0, 50).map(n => { const esNoLeida = !leidas.includes(String(n.id)); const fecha = new Date(n.fecha).toLocaleString('es-CO', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); const estadoIcon = { info: '', advertencia: '', problema: '', resuelto: '' }; const compConfig = getComponente(n.componente || 'tecnico'); const origenLabel = n.origen === 'supervisor' ? `${svgCompMini(compConfig.id)} ${compConfig.rolLabel}` : `${svgCompMini('contratista')} Operación`; const erR = (typeof ESTADOS_REVISION !== 'undefined' && ESTADOS_REVISION[n.estado_revision || 'pendiente']) || { label: 'Pendiente', icon: '', color: '#86868b', bg: '#f5f5f7' }; const refPadre = n._esRespuesta && n._textoPadre ? `
↩ En respuesta a: “${escapeHtml(n._textoPadre.slice(0, 60))}${n._textoPadre.length > 60 ? '…' : ''}”
` : ''; return `
${n.puntoId} · ${origenLabel} · ${estadoIcon[n.estado] || ''}
${refPadre}
${escapeHtml(n.texto)}
${fecha} ${erR.icon} ${erR.label}
`; }).join(''); } } // Lista MIS NOTAS (con estados editables) const listaMias = document.getElementById('notif-lista-mias'); if (listaMias) { if (notasMias.length === 0) { listaMias.innerHTML = '
Aún no has agregado notas.
'; } else { listaMias.innerHTML = notasMias.slice(0, 100).map(n => { const fecha = new Date(n.fecha).toLocaleString('es-CO', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); const estadoIcon = { info: '', advertencia: '', problema: '', resuelto: '' }; // Estado de revisión (si tiene): el contratista puede MARCAR estados a sus propias notas const estadoRev = n.estado_revision || 'pendiente'; const er = (typeof ESTADOS_REVISION !== 'undefined' && ESTADOS_REVISION[estadoRev]) || { label: 'Pendiente', icon: '', color: '#86868b', bg: '#f5f5f7' }; const btnEstado = ``; // [F8-4] Marcar visualmente si esta nota tiene respuestas NUEVAS sin leer const respNuevas = (datosObra.notas[n.puntoId] || []).filter(x => String(x.parent_id) === String(n.id) && !leidas.includes(String(x.id)) ).length; const badgeResp = respNuevas > 0 ? `↩ ${respNuevas} respuesta${respNuevas > 1 ? 's' : ''} nueva${respNuevas > 1 ? 's' : ''}` : ''; return `
${n.puntoId} · ${estadoIcon[n.estado] || ''} ${(n.estado || 'info').toUpperCase()}
${escapeHtml(n.texto)}
${fecha} ${badgeResp} ${btnEstado}
`; }).join(''); } } // [OBS4] Carpeta "Notas archivadas" al final del panel (observaciones del supervisor aprobadas) const archCont = document.getElementById('notif-archivadas'); if (archCont) { if (notasArchivadas.length === 0) { archCont.innerHTML = ''; } else { const _s = 'style="width:1em;height:1em;vertical-align:-.12em;display:inline-block;flex:none"'; const _check = ``; const _trash = ``; const _chev = ``; const itemsArch = notasArchivadas.map(n => { const fa = new Date(n.fecha).toLocaleString('es-CO', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); const cc = getComponente(n.componente || 'tecnico'); const rol = n.origen === 'supervisor' ? cc.rolLabel : 'Operación'; const del = (rolActivo === 'supervision') ? `` : ''; return `
${n.puntoId} · ${rol} · ${_check} Aprobado
${escapeHtml(n.texto)}
${fa}${del}
`; }).join(''); archCont.innerHTML = ` `; } } } function toggleArchivadas() { const b = document.getElementById('arch-body'); const ch = document.getElementById('arch-chevron'); if (!b) return; const abierto = b.style.display !== 'none'; b.style.display = abierto ? 'none' : 'block'; if (ch) ch.style.transform = abierto ? '' : 'rotate(180deg)'; } function cambiarTabNotif(tab) { document.querySelectorAll('.notif-tab').forEach(t => t.classList.toggle('activo', t.dataset.tab === tab)); const lista = document.getElementById('notif-lista'); const listaMias = document.getElementById('notif-lista-mias'); const resumen = document.getElementById('notif-resumen'); if (tab === 'recibidas') { if (lista) lista.style.display = 'block'; if (listaMias) listaMias.style.display = 'none'; if (resumen) resumen.style.display = 'block'; } else { if (lista) lista.style.display = 'none'; if (listaMias) listaMias.style.display = 'block'; if (resumen) resumen.style.display = 'none'; } } async function cambiarEstadoMiNota(puntoId, notaId) { // Contratista puede cambiar estado de SUS PROPIAS notas const nota = (datosObra.notas[puntoId] || []).find(n => String(n.id) === String(notaId)); if (!nota) return; if (rolActivo === 'contratista' && nota.origen !== 'contratista') { toast('Solo el supervisor cambia el estado de sus notas'); return; } const orden = ['pendiente', 'proceso', 'aprobado']; const actual = nota.estado_revision || 'pendiente'; const idx = orden.indexOf(actual); const nuevo = orden[(idx + 1) % orden.length]; nota.estado_revision = nuevo; guardarDatos(); vibrar([15]); toast(`Estado: ${(ESTADOS_REVISION[nuevo] || {}).label || nuevo}`); // Re-renderizar el historial cargarNotificaciones(); // Si Supabase está disponible, sincronizar — con cola de respaldo [F8-1] if (typeof actualizarEstadoNotaSupabase === 'function' && !String(notaId).startsWith('tmp_')) { actualizarEstadoNotaSupabase(notaId, nuevo).then(ok => { if (!ok && typeof SyncQueue !== 'undefined') { SyncQueue.encolar('estado_nota', { notaId, estado: nuevo }); } }); } } // [7] RESUMEN DIARIO function renderResumenDiario(todasNotas) { const resumenDiv = document.getElementById('notif-resumen'); if (!resumenDiv) return; if (todasNotas.length === 0) { resumenDiv.innerHTML = ''; return; } // Agrupar por prioridad const problemas = todasNotas.filter(n => n.estado === 'problema'); const advertencias = todasNotas.filter(n => n.estado === 'advertencia'); const info = todasNotas.filter(n => n.estado === 'info'); const resueltos = todasNotas.filter(n => n.estado === 'resuelto'); // Puntos únicos con notas pendientes (no resueltas) const puntosConProblemas = [...new Set(problemas.map(n => n.puntoId))]; const puntosConAdvertencias = [...new Set(advertencias.map(n => n.puntoId))]; const hoy = new Date().toLocaleDateString('es-CO', { weekday: 'long', day: 'numeric', month: 'long' }); let html = `
Resumen — ${hoy}
`; html += `
Total observaciones${todasNotas.length}
`; if (problemas.length > 0) { html += `
No conformidades${problemas.length}
`; puntosConProblemas.forEach(pid => { const cnt = problemas.filter(n => n.puntoId === pid).length; html += `
${pid}${cnt}
`; }); } if (advertencias.length > 0) { html += `
Advertencias${advertencias.length}
`; puntosConAdvertencias.forEach(pid => { const cnt = advertencias.filter(n => n.puntoId === pid).length; html += `
${pid}${cnt}
`; }); } if (info.length > 0) { html += `
Observaciones${info.length}
`; } if (resueltos.length > 0) { html += `
Aprobados${resueltos.length}
`; } resumenDiv.innerHTML = html; } function marcarNotificacionesLeidas() { const compActivoL = componenteActivo || COMPONENTE_DEFAULT; const mapa = obtenerLeidasMapa(); const ahora = Date.now(); Object.keys(datosObra.notas).forEach(puntoId => { if (!_idsObraActiva().has(String(puntoId))) return; (datosObra.notas[puntoId] || []).forEach(n => { if (rolActivo === 'contratista') { if (n.origen === 'supervisor' && (n.componente || 'tecnico') === compActivoL) mapa[String(n.id)] = ahora; } else { if (n.parent_id && (n.origen || 'contratista') === 'contratista' && (n.componente || 'tecnico') === compActivoL) mapa[String(n.id)] = ahora; } }); }); guardarLeidasMapa(mapa); // [F8-7] guarda + poda 30 días notificacionesSinLeer = 0; const badge = document.getElementById('notif-count'); if (badge) { badge.textContent = '0'; badge.classList.add('oculto'); } } function irAPuntoDesdeNotif(puntoId) { toggleNotificaciones(); const punto = PUNTOS.find(p => p.id === puntoId); if (punto) { map.setView(punto.coords, 17); setTimeout(() => abrirFicha(punto), 300); } } // ============================================================ // [2] MENÚ LATERAL // ============================================================ function toggleMenu() { const overlay = document.getElementById('menu-bg'); const menu = document.getElementById('menu-lateral'); overlay.classList.toggle('activo'); menu.classList.toggle('activo'); } // ============================================================ // CAMBIO DE CONTRASEÑA // ============================================================ function abrirCambioContrasena() { // Cerrar menú lateral primero document.getElementById('menu-bg').classList.remove('activo'); document.getElementById('menu-lateral').classList.remove('activo'); // Abrir modal (el ID correcto en HTML es 'pass-modal') setTimeout(() => { document.getElementById('pass-modal').classList.add('activo'); document.getElementById('pass-actual').value = ''; document.getElementById('pass-nueva').value = ''; document.getElementById('pass-confirmar').value = ''; document.getElementById('pass-error').style.visibility = 'hidden'; setTimeout(() => document.getElementById('pass-actual').focus(), 200); }, 150); } function cerrarCambioContrasena() { document.getElementById('pass-modal').classList.remove('activo'); } // === AGREGAR CUENTA DE SUPERVISOR === let _addCuentaCompElegido = null; function abrirAgregarCuenta() { if (rolActivo !== 'supervision') return; _addCuentaCompElegido = null; document.getElementById('add-cuenta-paso1').style.display = 'grid'; document.getElementById('add-cuenta-paso2').style.display = 'none'; document.getElementById('add-cuenta-confirmar').style.display = 'none'; document.getElementById('add-cuenta-titulo').textContent = 'Nueva cuenta'; document.getElementById('add-cuenta-paso1-info').style.display = 'block'; // Renderizar opciones (componentes NO activos quedan habilitados) const cuentasActivas = obtenerCuentasSupervisorActivas(); const grid = document.getElementById('add-cuenta-paso1'); grid.innerHTML = COMPONENTES_ORDEN.map(id => { const c = COMPONENTES[id]; const yaActivo = cuentasActivas.includes(id); return ``; }).join(''); document.getElementById('add-cuenta-modal').classList.add('activo'); } function seleccionarCompAgregar(compId) { _addCuentaCompElegido = compId; const c = getComponente(compId); document.getElementById('add-cuenta-titulo').textContent = `Nueva cuenta ${c.label}`; document.getElementById('add-cuenta-paso1').style.display = 'none'; document.getElementById('add-cuenta-paso1-info').style.display = 'none'; document.getElementById('add-cuenta-paso2').style.display = 'block'; document.getElementById('add-cuenta-paso2-info').textContent = `Crea una contraseña para la cuenta de ${c.rolLabel}:`; document.getElementById('add-cuenta-confirmar').style.display = 'inline-block'; document.getElementById('add-cuenta-pass').value = ''; document.getElementById('add-cuenta-pass2').value = ''; // [F2-4] Campo de nombre del supervisor de esa cuenta (obligatorio y validado) const nombreEl = document.getElementById('add-cuenta-nombre'); if (nombreEl) nombreEl.value = ''; document.getElementById('add-cuenta-error').style.visibility = 'hidden'; setTimeout(() => { const foco = document.getElementById('add-cuenta-nombre') || document.getElementById('add-cuenta-pass'); foco.focus(); }, 200); } async function confirmarAgregarCuenta() { const pass1 = document.getElementById('add-cuenta-pass').value.trim(); const pass2 = document.getElementById('add-cuenta-pass2').value.trim(); const nombreEl = document.getElementById('add-cuenta-nombre'); const nombreRaw = nombreEl ? nombreEl.value : ''; const errorEl = document.getElementById('add-cuenta-error'); const fallar = (msg) => { errorEl.textContent = msg; errorEl.style.visibility = 'visible'; }; // [F2-4] Nombre del supervisor: obligatorio, nombre + apellido, solo letras const vNombre = Validaciones.validarNombrePersona(nombreRaw); if (!vNombre.ok) { fallar(`Nombre: ${vNombre.msg}`); return; } // [F2-8] Política de contraseña: mín. 8 + 1 mayúscula + 1 número const pol = Validaciones.validarPoliticaPass(pass1); if (!pol.ok) { fallar(`Contraseña: ${pol.msg}`); return; } if (pass1 !== pass2) { fallar('Las contraseñas no coinciden'); return; } if (!_addCuentaCompElegido) return; const c = getComponente(_addCuentaCompElegido); // [F1-1 + F2-2] Guardar hash, no texto plano const hashed = await CryptoUtils.hashPassword(pass1); localStorage.setItem(c.passHashKey, JSON.stringify(hashed)); guardarCuentaSupervisor(_addCuentaCompElegido); guardarNombreSupervisor(_addCuentaCompElegido, vNombre.valor); // [F2-4] document.getElementById('add-cuenta-modal').classList.remove('activo'); vibrar([20, 30, 40]); toast(`Cuenta ${c.label} creada · ${vNombre.valor}`); actualizarMenuLateralSupervisor(); } function cerrarAgregarCuenta() { document.getElementById('add-cuenta-modal').classList.remove('activo'); } // === MENÚ LATERAL SUPERVISOR REORGANIZADO === const SUPERVISOR_NOMBRE_KEY_PREFIX = 's02_sup_nombre_'; function obtenerNombreSupervisor(compId) { return localStorage.getItem(SUPERVISOR_NOMBRE_KEY_PREFIX + compId) || getComponente(compId).rolLabel; } function guardarNombreSupervisor(compId, nombre) { localStorage.setItem(SUPERVISOR_NOMBRE_KEY_PREFIX + compId, nombre); } function editarNombreUsuario() { if (rolActivo !== 'supervision') return; editarNombreCuenta(componenteActivo); } // [F4-4] Renombrar CUALQUIER cuenta desde el menú lateral, sin entrar a ella. // [F1-3/F2-6] Con modal propio validado, no prompt() nativo. function editarNombreCuenta(compId) { const c = getComponente(compId); const actual = obtenerNombreSupervisor(compId); abrirInputModal({ titulo: `Nombre · ${c.label}`, info: 'Nombre y apellido del supervisor de esta cuenta (aparece en cada observación).', tipo: 'text', placeholder: 'Ej: María García', valor: actual === c.rolLabel ? '' : actual, okLabel: 'Guardar', validar: (v) => Validaciones.validarNombrePersona(v), onOk: (v) => { guardarNombreSupervisor(compId, Validaciones.validarNombrePersona(v).valor); actualizarMenuLateralSupervisor(); vibrar([15]); toast('Nombre actualizado'); } }); } function abrirSelectorProyectos() { toast('Por ahora solo: S02 Centenario · Fase II'); } function actualizarMenuLateralSupervisor() { if (rolActivo !== 'supervision') { // Contratista: ocultar bloque supervisor, mostrar nombre fijo document.getElementById('menu-grupo-supervisor').style.display = 'none'; document.getElementById('menu-edit-nombre-btn').style.display = 'none'; document.getElementById('menu-nombre-texto').textContent = PROYECTO.nombre; document.getElementById('menu-sub-area').textContent = PROYECTO.contratante; return; } // Supervisor: mostrar nombre editable + área supervisada const comp = getComponente(componenteActivo); const nombre = obtenerNombreSupervisor(componenteActivo); document.getElementById('menu-nombre-texto').textContent = nombre; document.getElementById('menu-sub-area').textContent = `Área: ${comp.labelLargo} · ${PROYECTO.contratante}`; document.getElementById('menu-edit-nombre-btn').style.display = 'flex'; document.getElementById('menu-grupo-supervisor').style.display = 'block'; // Listar cuentas activas + opción de "agregar otra" const cuentasActivas = obtenerCuentasSupervisorActivas(); const cont = document.getElementById('menu-cuentas-supervisor'); const puedeEliminar = cuentasActivas.length > 1; // [PDF #8] No permitir eliminar la última cuenta cont.innerHTML = cuentasActivas.map(id => { const c = COMPONENTES[id]; const activa = id === componenteActivo; const nombreCuenta = obtenerNombreSupervisor(id); // [CORRECCIÓN PDF #8] Botón eliminar solo si no es la cuenta activa y hay más de 1 const btnEliminar = (puedeEliminar && !activa) ? `` : ''; // [F4-4] Editar nombre de la cuenta sin tener que entrar a ella const btnEditar = ``; return ``; }).join(''); if (cuentasActivas.length < COMPONENTES_ORDEN.length) { cont.innerHTML += ``; } // [F4-3] Pintar badges de respuestas sin leer por cuenta actualizarBadgesComponentes(); } async function cambiarACuenta(compId) { if (compId === componenteActivo) { toggleMenu(); return; } const comp = getComponente(compId); const hashed = leerHashContrasena(comp.passHashKey); if (!hashed) { toast('Esta cuenta no tiene contraseña creada todavía'); return; } // [F1-2] Bloqueo exponencial también al cambiar de cuenta const scope = 'sup_' + compId; const restante = obtenerBloqueoRestante(scope); if (restante > 0) { toast(`Demasiados intentos. Espera ${Validaciones.formatearEspera(restante)}`); return; } toggleMenu(); // [F2-6] Modal propio con campo password ENMASCARADO (prompt() mostraba la // contraseña en pantalla a cualquiera al lado). abrirInputModal({ titulo: `Cambiar a ${comp.label}`, info: `Ingresa la contraseña de la cuenta ${comp.rolLabel} (${escapeHtml(obtenerNombreSupervisor(compId))}).`, tipo: 'password', placeholder: 'Contraseña', okLabel: 'Entrar', validar: () => ({ ok: true }), onOk: async (pass) => { const ok = await CryptoUtils.verifyPassword((pass || '').trim(), hashed.hash, hashed.salt); if (!ok) { registrarIntentoFallido(scope); // [F1-2] vibrar([200]); toast('Contraseña incorrecta'); return; } limpiarIntentos(scope); _completarCambioCuenta(compId, comp); } }); } function _completarCambioCuenta(compId, comp) { componenteActivo = compId; localStorage.setItem(COMPONENTE_STORAGE_KEY, compId); guardarSesion('supervision', compId); // [F1-4] vibrar([20, 30]); toast(`${comp.icono} Ahora en ${comp.label}`); // Refrescar UI const badge = document.getElementById('menu-rol-badge'); if (badge) badge.innerHTML = `${svgComp(comp.id)} ${comp.rolLabel}`; aplicarTemaComponente(comp); actualizarMenuLateralSupervisor(); if (typeof cargarNotificaciones === 'function') cargarNotificaciones(); // Re-renderizar notas del punto abierto si hay document.querySelectorAll('.notas-lista').forEach(lista => { const puntoId = lista.id.replace('notas-', ''); if (puntoId && typeof renderNotas === 'function') { lista.innerHTML = renderNotas(puntoId, 'supervision'); } }); } // [CORRECCIÓN PDF #8] Eliminar perfil específico de supervisión function eliminarCuentaSupervisor(compId) { if (rolActivo !== 'supervision') return; const comp = getComponente(compId); const cuentas = obtenerCuentasSupervisorActivas(); // Validaciones if (cuentas.length <= 1) { toast('No puedes eliminar la última cuenta'); return; } if (compId === componenteActivo) { toast('Cambia primero a otra cuenta para eliminar esta'); return; } // Confirmación clara: el confirm nativo funciona en PC y celular sin riesgo de bug de overlay const nombreCuenta = obtenerNombreSupervisor(compId); const ok = confirm(`¿Eliminar la cuenta "${comp.label}" (${nombreCuenta})?\n\nSe borrará la contraseña y el nombre asociados. Las notas de esa cuenta se conservan en el historial general, pero no podrás iniciar sesión hasta volver a crearla.`); if (!ok) return; // 1. Quitar de la lista de cuentas activas const nuevasCuentas = cuentas.filter(c => c !== compId); localStorage.setItem(CUENTAS_SUPERVISOR_KEY, JSON.stringify(nuevasCuentas)); // 2. Borrar la contraseña personalizada (la cuenta debe re-crearse desde cero) localStorage.removeItem(comp.passKey); // legacy texto plano (por si quedó) localStorage.removeItem(comp.passHashKey); // [F1-1] hash nuevo // 3. Borrar el nombre personalizado localStorage.removeItem(SUPERVISOR_NOMBRE_KEY_PREFIX + compId); // 4. Refrescar menú actualizarMenuLateralSupervisor(); vibrar([15, 20]); toast(`Cuenta "${comp.label}" eliminada`); } async function guardarNuevaContrasena() { const actual = document.getElementById('pass-actual').value.trim(); const nueva = document.getElementById('pass-nueva').value.trim(); const confirmar = document.getElementById('pass-confirmar').value.trim(); const errorEl = document.getElementById('pass-error'); // [F1-1 + F2-2] Sin defaults en código. Usamos passHashKey según rol activo. let passHashKey; if (rolActivo === 'supervision' && componenteActivo) { const comp = getComponente(componenteActivo); passHashKey = comp.passHashKey; } else { passHashKey = PASS_HASH_KEY_CONTRATISTA; } const hashed = leerHashContrasena(passHashKey); if (!hashed) { errorEl.textContent = 'No hay contraseña previa registrada para esta cuenta'; errorEl.style.visibility = 'visible'; return; } const okActual = await CryptoUtils.verifyPassword(actual, hashed.hash, hashed.salt); if (!okActual) { errorEl.textContent = 'La contraseña actual no es correcta'; errorEl.style.visibility = 'visible'; return; } // [F2-8] Política: mín. 8 caracteres + 1 mayúscula + 1 número const pol = Validaciones.validarPoliticaPass(nueva); if (!pol.ok) { errorEl.textContent = `Contraseña débil: ${pol.msg}`; errorEl.style.visibility = 'visible'; return; } if (nueva !== confirmar) { errorEl.textContent = 'Las contraseñas no coinciden'; errorEl.style.visibility = 'visible'; return; } const nuevoHash = await CryptoUtils.hashPassword(nueva); localStorage.setItem(passHashKey, JSON.stringify(nuevoHash)); cerrarCambioContrasena(); toast('Contraseña actualizada'); } // CERRAR SESIÓN function cerrarSesion() { toggleMenu(); if (confirm('¿Cerrar sesión? Volverás a la pantalla de selección de perfil.')) { sessionStorage.removeItem('s02_rol'); borrarSesion(); // [F1-4] rolActivo = null; // [F11-2] Se conserva el reload deliberadamente: la transición sin recarga // reintrodujo el bug de pantalla negra documentado en CORRECCIÓN PDF #7 // (overlays/listeners residuales de Leaflet). Decisión registrada en auditoría. location.reload(); } } // ============================================================ // FUNCIONES COMUNES // ============================================================ function abrirModalFicha() { const overlay = document.getElementById('modal-overlay'); const sheet = document.getElementById('modal-sheet'); overlay.classList.add('activo'); // Resetear transform si quedó algo del cierre anterior if (sheet) { sheet.style.transform = ''; sheet.style.transition = ''; // Activar drag-to-close inicializarDragSheet(sheet, overlay, cerrarModal); } vibrar([8]); } function cerrarModal() { _puntoAbiertoId = null; // [F3-1] const overlay = document.getElementById('modal-overlay'); const sheet = document.getElementById('modal-sheet'); if (sheet) { sheet.style.transition = 'transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)'; sheet.style.transform = 'translateY(100%)'; overlay.style.background = 'rgba(0,0,0,0)'; setTimeout(() => { overlay.classList.remove('activo'); sheet.style.transition = ''; sheet.style.transform = ''; overlay.style.background = ''; }, 300); } else { overlay.classList.remove('activo'); } } // ============================================================ // DRAG-TO-CLOSE PARA BOTTOM SHEETS (estilo iOS) // ============================================================ function inicializarDragSheet(sheetEl, overlayEl, onClose) { if (!sheetEl || sheetEl._dragInit) return; sheetEl._dragInit = true; let startY = 0, currentY = 0, dragging = false, sheetHeight = 0; const UMBRAL = 0.30; // 30% del alto = se cierra const VELOCIDAD_UMBRAL = 0.6; // px/ms — flick rápido cierra aunque sea poco let startTime = 0; const onStart = (e) => { // Solo arrancar drag si toca el handle o el header (no en areas con scroll/inputs) const target = e.target; const esHandle = target.classList.contains('sheet-handle') || target.closest('.sheet-handle'); const esHeader = target.classList.contains('ficha-header') || target.closest('.ficha-header'); const esBotonX = target.classList.contains('sheet-close') || target.closest('.sheet-close'); if (esBotonX) return; if (!esHandle && !esHeader) { // Si el scroll del contenido está en top, también permite drag desde el contenido const contenido = document.getElementById('ficha-contenido'); if (contenido && contenido.scrollTop > 0) return; } dragging = true; startY = (e.touches ? e.touches[0].clientY : e.clientY); currentY = startY; sheetHeight = sheetEl.getBoundingClientRect().height; startTime = Date.now(); sheetEl.style.transition = 'none'; }; const onMove = (e) => { if (!dragging) return; currentY = (e.touches ? e.touches[0].clientY : e.clientY); const delta = Math.max(0, currentY - startY); // solo hacia abajo sheetEl.style.transform = `translateY(${delta}px)`; // Aclarar backdrop progresivamente const opacidad = Math.max(0, 0.45 * (1 - delta / sheetHeight)); overlayEl.style.background = `rgba(0,0,0,${opacidad})`; }; const onEnd = () => { if (!dragging) return; dragging = false; const delta = currentY - startY; const tiempo = Date.now() - startTime; const velocidad = delta / Math.max(tiempo, 1); sheetEl.style.transition = 'transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)'; if (delta > sheetHeight * UMBRAL || velocidad > VELOCIDAD_UMBRAL) { // Cerrar vibrar([10]); onClose(); } else { // Regresar a posición sheetEl.style.transform = 'translateY(0)'; overlayEl.style.background = ''; setTimeout(() => { sheetEl.style.transition = ''; }, 300); } }; sheetEl.addEventListener('touchstart', onStart, { passive: true }); sheetEl.addEventListener('touchmove', onMove, { passive: true }); sheetEl.addEventListener('touchend', onEnd); sheetEl.addEventListener('touchcancel', onEnd); } // ============================================================ // VIBRACIÓN HÁPTICA (Android, no funciona en iOS Safari) // ============================================================ function vibrar(patron) { try { if (navigator.vibrate) navigator.vibrate(patron); } catch(e) {} } async function cambiarEstado(id, nuevoEstado) { // [F6-4] "Completado" es el cambio con peso legal: exige evidencia + confirmación. if (nuevoEstado === 'completado' && (datosObra.estados[id] || 'pendiente') !== 'completado') { const compActivoE = componenteActivo || COMPONENTE_DEFAULT; const tieneFoto = (datosObra.archivos[id] || []).some(a => a.tipo && a.tipo.startsWith('image/')); if (!tieneFoto) { vibrar([100]); toast('Para marcar como completado, sube al menos 1 foto de evidencia'); return; } if (!confirm(`¿Confirmas marcar ${id} como COMPLETADO?\n\nEste cambio queda en la bitácora con tu nombre y fecha.`)) return; // Nota de cierre opcional (contexto para auditoría) abrirInputModal({ titulo: `Cierre de ${id}`, info: 'Opcional: agrega una nota de cierre (qué se completó, observaciones finales). Déjala vacía para omitir.', tipo: 'text', placeholder: 'Nota de cierre (opcional)', okLabel: 'Completar punto', onOk: async (texto) => { await _aplicarCambioEstado(id, 'completado'); const t = (texto || '').trim(); if (t) { // Registrar la nota de cierre en el componente activo const inputFake = document.getElementById(`nueva-nota-${id}`); const selFake = document.getElementById(`estado-nota-${id}`); if (inputFake && selFake) { inputFake.value = `[Cierre] ${t}`; selFake.value = 'resuelto'; agregarNota(id); } } } }); return; } await _aplicarCambioEstado(id, nuevoEstado); } async function _aplicarCambioEstado(id, nuevoEstado) { datosObra.estados[id] = nuevoEstado; guardarDatos(); cargarMarcadores(); actualizarStats(); if (nuevoEstado === 'completado') vibrar([50, 30, 50, 30, 80]); else if (nuevoEstado === 'proceso') vibrar([30, 20, 30]); else vibrar([20]); const punto = PUNTOS.find(p => p.id === id); if (punto) abrirFicha(punto); const labels = { pendiente: 'Pendiente', proceso: 'En proceso', completado: 'Completado' }; toast(`${id} → ${labels[nuevoEstado]}`); // [F8-1] Si la sincronización falla, encolar para reintento persistente if (typeof guardarEstadoSupabase === 'function') { const ok = await guardarEstadoSupabase(id, nuevoEstado, autorActual()); if (!ok && typeof SyncQueue !== 'undefined') { SyncQueue.encolar('estado', { puntoId: id, estado: nuevoEstado, autor: autorActual() }); } } // [F9-3] Si hay ruta óptima activa y el punto salió del filtro (p. ej. se // completó y el filtro es "pendientes"), recalcular automáticamente. if (rutaOptimaActiva && nuevoEstado === 'completado') { limpiarRutaOptima(); const visibles = PUNTOS.filter(p => puntoPasaFiltro(p)); if (visibles.length >= 2) { toast('Recalculando ruta sin los completados…'); setTimeout(() => toggleRutaOptima(), 400); } } } // ============================================================ // [5] COORDENADAS MANUALES // ============================================================ const _coordAvisoLejos = {}; // por punto: ya se advirtió distancia async function guardarCoordenadas(puntoId) { const latInput = document.getElementById(`coord-lat-${puntoId}`); const lngInput = document.getElementById(`coord-lng-${puntoId}`); const altInput = document.getElementById(`coord-alt-${puntoId}`); if (!latInput || !lngInput) return; const latRaw = latInput.value.trim(); const lngRaw = lngInput.value.trim(); const altRaw = altInput ? altInput.value.trim() : ''; // Campos vacíos = borrar coordenadas custom (volver a las del plano) if (!latRaw && !lngRaw) { delete datosObra.coordenadasCustom[puntoId]; guardarDatos(); cargarMarcadores(); return; } // Mientras se escribe puede haber solo un campo: esperar a tener ambos if (!latRaw || !lngRaw) return; // [F6-2] Validación: numérica, rango geográfico y proximidad al punto del plano const punto = PUNTOS.find(p => p.id === puntoId); const v = Validaciones.validarCoordenada(latRaw, lngRaw, punto ? punto.coords : null, distHaversine, 5); if (!v.ok) { vibrar([80]); toast(`Coordenada inválida: ${v.msg}`); return; // NO se guarda basura ("asdf", NaN) ni se sincroniza } if (v.lejos && !_coordAvisoLejos[puntoId]) { _coordAvisoLejos[puntoId] = true; const seguir = confirm(`La coordenada ingresada está a ${v.distKm.toFixed(1)} km del punto del plano.\n\n¿Seguro que es correcta?`); if (!seguir) { _coordAvisoLejos[puntoId] = false; return; } } datosObra.coordenadasCustom[puntoId] = { lat: String(v.lat), lng: String(v.lng), alt: altRaw }; guardarDatos(); cargarMarcadores(); // [F8-1] Cola si la sincronización falla if (typeof guardarCoordenadasSupabase === 'function') { const ok = await guardarCoordenadasSupabase(puntoId, String(v.lat), String(v.lng), altRaw, autorActual()); if (ok === false && typeof SyncQueue !== 'undefined') { SyncQueue.encolar('coordenadas', { puntoId, lat: String(v.lat), lng: String(v.lng), alt: altRaw, autor: autorActual() }); } } } // ============================================================ // [4] NOMBRE DE FOTO — flujo: seleccionar → nombrar → subir // ============================================================ function iniciarNombreFoto(event, puntoId, tipo) { const _files = Array.from(event.target.files || []); if (_files.length > 1) { subirVariosArchivos(_files, puntoId, tipo); event.target.value = ''; return; } const file = _files[0]; if (!file) return; if (file.size > 10 * 1024 * 1024) { toast('Archivo muy grande (max 10MB).'); event.target.value = ''; return; } _pendingFile = file; _pendingPuntoId = puntoId; _pendingTipo = tipo; // Preview si es imagen const previewImg = document.getElementById('nombre-foto-preview'); if (file.type.startsWith('image/')) { const url = URL.createObjectURL(file); previewImg.src = url; previewImg.style.display = 'block'; _pendingPreviewUrl = url; } else { previewImg.style.display = 'none'; _pendingPreviewUrl = null; } document.getElementById('nombre-foto-original').textContent = `Original: ${file.name}`; // Sugerir nombre sin extensión const sinExt = file.name.replace(/\.[^/.]+$/, ''); document.getElementById('nombre-foto-input').value = ''; document.getElementById('nombre-foto-input').placeholder = sinExt; document.getElementById('nombre-foto-modal').classList.add('activo'); setTimeout(() => document.getElementById('nombre-foto-input').focus(), 200); event.target.value = ''; } function confirmarNombreFoto() { const input = document.getElementById('nombre-foto-input').value.trim(); const file = _pendingFile; const puntoId = _pendingPuntoId; const tipo = _pendingTipo; if (!file || !puntoId) return; // [F6-7] Nombre final saneado: sin / \ < > : " | ? * ni control chars, máx. 50 const ext = file.name.includes('.') ? '.' + file.name.split('.').pop() : ''; const base = input ? (input + ext) : file.name; const nombreFinal = Validaciones.sanitizarNombreArchivoVisible(base, 50); // Cerrar modal document.getElementById('nombre-foto-modal').classList.remove('activo'); if (_pendingPreviewUrl) URL.revokeObjectURL(_pendingPreviewUrl); // Proceder a subir con el nombre personalizado subirArchivoConNombre(file, puntoId, tipo, nombreFinal); _pendingFile = null; _pendingPuntoId = null; _pendingTipo = null; _pendingPreviewUrl = null; } // ============================================================ // ARCHIVOS Y FOTOS // ============================================================ // [HARDEN] Compresion de imagenes antes de subir/encolar (online y cola offline) async function comprimirImagen(file, maxLado, calidad) { maxLado = maxLado || 1600; calidad = calidad || 0.7; try { if (!file || !file.type || !file.type.startsWith('image/') || file.type === 'image/gif') return file; if (file.size <= 600 * 1024) return file; const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file); }); const img = await new Promise((res, rej) => { const im = new Image(); im.onload = () => res(im); im.onerror = rej; im.src = dataUrl; }); let w = img.width, h = img.height; if (w > maxLado || h > maxLado) { if (w >= h) { h = Math.round(h * maxLado / w); w = maxLado; } else { w = Math.round(w * maxLado / h); h = maxLado; } } const cv = document.createElement('canvas'); cv.width = w; cv.height = h; cv.getContext('2d').drawImage(img, 0, 0, w, h); const blob = await new Promise((res) => cv.toBlob(res, 'image/jpeg', calidad)); if (!blob || blob.size >= file.size) return file; const nombre = file.name.replace(/\.[^/.]+$/, '') + '.jpg'; return new File([blob], nombre, { type: 'image/jpeg', lastModified: Date.now() }); } catch (e) { console.warn('[comprimirImagen] usando original:', e); return file; } } // [MULTI] Subida en lote (varias fotos/archivos a la vez), secuencial y con compresion async function subirVariosArchivos(files, puntoId, tipo) { const validos = files.filter(f => f.size <= 10 * 1024 * 1024); const omit = files.length - validos.length; if (!validos.length) { toast('Todos superan 10MB.'); return; } toast('Subiendo ' + validos.length + (omit ? ' (' + omit + ' omitidos >10MB)' : '') + '...'); let ok = 0; for (const f of validos) { const base = (typeof Validaciones !== 'undefined' && Validaciones.sanitizarNombreArchivoVisible) ? Validaciones.sanitizarNombreArchivoVisible(f.name, 50) : f.name; try { await subirArchivoConNombre(f, puntoId, tipo, base); ok++; } catch (e) { console.error('[subirVarios]', e); } } toast('Listas ' + ok + '/' + validos.length); } async function subirArchivoConNombre(file, puntoId, tipo, nombrePersonalizado) { toast('Subiendo...'); if (file && file.type && file.type.startsWith('image/')) { try { file = await comprimirImagen(file); } catch (e) {} } // [F7-1] Autor del archivo (contratista o supervisor del componente) const autorRol = rolActivo === 'supervision' ? 'supervisor' : 'contratista'; const autorNombre = rolActivo === 'supervision' ? obtenerNombreSupervisor(componenteActivo || COMPONENTE_DEFAULT) : obtenerNombreContratista(); let resultadoSupabase = null; if (typeof subirArchivoSupabase === 'function') { resultadoSupabase = await subirArchivoSupabase(file, puntoId, tipo, autorNombre); } if (resultadoSupabase) { if (!datosObra.archivos[puntoId]) datosObra.archivos[puntoId] = []; const compActivo = componenteActivo || COMPONENTE_DEFAULT; const archivo = { id: resultadoSupabase.id, nombre: nombrePersonalizado, tipo: file.type || 'application/octet-stream', tamano: file.size, categoria: tipo, dataUrl: resultadoSupabase.url, storagePath: resultadoSupabase.storagePath, fecha: new Date().toISOString(), componente: compActivo, autorRol, autorNombre // [F7-1] }; datosObra.archivos[puntoId].push(archivo); // [CORRECCIÓN PDF #1] Si es la primera foto DEL COMPONENTE ACTIVO, marcarla como principal del componente const fotosDelComp = datosObra.archivos[puntoId].filter(a => a.tipo && a.tipo.startsWith('image/') && (!a.componente || a.componente === compActivo) ); if (fotosDelComp.length === 1 && file.type.startsWith('image/')) { setIdFotoPrincipal(puntoId, compActivo, archivo.id); } guardarDatos(); const lista = document.getElementById(`archivos-${puntoId}`); if (lista) lista.innerHTML = renderArchivos(puntoId); toast(`${tipo === 'foto' ? 'Foto' : 'Archivo'} subido: ${nombrePersonalizado}`); } else { // [F6-3] Sin conexión o fallo de subida: el archivo NO se pierde. // Se guarda localmente con id temporal Y se encola la subida en IndexedDB; // al volver la conexión, la cola la sube en orden y remapea el id/URL. const reader = new FileReader(); reader.onload = (e) => { if (!datosObra.archivos[puntoId]) datosObra.archivos[puntoId] = []; const compActivo = componenteActivo || COMPONENTE_DEFAULT; const tmpId = 'tmp_a_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); const archivo = { id: tmpId, nombre: nombrePersonalizado, tipo: file.type || 'application/octet-stream', tamano: file.size, categoria: tipo, dataUrl: e.target.result, fecha: new Date().toISOString(), componente: compActivo, soloLocal: true, autorRol, autorNombre // [F7-1] }; if (typeof SyncQueue !== 'undefined') { SyncQueue.encolar('archivo', { puntoId, categoria: tipo, blob: file, nombreOriginal: nombrePersonalizado, mime: file.type }, tmpId); } datosObra.archivos[puntoId].push(archivo); const fotosDelComp = datosObra.archivos[puntoId].filter(a => a.tipo && a.tipo.startsWith('image/') && (!a.componente || a.componente === compActivo) ); if (fotosDelComp.length === 1 && file.type.startsWith('image/')) { setIdFotoPrincipal(puntoId, compActivo, archivo.id); } guardarDatos(); const lista = document.getElementById(`archivos-${puntoId}`); if (lista) lista.innerHTML = renderArchivos(puntoId); toast(`${tipo === 'foto' ? 'Foto' : 'Archivo'} guardado local · se subirá al reconectar`); }; reader.readAsDataURL(file); } } function buscarArchivo(puntoId, archivoId) { return (datosObra.archivos[puntoId] || []).find(a => String(a.id) === String(archivoId)); } function renderArchivos(puntoId) { let archivos = datosObra.archivos[puntoId] || []; // FASE IV: Privacidad de fotos por componente // - Supervisor: SOLO ve archivos de SU componente (las viejas sin componente = legacy, visibles a todos) // - Contratista: ve TODO el archivos de su componente activo (más legacy) const compActivo = componenteActivo || COMPONENTE_DEFAULT; if (rolActivo === 'supervision') { archivos = archivos.filter(a => !a.componente || a.componente === compActivo); } else { // Contratista ve archivos del componente activo + legacy archivos = archivos.filter(a => !a.componente || a.componente === compActivo); } if (archivos.length === 0) return '

Sin archivos cargados

'; const esContratista = rolActivo === 'contratista'; const compActual = componenteActivo || COMPONENTE_DEFAULT; return archivos.map(a => { const esFoto = a.tipo && a.tipo.startsWith('image/'); const iconHtml = esFoto ? '' : '' + iconoArchivo(a.tipo) + ''; const kb = (a.tamano / 1024).toFixed(0); const fecha = new Date(a.fecha).toLocaleString('es-CO', { day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' }); const badgeLocal = a.soloLocal ? ' PENDIENTE DE SUBIR' : ''; // [F7-1] Identificar archivos subidos por el supervisor const compArch = getComponente(a.componente || 'tecnico'); const badgeAutor = a.autorRol === 'supervisor' ? ` ${compArch.rolLabel.toUpperCase()}` : ''; // [CORRECCIÓN PDF #1] Foto principal por componente const esPrincipal = String(obtenerIdFotoPrincipal(puntoId, compActivo)) === String(a.id); const badgeRef = esPrincipal ? ' REF' : ''; // Botón fijar como referencia: solo contratista, solo fotos, solo si NO es ya principal const btnFijar = (esContratista && esFoto && !esPrincipal) ? `` : ''; return `
${iconHtml}
${escapeHtml(a.nombre)}${badgeLocal}${badgeRef}${badgeAutor}
${kb} KB · ${fecha}
${btnFijar} ${(esContratista && a.autorRol !== 'supervisor') || (!esContratista && a.autorRol === 'supervisor') ? `` : ''}
`; }).join(''); } function fijarComoReferencia(puntoId, archivoId) { if (rolActivo !== 'contratista') return; // [CORRECCIÓN PDF #1] Usar helper por componente, no asignación directa const compActivo = componenteActivo || COMPONENTE_DEFAULT; setIdFotoPrincipal(puntoId, compActivo, archivoId); guardarDatos(); vibrar([15, 25]); toast('Foto fijada como referencia'); // Re-renderizar la lista de archivos (para mostrar el badge REF actualizado) const cont = document.querySelector('.archivos-lista'); if (cont) cont.innerHTML = renderArchivos(puntoId); // [CORRECCIÓN PDF #1] Refrescar la foto-ref principal en la ficha const archivo = buscarArchivo(puntoId, archivoId); if (archivo) { const fotoRefImg = document.querySelector('.foto-ref img'); if (fotoRefImg) fotoRefImg.src = archivo.dataUrl; } } function iconoArchivo(tipo) { if (!tipo) return ''; if (tipo.includes('pdf')) return ''; if (tipo.includes('word') || tipo.includes('document')) return ''; if (tipo.includes('sheet') || tipo.includes('excel')) return ''; return ''; } function verArchivo(puntoId, archivoId) { const archivo = buscarArchivo(puntoId, archivoId); if (!archivo) { toast('Archivo no encontrado'); return; } const esFoto = archivo.tipo && archivo.tipo.startsWith('image/'); if (esFoto) { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.92);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:16px;cursor:pointer;'; overlay.onclick = () => document.body.removeChild(overlay); const img = document.createElement('img'); img.src = archivo.dataUrl; img.style.cssText = 'max-width:100%;max-height:85vh;object-fit:contain;border-radius:12px;'; const lbl = document.createElement('div'); lbl.textContent = archivo.nombre; lbl.style.cssText = 'color:rgba(255,255,255,0.6);font-size:12px;margin-top:12px;text-align:center;'; const hint = document.createElement('div'); hint.textContent = 'Toca para cerrar'; hint.style.cssText = 'color:rgba(255,255,255,0.35);font-size:11px;margin-top:4px;'; overlay.appendChild(img); overlay.appendChild(lbl); overlay.appendChild(hint); document.body.appendChild(overlay); } else { const a = document.createElement('a'); a.href = archivo.dataUrl; a.target = '_blank'; a.rel = 'noopener'; a.click(); } } function descargarArchivo(puntoId, archivoId) { const archivo = buscarArchivo(puntoId, archivoId); if (!archivo) { toast('Archivo no encontrado'); return; } if (archivo.dataUrl && archivo.dataUrl.startsWith('data:')) { const a = document.createElement('a'); a.href = archivo.dataUrl; a.download = archivo.nombre; a.click(); } else { fetch(archivo.dataUrl).then(r => r.blob()).then(blob => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = archivo.nombre; a.click(); URL.revokeObjectURL(url); toast('Descargando...'); }).catch(() => window.open(archivo.dataUrl, '_blank')); } } async function borrarArchivo(puntoId, archivoId) { if (!confirm('¿Borrar este archivo?')) return; const archivo = buscarArchivo(puntoId, archivoId); // [F6-5] Si el archivo vive en Supabase, primero confirmar el borrado del // Storage; si falla, NO se borra de la BD ni de la vista (no más huérfanos). if (archivo && !archivo.soloLocal && typeof borrarArchivoSupabase === 'function') { toast('Borrando…'); const r = await borrarArchivoSupabase(archivoId, archivo.storagePath, archivo.tipo && archivo.tipo.startsWith('image/')); const ok = r === true || (r && r.ok === true); if (!ok) { vibrar([80]); toast('No se pudo borrar la foto del servidor. Intenta de nuevo.'); return; } } datosObra.archivos[puntoId] = (datosObra.archivos[puntoId] || []).filter(a => String(a.id) !== String(archivoId)); // [CORRECCIÓN PDF #1] Si la foto principal apuntaba a este archivo, limpiar el puntero // (en cualquier componente, no solo el activo) const reg = datosObra.fotoPrincipal[puntoId]; if (reg && typeof reg === 'object') { Object.keys(reg).forEach(comp => { if (String(reg[comp]) === String(archivoId)) delete reg[comp]; }); if (Object.keys(reg).length === 0) delete datosObra.fotoPrincipal[puntoId]; } else if (reg && String(reg) === String(archivoId)) { delete datosObra.fotoPrincipal[puntoId]; } guardarDatos(); const lista = document.getElementById(`archivos-${puntoId}`); if (lista) lista.innerHTML = renderArchivos(puntoId); toast('Archivo borrado'); } // ============================================================ // [3] NOTAS — privacidad por rol // ============================================================ async function agregarNota(puntoId) { const input = document.getElementById(`nueva-nota-${puntoId}`); const selectEstado = document.getElementById(`estado-nota-${puntoId}`); const texto = input.value.trim(); if (!texto) { vibrar([100]); toast('Escribe algo primero'); return; } vibrar([30]); if (!datosObra.notas[puntoId]) datosObra.notas[puntoId] = []; const idTemporal = 'tmp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); const compActivo = componenteActivo || COMPONENTE_DEFAULT; const nota = { id: idTemporal, texto: texto, estado: selectEstado.value, fecha: new Date().toISOString(), origen: rolActivo === 'supervision' ? 'supervisor' : 'contratista', componente: compActivo, estado_revision: rolActivo === 'supervision' ? 'pendiente' : null, parent_id: null, // [F2-4] Trazabilidad: nombre real del autor en cada observación autor: rolActivo === 'supervision' ? obtenerNombreSupervisor(compActivo) : obtenerNombreContratista() }; datosObra.notas[puntoId].unshift(nota); guardarDatos(); input.value = ''; const lista = document.getElementById(`notas-${puntoId}`); if (lista) lista.innerHTML = renderNotas(puntoId, rolActivo === 'supervision' ? 'supervision' : 'contratista'); if (rolActivo === 'supervision') { const comp = getComponente(compActivo); toast(`Observación ${comp.label} enviada`); } else { toast('Nota agregada'); } // [OBS3] Refrescar panel para ambos roles (el supervisor ve su observación al instante) cargarNotificaciones(); if (typeof guardarNotaSupabase === 'function') { const dataReal = await guardarNotaSupabase(puntoId, nota); if (dataReal && dataReal.id) { const idx = datosObra.notas[puntoId].findIndex(n => n.id === idTemporal); if (idx >= 0) { datosObra.notas[puntoId][idx].id = dataReal.id; guardarDatos(); if (lista) lista.innerHTML = renderNotas(puntoId, rolActivo === 'supervision' ? 'supervision' : 'contratista'); cargarNotificaciones(); } } else if (typeof SyncQueue !== 'undefined') { // [F8-1] Falló la sincronización: la nota NO queda huérfana con tmp_id. // Entra a la cola persistente y se reintenta al volver la conexión. SyncQueue.encolar('nota', { puntoId, nota }, idTemporal); } } } // [3] renderNotas con filtro por vista // === ESTADOS DE REVISIÓN === function renderNotas(puntoId, vista) { let notas = datosObra.notas[puntoId] || []; // Filtrar por componente activo const compActivo = componenteActivo || COMPONENTE_DEFAULT; notas = notas.filter(n => (n.componente || 'tecnico') === compActivo); // Separar notas raíz (sin parent_id) de respuestas (con parent_id) const notasRaiz = notas.filter(n => !n.parent_id); const respuestasPorPadre = {}; notas.filter(n => n.parent_id).forEach(n => { if (!respuestasPorPadre[n.parent_id]) respuestasPorPadre[n.parent_id] = []; respuestasPorPadre[n.parent_id].push(n); }); // PRIVACIDAD: // - Supervisor: solo ve notas raíz del SUPERVISOR (su componente) + las respuestas del contratista a ellas // - Contratista: ve TODAS las notas raíz (suyas + supervisor) + respuestas let notasMostrar = notasRaiz; if (vista === 'supervision') { notasMostrar = notasRaiz.filter(n => n.origen === 'supervisor'); } // Ordenar: más recientes primero notasMostrar.sort((a, b) => new Date(b.fecha) - new Date(a.fecha)); if (notasMostrar.length === 0) { const comp = getComponente(compActivo); return `

Sin notas en ${comp.label}

`; } return notasMostrar.map(n => renderNotaConHilo(puntoId, n, respuestasPorPadre[n.id] || [], vista)).join(''); } function renderNotaConHilo(puntoId, n, respuestas, vista) { const fecha = new Date(n.fecha).toLocaleString('es-CO', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); const estilo = { info: 'background:#e3f2fd;color:#0a3d62;', advertencia: 'background:#fff3cd;color:#856404;', problema: 'background:#f8d7da;color:#721c24;', resuelto: 'background:#d4edda;color:#155724;' }; const labels = { info: 'INFO', advertencia: 'AVISO', problema: 'PROBLEMA', resuelto: 'RESUELTO' }; const esSupervisor = n.origen === 'supervisor'; const claseNota = esSupervisor ? 'nota-item nota-sup' : 'nota-item'; const comp = getComponente(n.componente || 'tecnico'); const origenBadge = (esSupervisor && vista === 'contratista') ? `${comp.rolLabel.toUpperCase()}` : ''; // Estado de revisión (solo en notas del supervisor) let badgeEstado = ''; if (esSupervisor) { const estadoRev = n.estado_revision || 'pendiente'; const er = ESTADOS_REVISION[estadoRev]; if (vista === 'supervision') { // Supervisor: badge clickeable para cambiar estado badgeEstado = ``; } else { // Contratista: badge solo lectura badgeEstado = `${er.icon} ${er.label}`; } } // Botones de acción según permisos let botonesAccion = ''; if (!esSupervisor && vista === 'contratista') { // [F2] Solo el contratista puede borrar SUS propias notas. El supervisor NO borra (solo cambia estado). botonesAccion = ``; } // NOTA: contratista NO puede borrar notas del supervisor (botón omitido) // Hilo de respuestas const respuestasHtml = respuestas .sort((a, b) => new Date(a.fecha) - new Date(b.fecha)) .map(r => { const fechaR = new Date(r.fecha).toLocaleString('es-CO', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); const puedeBorrarRespuesta = false; // [OBS2] nadie borra respuestas dentro del hilo const btnDelR = puedeBorrarRespuesta ? `` : ''; const labelOrigen = r.autor ? escapeHtml(r.autor) : (r.origen === 'contratista' ? 'Operación' : comp.rolLabel); return `
${labelOrigen}${fechaR}${btnDelR}
${escapeHtml(r.texto)}
`; }).join(''); // Input de respuesta (solo si el contratista mira nota del supervisor, o supervisor mira con respuestas del contratista para contestar de vuelta) let inputRespuesta = ''; if (esSupervisor && vista === 'contratista') { // Contratista puede responder a notas del supervisor inputRespuesta = `
`; } else if (esSupervisor && vista === 'supervision' && respuestas.length > 0) { // Supervisor puede continuar conversación inputRespuesta = `
`; } return `
${escapeHtml(n.texto)}
${respuestasHtml ? `
${respuestasHtml}
` : ''} ${inputRespuesta}
`; } async function cambiarEstadoNota(puntoId, notaId) { if (rolActivo !== 'supervision') return; const notas = datosObra.notas[puntoId] || []; const nota = notas.find(n => String(n.id) === String(notaId)); if (!nota) return; // Ciclo: pendiente → proceso → aprobado → pendiente const actual = nota.estado_revision || 'pendiente'; const siguiente = actual === 'pendiente' ? 'proceso' : (actual === 'proceso' ? 'aprobado' : 'pendiente'); nota.estado_revision = siguiente; guardarDatos(); vibrar([15, 20]); const er = ESTADOS_REVISION[siguiente]; toast(`${er.icon} ${er.label}`); // Re-renderizar const lista = document.getElementById(`notas-${puntoId}`); if (lista) lista.innerHTML = renderNotas(puntoId, 'supervision'); // [OBS4] Refrescar panel: si quedó "aprobado" baja a Notas archivadas if (typeof cargarNotificaciones === 'function') cargarNotificaciones(); // Sync Supabase — con cola de respaldo [F8-1] if (typeof actualizarEstadoNotaSupabase === 'function') { actualizarEstadoNotaSupabase(notaId, siguiente).then(ok => { if (!ok && typeof SyncQueue !== 'undefined') { SyncQueue.encolar('estado_nota', { notaId, estado: siguiente }); } }); } } async function enviarRespuestaNota(puntoId, parentId) { const input = document.getElementById(`reply-input-${parentId}`); if (!input) return; const texto = input.value.trim(); if (!texto) return; vibrar([20]); const idTemp = 'tmp_' + Date.now(); const compActivo = componenteActivo || COMPONENTE_DEFAULT; const nota = { id: idTemp, texto, estado: 'info', fecha: new Date().toISOString(), origen: rolActivo === 'supervision' ? 'supervisor' : 'contratista', componente: compActivo, parent_id: parentId, autor: rolActivo === 'supervision' ? obtenerNombreSupervisor(compActivo) : obtenerNombreContratista() // [F2-4] }; if (!datosObra.notas[puntoId]) datosObra.notas[puntoId] = []; datosObra.notas[puntoId].push(nota); guardarDatos(); input.value = ''; // Re-renderizar const lista = document.getElementById(`notas-${puntoId}`); if (lista) lista.innerHTML = renderNotas(puntoId, rolActivo === 'supervision' ? 'supervision' : 'contratista'); toast('Respuesta enviada'); // Sync Supabase — con cola de respaldo [F8-1] if (typeof guardarNotaSupabase === 'function') { const dataReal = await guardarNotaSupabase(puntoId, nota); if (dataReal && dataReal.id) { const idx = datosObra.notas[puntoId].findIndex(n => n.id === idTemp); if (idx >= 0) { datosObra.notas[puntoId][idx].id = dataReal.id; guardarDatos(); } } else if (typeof SyncQueue !== 'undefined') { SyncQueue.encolar('nota', { puntoId, nota }, idTemp); } } } async function borrarNota(puntoId, notaId) { // Verificar permisos: solo se puede borrar nota propia (excepto Supervisor borra cualquier nota de SU componente) const nota = (datosObra.notas[puntoId] || []).find(n => String(n.id) === String(notaId)); if (!nota) return; // [F2] El supervisor NO puede borrar observaciones en el flujo normal. // [OBS4] Excepción: SÍ puede borrar sus observaciones APROBADAS (archivadas). if (rolActivo === 'supervision' && !(nota.origen === 'supervisor' && nota.estado_revision === 'aprobado')) { toast('El supervisor no puede borrar observaciones'); return; } if (rolActivo === 'contratista' && nota.origen === 'supervisor') { toast('No puedes borrar notas del supervisor'); return; } // [F8-5] Trazabilidad legal: una nota solo puede borrarse dentro de los // primeros 5 minutos de creada (corrección de errores de tipeo). Después // queda como registro permanente de la conversación de obra. // Excepción: observaciones APROBADAS del supervisor (archivado, ya validado arriba). const esArchivadaSup = rolActivo === 'supervision' && nota.origen === 'supervisor' && nota.estado_revision === 'aprobado'; if (!esArchivadaSup) { const edadMs = Date.now() - new Date(nota.fecha).getTime(); if (edadMs > 5 * 60 * 1000) { vibrar([80]); toast('Las notas con más de 5 minutos no se pueden borrar (trazabilidad de obra)'); return; } } if (!confirm('¿Borrar esta nota?')) return; // Borrar también las respuestas (hijos) si las hay datosObra.notas[puntoId] = (datosObra.notas[puntoId] || []).filter(n => String(n.id) !== String(notaId) && String(n.parent_id) !== String(notaId) ); guardarDatos(); const lista = document.getElementById(`notas-${puntoId}`); if (lista) lista.innerHTML = renderNotas(puntoId, rolActivo === 'supervision' ? 'supervision' : 'contratista'); toast('Nota borrada'); if (typeof cargarNotificaciones === 'function') cargarNotificaciones(); if (typeof borrarNotaSupabase === 'function' && !String(notaId).startsWith('tmp_')) { borrarNotaSupabase(notaId).then(ok => { if (!ok && typeof SyncQueue !== 'undefined') { SyncQueue.encolar('borrar_nota', { notaId }); // [F8-1] } }); } } function escapeHtml(text) { const d = document.createElement('div'); d.textContent = text; return d.innerHTML; } // ============================================================ // BITÁCORA (solo contratista) // ============================================================ function abrirBitacoraModal() { const contenido = document.getElementById('bitacora-contenido'); contenido.innerHTML = `

Bitácora de Obra

Bitácoras guardadas
${renderBitacorasGuardadas()}
`; // Event delegation para los botones generados dinámicamente contenido.onclick = (e) => { const verBtn = e.target.closest('[data-bita-ver]'); const delBtn = e.target.closest('[data-bita-del]'); const vfyBtn = e.target.closest("[data-bita-verify]"); if (vfyBtn) { e.stopPropagation(); vibrar([10]); verificarBitacora(vfyBtn.dataset.bitaVerify); } else if (verBtn) { e.stopPropagation(); vibrar([10]); verBitacora(verBtn.dataset.bitaVer); } else if (delBtn) { e.stopPropagation(); vibrar([10]); borrarBitacora(delBtn.dataset.bitaDel); } }; const overlay = document.getElementById('bitacora-overlay'); const sheet = overlay.querySelector('.bitacora-sheet'); overlay.classList.add('activo'); if (sheet) { sheet.style.transform = ''; sheet.style.transition = ''; inicializarDragSheet(sheet, overlay, cerrarBitacoraModal); } vibrar([8]); } function cerrarBitacoraModal() { const overlay = document.getElementById('bitacora-overlay'); const sheet = overlay.querySelector('.bitacora-sheet'); if (sheet) { sheet.style.transition = 'transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)'; sheet.style.transform = 'translateY(100%)'; overlay.style.background = 'rgba(0,0,0,0)'; setTimeout(() => { overlay.classList.remove('activo'); sheet.style.transition = ''; sheet.style.transform = ''; overlay.style.background = ''; }, 300); } else { overlay.classList.remove('activo'); } } function generarBitacoraHoy() { const hoy = new Date(); const ini = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate()); const fin = new Date(ini); fin.setDate(fin.getDate() + 1); generarBitacora(ini, fin, `Bitácora del ${hoy.toLocaleDateString('es-CO')}`); } // [F10-2] Rango de fechas con modal propio (date pickers + validación), // no más prompt() nativo con formato a mano. function generarBitacoraRango() { const m = document.getElementById('rango-modal'); if (!m) return; const hoy = new Date(); const hace7 = new Date(hoy); hace7.setDate(hace7.getDate() - 7); const fmt = (d) => d.toISOString().slice(0, 10); document.getElementById('rango-desde').value = fmt(hace7); document.getElementById('rango-hasta').value = fmt(hoy); document.getElementById('rango-desde').max = fmt(hoy); document.getElementById('rango-hasta').max = fmt(hoy); document.getElementById('rango-error').style.visibility = 'hidden'; m.classList.add('activo'); } function cerrarRangoModal() { document.getElementById('rango-modal').classList.remove('activo'); } function confirmarRangoBitacora() { const d = document.getElementById('rango-desde').value; const h = document.getElementById('rango-hasta').value; const err = document.getElementById('rango-error'); if (!d || !h) { err.textContent = 'Selecciona ambas fechas'; err.style.visibility = 'visible'; return; } if (d > h) { err.textContent = '"Desde" debe ser anterior o igual a "Hasta"'; err.style.visibility = 'visible'; return; } cerrarRangoModal(); const i = new Date(d + 'T00:00:00'); const f = new Date(h + 'T00:00:00'); f.setDate(f.getDate() + 1); generarBitacora(i, f, `Bitácora del ${d} al ${h}`); } async function generarBitacora(inicio, fin, titulo) { toast('Generando bitácora…'); const actividades = []; PUNTOS.forEach(punto => { const notas = (datosObra.notas[punto.id] || []).filter(n => { const f = new Date(n.fecha); return f >= inicio && f < fin; }); const archivos = (datosObra.archivos[punto.id] || []).filter(a => { const f = new Date(a.fecha); return f >= inicio && f < fin; }); if (notas.length > 0 || archivos.length > 0) actividades.push({ punto, notas, archivos }); }); // [F10-1] Evidencia legal autocontenida: las fotos se incrustan en BASE64 // dentro del HTML. Una bitácora archivada nunca se queda "en blanco" si se // borra el Storage o no hay internet. await _incrustarFotosBase64(actividades); // [F10-3] Resumen consolidado de observaciones del período (todos los componentes) const resumenObs = _resumirObservacionesPeriodo(inicio, fin); let html = construirHtmlBitacora(titulo, actividades, resumenObs); // [F10-4] Sello de integridad: SHA-256 del contenido + fecha + autor. // El hash queda impreso en el pie y guardado en BD (tabla append-only, // ver sql/02_hardening_rls.sql). Cualquier edición retroactiva se detecta // recalculando el hash y comparando. const generadaPor = rolActivo === 'supervision' ? obtenerNombreSupervisor(componenteActivo || COMPONENTE_DEFAULT) : obtenerNombreContratista(); const fechaGen = new Date().toISOString(); const hash = await sha256Hex(html + '|' + fechaGen + '|' + generadaPor); html += `
Sello de integridad SHA-256: ${hash}
Generada por ${escapeHtml(generadaPor)} · ${new Date(fechaGen).toLocaleString('es-CO')}
`; const bitacora = { id: 'b_' + Date.now(), fecha: fechaGen, titulo, contenido: html, actividades: actividades.length, hash, generadaPor }; datosObra.bitacoras.unshift(bitacora); guardarDatos(); abrirBitacoraVista(html); toast('Bitácora generada y sellada'); if (typeof guardarBitacoraSupabase === 'function') { const data = await guardarBitacoraSupabase(bitacora); if (!data && typeof SyncQueue !== 'undefined') SyncQueue.encolar('bitacora', { bitacora }); // [F8-1] } } // [F10-1] Convierte las URLs remotas de fotos del período a dataURL base64. // Limita el tamaño total incrustado a ~15 MB para no romper localStorage. async function _incrustarFotosBase64(actividades) { const LIMITE_TOTAL = 15 * 1024 * 1024; let acumulado = 0; for (const act of actividades) { for (const f of act.archivos) { if (!f.tipo || !f.tipo.startsWith('image/')) continue; if (f.dataUrl && f.dataUrl.startsWith('data:')) { acumulado += f.dataUrl.length; continue; } if (acumulado > LIMITE_TOTAL) { f._sinIncrustar = true; continue; } try { const resp = await fetch(f.dataUrl); if (!resp.ok) throw new Error('HTTP ' + resp.status); const blob = await resp.blob(); const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(blob); }); f._dataUrlBitacora = dataUrl; acumulado += dataUrl.length; } catch (e) { console.warn('[Bitácora] No se pudo incrustar foto, queda con URL externa:', f.nombre, e); f._sinIncrustar = true; } } } } // [F10-3] Resumen de observaciones del período, de TODOS los componentes function _resumirObservacionesPeriodo(inicio, fin) { const obs = []; Object.keys(datosObra.notas).forEach(pid => { if (!_idsObraActiva().has(String(pid))) return; (datosObra.notas[pid] || []).forEach(n => { const f = new Date(n.fecha); if (f >= inicio && f < fin && !n.parent_id) obs.push({ ...n, puntoId: pid }); }); }); obs.sort((a, b) => new Date(a.fecha) - new Date(b.fecha)); return obs; } function construirHtmlBitacora(titulo, actividades, resumenObs) { const completados = PUNTOS.filter(p => datosObra.estados[p.id] === 'completado').length; const enProceso = PUNTOS.filter(p => datosObra.estados[p.id] === 'proceso').length; const pendientes = PUNTOS.length - completados - enProceso; let html = `
${PROYECTO.contratante}

${PROYECTO.nombre} · ${PROYECTO.fase}

${titulo}

Generado: ${new Date().toLocaleString('es-CO')}
`; html += `
${PUNTOS.length}
Total
${pendientes}
Pendientes
${enProceso}
En proceso
${completados}
Completados
`; html += `

Actividades (${actividades.length})

`; if (actividades.length === 0) { html += '

Sin actividades.

'; } else { actividades.forEach(({ punto, notas, archivos }) => { html += `
${punto.id}

${punto.titulo}

${punto.direccion}
`; if (notas.length > 0) { notas.forEach(n => { const hr = new Date(n.fecha).toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' }); html += `
[${hr}·${(n.estado || 'info').toUpperCase()}${n.origen === 'supervisor' ? ' · ' + (getComponente(n.componente || 'tecnico').rolLabel || 'SUPERVISIÓN').toUpperCase() : ' · OPERACIÓN'}]
${escapeHtml(n.texto)}
`; }); } if (archivos.length > 0) { const fotos = archivos.filter(a => a.tipo && a.tipo.startsWith('image/')); if (fotos.length > 0) { html += '
'; fotos.forEach(f => { const srcFoto = f._dataUrlBitacora || f.dataUrl; const marca = f._sinIncrustar ? '
⚠ referencia externa
' : ''; html += `
${marca}
`; }); html += '
'; } } html += '
'; }); } // [F10-3] Observaciones del período — todos los componentes, con autor y estado de gestión const obs = resumenObs || []; html += `

Observaciones del período (${obs.length})

`; if (obs.length === 0) { html += '

Sin observaciones en el período.

'; } else { const porTipo = { problema: 0, advertencia: 0, info: 0, resuelto: 0 }; obs.forEach(o => { porTipo[o.estado || 'info'] = (porTipo[o.estado || 'info'] || 0) + 1; }); html += `
No conformidades: ${porTipo.problema} · Advertencias: ${porTipo.advertencia} · Informativas: ${porTipo.info} · Aprobadas/Resueltas: ${porTipo.resuelto}
`; obs.forEach(o => { const compO = getComponente(o.componente || 'tecnico'); const quien = o.origen === 'supervisor' ? compO.rolLabel : 'Operación'; const autorTxt = o.autor ? ` · ${escapeHtml(o.autor)}` : ''; const fechaO = new Date(o.fecha).toLocaleString('es-CO', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); const gestion = o.origen === 'supervisor' ? ` · Gestión: ${(o.estado_revision || 'pendiente')}` : ''; html += `
[${o.puntoId} · ${fechaO} · ${(o.estado || 'info').toUpperCase()} · ${quien}${autorTxt}${gestion}]
${escapeHtml(o.texto)}
`; }); } html += `
${PROYECTO.nombre} · ${PROYECTO.contratante}
`; return html; } function abrirBitacoraVista(html, titulo) { // Vista interna en lugar de window.open (falla en iOS Safari/PWA) const overlay = document.getElementById('bitacora-overlay'); const contenido = document.getElementById('bitacora-contenido'); contenido.innerHTML = `
${html}
`; if (!overlay.classList.contains('activo')) { const sheet = overlay.querySelector('.bitacora-sheet'); overlay.classList.add('activo'); if (sheet) { sheet.style.transform = ''; sheet.style.transition = ''; inicializarDragSheet(sheet, overlay, cerrarBitacoraModal); } } } function imprimirBitacoraActual() { const cont = document.getElementById('bitacora-imprimible'); if (!cont) return; const w = window.open('', '_blank'); if (!w) { toast('Permite ventanas emergentes para imprimir'); return; } w.document.write(`Bitácora${cont.innerHTML} TRAZZO · Trazabilidad legal para obra pública
Operando activamente en obras del sector al sur del país

Tu obra,
en orden.
Toda.

Mientras tu equipo opera, libera horas administrativas para volver al campo: cada punto firmado ya es el anexo final. Cuando llegue la auditoría, abres el mapa, tocas el punto y entregas evidencia georreferenciada con respaldo legal. No carpetas, no Excel, no WhatsApp.

100%del expediente, trazable
−33%reprocesos por mala documentación
48hde auditoría resueltas en minutos
Coordenadas GPS reales Bitácora firmada semanalmente Timestamps inmutables Soporte válido para el expediente contractual Acueducto · Alcantarillado · Vías · Conexiones Coordenadas GPS reales Bitácora firmada semanalmente Timestamps inmutables Soporte válido para el expediente contractual Acueducto · Alcantarillado · Vías · Conexiones

Responde en silencio.
Nadie está mirando.

Cuatro preguntas honestas sobre tu obra de hoy. Nadie ve tus respuestas. Si dudas en una sola, sigue bajando.

01
Si el ente de control pidiera hoy el expediente firmado de los últimos tres cambios de diseño, ¿lo entregarías en 48 horas — o empezaría la búsqueda?
02
¿Encuentras en menos de 5 minutos el acta de un punto ejecutado hace 4 meses, o tienes que rezar para que aparezca?
03
Las coordenadas del informe final, ¿coinciden con donde realmente quedó la infraestructura, o con donde el plano decía que iba?
04
Cuando el supervisor SST, Ambiental o Social levanta un hallazgo en campo, ¿queda con autor, hora y respuesta — o se la lleva el viento?
Sigue bajando.
No estás solo. Esto es exactamente lo que TRAZZO resuelve por diseño — y lo que vas a ver a continuación es cómo funciona en una obra real.

Excel.
WhatsApp.
Carpeta física.
El mapa ES la bitácora.

No es un capricho de diseño. Es la única forma en que un sistema te defiende ante la Ley 80 y la Contraloría. Tocas un marcador y ahí está toda la vida documental del punto: foto, GPS, historial firmado, hallazgos técnicos, SST, ambientales y sociales. No vendemos una app de fotos. Vendemos un expediente que aguanta auditoría.

Dolor #1 · Respaldo ante demandas
EVIDENCIA FIRMADA

Cada observación con autor, hora, GPS y foto. Inmutable. Trazable. El día que llegue la demanda, tu equipo tiene con qué defenderse.

Dolor #2 · Control financiero total
FINANCIERO EN VIVO

Cronograma de avance, balance, actas parciales y gastos de obra al día. El interventor sabe cuánto se ha gastado del contrato en cualquier momento, sin pedir Excels.

Dolor #3 · Logística entre frentes
RUTA ÓPTIMA

Sistema avanzado de optimización de transporte entre frentes de obra. Menos kilómetros muertos, menos combustible, menos horas perdidas en logística.

Capacidad técnica
GPS REAL · OFFLINE

Coordenadas capturadas en el momento de la intervención — no las del plano teórico. Funciona sin internet y sincroniza cuando vuelve la señal.

Visión territorial

Un mapa, todos los puntos.

Cada frente, cada intervención, cada alerta como un marcador real, no teórico. Filtra por estado, por componente, por responsable. El director de obra ve lo macro; el técnico ve lo micro. Mismo mapa.

  • Vista por tipo
  • Contador en vivo de pendientes
  • Sincronización automática entre dispositivos
Mapa GIS con marcadores
Ficha técnica de un punto
Detalle por punto

Toda la vida de un punto, en una pantalla.

Foto de referencia del diseño, GPS de Operación, historial fotográfico día por día, observaciones por componente, estado. El interventor ya no abre cinco apps para validar un punto: lo toca y ya.

Bitácora legal

Imprime el viernes, firma, anexa.

Generación automática de la bitácora semanal con todos los puntos intervenidos, sus fotos, sus observaciones y sus firmas digitales. Sale ya formateada para anexar al expediente contractual. Cero diseño manual.

  • PDF imprimible por semana o por rango
  • Cada página con foto y referencia geográfica
  • Listo para auditoría sin reorganización
Bitácora imprimible
Sistema de observaciones timestamped
Observaciones inmutables

"¿Quién aprobó esto?" deja de ser una pregunta sin respuesta.

Cada observación que cualquier supervisor hace queda registrada con autor, fecha, hora, punto exacto y respuesta del otro lado. No se borra. No se edita. Es la evidencia que faltaba.

Logística entre frentes

La ruta más corta entre todos tus frentes.

Sistema avanzado de optimización de transporte. TRAZZO calcula el orden óptimo de visitas y la ruta más eficiente entre frentes, almacenes y puntos críticos. Menos kilómetros muertos, menos combustible, menos horas perdidas en logística.

  • Orden óptimo de visitas a frentes activos
  • Cálculo de distancia y tiempo entre puntos
  • Útil para inspectores, supervisores y cuadrillas
Ruta óptima entre frentes de obra

Una Operación. Cuatro supervisores.
Cero confusión.

Cada componente con su cuenta separada. Cada supervisor ve solo lo suyo. La interventoría puede combinar varios roles en un mismo dispositivo.

OPERACIÓN
EJECUTA
Registra avances · sube fotos · captura GPS real · responde observaciones · gestiona cronograma de avance, balance y actas parciales
TÉCNICO
Interventor
Cronograma de avance, gastos de obra y actas. Sabe en todo momento cuánto se ha gastado del contrato.
SST
Seguridad
Seguridad y salud en el trabajo, EPP, condiciones laborales, incidentes.
AMBIENTAL
Sostenibilidad
Impacto ambiental, manejo de residuos, plan de gestión ambiental del proyecto.
SOCIAL
Comunidad
Relación con la comunidad, PQRS, comunicaciones con vecinos del frente.

El precio de no tener expediente.

Esto no es ahorro hipotético de horas. Es la exposición financiera que tu contrato carga sin trazabilidad: multas y cláusula penal pecuniaria que impone la entidad, sobrecostos por reproceso de actividades mal soportadas, y afectación de la garantía de cumplimiento. Porcentajes conservadores frente al valor del contrato.

Valor del contrato $2.000M COP
Plazo del contrato 12 meses
PÉRDIDA POTENCIAL EVITADA
$300M
exposición total estimada sobre tu contrato
Multas y cláusula penal pecuniaria
5% del contrato · impuestas por la entidad ante incumplimiento documental
$100M
Sobrecosto por reproceso
5% del contrato · actividades rehechas por soporte deficiente
$100M
Afectación de garantía de cumplimiento
5% del valor asegurado · siniestro sobre la póliza y primas futuras
$100M

Lo que estás
pensando ahora.

Es normal que lo parezca — hasta que lo pones al lado de un solo reproceso. Una actividad rehecha por soporte deficiente la pagas hasta tres veces: la obra, la corrección y el tiempo de obra detenida. TRAZZO cuesta una fracción de un único hallazgo de la Contraloría. No es un gasto de software; es el costo de blindar el expediente contractual.
Y funcionan — hasta el día del litigio. Un Excel editable y un chat de WhatsApp no son evidencia documental ante un juez ni ante el ente de control, porque cualquiera pudo cambiarlos después. TRAZZO guarda cada registro con autor, hora de servidor y coordenada de captura, sin función de editar ni borrar. Es la diferencia entre "creo que lo aprobamos" y "aquí está, firmado y fechado".
TRAZZO funciona offline en el frente de obra. La cuadrilla captura puntos, fotos y coordenadas sin señal, y todo sincroniza solo cuando el dispositivo vuelve a tener conexión en la oficina o en la vía. El GPS no depende de datos móviles. La obra no se detiene porque no haya cobertura.
TRAZZO se opera con el dedo gordo y una mano sucia. Si tu gente usa WhatsApp, sabe usar TRAZZO — la curva de aprendizaje en campo es de unos 90 minutos. Durante la implementación capacitamos al equipo presencialmente y dejamos manuales gráficos por rol. Operación solo abre una app de mapa y toca el punto.
Sí. Cada registro queda con timestamp inmutable, autor identificado, coordenadas GPS de captura y trazabilidad de modificaciones. La bitácora semanal se exporta ya formateada para imprimir, firmar por las partes (interventor, Operación, supervisor) y anexar al expediente — compatible con firma electrónica conforme a la Ley 527 de 1999. Si una observación es cuestionada, se reproduce byte por byte.
La entidad pública es propietaria de los datos del proyecto. Al cierre se entrega un export completo en formato abierto (JSON + carpeta de imágenes organizadas + PDFs de bitácora) sin dependencias propietarias. Toda la información del expediente queda disponible aunque TRAZZO deje de operarse.

Una demo de 20 minutos
sobre tu obra real.

No te mostramos un demo genérico. Tú nos das un proyecto tuyo activo y te enseñamos cómo se vería operado en TRAZZO — tus frentes, tus puntos, tu expediente. Si no te convence, perdiste 20 minutos. Si te convence, ganaste los próximos 6 meses de auditoría tranquila.

La demo es sobre tu obra, no genérica. Si no le ves valor, perdiste 20 minutos. Si te convence, te explicamos la garantía por escrito que cubre tu trazabilidad ante Contraloría.

Recibimos tu solicitud

Te contactaremos en menos de 24 horas hábiles por el medio que prefieras para confirmar fecha y hora de la demo. Mientras tanto, puedes entrar al sistema a explorar.

Tus datos quedan únicamente con nuestro equipo · No spam · No compartimos información