1403 lines
61 KiB
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">×</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">×</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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
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>
|