excel-planner/web/Tablero_proyectos_v7.html

1403 lines
61 KiB
HTML

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tablero de Proyectos RTC - Sapian</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--sapian-dark: #02493E;
--sapian-light: #0A8B74;
--sapian-accent: #11B89A;
--sapian-lime: #CDDC39;
--success: #10B981;
--warning: #F59E0B;
--danger: #EF4444;
--bg: #F0F4F4;
--card-bg: #FFFFFF;
--text: #334155;
}
body {
font-family: 'Segoe UI', Tahoma, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
display: flex;
height: 100vh;
overflow: hidden;
}
/* Sidebar Styles */
.sidebar {
width: 320px;
background: linear-gradient(145deg, var(--sapian-dark) 0%, #033a31 100%);
color: white;
display: flex;
flex-direction: column;
box-shadow: 4px 0 15px rgba(0,0,0,0.1);
z-index: 10;
}
.sidebar-header {
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
.sidebar-logo {
width: 144px;
height: auto;
margin-bottom: 15px;
display: block;
}
.sidebar h2 {
font-size: 1.2rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
padding-bottom: 15px;
margin: 0 0 20px 0;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
justify-content: center;
text-align: center;
}
/* Botones Sidebar */
.btn-action {
width: 100%;
border: none;
padding: 10px;
border-radius: 8px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.3s ease;
margin-bottom: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.btn-pdf { background: var(--sapian-lime); color: var(--sapian-dark); }
.btn-pdf:hover { background: #dce775; transform: translateY(-1px); }
.btn-config { background: rgba(255,255,255,0.1); color: white; border: 1px solid rgba(255,255,255,0.2); }
.btn-config:hover { background: rgba(255,255,255,0.2); transform: translateY(-1px); }
.btn-home { background: rgba(255,255,255,0.1); color: white; border: 1px solid rgba(255,255,255,0.2); margin-bottom: 20px;}
.btn-home:hover { background: rgba(255,255,255,0.2); }
.tabs-container {
display: flex;
background: rgba(0,0,0,0.2);
border-radius: 30px;
margin-bottom: 15px;
padding: 4px;
width: 100%;
}
.tab-btn {
flex: 1;
background: transparent;
border: none;
color: #a7f3d0;
padding: 8px 0;
border-radius: 30px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.tab-btn.active {
background: var(--sapian-light);
color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.project-list-container { flex: 1; overflow-y: auto; padding: 0 20px 20px; }
.project-list { list-style: none; padding: 0; margin: 0;}
.project-list li {
padding: 14px 18px; margin-bottom: 10px; background: rgba(255,255,255,0.05);
border-radius: 8px; cursor: pointer; transition: all 0.3s ease;
border-left: 4px solid transparent; font-weight: 500;
display: flex; justify-content: space-between; align-items: center;
}
.project-list li:hover { background: rgba(255,255,255,0.1); border-left: 4px solid var(--sapian-light); }
.project-list li.active { background: var(--sapian-light); border-left: 4px solid white; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
/* Main Content Styles */
.main-content {
flex: 1; padding: 30px 40px; display: flex; flex-direction: column;
box-sizing: border-box; height: 100vh; overflow-y: auto;
}
.card {
background: var(--card-bg); border-radius: 12px; padding: 25px 30px;
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.05), 0 4px 6px -2px rgba(0,0,0,0.025);
}
.top-card { margin-bottom: 20px; border-top: 5px solid var(--sapian-dark); flex-shrink: 0; }
.table-card { flex: 1; display: flex; flex-direction: column; overflow: hidden; padding-bottom: 0; }
.header-flex { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #e2e8f0; padding-bottom: 15px; margin-bottom: 20px;}
h1 { margin: 0; color: var(--sapian-dark); font-size: 1.8rem;}
h3 { color: var(--sapian-dark); margin-top: 0; margin-bottom: 15px;}
.status-badge { padding: 8px 16px; border-radius: 30px; font-weight: 600; font-size: 0.95rem; color: white; background: var(--sapian-light); }
/* Dashboard Ejecutivo */
#placeholder { display: block; flex: 1; }
.dash-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; }
.dash-header h1 { font-size: 2rem; color: var(--sapian-dark); margin: 0; }
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 25px; }
.kpi-card {
background: white; border-radius: 12px; padding: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.05); border-left: 5px solid var(--sapian-light);
display: flex; flex-direction: column; justify-content: center;
}
.kpi-card.danger { border-left-color: var(--danger); }
.kpi-card.success { border-left-color: var(--success); }
.kpi-card.dark { border-left-color: var(--sapian-dark); }
.kpi-title { font-size: 0.9rem; color: #64748b; font-weight: 600; text-transform: uppercase; margin-bottom: 5px; }
.kpi-value { font-size: 2.2rem; font-weight: bold; color: var(--sapian-dark); margin: 0; }
.charts-grid { display: grid; grid-template-columns: 1fr 2fr; gap: 20px; }
.chart-card { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); }
.chart-card h3 { font-size: 1.1rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 10px; margin-bottom: 15px; }
.chart-container { position: relative; height: 300px; width: 100%; display: flex; justify-content: center; align-items: center;}
/* Elementos del Proyecto Específico */
#dashboardContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.progress-container { width: 100%; background: #e2e8f0; border-radius: 15px; margin: 10px 0; height: 28px; position: relative; overflow: hidden; }
.progress-bar { height: 100%; border-radius: 15px; transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); }
.progress-text { position: absolute; width: 100%; text-align: center; top: 4px; font-weight: bold; color: white; text-shadow: 1px 1px 3px rgba(0,0,0,0.6); letter-spacing: 1px;}
.alert-box { display: none; background: #FEF2F2; border-left: 5px solid var(--danger); padding: 15px; margin-top: 15px; border-radius: 6px; }
.alert-box h4 { margin: 0 0 5px 0; color: var(--danger); display: flex; align-items: center; gap: 8px;}
.alert-box p { margin: 0; font-size: 0.95rem; line-height: 1.5;}
.table-wrapper { flex: 1; overflow-y: auto; padding-bottom: 20px; padding-right: 10px; }
table { width: 100%; border-collapse: separate; border-spacing: 0; }
th, td { text-align: left; padding: 14px 15px; border-bottom: 1px solid #e2e8f0; }
th { background: #f8fafc; color: var(--sapian-dark); font-weight: 600; text-transform: uppercase; font-size: 0.85rem; position: sticky; top: 0; z-index: 2; box-shadow: 0 2px 2px -1px rgba(0,0,0,0.1); }
tr:hover td { background-color: #f8fafc; }
.task-delayed { color: var(--danger); font-weight: bold; background-color: #fff1f2;}
.loader { border: 4px solid rgba(255,255,255,0.1); border-top: 4px solid var(--sapian-accent); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 30px auto; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
#pdfTemplateContainer { display: none; }
.pdf-page {
padding: 28px 32px;
background: white;
color: #1a1a1a;
font-family: Arial, Helvetica, sans-serif;
font-size: 11px;
line-height: 1.45;
}
.pdf-header {
display: flex;
justify-content: space-between;
border-bottom: 3px solid var(--sapian-dark);
padding-bottom: 12px;
margin-bottom: 16px;
align-items: center;
page-break-inside: avoid;
break-inside: avoid;
}
.pdf-logo { height: 56px; max-width: 140px; object-fit: contain; }
.pdf-title-container { text-align: right; color: var(--sapian-dark); flex: 1; margin-left: 16px; }
.pdf-h1 { font-size: 17px; margin: 0; font-weight: bold; line-height: 1.25; }
.pdf-p { font-size: 11px; margin: 3px 0 0 0; line-height: 1.35; }
.pdf-section-title {
background-color: var(--sapian-dark);
color: white;
padding: 7px 12px;
font-size: 12px;
font-weight: bold;
margin-top: 18px;
margin-bottom: 10px;
page-break-after: avoid;
break-after: avoid;
}
.pdf-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 14px;
font-size: 10.5px;
page-break-inside: avoid;
break-inside: avoid;
}
.pdf-table th, .pdf-table td { border: 1px solid #ccc; padding: 5px 8px; text-align: left; vertical-align: top; }
.pdf-table th { background-color: #eef2f2; font-weight: bold; color: var(--sapian-dark); }
.pdf-intro-text { font-size: 11px; margin: 4px 0 14px 0; line-height: 1.45; }
.pdf-project-card {
margin-bottom: 20px;
padding: 14px 16px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fafafa;
}
.pdf-page-break-before {
page-break-before: always;
break-before: page;
}
.pdf-project-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
border-bottom: 2px solid var(--sapian-light);
padding-bottom: 8px;
margin-bottom: 10px;
page-break-after: avoid;
break-after: avoid;
}
.pdf-project-name {
color: var(--sapian-dark);
font-size: 13px;
font-weight: bold;
margin: 0;
flex: 1;
line-height: 1.3;
}
.pdf-project-meta {
font-size: 10px;
color: #475569;
text-align: right;
white-space: nowrap;
line-height: 1.4;
}
.pdf-project-meta strong { color: var(--sapian-dark); font-size: 11px; }
.pdf-alcance {
font-size: 10.5px;
margin: 0 0 12px 0;
line-height: 1.45;
color: #334155;
}
.pdf-subsection {
margin-bottom: 12px;
page-break-inside: avoid;
break-inside: avoid;
}
.pdf-subsection:last-child { margin-bottom: 0; }
.pdf-subsection-title {
font-size: 10.5px;
font-weight: bold;
color: var(--sapian-dark);
text-transform: uppercase;
letter-spacing: 0.02em;
margin: 0 0 6px 0;
padding: 4px 8px;
background: #e8f5f1;
border-left: 3px solid var(--sapian-light);
page-break-after: avoid;
break-after: avoid;
}
.pdf-task-table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
margin: 0;
background: white;
}
.pdf-task-table th, .pdf-task-table td {
border: 1px solid #e2e8f0;
padding: 4px 6px;
text-align: left;
vertical-align: top;
}
.pdf-task-table th {
background: #f1f5f9;
color: var(--sapian-dark);
font-weight: 600;
font-size: 9.5px;
}
.pdf-task-table thead { display: table-header-group; }
.pdf-task-table td.pdf-empty {
text-align: center;
color: #64748b;
font-style: italic;
padding: 8px;
}
.pdf-novedad {
background-color: #fff8e1;
color: #7c5e10;
padding: 8px 10px;
font-size: 10px;
border-radius: 4px;
margin-top: 10px;
border-left: 3px solid #f59e0b;
page-break-inside: avoid;
break-inside: avoid;
}
/* Modal configuración acta */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(2, 73, 62, 0.55);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
}
.modal-overlay.open { display: flex; }
.modal-panel {
background: white;
border-radius: 12px;
width: 100%;
max-width: 560px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
}
.modal-header {
background: linear-gradient(145deg, var(--sapian-dark) 0%, #033a31 100%);
color: white;
padding: 18px 22px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 { margin: 0; font-size: 1.15rem; display: flex; align-items: center; gap: 8px; }
.modal-close {
background: transparent;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
padding: 0 4px;
opacity: 0.85;
}
.modal-close:hover { opacity: 1; }
.modal-body { padding: 20px 22px; overflow-y: auto; flex: 1; }
.modal-field { margin-bottom: 14px; }
.modal-field label {
display: block;
font-size: 0.8rem;
font-weight: 600;
color: var(--sapian-dark);
margin-bottom: 5px;
text-transform: uppercase;
}
.modal-field input, .modal-field textarea {
width: 100%;
padding: 9px 11px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 0.95rem;
box-sizing: border-box;
font-family: inherit;
}
.modal-field textarea { resize: vertical; min-height: 52px; }
.modal-field input:focus, .modal-field textarea:focus {
outline: none;
border-color: var(--sapian-light);
box-shadow: 0 0 0 2px rgba(10, 139, 116, 0.2);
}
.asistentes-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 18px 0 8px;
}
.asistentes-header h3 { margin: 0; font-size: 0.95rem; color: var(--sapian-dark); }
.btn-add-row {
background: var(--sapian-light);
color: white;
border: none;
padding: 6px 12px;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.btn-add-row:hover { background: var(--sapian-accent); }
.asistente-row {
display: grid;
grid-template-columns: 1fr 1fr 36px;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
.btn-remove-row {
background: #fee2e2;
color: var(--danger);
border: none;
border-radius: 6px;
height: 36px;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
}
.btn-remove-row:hover { background: #fecaca; }
.modal-footer {
padding: 14px 22px;
border-top: 1px solid #e2e8f0;
display: flex;
gap: 10px;
justify-content: flex-end;
flex-wrap: wrap;
}
.modal-btn {
border: none;
padding: 9px 18px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
}
.modal-btn-primary { background: var(--sapian-dark); color: white; }
.modal-btn-primary:hover { background: var(--sapian-light); }
.modal-btn-secondary { background: #f1f5f9; color: var(--text); }
.modal-btn-secondary:hover { background: #e2e8f0; }
.modal-hint { font-size: 0.8rem; color: #64748b; margin: 0 0 12px; line-height: 1.4; }
.modal-status { font-size: 0.85rem; margin-top: 8px; min-height: 1.2em; }
.modal-status.ok { color: var(--success); }
.modal-status.err { color: var(--danger); }
</style>
</head>
<body>
<div class="sidebar">
<div class="sidebar-header">
<img src="logo_sapian_144.png" alt="Sapian Logo" class="sidebar-logo">
<h2>📊 Proyectos RTC</h2>
<button class="btn-action btn-pdf" onclick="generarPDFComite()">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
Exportar Acta
</button>
<button class="btn-action btn-config" onclick="abrirConfigActa()" title="Configurar nombres del acta PDF">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
Configuración
</button>
<button class="btn-action btn-home" onclick="mostrarVistaGeneral()">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path></svg>
Vista General
</button>
<div class="tabs-container">
<button id="btnActivos" class="tab-btn active" onclick="cambiarPestana('activos')">🚀 En Curso</button>
<button id="btnCerrados" class="tab-btn" onclick="cambiarPestana('cerrados')">✅ Cerrados</button>
</div>
</div>
<div class="project-list-container">
<div id="loading" class="loader"></div>
<ul class="project-list" id="projectList"></ul>
</div>
</div>
<div class="main-content">
<div id="placeholder">
<div class="dash-header">
<h1>Resumen Ejecutivo de Portafolio</h1>
<span id="fechaActualizacion" style="color: #64748b; font-weight: 500;">Cargando datos...</span>
</div>
<div class="kpi-grid">
<div class="kpi-card dark">
<span class="kpi-title">Total Proyectos</span>
<span class="kpi-value" id="kpiTotal">0</span>
</div>
<div class="kpi-card">
<span class="kpi-title">En Curso (Activos)</span>
<span class="kpi-value" id="kpiActivos" style="color: var(--sapian-light);">0</span>
</div>
<div class="kpi-card danger">
<span class="kpi-title">Proyectos Retrasados</span>
<span class="kpi-value" id="kpiRetrasados" style="color: var(--danger);">0</span>
</div>
<div class="kpi-card success">
<span class="kpi-title">Proyectos Cerrados</span>
<span class="kpi-value" id="kpiCerrados" style="color: var(--success);">0</span>
</div>
</div>
<div class="charts-grid">
<div class="chart-card">
<h3>Estado del Portafolio</h3>
<div class="chart-container">
<canvas id="estadoChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3>Avance Proyectos Activos (%)</h3>
<div class="chart-container">
<canvas id="avanceChart"></canvas>
</div>
</div>
</div>
</div>
<div id="dashboardContent" style="display:none;">
<div class="card top-card">
<div class="header-flex">
<h1 id="projName">Cargando...</h1>
<span id="projStatus" class="status-badge">...</span>
</div>
<h4 style="margin-bottom: 5px; color: var(--sapian-dark);">Avance General del Proyecto</h4>
<div class="progress-container">
<div id="projProgressBar" class="progress-bar" style="width: 0%; background-color: var(--success);"></div>
<div id="projProgressText" class="progress-text">0%</div>
</div>
<div id="delayAlert" class="alert-box">
<h4>⚠️ Alerta de Retraso Detectado</h4>
<p id="delayComment">Cargando comentarios...</p>
</div>
</div>
<div class="card table-card">
<h3>Detalle de Tareas (Plan de Trabajo)</h3>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Tarea</th>
<th>Responsable</th>
<th>Fecha Límite</th>
<th>Estado</th>
</tr>
</thead>
<tbody id="taskTableBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div id="pdfTemplateContainer">
<div id="pdfContent" class="pdf-page"></div>
</div>
<div id="actaConfigModal" class="modal-overlay" onclick="cerrarConfigActaSiFondo(event)">
<div class="modal-panel" role="dialog" aria-labelledby="actaConfigTitle" aria-modal="true">
<div class="modal-header">
<h2 id="actaConfigTitle">⚙️ Configuración del Acta</h2>
<button type="button" class="modal-close" onclick="cerrarConfigActa()" aria-label="Cerrar">&times;</button>
</div>
<div class="modal-body">
<p class="modal-hint">Estos datos aparecen en el PDF al exportar. Los cambios se guardan en el servidor y aplican para todos los usuarios del tablero.</p>
<div class="modal-field">
<label for="cfgTitulo">Título del acta</label>
<input type="text" id="cfgTitulo" autocomplete="off">
</div>
<div class="modal-field">
<label for="cfgSubtitulo">Subtítulo</label>
<input type="text" id="cfgSubtitulo" autocomplete="off">
</div>
<div class="modal-field">
<label for="cfgElaborado">Elaborado por</label>
<input type="text" id="cfgElaborado" autocomplete="off">
</div>
<div class="modal-field">
<label for="cfgLugar">Lugar de la reunión</label>
<input type="text" id="cfgLugar" autocomplete="off">
</div>
<div class="modal-field">
<label for="cfgDescripcion">Descripción / objetivo</label>
<textarea id="cfgDescripcion" rows="2"></textarea>
</div>
<div class="asistentes-header">
<h3>Asistentes al comité</h3>
<button type="button" class="btn-add-row" onclick="agregarFilaAsistente()">+ Agregar</button>
</div>
<div id="asistentesRows"></div>
<div id="actaConfigStatus" class="modal-status"></div>
</div>
<div class="modal-footer">
<button type="button" class="modal-btn modal-btn-secondary" onclick="restaurarConfigActaDefecto()">Restaurar valores</button>
<button type="button" class="modal-btn modal-btn-secondary" onclick="cerrarConfigActa()">Cancelar</button>
<button type="button" class="modal-btn modal-btn-primary" onclick="guardarConfigActa()">Guardar</button>
</div>
</div>
</div>
<script src="/config.js"></script>
<script>
const URL_GOOGLE_SHEET = "/api/dashboard.tsv";
const USE_EVENTS = window.DASHBOARD_USE_EVENTS !== false;
const REFRESH_MS = window.DASHBOARD_REFRESH_MS || window.DASHBOARD_FALLBACK_MS || 60000;
let fetchEnCurso = false;
let fetchPendienteForce = false;
const currentSystemDate = new Date();
let proyectosGlobal = [];
let estadoFiltroActual = 'activos';
let chartEstado = null;
let chartAvance = null;
let proyectoActivoId = null;
const ACTA_CONFIG_STORAGE_KEY = "rtc_acta_config_v1";
const ACTA_CONFIG_DEFAULT = {
titulo: "ACTA COMITÉ DE PROYECTOS RTC",
subtitulo: "Reporte Automático de Sistema",
elaboradoPor: "Alexander Morales",
lugar: "Virtual / Presencial",
descripcion: "Análisis de los proyectos activos y proyección operativa.",
asistentes: [
{ nombre: "Jojn B. Casas", cargo: "Gerente General" },
{ nombre: "Oscar Garcia", cargo: "Director Operación RTC" },
{ nombre: "Ayda Franco", cargo: "Líder Mesa de Servicios" },
{ nombre: "Alexander Morales", cargo: "Líder Proyectos" }
]
};
let actaConfig = structuredClone(ACTA_CONFIG_DEFAULT);
function normalizarConfigActa(data) {
const base = structuredClone(ACTA_CONFIG_DEFAULT);
if (!data || typeof data !== "object") return base;
base.titulo = String(data.titulo || base.titulo).trim();
base.subtitulo = String(data.subtitulo || base.subtitulo).trim();
base.elaboradoPor = String(data.elaboradoPor || base.elaboradoPor).trim();
base.lugar = String(data.lugar || base.lugar).trim();
base.descripcion = String(data.descripcion || base.descripcion).trim();
if (Array.isArray(data.asistentes) && data.asistentes.length) {
base.asistentes = data.asistentes
.map(a => ({
nombre: String((a && a.nombre) || "").trim(),
cargo: String((a && a.cargo) || "").trim()
}))
.filter(a => a.nombre || a.cargo);
}
if (!base.asistentes.length) {
base.asistentes = structuredClone(ACTA_CONFIG_DEFAULT.asistentes);
}
return base;
}
async function cargarConfigActa() {
try {
const res = await fetch("/api/acta-config", { cache: "no-store" });
if (res.ok) {
actaConfig = normalizarConfigActa(await res.json());
localStorage.setItem(ACTA_CONFIG_STORAGE_KEY, JSON.stringify(actaConfig));
return;
}
} catch (e) {
console.warn("No se pudo cargar config del acta desde servidor.", e);
}
try {
const local = localStorage.getItem(ACTA_CONFIG_STORAGE_KEY);
if (local) {
actaConfig = normalizarConfigActa(JSON.parse(local));
}
} catch (e) {
actaConfig = structuredClone(ACTA_CONFIG_DEFAULT);
}
}
function renderFilasAsistentes() {
const cont = document.getElementById("asistentesRows");
if (!cont) return;
cont.innerHTML = "";
actaConfig.asistentes.forEach((asistente, index) => {
const row = document.createElement("div");
row.className = "asistente-row";
row.innerHTML = `
<input type="text" placeholder="Nombre" value="${escaparHTML(asistente.nombre)}" data-field="nombre" data-index="${index}">
<input type="text" placeholder="Cargo" value="${escaparHTML(asistente.cargo)}" data-field="cargo" data-index="${index}">
<button type="button" class="btn-remove-row" onclick="quitarFilaAsistente(${index})" title="Quitar fila">&times;</button>
`;
cont.appendChild(row);
});
}
function leerConfigDesdeFormulario() {
const asistentes = [];
document.querySelectorAll("#asistentesRows .asistente-row").forEach(row => {
const nombre = row.querySelector('[data-field="nombre"]').value.trim();
const cargo = row.querySelector('[data-field="cargo"]').value.trim();
if (nombre || cargo) asistentes.push({ nombre, cargo });
});
return normalizarConfigActa({
titulo: document.getElementById("cfgTitulo").value,
subtitulo: document.getElementById("cfgSubtitulo").value,
elaboradoPor: document.getElementById("cfgElaborado").value,
lugar: document.getElementById("cfgLugar").value,
descripcion: document.getElementById("cfgDescripcion").value,
asistentes: asistentes.length ? asistentes : ACTA_CONFIG_DEFAULT.asistentes
});
}
function rellenarFormularioConfig() {
document.getElementById("cfgTitulo").value = actaConfig.titulo;
document.getElementById("cfgSubtitulo").value = actaConfig.subtitulo;
document.getElementById("cfgElaborado").value = actaConfig.elaboradoPor;
document.getElementById("cfgLugar").value = actaConfig.lugar;
document.getElementById("cfgDescripcion").value = actaConfig.descripcion;
renderFilasAsistentes();
}
function mostrarEstadoConfig(mensaje, esError) {
const el = document.getElementById("actaConfigStatus");
if (!el) return;
el.textContent = mensaje || "";
el.className = "modal-status" + (mensaje ? (esError ? " err" : " ok") : "");
}
function abrirConfigActa() {
rellenarFormularioConfig();
mostrarEstadoConfig("");
document.getElementById("actaConfigModal").classList.add("open");
}
function cerrarConfigActa() {
document.getElementById("actaConfigModal").classList.remove("open");
mostrarEstadoConfig("");
}
function cerrarConfigActaSiFondo(event) {
if (event.target.id === "actaConfigModal") cerrarConfigActa();
}
function agregarFilaAsistente() {
actaConfig = leerConfigDesdeFormulario();
actaConfig.asistentes.push({ nombre: "", cargo: "" });
renderFilasAsistentes();
}
function quitarFilaAsistente(index) {
actaConfig = leerConfigDesdeFormulario();
actaConfig.asistentes.splice(index, 1);
if (!actaConfig.asistentes.length) {
actaConfig.asistentes.push({ nombre: "", cargo: "" });
}
renderFilasAsistentes();
}
function restaurarConfigActaDefecto() {
if (!confirm("¿Restaurar los nombres y cargos por defecto del acta?")) return;
actaConfig = structuredClone(ACTA_CONFIG_DEFAULT);
rellenarFormularioConfig();
mostrarEstadoConfig("Valores por defecto cargados. Pulsa Guardar para aplicarlos.", false);
}
async function guardarConfigActa() {
const payload = leerConfigDesdeFormulario();
mostrarEstadoConfig("Guardando...", false);
try {
const res = await fetch("/api/acta-config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
throw new Error(data.error || "No se pudo guardar en el servidor");
}
actaConfig = normalizarConfigActa(data.config || payload);
localStorage.setItem(ACTA_CONFIG_STORAGE_KEY, JSON.stringify(actaConfig));
mostrarEstadoConfig("Configuración guardada. Aplica a todos los usuarios del tablero.", false);
setTimeout(cerrarConfigActa, 1200);
} catch (e) {
actaConfig = payload;
localStorage.setItem(ACTA_CONFIG_STORAGE_KEY, JSON.stringify(actaConfig));
mostrarEstadoConfig(
"Guardado solo en este navegador (servidor no disponible). " + e.message,
true
);
}
}
function filasAsistentesPDF() {
if (!actaConfig.asistentes.length) {
return '<tr><td colspan="3" style="text-align:center;">Sin asistentes configurados.</td></tr>';
}
return actaConfig.asistentes.map(a =>
`<tr><td>${escaparHTML(a.nombre)}</td><td>${escaparHTML(a.cargo)}</td><td></td></tr>`
).join("");
}
function formatearFechaActa(fecha) {
const texto = String(fecha || "").trim();
if (!texto) return "—";
const partes = texto.split("-");
if (partes.length === 3) {
return `${partes[2]}/${partes[1]}/${partes[0]}`;
}
return texto;
}
function tablaTareasPDF(tareas, vacio) {
if (!tareas.length) {
return `<table class="pdf-task-table"><tbody><tr><td colspan="3" class="pdf-empty">${escaparHTML(vacio)}</td></tr></tbody></table>`;
}
const filas = tareas.map(t =>
`<tr>
<td>${escaparHTML(t.nombre)}</td>
<td style="white-space:nowrap;">${escaparHTML(formatearFechaActa(t.fechaFin))}</td>
<td>${escaparHTML(t.responsable || "Sin asignar")}</td>
</tr>`
).join("");
return `<table class="pdf-task-table">
<thead><tr><th width="52%">Tarea</th><th width="18%">Fecha límite</th><th width="30%">Responsable</th></tr></thead>
<tbody>${filas}</tbody>
</table>`;
}
function renderBloqueProyectoPDF(proyecto, index) {
const realizadas = proyecto.tareas.filter(t => tareaCompletada(t.estado));
const pendientes = proyecto.tareas.filter(t => !tareaCompletada(t.estado));
const saltoPagina = index > 0 ? " pdf-page-break-before" : "";
const novedad = proyecto.comentarioRetraso &&
proyecto.comentarioRetraso.toLowerCase() !== "ninguno" &&
proyecto.comentarioRetraso.trim() !== ""
? `<div class="pdf-novedad"><strong>Novedad:</strong> ${escaparHTML(proyecto.comentarioRetraso)}</div>`
: "";
return `
<div class="pdf-project-card${saltoPagina}">
<div class="pdf-project-header">
<h3 class="pdf-project-name">${escaparHTML(proyecto.nombre)}</h3>
<div class="pdf-project-meta">
<strong>${proyecto.progreso}%</strong> avance<br>
${escaparHTML(proyecto.estadoGlobal)}
</div>
</div>
<p class="pdf-alcance"><strong>Alcance objetivo:</strong> Implementación y actualización del servicio.</p>
<div class="pdf-subsection">
<div class="pdf-subsection-title">Actividades realizadas (${realizadas.length})</div>
${tablaTareasPDF(realizadas, "Sin actividades cerradas recientemente.")}
</div>
<div class="pdf-subsection">
<div class="pdf-subsection-title">Actividades pendientes (${pendientes.length})</div>
${tablaTareasPDF(pendientes, "Sin actividades pendientes en plan.")}
</div>
${novedad}
</div>
`;
}
// =========================================================================
// NUEVA FUNCIÓN V7: MOTOR AUTÓNOMO DE CORRECCIÓN ORTOGRÁFICA
// =========================================================================
function corregirOrtografia(texto) {
if (!texto) return "";
let corregido = texto;
// Diccionario de los errores y faltas de ortografía más comunes
const correcciones = {
"produccion": "producción",
"configuracion": "configuración",
"estabilizacion": "estabilización",
"instalacion": "instalación",
"migracion": "migración",
"reunion": "reunión",
"telefonia": "telefonía",
"tecnico": "técnico",
"comite": "comité",
"planeacion": "planeación",
"ejecucion": "ejecución",
"actualizacion": "actualización",
"recepcion": "recepción",
"capacitacion": "capacitación",
"aprobacion": "aprobación",
"retrazado": "retrasado", // Corrección de Z a S
"atrazo": "retraso", // Corrección de Z a S
"kifkof": "Kickoff",
"kick off": "Kickoff",
"analisis": "análisis",
"proyeccion": "proyección",
"operacion": "operación",
"informacion": "información",
"version": "versión",
"conexion": "conexión"
};
// Escanea y reemplaza manteniendo mayúsculas si corresponde
for (const [incorrecto, correcto] of Object.entries(correcciones)) {
const regex = new RegExp("\\b" + incorrecto + "\\b", "gi");
corregido = corregido.replace(regex, (match) => {
// Mantiene la primera letra mayúscula si el usuario la escribió así
if (match.charAt(0) === match.charAt(0).toUpperCase()) {
return correcto.charAt(0).toUpperCase() + correcto.slice(1);
}
return correcto;
});
}
// Obliga siempre a que la primera letra del texto sea mayúscula para dar mejor estética
corregido = corregido.charAt(0).toUpperCase() + corregido.slice(1);
return corregido;
}
async function fetchGoogleSheet(forceRefresh = false) {
if (fetchEnCurso) {
if (forceRefresh) fetchPendienteForce = true;
return;
}
fetchEnCurso = true;
try {
const url = forceRefresh ? `${URL_GOOGLE_SHEET}?refresh=1` : URL_GOOGLE_SHEET;
const response = await fetch(url);
if (!response.ok) throw new Error("Fallo en la respuesta de red");
const data = await response.text();
if (data.startsWith("Error al obtener datos")) {
throw new Error(data);
}
procesarDatos(data);
} catch (error) {
console.error("Error al cargar:", error);
if (!proyectosGlobal.length) {
document.getElementById('fechaActualizacion').innerText = "Cargando datos de Planner...";
}
} finally {
fetchEnCurso = false;
document.getElementById('loading').style.display = 'none';
if (fetchPendienteForce) {
fetchPendienteForce = false;
fetchGoogleSheet(true);
}
}
}
function normalizarId(id) {
return String(id || "").trim();
}
function proyectoPorId(id) {
const buscado = normalizarId(id);
return proyectosGlobal.find(p => normalizarId(p.id) === buscado);
}
function procesarDatos(tsvData) {
const lineas = tsvData.split('\n').filter(linea => linea.trim() !== "");
const filas = lineas.slice(1);
const proyectosMap = {};
filas.forEach(fila => {
const columnas = fila.split('\t');
if (columnas.length < 10) return;
const idProy = normalizarId(columnas[0]);
if (!idProy) return;
if (!proyectosMap[idProy]) {
proyectosMap[idProy] = {
id: idProy,
// AQUÍ SE APLICA LA LIMPIEZA ORTOGRÁFICA EN VIVO
nombre: corregirOrtografia(columnas[1].trim()),
estadoGlobal: corregirOrtografia(columnas[2].trim()),
progreso: parseInt(columnas[3].trim()) || 0,
fechaObjetivo: columnas[4].trim(),
comentarioRetraso: corregirOrtografia(columnas[5].trim()),
tareas: []
};
}
proyectosMap[idProy].tareas.push({
// AQUÍ TAMBIÉN SE CORRIGE LA DATA DE LAS TAREAS
nombre: corregirOrtografia(columnas[6].trim()),
responsable: corregirOrtografia(columnas[7].trim()),
fechaFin: columnas[8].trim(),
estado: corregirOrtografia(columnas[9].trim())
});
});
proyectosGlobal = Object.values(proyectosMap);
document.getElementById('loading').style.display = 'none';
const opcionesFecha = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute:'2-digit' };
document.getElementById('fechaActualizacion').innerText = "Última actualización: " + new Date().toLocaleDateString('es-ES', opcionesFecha);
renderizarListaProyectos();
renderizarDashboardGeneral();
if (proyectoActivoId) {
const li = document.querySelector(
`.project-list li[data-id="${CSS.escape(normalizarId(proyectoActivoId))}"]`
);
if (li) {
renderizarDetalleProyecto(proyectoActivoId, li);
} else {
mostrarVistaGeneral();
}
}
}
function mostrarVistaGeneral() {
proyectoActivoId = null;
document.querySelectorAll('.project-list li').forEach(el => el.classList.remove('active'));
document.getElementById('dashboardContent').style.display = 'none';
document.getElementById('placeholder').style.display = 'block';
document.getElementById('taskTableBody').innerHTML = '';
}
function filtrarProyectosPorPestana() {
return proyectosGlobal.filter(p => {
const esCerrado = (
p.progreso === 100 ||
p.estadoGlobal.toLowerCase().includes('completado') ||
p.estadoGlobal.toLowerCase().includes('cerrado')
);
return estadoFiltroActual === 'cerrados' ? esCerrado : !esCerrado;
});
}
function cambiarPestana(tipo) {
estadoFiltroActual = tipo;
document.getElementById('btnActivos').classList.toggle('active', tipo === 'activos');
document.getElementById('btnCerrados').classList.toggle('active', tipo === 'cerrados');
renderizarListaProyectos();
if (!proyectoActivoId) return;
const sigueVisible = filtrarProyectosPorPestana().some(
p => normalizarId(p.id) === normalizarId(proyectoActivoId)
);
if (!sigueVisible) {
mostrarVistaGeneral();
return;
}
const li = document.querySelector(
`.project-list li[data-id="${CSS.escape(normalizarId(proyectoActivoId))}"]`
);
if (li) renderizarDetalleProyecto(proyectoActivoId, li);
}
function renderizarListaProyectos() {
const list = document.getElementById('projectList');
list.innerHTML = "";
const proyectosFiltrados = filtrarProyectosPorPestana();
if (proyectosFiltrados.length === 0) {
list.innerHTML = `<li style="text-align:center; display:block; cursor:default; background:transparent;">No hay proyectos</li>`;
return;
}
proyectosFiltrados.forEach(proj => {
const li = document.createElement('li');
const icono = estadoFiltroActual === 'cerrados' ? '✅' : '🚀';
li.dataset.id = normalizarId(proj.id);
if (normalizarId(proj.id) === normalizarId(proyectoActivoId)) {
li.classList.add('active');
}
li.innerHTML = `<span>${proj.nombre}</span> <span style="font-size:0.8em; opacity:0.8;">${icono} ${proj.progreso}%</span>`;
li.addEventListener('click', () => renderizarDetalleProyecto(proj.id, li));
list.appendChild(li);
});
}
function renderizarTablaTareas(tareas) {
const tbody = document.getElementById('taskTableBody');
tbody.replaceChildren();
if (!tareas.length) {
const tr = document.createElement('tr');
tr.innerHTML = `<td colspan="4" style="text-align:center; color:#64748b;">Sin tareas en este proyecto</td>`;
tbody.appendChild(tr);
return;
}
tareas.forEach(tarea => {
const tr = document.createElement('tr');
let isTaskDelayed = false;
const tPartes = (tarea.fechaFin || "").split("-");
if (tPartes.length === 3) {
const tFecha = new Date(tPartes[0], tPartes[1] - 1, tPartes[2]);
isTaskDelayed = (
tarea.estado !== "100%" &&
tarea.estado.toLowerCase() !== "completado"
) && currentSystemDate > tFecha;
}
tr.innerHTML = `
<td>${tarea.nombre}</td>
<td>${tarea.responsable}</td>
<td class="${isTaskDelayed ? 'task-delayed' : ''}">${tarea.fechaFin}</td>
<td><span style="font-weight: 500; color: ${tarea.estado === '100%' ? 'var(--sapian-light)' : 'inherit'}">${tarea.estado}</span></td>
`;
tbody.appendChild(tr);
});
}
function renderizarDetalleProyecto(id, listElement) {
const projectId = normalizarId(id);
const p = proyectoPorId(projectId);
if (!p || !listElement) return;
proyectoActivoId = projectId;
document.querySelectorAll('.project-list li').forEach(el => el.classList.remove('active'));
listElement.classList.add('active');
document.getElementById('placeholder').style.display = 'none';
document.getElementById('dashboardContent').style.display = 'flex';
document.getElementById('projName').innerText = p.nombre;
document.getElementById('projStatus').innerText = p.estadoGlobal;
const bar = document.getElementById('projProgressBar');
document.getElementById('projProgressText').innerText = p.progreso + '%';
bar.style.width = p.progreso + '%';
const partesFecha = (p.fechaObjetivo || "").split("-");
let estaRetrasado = false;
if (partesFecha.length === 3) {
const fechaObj = new Date(partesFecha[0], partesFecha[1] - 1, partesFecha[2]);
estaRetrasado = p.progreso < 100 && currentSystemDate > fechaObj;
}
const alertBox = document.getElementById('delayAlert');
if (estaRetrasado) {
bar.style.backgroundColor = "var(--danger)";
alertBox.style.display = "block";
document.getElementById('delayComment').innerHTML =
`<strong>Motivo reportado:</strong> ${p.comentarioRetraso} <br><br><em>Fecha objetivo vencida: ${p.fechaObjetivo}</em>`;
} else {
bar.style.backgroundColor = "var(--sapian-light)";
alertBox.style.display = "none";
}
const tareas = Array.isArray(p.tareas) ? [...p.tareas] : [];
renderizarTablaTareas(tareas);
}
function loadProject(id, listElement) {
renderizarDetalleProyecto(id, listElement);
}
function renderizarDashboardGeneral() {
let total = proyectosGlobal.length;
let cerrados = 0;
let activos = 0;
let retrasados = 0;
let nombresActivos = [];
let progresoActivos = [];
proyectosGlobal.forEach(p => {
const esCerrado = (p.progreso === 100 || p.estadoGlobal.toLowerCase().includes('completado') || p.estadoGlobal.toLowerCase().includes('cerrado'));
if (esCerrado) {
cerrados++;
} else {
activos++;
nombresActivos.push(p.nombre.substring(0, 20) + '...');
progresoActivos.push(p.progreso);
let partesFecha = p.fechaObjetivo.split("-");
if(partesFecha.length === 3) {
const fechaObj = new Date(partesFecha[0], partesFecha[1] - 1, partesFecha[2]);
if(currentSystemDate > fechaObj) {
retrasados++;
}
}
}
});
document.getElementById('kpiTotal').innerText = total;
document.getElementById('kpiActivos').innerText = activos;
document.getElementById('kpiCerrados').innerText = cerrados;
document.getElementById('kpiRetrasados').innerText = retrasados;
if(chartEstado) chartEstado.destroy();
if(chartAvance) chartAvance.destroy();
const ctxEstado = document.getElementById('estadoChart').getContext('2d');
chartEstado = new Chart(ctxEstado, {
type: 'doughnut',
data: {
labels: ['A Tiempo', 'Retrasados', 'Cerrados'],
datasets: [{
data: [activos - retrasados, retrasados, cerrados],
backgroundColor: ['#0A8B74', '#EF4444', '#10B981'],
hoverOffset: 4
}]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom' } } }
});
const ctxAvance = document.getElementById('avanceChart').getContext('2d');
chartAvance = new Chart(ctxAvance, {
type: 'bar',
data: {
labels: nombresActivos,
datasets: [{
label: '% de Avance',
data: progresoActivos,
backgroundColor: '#CDDC39',
borderColor: '#02493E',
borderWidth: 1,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true, max: 100 } },
plugins: { legend: { display: false } }
}
});
}
function escaparHTML(texto) {
if (!texto) return "";
return String(texto)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function tareaCompletada(estado) {
const valor = (estado || "").trim().toLowerCase();
if (!valor) return false;
if (valor === "100%" || valor.includes("completad")) return true;
const numero = parseInt(valor.replace("%", ""), 10);
return !isNaN(numero) && numero >= 100;
}
function esperarImagenes(contenedor) {
const imagenes = contenedor.querySelectorAll("img");
if (!imagenes.length) return Promise.resolve();
return Promise.all(Array.from(imagenes).map(img => new Promise(resolve => {
if (img.complete) {
resolve();
return;
}
img.onload = resolve;
img.onerror = resolve;
})));
}
function generarPDFComite() {
if (!proyectosGlobal.length) {
alert("Los datos aún no han cargado. Espera unos segundos e intenta de nuevo.");
return;
}
const proyectosActivos = proyectosGlobal.filter(p =>
p.progreso < 100 &&
!p.estadoGlobal.toLowerCase().includes("completado") &&
!p.estadoGlobal.toLowerCase().includes("cerrado")
);
const fechaHoy = new Date().toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric' });
const logoUrl = `${window.location.origin}/logo_sapian_144.png`;
let tablaResumenHTML = '';
if (proyectosActivos.length === 0) {
tablaResumenHTML = '<tr><td colspan="3" style="text-align:center;">Sin proyectos activos en este momento.</td></tr>';
} else {
proyectosActivos.forEach(p => {
tablaResumenHTML += `<tr><td><strong>${escaparHTML(p.nombre)}</strong></td><td>${escaparHTML(p.estadoGlobal)}</td><td style="text-align:center;"><strong>${p.progreso}%</strong></td></tr>`;
});
}
let detalleProyectosHTML = '';
proyectosActivos.forEach((p, index) => {
detalleProyectosHTML += renderBloqueProyectoPDF(p, index);
});
if (!detalleProyectosHTML) {
detalleProyectosHTML = '<p class="pdf-intro-text">No hay proyectos activos para detallar en esta acta.</p>';
}
const pdfTemplate = `
<div class="pdf-header">
<img src="${logoUrl}" alt="Sapian" class="pdf-logo" crossorigin="anonymous">
<div class="pdf-title-container">
<h1 class="pdf-h1">${escaparHTML(actaConfig.titulo)}</h1>
<p class="pdf-p">${escaparHTML(actaConfig.subtitulo)}</p>
<p class="pdf-p"><strong>Elaborado por:</strong> ${escaparHTML(actaConfig.elaboradoPor)}</p>
</div>
</div>
<div class="pdf-section-title">INFORMACIÓN DE LA REUNIÓN</div>
<table class="pdf-table"><tr><th width="15%">Fecha</th><td width="35%">${fechaHoy}</td><th width="15%">Lugar</th><td width="35%">${escaparHTML(actaConfig.lugar)}</td></tr></table>
<p class="pdf-intro-text">${escaparHTML(actaConfig.descripcion)}</p>
<table class="pdf-table">
<tr><th>Nombre</th><th>Cargo</th><th>Asistencia (Sí/No)</th></tr>
${filasAsistentesPDF()}
</table>
<div class="pdf-section-title">RESUMEN DE PROYECTOS ACTIVOS</div>
<table class="pdf-table"><tr><th width="45%">Proyecto</th><th width="40%">Estado actual</th><th width="15%" style="text-align:center;">Progreso</th></tr>${tablaResumenHTML}</table>
<div class="pdf-section-title pdf-page-break-before">DETALLE POR PROYECTO</div>
${detalleProyectosHTML}
`;
const element = document.getElementById('pdfContent');
element.innerHTML = pdfTemplate;
const opt = {
margin: [12, 12, 14, 12],
filename: `Acta_Comite_Proyectos_${fechaHoy.replace(/\//g, '-')}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, logging: false, letterRendering: true },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
pagebreak: {
mode: ['css', 'legacy'],
avoid: ['.pdf-header', '.pdf-section-title', '.pdf-subsection', '.pdf-table', '.pdf-novedad', '.pdf-project-header']
}
};
const btn = document.querySelector('.btn-pdf');
const textoOriginal = btn.innerHTML;
btn.innerHTML = "⏳ Generando...";
btn.disabled = true;
esperarImagenes(element).then(() => {
return html2pdf().set(opt).from(element).save();
}).then(() => {
btn.innerHTML = textoOriginal;
btn.disabled = false;
}).catch((error) => {
console.error("Error al generar PDF:", error);
alert("No se pudo generar el PDF. Intenta de nuevo en unos segundos.");
btn.innerHTML = textoOriginal;
btn.disabled = false;
});
}
function conectarEventosPlanner() {
if (!USE_EVENTS || typeof EventSource === "undefined") return;
const eventsUrl = "/api/events";
const source = new EventSource(eventsUrl);
source.addEventListener("planner-changed", () => {
fetchGoogleSheet(true);
});
source.addEventListener("planner-error", () => {
console.warn("Error al sincronizar datos de Planner.");
});
source.onerror = () => {
console.warn("Conexion SSE interrumpida. Reintentando...");
};
}
window.onload = () => {
cargarConfigActa();
fetchGoogleSheet(false);
conectarEventosPlanner();
if (REFRESH_MS > 0) {
setInterval(() => fetchGoogleSheet(true), REFRESH_MS);
}
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
fetchGoogleSheet(true);
}
});
};
</script>
</body>
</html>