cambios importates
This commit is contained in:
@@ -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()]);
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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 · Laboratorio Clínico · 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 (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 (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 (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 (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&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 (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 (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 (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 (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 (Semana 10 · 16–20 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 4–7). 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 · Laboratorio Clínico · 14 Abril – 20 Junio 2026 (67 días calendario) · 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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. < 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 => ({'<':'<','>':'>','&':'&','"':'"'}[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
@@ -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 => ({'<':'<','>':'>','&':'&','"':'"'}[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
@@ -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 => ({'<':'<','>':'>','&':'&','"':'"'}[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',
|
||||
|
||||
Reference in New Issue
Block a user