`;
}).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 = `
`;
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.
Coordenadas GPS realesBitácora firmada semanalmenteTimestamps inmutablesSoporte válido para el expediente contractualAcueducto · Alcantarillado · Vías · ConexionesCoordenadas GPS realesBitácora firmada semanalmenteTimestamps inmutablesSoporte válido para el expediente contractualAcueducto · Alcantarillado · Vías · Conexiones
02 · Indagación
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.
03 · La idea central
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
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
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
04 · Modelo operativo
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.
05 · Tu exposición real
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 contrato12 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
06 · Preguntas frecuentes
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.
08 · Cierre
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