diff --git a/api/kpi_actividad.php b/api/kpi_actividad.php new file mode 100644 index 0000000..570d130 --- /dev/null +++ b/api/kpi_actividad.php @@ -0,0 +1,131 @@ + $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()]); +} diff --git a/api/kpi_respuesta_asesor.php b/api/kpi_respuesta_asesor.php new file mode 100644 index 0000000..6b7eb30 --- /dev/null +++ b/api/kpi_respuesta_asesor.php @@ -0,0 +1,212 @@ + $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()]); +} diff --git a/cronograma_turnero.html b/cronograma_turnero.html new file mode 100644 index 0000000..b3765a2 --- /dev/null +++ b/cronograma_turnero.html @@ -0,0 +1,456 @@ + + +
+ + +| # | +Fase | +Actividad | +Entregables principales | +Días | +Inicio | +Fin | +Estado | +S114-20 Abr | +S221-27 Abr | +S328 Abr-4 May | +S45-11 May | +S512-18 May | +S619-25 May | +S726 May-1 Jun | +S82-8 Jun | +S99-15 Jun | +S1016-20 Jun | +
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ⚙️ BLOQUE 1 — ADECUACIÓN ERP BASE (Semana 1 — COMPLETADO) | |||||||||||||||||
| 1 | +ERP | +Adecuar Software como ERP | +Estructura core/ · modules/ · shared/ · Router centralizado · Sidebar dinámico · SYSTEM_MODULES en BD · 11 módulos migrados a nueva estructura | +7 | +14 Abr | +20 Abr | +✅ Completado | +· | · | · | · | · | · | · | · | · | +|
| 🗄️ BLOQUE 2 — BASE DE DATOS DEL TURNERO — FASE 3.1 (Semana 2) | |||||||||||||||||
| 2 | +F 3.1 | +Migración 003 — Tablas Turnero | +exam_tipos · exam_tipo_consentimientos · turnero_lugares · turnero_prioridades · turnero_sesiones · turnero_turnos · turnero_solicitudes · turnero_examen_items · turnero_consentimientos · ALTER lab_formularios | +5 | +21 Abr | +25 Abr | +🔵 Próximo | +· | · | · | · | · | · | · | · | · | +|
| 3 | +F 3.1 | +Datos iniciales + module.php | +Prioridades A-F por defecto · Registro en system_modules · module.php descriptor · Asignación roles recepcionista / auxiliar | +2 | +26 Abr | +27 Abr | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| ⚡ BLOQUE 3 — MOTOR DE PRIORIDADES + APIs CORE — FASE 3.2 (Semana 3) | |||||||||||||||||
| 4 | +F 3.2 | +APIs de turno y solicitud | +create_turno.php (sesión diaria + correlativo) · create_solicitud.php (exámenes + pago + lugar + consentimientos auto) · Actualización estado turno | +4 | +28 Abr | +01 May | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| 5 | +F 3.2 | +Motor de cola + SSE | +llamar_turno.php (motor prioridad + FIFO) · cambiar_estado.php (validaciones bloqueo) · get_cola.php · sse_turno.php (Server-Sent Events) | +3 | +02 May | +04 May | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| 📺 BLOQUE 4 — KIOSKO + PANTALLAS TV — FASE 3.3 (Semana 4) | |||||||||||||||||
| 6 | +F 3.3 | +Kiosko táctil | +views/kiosko.php fullscreen · Botones A-F (icono + descripción) · Campo nombre/celular (opcional) · Código generado (ej. "E-042") · Sin exposición de datos | +3 | +05 May | +07 May | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| 7 | +F 3.3 | +Pantallas TV / Display | +views/display.php · ?display=recepcion (cola general) · ?display=lugar&lugar_id=X (cola del lugar) · SSE en tiempo real · Colores prioridad + animación | +4 | +08 May | +11 May | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| 🖥️ BLOQUE 5 — PUESTO DE RECEPCIÓN — FASE 3.4 (Semana 5) | |||||||||||||||||
| 8 | +F 3.4a | +Vista Recepción — Atención al paciente | +views/recepcion.php · Cola general con prioridad visual · Llamar siguiente · Buscador/creación paciente (reutiliza lab_pacientes) · Catálogo exam_tipos + selector | +4 | +12 May | +15 May | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| 9 | +F 3.4b | +Solicitudes + Pago + Lugar destino | +Registro pago (monto + método) · Selector Lugar destino · Botón Crear Solicitud · create_solicitud.php · Cálculo auto consentimientos requeridos | +3 | +16 May | +18 May | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| 10 | +F 3.4c | +Envío Consentimientos por WhatsApp | +send_consentimiento.php · Deduplicación de formularios · Tokens UUID únicos · WhatsAppService + plantilla Meta "consentimiento_turno" · Estados en tiempo real | +7 | +19 May | +25 May | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| 🔬 BLOQUE 6 — PUESTO LUGAR / ESTACIÓN DE SERVICIO — FASE 3.5 (Semana 7) | |||||||||||||||||
| 11 | +F 3.5 | +Vista Lugar / Estación genérica | +views/lugar.php (?lugar_id=X) · Cola priorizada del lugar · Llamar siguiente · Ficha: datos + exámenes + estado consentimientos · Bloqueo si pendientes | +4 | +26 May | +29 May | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| 12 | +F 3.5 | +Flujo de servicio: Iniciar / Finalizar | +Botón "Iniciar atención" (habilitado si consentimientos OK) · Botón "Finalizar" → estado finalizado · Botón "Ausente" / Regresar a cola · polling 5s | +3 | +30 May | +01 Jun | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| ✍️ BLOQUE 7 — FIRMA DIGITAL DE CONSENTIMIENTOS — FASE 3.6 (Semana 8) | |||||||||||||||||
| 13 | +F 3.6 | +Integración firma en flujo turnero | +Campo tipo en lab_formularios · ver_formulario_enviado.php detecta token de turno · Actualiza turnero_consentimientos · Registro evidencia: IP, UA, fecha, versión | +4 | +02 Jun | +05 Jun | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| 14 | +F 3.6 | +"Firmar aquí" en tablet del puesto | +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) | +3 | +06 Jun | +08 Jun | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| ⚙️ BLOQUE 8 — PANEL ADMIN + CONFIGURACIÓN — FASE 3.7 (Semana 9) | |||||||||||||||||
| 15 | +F 3.7 | +Configuración: Lugares + Exámenes | +Tab Lugares: CRUD + URL TV automática · Tab Exámenes y Consentimientos: M:N (exam_tipos ↔ formularios) · Vista inversa por consentimiento | +4 | +09 Jun | +12 Jun | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| 16 | +F 3.7 | +Dashboard Admin + Prioridades + Sesión | +Dashboard: KPIs del día + tiempo promedio + exportar CSV · Tab Prioridades (CRUD) · Tab Sesiones (abrir/cerrar/historial) · Tab WhatsApp (plantilla Meta) | +3 | +13 Jun | +15 Jun | +⬜ Pendiente | +· | · | · | · | · | · | · | · | · | +|
| 🧪 BLOQUE 9 — QA · CAPACITACIÓN · GO-LIVE (Semana 10 · 16–20 Jun) | |||||||||||||||||
| 17 | +QA | +Testing end-to-end y corrección de bugs | +Flujo completo paciente · Múltiples lugares simultáneos · SSE stress test · Consentimientos en tiempo real · Validación OWASP Top 10 | +2 | +16 Jun | +17 Jun | +🟡 Pendiente | +· | · | · | · | · | · | · | · | · | + |
| 18 | +CAP | +Capacitación del equipo | +Manual recepcionista · Manual auxiliar/lugar · Manual administrador · Capacitación presencial o remota · Guías rápidas por rol | +1 | +18 Jun | +18 Jun | +🟣 Pendiente | +· | · | · | · | · | · | · | · | · | + |
| 19 | +DEPLOY | +Go-Live y soporte post-lanzamiento | +Deploy en producción · Configuración inicial: lugares, exámenes, prioridades, plantillas Meta · Prueba en producción · Monitoreo · Ajustes finales | +2 | +19 Jun | +20 Jun | +🟢 Pendiente | +· | · | · | · | · | · | · | · | · | + |
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.
+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.
+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.
+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.
+Todas las APIs implementan: requireMethod() + verificar sesión + PDO preparado + sanitización de parámetros. Pantallas sin login (kiosko, TV) nunca exponen datos sensibles.
+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.
+| Asesor | +Atenciones | +Prom. 1ª respuesta | +Prom. duración | +Velocidad | +
|---|---|---|---|---|