cambios importates

This commit is contained in:
Lizandro Guarnizo
2026-05-11 12:21:56 -05:00
parent 39de37e0a7
commit f197118acb
7 changed files with 1364 additions and 13 deletions
+131
View File
@@ -0,0 +1,131 @@
<?php
/**
* GET /api/kpi_actividad.php
*
* Devuelve métricas de actividad de pacientes por rango de fechas:
* - Por día: usuarios únicos que escriben y agendamientos creados.
* - Por día de la semana: agregado del rango seleccionado.
*
* Parámetros GET:
* desde (YYYY-MM-DD) — fecha inicio, default hoy-29 días
* hasta (YYYY-MM-DD) — fecha fin, default hoy
*/
require_once __DIR__ . '/../config/config.php';
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', '0');
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
try {
$db = Database::getInstance();
// ── Rango de fechas ──────────────────────────────────────
$hoy = date('Y-m-d');
$desde = $_GET['desde'] ?? date('Y-m-d', strtotime('-29 days'));
$hasta = $_GET['hasta'] ?? $hoy;
// Sanitizar: solo fechas válidas
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $desde)) $desde = date('Y-m-d', strtotime('-29 days'));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $hasta)) $hasta = $hoy;
// No permitir rangos invertidos
if ($desde > $hasta) [$desde, $hasta] = [$hasta, $desde];
// ── Mensajes entrantes por día (usuarios únicos) ─────────
$msgRows = $db->fetchAll("
SELECT
DATE(created_at) AS fecha,
COUNT(DISTINCT user_id) AS usuarios,
COUNT(*) AS total_mensajes
FROM conversations
WHERE direction = 'incoming'
AND DATE(created_at) BETWEEN ? AND ?
GROUP BY DATE(created_at)
ORDER BY fecha ASC
", [$desde, $hasta]);
// ── Agendamientos (turnos creados) por día ───────────────
$turnosRows = $db->fetchAll("
SELECT
DATE(creado_at) AS fecha,
COUNT(*) AS agendamientos
FROM turnero_turnos
WHERE DATE(creado_at) BETWEEN ? AND ?
GROUP BY DATE(creado_at)
ORDER BY fecha ASC
", [$desde, $hasta]);
// ── Construir mapa fecha → datos combinados ──────────────
$dias = [];
// Generar todos los días del rango para que no haya huecos
$cur = new DateTime($desde);
$fin = new DateTime($hasta);
while ($cur <= $fin) {
$f = $cur->format('Y-m-d');
$dias[$f] = ['fecha' => $f, 'usuarios' => 0, 'total_mensajes' => 0, 'agendamientos' => 0];
$cur->modify('+1 day');
}
foreach ($msgRows as $r) {
if (isset($dias[$r['fecha']])) {
$dias[$r['fecha']]['usuarios'] = (int)$r['usuarios'];
$dias[$r['fecha']]['total_mensajes'] = (int)$r['total_mensajes'];
}
}
foreach ($turnosRows as $r) {
if (isset($dias[$r['fecha']])) {
$dias[$r['fecha']]['agendamientos'] = (int)$r['agendamientos'];
}
}
$porDia = array_values($dias);
// ── Agrupado por día de la semana (0=Domingo … 6=Sábado) ─
$semana = [
0 => ['dia' => 'Domingo', 'usuarios' => 0, 'agendamientos' => 0],
1 => ['dia' => 'Lunes', 'usuarios' => 0, 'agendamientos' => 0],
2 => ['dia' => 'Martes', 'usuarios' => 0, 'agendamientos' => 0],
3 => ['dia' => 'Miércoles', 'usuarios' => 0, 'agendamientos' => 0],
4 => ['dia' => 'Jueves', 'usuarios' => 0, 'agendamientos' => 0],
5 => ['dia' => 'Viernes', 'usuarios' => 0, 'agendamientos' => 0],
6 => ['dia' => 'Sábado', 'usuarios' => 0, 'agendamientos' => 0],
];
foreach ($porDia as $d) {
$dow = (int)(new DateTime($d['fecha']))->format('w'); // 0=Dom
$semana[$dow]['usuarios'] += $d['usuarios'];
$semana[$dow]['agendamientos'] += $d['agendamientos'];
}
// ── Totales del rango ────────────────────────────────────
$totalUsuarios = array_sum(array_column($porDia, 'usuarios'));
$totalMensajes = array_sum(array_column($porDia, 'total_mensajes'));
$totalAgendamientos = array_sum(array_column($porDia, 'agendamientos'));
// Día de semana pico (más usuarios)
$picoDow = array_keys($semana, max($semana, fn($a,$b) => $a['usuarios'] <=> $b['usuarios']))[0] ?? null;
$picoDow = array_reduce(array_keys($semana), fn($carry, $k) => ($semana[$k]['usuarios'] > $semana[$carry]['usuarios'] ? $k : $carry), 0);
echo json_encode([
'ok' => true,
'desde' => $desde,
'hasta' => $hasta,
'totales' => [
'usuarios' => $totalUsuarios,
'mensajes' => $totalMensajes,
'agendamientos' => $totalAgendamientos,
'pico_semana' => $semana[$picoDow]['dia'] ?? '—',
],
'por_dia' => $porDia,
'por_dia_semana' => array_values($semana),
], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
+212
View File
@@ -0,0 +1,212 @@
<?php
/**
* GET /api/kpi_respuesta_asesor.php
*
* KPI de tiempos de respuesta de asesores.
* Calcula, por cada sesión de atención:
* - Tiempo (min) entre "Atender" y la primera respuesta outgoing del asesor.
* - Duración total de la sesión (Atender → Finalizar).
* Devuelve:
* - Promedios globales.
* - Desglose por asesor.
* - Distribución de tiempos (histograma).
* - Serie por día para ver tendencia.
*
* Parámetros GET:
* desde YYYY-MM-DD (default: hoy - 29 días)
* hasta YYYY-MM-DD (default: hoy)
*/
require_once __DIR__ . '/../config/config.php';
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', '0');
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
try {
$db = Database::getInstance();
$hoy = date('Y-m-d');
$desde = $_GET['desde'] ?? date('Y-m-d', strtotime('-29 days'));
$hasta = $_GET['hasta'] ?? $hoy;
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $desde)) $desde = date('Y-m-d', strtotime('-29 days'));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $hasta)) $hasta = $hoy;
if ($desde > $hasta) [$desde, $hasta] = [$hasta, $desde];
// ── Sesiones de atención en el rango ─────────────────────────────────
// Para cada "attend" buscamos:
// a) El primer mensaje outgoing del mismo user_id DESPUÉS del attend
// y dentro de 4 horas (descartamos bots post-sesión).
// b) El primer "finish" del mismo user_id DESPUÉS del attend.
$sesiones = $db->fetchAll("
SELECT
oa.id AS sesion_id,
oa.user_id,
oa.operator_id,
COALESCE(au.full_name, au.username, CONCAT('Asesor #', oa.operator_id)) AS operador,
oa.created_at AS attend_at,
-- Primera respuesta outgoing del asesor
(
SELECT MIN(c.created_at)
FROM conversations c
WHERE c.user_id = oa.user_id
AND c.direction = 'outgoing'
AND c.created_at > oa.created_at
AND c.created_at < oa.created_at + INTERVAL 4 HOUR
) AS primera_respuesta_at,
-- Fin de la sesión
(
SELECT MIN(oa2.created_at)
FROM operator_activity oa2
WHERE oa2.user_id = oa.user_id
AND oa2.action = 'finish'
AND oa2.created_at > oa.created_at
AND oa2.created_at < oa.created_at + INTERVAL 8 HOUR
) AS finish_at
FROM operator_activity oa
LEFT JOIN admin_users au ON au.id = oa.operator_id
WHERE oa.action = 'attend'
AND DATE(oa.created_at) BETWEEN ? AND ?
ORDER BY oa.created_at ASC
", [$desde, $hasta]);
// ── Calcular métricas por sesión ─────────────────────────────────────
$totalSesiones = 0;
$conRespuesta = 0;
$sumaMinRespuesta = 0;
$sumaMinDuracion = 0;
$conDuracion = 0;
$sinRespuesta = 0;
// Por asesor
$asesores = [];
// Por día (para tendencia)
$porDia = [];
// Histograma: cubos < 1, 1-3, 3-5, 5-10, 10-20, >20 minutos
$hist = ['< 1 min' => 0, '1-3 min' => 0, '3-5 min' => 0, '5-10 min' => 0, '10-20 min' => 0, '> 20 min' => 0, 'Sin resp.' => 0];
foreach ($sesiones as $s) {
$totalSesiones++;
$attendDt = new DateTime($s['attend_at']);
$fechaDia = $attendDt->format('Y-m-d');
if (!isset($porDia[$fechaDia])) {
$porDia[$fechaDia] = ['fecha' => $fechaDia, 'sesiones' => 0, 'suma_resp' => 0, 'con_resp' => 0];
}
$porDia[$fechaDia]['sesiones']++;
$opId = $s['operator_id'] ?? 0;
if (!isset($asesores[$opId])) {
$asesores[$opId] = [
'operador' => $s['operador'],
'sesiones' => 0,
'con_resp' => 0,
'suma_resp' => 0,
'suma_dur' => 0,
'con_dur' => 0,
];
}
$asesores[$opId]['sesiones']++;
// Tiempo de primera respuesta
if (!empty($s['primera_respuesta_at'])) {
$respDt = new DateTime($s['primera_respuesta_at']);
$minResp = round(($respDt->getTimestamp() - $attendDt->getTimestamp()) / 60, 1);
if ($minResp < 0) $minResp = 0;
$conRespuesta++;
$sumaMinRespuesta += $minResp;
$asesores[$opId]['con_resp']++;
$asesores[$opId]['suma_resp'] += $minResp;
$porDia[$fechaDia]['suma_resp'] += $minResp;
$porDia[$fechaDia]['con_resp']++;
// Histograma
if ($minResp < 1) $hist['< 1 min']++;
elseif ($minResp < 3) $hist['1-3 min']++;
elseif ($minResp < 5) $hist['3-5 min']++;
elseif ($minResp < 10) $hist['5-10 min']++;
elseif ($minResp < 20) $hist['10-20 min']++;
else $hist['> 20 min']++;
} else {
$sinRespuesta++;
$hist['Sin resp.']++;
}
// Duración de la sesión
if (!empty($s['finish_at'])) {
$finDt = new DateTime($s['finish_at']);
$minDur = round(($finDt->getTimestamp() - $attendDt->getTimestamp()) / 60, 1);
if ($minDur >= 0) {
$conDuracion++;
$sumaMinDuracion += $minDur;
$asesores[$opId]['suma_dur'] += $minDur;
$asesores[$opId]['con_dur']++;
}
}
}
$promedioRespuesta = $conRespuesta > 0 ? round($sumaMinRespuesta / $conRespuesta, 1) : null;
$promedioDuracion = $conDuracion > 0 ? round($sumaMinDuracion / $conDuracion, 1) : null;
$pctRapidas = $totalSesiones > 0 ? round(($hist['< 1 min'] + $hist['1-3 min']) / $totalSesiones * 100) : 0;
// Formatear asesores
$listaAsesores = [];
foreach ($asesores as $a) {
$listaAsesores[] = [
'operador' => $a['operador'],
'sesiones' => $a['sesiones'],
'prom_respuesta' => $a['con_resp'] > 0 ? round($a['suma_resp'] / $a['con_resp'], 1) : null,
'prom_duracion' => $a['con_dur'] > 0 ? round($a['suma_dur'] / $a['con_dur'], 1) : null,
];
}
usort($listaAsesores, fn($a, $b) => ($a['prom_respuesta'] ?? 9999) <=> ($b['prom_respuesta'] ?? 9999));
// Rellenar días sin actividad en el rango
$cur = new DateTime($desde);
$fin = new DateTime($hasta);
while ($cur <= $fin) {
$f = $cur->format('Y-m-d');
if (!isset($porDia[$f])) $porDia[$f] = ['fecha' => $f, 'sesiones' => 0, 'suma_resp' => 0, 'con_resp' => 0];
$cur->modify('+1 day');
}
ksort($porDia);
$serieDia = array_map(fn($d) => [
'fecha' => $d['fecha'],
'sesiones' => $d['sesiones'],
'prom_respuesta' => $d['con_resp'] > 0 ? round($d['suma_resp'] / $d['con_resp'], 1) : null,
], array_values($porDia));
echo json_encode([
'ok' => true,
'desde' => $desde,
'hasta' => $hasta,
'totales' => [
'sesiones' => $totalSesiones,
'con_respuesta' => $conRespuesta,
'sin_respuesta' => $sinRespuesta,
'prom_respuesta' => $promedioRespuesta,
'prom_duracion' => $promedioDuracion,
'pct_rapidas' => $pctRapidas,
],
'asesores' => $listaAsesores,
'histograma' => array_map(fn($k, $v) => ['rango' => $k, 'sesiones' => $v], array_keys($hist), $hist),
'serie_dia' => $serieDia,
], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
+456
View File
@@ -0,0 +1,456 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cronograma — Sistema Turnero Inteligente (60 días)</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',system-ui,sans-serif;background:#f1f5f9;color:#1e293b;font-size:13px}
/* HEADER */
.hdr{background:linear-gradient(135deg,#1565c0 0%,#0d47a1 100%);color:#fff;padding:2rem 2.5rem}
.hdr h1{font-size:1.7rem;font-weight:800;letter-spacing:-0.02em}
.hdr .sub{opacity:.8;margin-top:.3rem;font-size:.9rem}
.stats{display:flex;gap:1.2rem;margin-top:1.5rem;flex-wrap:wrap}
.stat{background:rgba(255,255,255,.15);border-radius:10px;padding:.7rem 1.4rem;text-align:center;min-width:110px}
.stat .v{font-size:1.9rem;font-weight:800;line-height:1}
.stat .l{font-size:.7rem;opacity:.78;margin-top:.3rem;text-transform:uppercase;letter-spacing:.04em}
/* TOOLBAR */
.toolbar{background:#fff;padding:.8rem 2.5rem;display:flex;gap:.8rem;align-items:center;border-bottom:1px solid #e2e8f0;flex-wrap:wrap}
.btn{padding:.45rem 1.1rem;border-radius:8px;border:none;cursor:pointer;font-weight:600;font-size:.8rem;display:inline-flex;align-items:center;gap:.4rem;text-decoration:none}
.btn-xl{background:#217346;color:#fff}
.btn-pr{background:#374151;color:#fff}
.toolbar-note{font-size:.75rem;color:#64748b;margin-left:auto}
/* LEGEND */
.legend{background:#fff;padding:.7rem 2.5rem;display:flex;gap:1.2rem;flex-wrap:wrap;border-bottom:2px solid #e2e8f0;align-items:center}
.legend-title{font-weight:700;font-size:.78rem;color:#475569;margin-right:.5rem}
.li{display:flex;align-items:center;gap:.4rem;font-size:.75rem;color:#475569}
.ld{width:18px;height:14px;border-radius:3px;flex-shrink:0}
/* TABLE WRAPPER */
.wrap{padding:1.5rem 2.5rem;overflow-x:auto}
/* GANTT TABLE */
table{border-collapse:separate;border-spacing:0;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.1);width:100%;min-width:1100px}
thead tr th{background:#1e3a5f;color:#fff;padding:.65rem .45rem;text-align:center;font-weight:600;font-size:.73rem;white-space:nowrap;border-right:1px solid rgba(255,255,255,.1)}
thead tr th.tl{text-align:left;padding-left:.9rem}
.wd{display:block;font-size:.6rem;color:rgba(255,255,255,.65);font-weight:400;margin-top:.15rem}
tbody tr:nth-child(even) td{background:#f8fafc}
tbody tr:nth-child(odd) td{background:#fff}
tbody tr:hover td{background:#eff6ff!important}
td{padding:.55rem .45rem;border-bottom:1px solid #e8edf2;border-right:1px solid #f0f4f8;vertical-align:middle}
td.tl{padding-left:.9rem}
td.cn{text-align:center}
/* PHASE HEADER ROW */
tr.ph td{background:#334155!important;color:#fff;font-weight:700;font-size:.78rem;padding:.35rem .9rem;letter-spacing:.03em}
tr.ph td:first-child{border-radius:0}
/* BADGES */
.badge{display:inline-block;padding:.18rem .65rem;border-radius:100px;font-size:.68rem;font-weight:700;white-space:nowrap}
.b-done{background:#dcfce7;color:#14532d}
.b-next{background:#dbeafe;color:#1e3a8a}
.b-pend{background:#f1f5f9;color:#475569}
.b-qa{background:#fef3c7;color:#78350f}
.b-cap{background:#ede9fe;color:#4c1d95}
.b-dep{background:#d1fae5;color:#064e3b}
/* GANTT CELL COLORS */
.g0{background:#86efac!important} /* completado */
.g1{background:#60a5fa!important} /* dev fase 1 */
.g2{background:#818cf8!important} /* dev fase 2 */
.g3{background:#a78bfa!important} /* dev fase 3 */
.g4{background:#fbbf24!important} /* QA / testing */
.g5{background:#c084fc!important} /* capacitación */
.g6{background:#34d399!important} /* deploy */
.ge{background:#f1f5f9!important;color:transparent} /* empty */
/* DELIVERABLES CELL */
.del{font-size:.71rem;color:#475569;line-height:1.4}
.del b{color:#1e293b;font-size:.72rem}
/* PHASE BADGE */
.pbadge{display:inline-block;background:#1d4ed8;color:#fff;font-size:.68rem;font-weight:700;padding:.15rem .55rem;border-radius:5px;white-space:nowrap}
/* NOTES SECTION */
.notes{background:#fff;margin:0 2.5rem 2.5rem;border-radius:12px;padding:1.5rem;box-shadow:0 1px 3px rgba(0,0,0,.08)}
.notes h3{font-size:.95rem;color:#1e3a5f;margin-bottom:1rem;font-weight:700}
.notes-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1rem}
.note-card{padding:1rem;border-radius:8px;border-left:4px solid #60a5fa;background:#f8fafc}
.note-card b{display:block;margin-bottom:.35rem;color:#1e3a5f;font-size:.82rem}
.note-card p{font-size:.75rem;color:#475569;line-height:1.5}
.note-card.nc1{border-left-color:#86efac}
.note-card.nc2{border-left-color:#fbbf24}
.note-card.nc3{border-left-color:#a78bfa}
/* FOOTER */
.footer{text-align:center;padding:1rem;font-size:.72rem;color:#94a3b8}
/* PRINT */
@media print{
.toolbar{display:none}
body{background:#fff}
.wrap{padding:.5rem}
table{font-size:8pt;min-width:unset}
.hdr{padding:1rem}
.notes{margin:0;padding:1rem}
}
</style>
</head>
<body>
<!-- HEADER -->
<div class="hdr">
<h1>📅 Cronograma de Implementación — Sistema Turnero Inteligente</h1>
<div class="sub">Proyecto: ERP Multi-Módulo &nbsp;·&nbsp; Laboratorio Clínico &nbsp;·&nbsp; 14 Abril 20 Junio 2026</div>
<div class="stats">
<div class="stat"><div class="v">67</div><div class="l">Días calendario</div></div>
<div class="stat"><div class="v">10</div><div class="l">Semanas</div></div>
<div class="stat"><div class="v">9</div><div class="l">Entregables</div></div>
<div class="stat"><div class="v">1/10</div><div class="l">Completado</div></div>
<div class="stat"><div class="v">20 Jun</div><div class="l">Go-live</div></div>
</div>
</div>
<!-- LEGEND -->
<div class="legend">
<span class="legend-title">Referencias:</span>
<span class="li"><span class="ld" style="background:#86efac"></span>Completado</span>
<span class="li"><span class="ld" style="background:#60a5fa"></span>Desarrollo (BD + APIs)</span>
<span class="li"><span class="ld" style="background:#818cf8"></span>Desarrollo (Vistas)</span>
<span class="li"><span class="ld" style="background:#a78bfa"></span>Desarrollo (Admin)</span>
<span class="li"><span class="ld" style="background:#fbbf24"></span>Pruebas / QA</span>
<span class="li"><span class="ld" style="background:#c084fc"></span>Capacitación</span>
<span class="li"><span class="ld" style="background:#34d399"></span>Go-Live / Producción</span>
</div>
<!-- TOOLBAR -->
<div class="toolbar">
<button class="btn btn-xl" onclick="exportExcel()">📥 Descargar Excel (.xls)</button>
<button class="btn btn-pr" onclick="window.print()">🖨️ Imprimir / PDF</button>
<span class="toolbar-note">67 días calendario · 14 Abr 20 Jun 2026</span>
</div>
<!-- GANTT TABLE -->
<div class="wrap">
<table id="gt">
<thead>
<tr>
<th class="tl" style="width:24px">#</th>
<th class="tl" style="width:72px">Fase</th>
<th class="tl" style="width:195px">Actividad</th>
<th class="tl" style="width:270px">Entregables principales</th>
<th style="width:46px">Días</th>
<th style="width:78px">Inicio</th>
<th style="width:78px">Fin</th>
<th style="width:90px">Estado</th>
<th>S1<span class="wd">14-20 Abr</span></th>
<th>S2<span class="wd">21-27 Abr</span></th>
<th>S3<span class="wd">28 Abr-4 May</span></th>
<th>S4<span class="wd">5-11 May</span></th>
<th>S5<span class="wd">12-18 May</span></th>
<th>S6<span class="wd">19-25 May</span></th>
<th>S7<span class="wd">26 May-1 Jun</span></th>
<th>S8<span class="wd">2-8 Jun</span></th>
<th>S9<span class="wd">9-15 Jun</span></th>
<th>S10<span class="wd">16-20 Jun</span></th>
</tr>
</thead>
<tbody>
<!-- ═══ BLOQUE 1: ERP BASE ═══ -->
<tr class="ph"><td colspan="18">⚙️ BLOQUE 1 — ADECUACIÓN ERP BASE &nbsp;&nbsp; (Semana 1 — COMPLETADO)</td></tr>
<tr>
<td class="cn">1</td>
<td><span class="pbadge">ERP</span></td>
<td class="tl"><b>Adecuar Software como ERP</b></td>
<td class="del">Estructura <b>core/ · modules/ · shared/</b> · Router centralizado · Sidebar dinámico · SYSTEM_MODULES en BD · 11 módulos migrados a nueva estructura</td>
<td class="cn">7</td>
<td>14 Abr</td>
<td>20 Abr</td>
<td><span class="badge b-done">✅ Completado</span></td>
<td class="g0"></td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td>
</tr>
<!-- ═══ BLOQUE 2: BD TURNERO ═══ -->
<tr class="ph"><td colspan="18">🗄️ BLOQUE 2 — BASE DE DATOS DEL TURNERO — FASE 3.1 &nbsp;&nbsp; (Semana 2)</td></tr>
<tr>
<td class="cn">2</td>
<td><span class="pbadge" style="background:#0891b2">F 3.1</span></td>
<td class="tl"><b>Migración 003 — Tablas Turnero</b></td>
<td class="del"><b>exam_tipos · exam_tipo_consentimientos</b> · turnero_lugares · turnero_prioridades · turnero_sesiones · turnero_turnos · turnero_solicitudes · turnero_examen_items · turnero_consentimientos · ALTER lab_formularios</td>
<td class="cn">5</td>
<td>21 Abr</td>
<td>25 Abr</td>
<td><span class="badge b-next">🔵 Próximo</span></td>
<td class="ge">·</td><td class="g1"></td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td>
</tr>
<tr>
<td class="cn">3</td>
<td><span class="pbadge" style="background:#0891b2">F 3.1</span></td>
<td class="tl"><b>Datos iniciales + module.php</b></td>
<td class="del">Prioridades A-F por defecto · Registro en <b>system_modules</b> · module.php descriptor · Asignación roles recepcionista / auxiliar</td>
<td class="cn">2</td>
<td>26 Abr</td>
<td>27 Abr</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="g1"></td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td>
</tr>
<!-- ═══ BLOQUE 3: MOTOR DE COLA ═══ -->
<tr class="ph"><td colspan="18">⚡ BLOQUE 3 — MOTOR DE PRIORIDADES + APIs CORE — FASE 3.2 &nbsp;&nbsp; (Semana 3)</td></tr>
<tr>
<td class="cn">4</td>
<td><span class="pbadge" style="background:#0369a1">F 3.2</span></td>
<td class="tl"><b>APIs de turno y solicitud</b></td>
<td class="del"><b>create_turno.php</b> (sesión diaria + correlativo) · <b>create_solicitud.php</b> (exámenes + pago + lugar + consentimientos auto) · Actualización estado turno</td>
<td class="cn">4</td>
<td>28 Abr</td>
<td>01 May</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="g1"></td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td>
</tr>
<tr>
<td class="cn">5</td>
<td><span class="pbadge" style="background:#0369a1">F 3.2</span></td>
<td class="tl"><b>Motor de cola + SSE</b></td>
<td class="del"><b>llamar_turno.php</b> (motor prioridad + FIFO) · <b>cambiar_estado.php</b> (validaciones bloqueo) · <b>get_cola.php</b> · <b>sse_turno.php</b> (Server-Sent Events)</td>
<td class="cn">3</td>
<td>02 May</td>
<td>04 May</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="g1"></td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td>
</tr>
<!-- ═══ BLOQUE 4: PANTALLAS ═══ -->
<tr class="ph"><td colspan="18">📺 BLOQUE 4 — KIOSKO + PANTALLAS TV — FASE 3.3 &nbsp;&nbsp; (Semana 4)</td></tr>
<tr>
<td class="cn">6</td>
<td><span class="pbadge" style="background:#6d28d9">F 3.3</span></td>
<td class="tl"><b>Kiosko táctil</b></td>
<td class="del"><b>views/kiosko.php</b> fullscreen · Botones A-F (icono + descripción) · Campo nombre/celular (opcional) · Código generado (ej. "E-042") · Sin exposición de datos</td>
<td class="cn">3</td>
<td>05 May</td>
<td>07 May</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g2"></td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td>
</tr>
<tr>
<td class="cn">7</td>
<td><span class="pbadge" style="background:#6d28d9">F 3.3</span></td>
<td class="tl"><b>Pantallas TV / Display</b></td>
<td class="del"><b>views/display.php</b> · ?display=recepcion (cola general) · ?display=lugar&amp;lugar_id=X (cola del lugar) · SSE en tiempo real · Colores prioridad + animación</td>
<td class="cn">4</td>
<td>08 May</td>
<td>11 May</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g2"></td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td>
</tr>
<!-- ═══ BLOQUE 5: RECEPCIÓN ═══ -->
<tr class="ph"><td colspan="18">🖥️ BLOQUE 5 — PUESTO DE RECEPCIÓN — FASE 3.4 &nbsp;&nbsp; (Semana 5)</td></tr>
<tr>
<td class="cn">8</td>
<td><span class="pbadge" style="background:#0f766e">F 3.4a</span></td>
<td class="tl"><b>Vista Recepción — Atención al paciente</b></td>
<td class="del"><b>views/recepcion.php</b> · Cola general con prioridad visual · Llamar siguiente · Buscador/creación paciente (reutiliza lab_pacientes) · Catálogo exam_tipos + selector</td>
<td class="cn">4</td>
<td>12 May</td>
<td>15 May</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g2"></td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td>
</tr>
<tr>
<td class="cn">9</td>
<td><span class="pbadge" style="background:#0f766e">F 3.4b</span></td>
<td class="tl"><b>Solicitudes + Pago + Lugar destino</b></td>
<td class="del">Registro pago (monto + método) · Selector Lugar destino · Botón Crear Solicitud · <b>create_solicitud.php</b> · Cálculo auto consentimientos requeridos</td>
<td class="cn">3</td>
<td>16 May</td>
<td>18 May</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g2"></td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td>
</tr>
<tr>
<td class="cn">10</td>
<td><span class="pbadge" style="background:#0f766e">F 3.4c</span></td>
<td class="tl"><b>Envío Consentimientos por WhatsApp</b></td>
<td class="del"><b>send_consentimiento.php</b> · Deduplicación de formularios · Tokens UUID únicos · WhatsAppService + plantilla Meta "consentimiento_turno" · Estados en tiempo real</td>
<td class="cn">7</td>
<td>19 May</td>
<td>25 May</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g2"></td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td>
</tr>
<!-- ═══ BLOQUE 6: LUGAR / ESTACIÓN ═══ -->
<tr class="ph"><td colspan="18">🔬 BLOQUE 6 — PUESTO LUGAR / ESTACIÓN DE SERVICIO — FASE 3.5 &nbsp;&nbsp; (Semana 7)</td></tr>
<tr>
<td class="cn">11</td>
<td><span class="pbadge" style="background:#7e22ce">F 3.5</span></td>
<td class="tl"><b>Vista Lugar / Estación genérica</b></td>
<td class="del"><b>views/lugar.php</b> (?lugar_id=X) · Cola priorizada del lugar · Llamar siguiente · Ficha: datos + exámenes + estado consentimientos · Bloqueo si pendientes</td>
<td class="cn">4</td>
<td>26 May</td>
<td>29 May</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g2"></td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td>
</tr>
<tr>
<td class="cn">12</td>
<td><span class="pbadge" style="background:#7e22ce">F 3.5</span></td>
<td class="tl"><b>Flujo de servicio: Iniciar / Finalizar</b></td>
<td class="del">Botón "Iniciar atención" (habilitado si consentimientos OK) · Botón "Finalizar" → estado finalizado · Botón "Ausente" / Regresar a cola · polling 5s</td>
<td class="cn">3</td>
<td>30 May</td>
<td>01 Jun</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g2"></td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td>
</tr>
<!-- ═══ BLOQUE 7: FIRMA DIGITAL ═══ -->
<tr class="ph"><td colspan="18">✍️ BLOQUE 7 — FIRMA DIGITAL DE CONSENTIMIENTOS — FASE 3.6 &nbsp;&nbsp; (Semana 8)</td></tr>
<tr>
<td class="cn">13</td>
<td><span class="pbadge" style="background:#be185d">F 3.6</span></td>
<td class="tl"><b>Integración firma en flujo turnero</b></td>
<td class="del">Campo <b>tipo</b> en lab_formularios · ver_formulario_enviado.php detecta token de turno · Actualiza turnero_consentimientos · Registro evidencia: IP, UA, fecha, versión</td>
<td class="cn">4</td>
<td>02 Jun</td>
<td>05 Jun</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g2"></td><td class="ge">·</td><td class="ge">·</td>
</tr>
<tr>
<td class="cn">14</td>
<td><span class="pbadge" style="background:#be185d">F 3.6</span></td>
<td class="tl"><b>"Firmar aquí" en tablet del puesto</b></td>
<td class="del">Botón "Firmar aquí" en lugar.php → modal/iframe con ver_formulario_enviado.php · Firma física en pantalla del auxiliar · Detección en tiempo real (polling 5s)</td>
<td class="cn">3</td>
<td>06 Jun</td>
<td>08 Jun</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g2"></td><td class="ge">·</td><td class="ge">·</td>
</tr>
<!-- ═══ BLOQUE 8: PANEL ADMIN ═══ -->
<tr class="ph"><td colspan="18">⚙️ BLOQUE 8 — PANEL ADMIN + CONFIGURACIÓN — FASE 3.7 &nbsp;&nbsp; (Semana 9)</td></tr>
<tr>
<td class="cn">15</td>
<td><span class="pbadge" style="background:#b45309">F 3.7</span></td>
<td class="tl"><b>Configuración: Lugares + Exámenes</b></td>
<td class="del">Tab Lugares: CRUD + URL TV automática · Tab Exámenes y Consentimientos: M:N (exam_tipos ↔ formularios) · Vista inversa por consentimiento</td>
<td class="cn">4</td>
<td>09 Jun</td>
<td>12 Jun</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g3"></td><td class="ge">·</td>
</tr>
<tr>
<td class="cn">16</td>
<td><span class="pbadge" style="background:#b45309">F 3.7</span></td>
<td class="tl"><b>Dashboard Admin + Prioridades + Sesión</b></td>
<td class="del">Dashboard: KPIs del día + tiempo promedio + exportar CSV · Tab Prioridades (CRUD) · Tab Sesiones (abrir/cerrar/historial) · Tab WhatsApp (plantilla Meta)</td>
<td class="cn">3</td>
<td>13 Jun</td>
<td>15 Jun</td>
<td><span class="badge b-pend">⬜ Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g3"></td><td class="ge">·</td>
</tr>
<!-- ═══ BLOQUE 9: QA + CAPACITACIÓN + GO-LIVE ═══ -->
<tr class="ph"><td colspan="18">🧪 BLOQUE 9 — QA · CAPACITACIÓN · GO-LIVE &nbsp;&nbsp; (Semana 10 · 1620 Jun)</td></tr>
<tr>
<td class="cn">17</td>
<td><span class="pbadge" style="background:#b45309">QA</span></td>
<td class="tl"><b>Testing end-to-end y corrección de bugs</b></td>
<td class="del">Flujo completo paciente · Múltiples lugares simultáneos · SSE stress test · Consentimientos en tiempo real · Validación OWASP Top 10</td>
<td class="cn">2</td>
<td>16 Jun</td>
<td>17 Jun</td>
<td><span class="badge b-qa">🟡 Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g4"></td>
</tr>
<tr>
<td class="cn">18</td>
<td><span class="pbadge" style="background:#5b21b6">CAP</span></td>
<td class="tl"><b>Capacitación del equipo</b></td>
<td class="del">Manual recepcionista · Manual auxiliar/lugar · Manual administrador · Capacitación presencial o remota · Guías rápidas por rol</td>
<td class="cn">1</td>
<td>18 Jun</td>
<td>18 Jun</td>
<td><span class="badge b-cap">🟣 Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g5"></td>
</tr>
<tr>
<td class="cn">19</td>
<td><span class="pbadge" style="background:#065f46">DEPLOY</span></td>
<td class="tl"><b>Go-Live y soporte post-lanzamiento</b></td>
<td class="del">Deploy en producción · Configuración inicial: lugares, exámenes, prioridades, plantillas Meta · Prueba en producción · Monitoreo · Ajustes finales</td>
<td class="cn">2</td>
<td>19 Jun</td>
<td>20 Jun</td>
<td><span class="badge b-dep">🟢 Pendiente</span></td>
<td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="ge">·</td><td class="g6"></td>
</tr>
</tbody>
</table>
</div>
<!-- NOTES -->
<div class="notes">
<h3>📌 Notas del Cronograma</h3>
<div class="notes-grid">
<div class="note-card nc1">
<b>🔄 Metodología incremental</b>
<p>Desarrollo iterativo. El sistema sigue funcionando en producción durante toda la implementación. Los módulos legacy (lab_*.php) permanecen activos hasta que su equivalente nuevo esté validado.</p>
</div>
<div class="note-card">
<b>🔗 Reutilización de componentes</b>
<p>Los consentimientos reutilizan el módulo de Formularios existente. El buscador de pacientes reutiliza lab_pacientes. WhatsApp reutiliza el servicio ya activo. La firma digital ya está implementada.</p>
</div>
<div class="note-card nc2">
<b>⚠️ Dependencias críticas</b>
<p>El Bloque 3 (API Core) debe completarse antes de iniciar las vistas (Bloques 47). La plantilla Meta "consentimiento_turno" debe aprobarse en WhatsApp Business Manager preferiblemente en la semana 3. La última semana (S10) es compacta: QA + capacitación + go-live en 5 días — cualquier retraso acumulado en semanas anteriores impacta directamente la fecha de lanzamiento.</p>
</div>
<div class="note-card nc3">
<b>📱 Compatibilidad móvil</b>
<p>Kiosko: táctil fullscreen (tablet Android/iPad). Display TV: cualquier pantalla con navegador. Puesto Recepción y Lugar: PC o tablet. Firma paciente: cualquier celular vía WhatsApp.</p>
</div>
<div class="note-card nc1">
<b>🛡️ Seguridad OWASP</b>
<p>Todas las APIs implementan: requireMethod() + verificar sesión + PDO preparado + sanitización de parámetros. Pantallas sin login (kiosko, TV) nunca exponen datos sensibles.</p>
</div>
<div class="note-card">
<b>📊 Soporte post-lanzamiento</b>
<p>30 días de soporte incluidos tras el go-live (20 Jun 20 Jul 2026) para ajustes basados en uso real, reportes de KPIs, y optimizaciones de UX identificadas en producción.</p>
</div>
</div>
</div>
<div class="footer">
Sistema Turnero Inteligente — ERP Multi-Módulo &nbsp;·&nbsp; Laboratorio Clínico &nbsp;·&nbsp; 14 Abril 20 Junio 2026 (67 días calendario) &nbsp;·&nbsp; GitHub Copilot
</div>
<script>
function exportExcel(){
const tbl = document.getElementById('gt');
const htm = '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><head><meta charset="UTF-8"><!--[if gte mso 9]><xml><x:ExcelWorkbook><x:ExcelWorksheets><x:ExcelWorksheet><x:Name>Cronograma Turnero</x:Name><x:WorksheetOptions><x:DisplayGridlines/></x:WorksheetOptions></x:ExcelWorksheet></x:ExcelWorksheets></x:ExcelWorkbook></xml><![endif]--></head><body>'+tbl.outerHTML+'</body></html>';
const blob = new Blob(['\ufeff'+htm],{type:'application/vnd.ms-excel;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'Cronograma_Turnero_60dias.xls';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
</body>
</html>
+3 -2
View File
@@ -1238,8 +1238,9 @@ const formVer = {
<label class="form-label small mb-1">Teléfono</label>
<div class="input-group input-group-sm">
<select id="na-np-prefijo" class="form-select form-select-sm" style="max-width:72px">
<option value="57" selected>57</option>
<option value="58">58</option>
<option value="57" selected>🇨🇴 57</option>
<option value="58">🇻🇪 58</option>
<option value="1">🇺🇸 1</option>
</select>
<input type="tel" id="na-np-telefono" class="form-control form-control-sm"
placeholder="3001234567">
+502 -1
View File
@@ -202,6 +202,194 @@ try {
</div>
</div>
<!-- ══ KPI: Actividad de Pacientes ══════════════════════════════ -->
<div class="row mt-4" id="kpi-actividad-section">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white d-flex flex-wrap align-items-center gap-2 justify-content-between">
<h5 class="mb-0"><i class="fas fa-chart-bar text-primary me-2"></i>Actividad de Pacientes — KPI</h5>
<div class="d-flex flex-wrap gap-2 align-items-center">
<!-- Rango rápido -->
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary kpi-quick" data-days="7">7 días</button>
<button class="btn btn-secondary kpi-quick active" data-days="30">30 días</button>
<button class="btn btn-outline-secondary kpi-quick" data-days="90">90 días</button>
</div>
<!-- Rango personalizado -->
<input type="date" id="kpi-desde" class="form-control form-control-sm" style="width:140px">
<span class="text-muted small"></span>
<input type="date" id="kpi-hasta" class="form-control form-control-sm" style="width:140px">
<button class="btn btn-primary btn-sm" onclick="cargarKPI()">
<i class="fas fa-filter me-1"></i>Filtrar
</button>
</div>
</div>
<div class="card-body">
<!-- Tarjetas de totales -->
<div class="row g-3 mb-4" id="kpi-cards">
<div class="col-6 col-md-3">
<div class="card border-0 bg-primary bg-opacity-10 text-center py-3">
<div class="fs-2 fw-bold text-primary" id="kpi-total-usuarios">—</div>
<div class="small text-muted mt-1"><i class="fas fa-users me-1"></i>Usuarios únicos</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 bg-success bg-opacity-10 text-center py-3">
<div class="fs-2 fw-bold text-success" id="kpi-total-mensajes">—</div>
<div class="small text-muted mt-1"><i class="fab fa-whatsapp me-1"></i>Mensajes recibidos</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 bg-warning bg-opacity-10 text-center py-3">
<div class="fs-2 fw-bold text-warning" id="kpi-total-agend">—</div>
<div class="small text-muted mt-1"><i class="fas fa-calendar-check me-1"></i>Agendamientos</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 bg-info bg-opacity-10 text-center py-3">
<div class="fs-4 fw-bold text-info" id="kpi-pico-dia">—</div>
<div class="small text-muted mt-1"><i class="fas fa-trophy me-1"></i>Día más activo</div>
</div>
</div>
</div>
<!-- Gráficas -->
<div class="row g-3">
<div class="col-lg-8">
<div class="card border-0 bg-light">
<div class="card-body">
<h6 class="text-muted mb-3"><i class="fas fa-calendar-alt me-1"></i>Por día</h6>
<div style="position:relative; height:220px">
<canvas id="kpiChartDia"></canvas>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 bg-light">
<div class="card-body">
<h6 class="text-muted mb-3"><i class="fas fa-calendar-week me-1"></i>Por día de la semana</h6>
<div style="position:relative; height:220px">
<canvas id="kpiChartSemana"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════ -->
<!-- ══ KPI: Tiempo de Respuesta de Asesores ════════════════════ -->
<div class="row mt-4" id="kpi-asesor-section">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white d-flex flex-wrap align-items-center gap-2 justify-content-between">
<h5 class="mb-0"><i class="fas fa-stopwatch text-danger me-2"></i>Tiempo de Respuesta de Asesores</h5>
<div class="d-flex flex-wrap gap-2 align-items-center">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary kpi-asesor-quick" data-days="7">7 días</button>
<button class="btn btn-secondary kpi-asesor-quick active" data-days="30">30 días</button>
<button class="btn btn-outline-secondary kpi-asesor-quick" data-days="90">90 días</button>
</div>
<input type="date" id="kpia-desde" class="form-control form-control-sm" style="width:140px">
<span class="text-muted small"></span>
<input type="date" id="kpia-hasta" class="form-control form-control-sm" style="width:140px">
<button class="btn btn-danger btn-sm" onclick="cargarKPIAsesor()">
<i class="fas fa-filter me-1"></i>Filtrar
</button>
</div>
</div>
<div class="card-body">
<!-- Tarjetas de totales -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-2">
<div class="card border-0 bg-danger bg-opacity-10 text-center py-3">
<div class="fs-2 fw-bold text-danger" id="kpia-total-sesiones">—</div>
<div class="small text-muted mt-1"><i class="fas fa-headset me-1"></i>Atenciones</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card border-0 bg-warning bg-opacity-10 text-center py-3">
<div class="fs-2 fw-bold text-warning" id="kpia-prom-resp">—</div>
<div class="small text-muted mt-1"><i class="fas fa-clock me-1"></i>Prom. 1ª respuesta</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card border-0 bg-info bg-opacity-10 text-center py-3">
<div class="fs-2 fw-bold text-info" id="kpia-prom-dur">—</div>
<div class="small text-muted mt-1"><i class="fas fa-hourglass-half me-1"></i>Prom. duración</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card border-0 bg-success bg-opacity-10 text-center py-3">
<div class="fs-2 fw-bold text-success" id="kpia-pct-rapidas">—</div>
<div class="small text-muted mt-1"><i class="fas fa-bolt me-1"></i>Resp. &lt; 3 min</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card border-0 bg-secondary bg-opacity-10 text-center py-3">
<div class="fs-2 fw-bold text-secondary" id="kpia-sin-resp">—</div>
<div class="small text-muted mt-1"><i class="fas fa-comment-slash me-1"></i>Sin respuesta</div>
</div>
</div>
</div>
<!-- Gráficas -->
<div class="row g-3 mb-3">
<div class="col-lg-8">
<div class="card border-0 bg-light">
<div class="card-body">
<h6 class="text-muted mb-3"><i class="fas fa-chart-line me-1"></i>Promedio de respuesta por día (minutos)</h6>
<div style="position:relative; height:200px">
<canvas id="kpiaChartDia"></canvas>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 bg-light">
<div class="card-body">
<h6 class="text-muted mb-3"><i class="fas fa-chart-pie me-1"></i>Distribución de tiempos</h6>
<div style="position:relative; height:200px">
<canvas id="kpiaChartHist"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Tabla por asesor -->
<div class="card border-0 bg-light">
<div class="card-body p-0">
<h6 class="text-muted mb-0 px-3 pt-3"><i class="fas fa-user-tie me-1"></i>Detalle por asesor</h6>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 mt-2" id="kpia-tabla-asesores">
<thead class="table-light">
<tr>
<th>Asesor</th>
<th class="text-center">Atenciones</th>
<th class="text-center">Prom. 1ª respuesta</th>
<th class="text-center">Prom. duración</th>
<th>Velocidad</th>
</tr>
</thead>
<tbody id="kpia-body-asesores">
<tr><td colspan="5" class="text-center text-muted py-3"><i class="fas fa-spinner fa-spin"></i></td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════ -->
<!-- Rate Limiting Monitor -->
<div class="row mt-4">
<div class="col-12">
@@ -2312,7 +2500,320 @@ try {
});
})();
</script>
<!-- ── / Historial Envíos Masivos JS ────────────────────────────────── -->
<!-- ── KPI Actividad de Pacientes JS ────────────────────────────────── -->
<script>
(function () {
'use strict';
let chartDia = null;
let chartSemana = null;
const fmt = n => Number(n).toLocaleString('es-CO');
// ── Inicializar fechas en los inputs ──────────────────────────────
function initFechas(days = 30) {
const hoy = new Date();
const desde = new Date(hoy); desde.setDate(hoy.getDate() - (days - 1));
document.getElementById('kpi-hasta').value = hoy.toISOString().slice(0, 10);
document.getElementById('kpi-desde').value = desde.toISOString().slice(0, 10);
}
// ── Carga y renderiza ─────────────────────────────────────────────
window.cargarKPI = async function () {
const desde = document.getElementById('kpi-desde').value;
const hasta = document.getElementById('kpi-hasta').value;
if (!desde || !hasta) return;
try {
const r = await fetch(`api/kpi_actividad.php?desde=${desde}&hasta=${hasta}`);
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Error');
// Tarjetas
document.getElementById('kpi-total-usuarios').textContent = fmt(d.totales.usuarios);
document.getElementById('kpi-total-mensajes').textContent = fmt(d.totales.mensajes);
document.getElementById('kpi-total-agend').textContent = fmt(d.totales.agendamientos);
document.getElementById('kpi-pico-dia').textContent = d.totales.pico_semana;
// Gráfica por día
const labsDia = d.por_dia.map(x => {
const f = new Date(x.fecha + 'T00:00:00');
return f.toLocaleDateString('es-CO', { day:'2-digit', month:'short' });
});
const dataUsuarios = d.por_dia.map(x => x.usuarios);
const dataAgend = d.por_dia.map(x => x.agendamientos);
if (chartDia) chartDia.destroy();
chartDia = new Chart(document.getElementById('kpiChartDia'), {
type: 'bar',
data: {
labels: labsDia,
datasets: [
{
label: 'Usuarios que escriben',
data: dataUsuarios,
backgroundColor: 'rgba(13,110,253,0.6)',
borderRadius: 4,
order: 1,
},
{
label: 'Agendamientos',
data: dataAgend,
backgroundColor: 'rgba(255,193,7,0.75)',
borderRadius: 4,
order: 2,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'top' } },
scales: {
x: { ticks: { maxRotation: 45, font: { size: 10 } } },
y: { beginAtZero: true, ticks: { precision: 0 } },
},
},
});
// Gráfica por día de semana
const labsSem = d.por_dia_semana.map(x => x.dia);
const dataSemU = d.por_dia_semana.map(x => x.usuarios);
const dataSemA = d.por_dia_semana.map(x => x.agendamientos);
if (chartSemana) chartSemana.destroy();
chartSemana = new Chart(document.getElementById('kpiChartSemana'), {
type: 'bar',
data: {
labels: labsSem,
datasets: [
{
label: 'Usuarios',
data: dataSemU,
backgroundColor: 'rgba(13,110,253,0.6)',
borderRadius: 4,
},
{
label: 'Agendamientos',
data: dataSemA,
backgroundColor: 'rgba(255,193,7,0.75)',
borderRadius: 4,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'top' } },
scales: {
y: { beginAtZero: true, ticks: { precision: 0 } },
},
},
});
} catch (e) {
console.error('KPI error:', e);
}
};
// ── Botones de rango rápido ───────────────────────────────────────
document.querySelectorAll('.kpi-quick').forEach(btn => {
btn.addEventListener('click', function () {
document.querySelectorAll('.kpi-quick').forEach(b => {
b.classList.remove('btn-secondary', 'active');
b.classList.add('btn-outline-secondary');
});
this.classList.remove('btn-outline-secondary');
this.classList.add('btn-secondary', 'active');
initFechas(parseInt(this.dataset.days, 10));
cargarKPI();
});
});
// ── Carga inicial (30 días por defecto) ───────────────────────────
// Solo se ejecuta cuando la pestaña dashboard está visible
function kpiInit() {
initFechas(30);
cargarKPI();
}
// Esperar a que el tab dashboard esté activo
const observer = new MutationObserver(() => {
const sec = document.getElementById('kpi-actividad-section');
if (sec && sec.closest('.tab-content')?.style.display !== 'none') {
kpiInit();
observer.disconnect();
}
});
// Si la sección ya es visible al cargar, inicializar de inmediato
document.addEventListener('DOMContentLoaded', () => {
const sec = document.getElementById('kpi-actividad-section');
if (sec) kpiInit();
});
})();
</script>
<!-- ── KPI Tiempo de Respuesta Asesores JS ──────────────────────────── -->
<script>
(function () {
'use strict';
let chartDiaA = null;
let chartHistA = null;
const fmtMin = m => m === null ? '—' : (m < 60 ? m + ' min' : Math.floor(m/60) + 'h ' + (m%60) + 'm');
const escA = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
function initFechasA(days = 30) {
const hoy = new Date();
const desde = new Date(hoy); desde.setDate(hoy.getDate() - (days - 1));
document.getElementById('kpia-hasta').value = hoy.toISOString().slice(0, 10);
document.getElementById('kpia-desde').value = desde.toISOString().slice(0, 10);
}
function velocidadBadge(min) {
if (min === null) return '<span class="badge bg-secondary">Sin datos</span>';
if (min < 3) return '<span class="badge bg-success">Muy rápido</span>';
if (min < 10) return '<span class="badge bg-primary">Normal</span>';
if (min < 20) return '<span class="badge bg-warning text-dark">Lento</span>';
return '<span class="badge bg-danger">Muy lento</span>';
}
window.cargarKPIAsesor = async function () {
const desde = document.getElementById('kpia-desde').value;
const hasta = document.getElementById('kpia-hasta').value;
if (!desde || !hasta) return;
try {
const r = await fetch(`api/kpi_respuesta_asesor.php?desde=${desde}&hasta=${hasta}`);
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Error');
const t = d.totales;
document.getElementById('kpia-total-sesiones').textContent = t.sesiones;
document.getElementById('kpia-prom-resp').textContent = fmtMin(t.prom_respuesta);
document.getElementById('kpia-prom-dur').textContent = fmtMin(t.prom_duracion);
document.getElementById('kpia-pct-rapidas').textContent = t.pct_rapidas + '%';
document.getElementById('kpia-sin-resp').textContent = t.sin_respuesta;
// Gráfica por día
const labsDia = d.serie_dia.map(x => {
const f = new Date(x.fecha + 'T00:00:00');
return f.toLocaleDateString('es-CO', { day:'2-digit', month:'short' });
});
const dataResp = d.serie_dia.map(x => x.prom_respuesta);
const dataSes = d.serie_dia.map(x => x.sesiones);
if (chartDiaA) chartDiaA.destroy();
chartDiaA = new Chart(document.getElementById('kpiaChartDia'), {
data: {
labels: labsDia,
datasets: [
{
type: 'line',
label: 'Prom. respuesta (min)',
data: dataResp,
borderColor: 'rgba(220,53,69,0.9)',
backgroundColor: 'rgba(220,53,69,0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
yAxisID: 'y',
},
{
type: 'bar',
label: 'Atenciones',
data: dataSes,
backgroundColor: 'rgba(108,117,125,0.3)',
borderRadius: 3,
yAxisID: 'y2',
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'top' } },
scales: {
x: { ticks: { maxRotation: 45, font: { size: 10 } } },
y: { beginAtZero: true, ticks: { precision: 0 }, title: { display: true, text: 'Min' } },
y2: { beginAtZero: true, position: 'right', grid: { drawOnChartArea: false }, ticks: { precision: 0 }, title: { display: true, text: 'Atenciones' } },
},
},
});
// Histograma de distribución
const labsH = d.histograma.map(x => x.rango);
const dataH = d.histograma.map(x => x.sesiones);
const colores = [
'rgba(25,135,84,0.7)', // < 1 min verde
'rgba(13,110,253,0.7)', // 1-3 min azul
'rgba(255,193,7,0.7)', // 3-5 min amarillo
'rgba(253,126,20,0.7)', // 5-10 min naranja
'rgba(220,53,69,0.7)', // 10-20 rojo
'rgba(108,17,17,0.7)', // >20 rojo oscuro
'rgba(108,117,125,0.7)', // Sin resp gris
];
if (chartHistA) chartHistA.destroy();
chartHistA = new Chart(document.getElementById('kpiaChartHist'), {
type: 'doughnut',
data: {
labels: labsH,
datasets: [{ data: dataH, backgroundColor: colores, borderWidth: 1 }],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { font: { size: 11 } } },
},
},
});
// Tabla de asesores
const tbody = document.getElementById('kpia-body-asesores');
if (!d.asesores.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-2">Sin datos</td></tr>';
} else {
tbody.innerHTML = d.asesores.map(a => `
<tr>
<td class="fw-semibold">${escA(a.operador)}</td>
<td class="text-center">${a.sesiones}</td>
<td class="text-center">${fmtMin(a.prom_respuesta)}</td>
<td class="text-center">${fmtMin(a.prom_duracion)}</td>
<td>${velocidadBadge(a.prom_respuesta)}</td>
</tr>
`).join('');
}
} catch (e) {
console.error('KPI Asesores error:', e);
}
};
// Botones rápidos
document.querySelectorAll('.kpi-asesor-quick').forEach(btn => {
btn.addEventListener('click', function () {
document.querySelectorAll('.kpi-asesor-quick').forEach(b => {
b.classList.remove('btn-secondary', 'active');
b.classList.add('btn-outline-secondary');
});
this.classList.remove('btn-outline-secondary');
this.classList.add('btn-secondary', 'active');
initFechasA(parseInt(this.dataset.days, 10));
cargarKPIAsesor();
});
});
// Carga inicial
document.addEventListener('DOMContentLoaded', () => {
const sec = document.getElementById('kpi-asesor-section');
if (sec) { initFechasA(30); cargarKPIAsesor(); }
});
})();
</script>
<!-- ── / KPI Tiempo de Respuesta Asesores JS ─────────────────────────── -->
</body>
</html>
+34 -2
View File
@@ -963,7 +963,7 @@ async function cargarNotasDetalle(domId) {
html += `<div class="p-2 rounded mb-2" style="background:#f8fafc;border:1px solid #e2e8f0">
<div class="fw-semibold small">${esc(n.titulo||'Sin título')}</div>
<div class="small mt-1" style="white-space:pre-line">${n.cuerpo||''}</div>
${n.imagen_path ? `<img src="uploads/media/${esc(n.imagen_path)}" class="img-fluid rounded mt-1" style="max-height:160px;cursor:zoom-in" onclick="window.open('uploads/media/${esc(n.imagen_path)}','_blank')">` : ''}
${_renderArchivosAdmin(n)}
<div class="text-muted mt-1" style="font-size:.7rem">${esc(n.created_at||'')}</div>
</div>`;
});
@@ -1054,7 +1054,8 @@ async function verInformeDom() {
`<div class="mb-2 p-2 rounded" style="background:#f8fafc;border:1px solid #e2e8f0">
<div class="fw-semibold small">${esc(n.titulo||'Sin título')}</div>
<div class="small mt-1" style="white-space:pre-line">${n.cuerpo||''}</div>
${n.imagen_path ? `<img src="uploads/media/${esc(n.imagen_path)}" class="img-fluid rounded mt-1" style="max-height:120px;cursor:zoom-in" onclick="window.open('uploads/media/${esc(n.imagen_path)}','_blank')">` : ''}
${_renderArchivosAdmin(n)}
<div class="text-muted mt-1" style="font-size:.68rem">${esc(n.enfermera_nombre||'')}${n.created_at ? ' · ' + esc(n.created_at) : ''}</div>
</div>`
).join('');
} else {
@@ -1332,6 +1333,37 @@ function exportarDomicilios() {
// ── Utils ──────────────────────────────────────────────────────────────────
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
// Renderiza los archivos adjuntos de una nota (soporta nuevo campo `archivos` JSON
// y el campo legacy `imagen_path`).
function _renderArchivosAdmin(n) {
let lista = [];
if (n.archivos) {
try { lista = typeof n.archivos === 'string' ? JSON.parse(n.archivos) : n.archivos; } catch(e) {}
}
if (!lista.length && n.imagen_path) {
lista = [{ nombre: n.imagen_path, path: n.imagen_path, mime: 'image/jpeg', size: 0 }];
}
if (!lista.length) return '';
return '<div class="d-flex flex-wrap gap-2 mt-2">' + lista.map(a => {
const src = `uploads/media/${esc(a.path)}`;
const esImg = (a.mime || '').startsWith('image/');
if (esImg) {
return `<img src="${src}" class="rounded" style="height:80px;width:80px;object-fit:cover;cursor:pointer"
onclick="window.open('${src}','_blank')" title="${esc(a.nombre)}">`;
}
const icon = (a.mime||'').includes('pdf') ? 'fa-file-pdf text-danger'
: (a.mime||'').includes('word') ? 'fa-file-word text-primary'
: (a.mime||'').includes('excel') || (a.mime||'').includes('sheet') ? 'fa-file-excel text-success'
: 'fa-file text-secondary';
return `<a href="${src}" target="_blank" class="border rounded p-1 text-center text-decoration-none"
style="width:80px;height:80px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;color:inherit">
<i class="fas ${icon} fa-lg"></i>
<div style="font-size:.6rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:74px">${esc(a.nombre)}</div>
</a>`;
}).join('') + '</div>';
}
function formatFechaNac(fnac) {
if (!fnac) return '—';
const d = new Date(fnac + 'T00:00:00');
+26 -8
View File
@@ -118,9 +118,13 @@ require_once __DIR__ . '/shared/components/sidebar.php';
<div class="col-md-4">
<label class="form-label">Tipo doc.</label>
<select class="form-select" name="tipo_documento" id="pac-tipo-doc">
<option value="CC">CC</option><option value="CE">CE</option>
<option value="TI">TI</option><option value="PA">PA</option>
<option value="NIT">NIT</option><option value="RC">RC</option>
<option value="CC">CC — Cédula de Ciudadanía</option>
<option value="CE">CE — Cédula de Extranjería</option>
<option value="TI">TI — Tarjeta de Identidad</option>
<option value="PA">PA — Pasaporte</option>
<option value="DE">DE — Documento Extranjero</option>
<option value="NIT">NIT — NIT</option>
<option value="RC">RC — Registro Civil</option>
</select>
</div>
<div class="col-md-4">
@@ -131,8 +135,9 @@ require_once __DIR__ . '/shared/components/sidebar.php';
<label class="form-label">Teléfono</label>
<div class="input-group">
<select id="pac-tel-prefijo" class="form-select" style="max-width:80px">
<option value="57" selected>57</option>
<option value="58">58</option>
<option value="57" selected>🇨🇴 57</option>
<option value="58">🇻🇪 58</option>
<option value="1">🇺🇸 1</option>
</select>
<input type="tel" class="form-control" name="telefono" id="pac-tel" placeholder="3001234567">
</div>
@@ -212,7 +217,7 @@ async function cargarLista(pag = 1) {
<div class="fw-semibold">${esc(p.nombre_completo)}</div>
${p.phone_number ? `<small class="text-muted"><i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}</small>` : ''}
</td>
<td>${esc(p.tipo_documento)} ${esc(p.numero_documento||'—')}</td>
<td>${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</td>
<td>${esc(p.telefono||'—')}</td>
<td>${esc(p.eps||'—')}</td>
<td><span class="badge bg-primary hist-badge">${p.total_ordenes||0}</span></td>
@@ -257,7 +262,7 @@ async function verDetalle(id) {
document.getElementById('detail-body').innerHTML = `
<dl class="row small mb-3">
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${esc(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd>
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
@@ -307,7 +312,10 @@ function abrirFormulario(p = null) {
document.getElementById('pac-tipo-doc').value= p.tipo_documento || 'CC';
document.getElementById('pac-tel-prefijo').value = '57';
let telRaw = p.telefono || '';
if (/^(57|58)\d{10}$/.test(telRaw)) {
if (/^1\d{10}$/.test(telRaw)) {
document.getElementById('pac-tel-prefijo').value = '1';
telRaw = telRaw.slice(1);
} else if (/^(57|58)\d{10}$/.test(telRaw)) {
document.getElementById('pac-tel-prefijo').value = telRaw.slice(0, 2);
telRaw = telRaw.slice(2);
}
@@ -362,6 +370,16 @@ async function guardarPaciente() {
// ── Utils ──────────────────────────────────────────────────────────────────
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
const TIPO_DOC = {
CC: 'CC — Cédula de Ciudadanía',
CE: 'CE — Cédula de Extranjería',
TI: 'TI — Tarjeta de Identidad',
PA: 'PA — Pasaporte',
DE: 'DE — Documento Extranjero',
NIT: 'NIT',
RC: 'RC — Registro Civil',
};
const tipoDocLabel = t => TIPO_DOC[t] || esc(t||'—');
const formatFecha = s => s ? new Date(s).toLocaleDateString('es-CO') : '—';
const colorEstado = e => ({
pendiente:'warning', en_revision:'info', autorizada:'success',