From 34d0fed21b1f03fc14c2e3e38166f01673c2a33e Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:03:21 -0500 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20LIA=20voz+saludo,=20proyecci?= =?UTF-8?q?=C3=B3n=20Chart.js,=20Excel,=20informe=20WA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LIA: saludo de voz al abrir (1x/día), auto-escucha 3s, olas animadas - Gráfica de barras (Chart.js) con facturación real + proyección lineal - Exportar a Excel (.xls con HTML table, se abre directo en Excel) - Botón "Informe WA": envía resumen del día por WhatsApp a número elegido - Diseño accesible: texto más grande, botones claros para usuario adulto Co-Authored-By: Claude Sonnet 4.6 --- modules/turnero/api/enviar_informe_dia.php | 89 ++++++ modules/turnero/api/export_excel.php | 108 +++++++ modules/turnero/api/get_proyeccion.php | 65 +++++ modules/turnero/views/dashboard.php | 311 +++++++++++++++++---- 4 files changed, 521 insertions(+), 52 deletions(-) create mode 100644 modules/turnero/api/enviar_informe_dia.php create mode 100644 modules/turnero/api/export_excel.php create mode 100644 modules/turnero/api/get_proyeccion.php diff --git a/modules/turnero/api/enviar_informe_dia.php b/modules/turnero/api/enviar_informe_dia.php new file mode 100644 index 0000000..0b6ae6a --- /dev/null +++ b/modules/turnero/api/enviar_informe_dia.php @@ -0,0 +1,89 @@ +prepare("SELECT id, inicio_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1"); +$ses->execute([$hoy]); +$sesion = $ses->fetch(PDO::FETCH_ASSOC); +if (!$sesion) jsonError('No hay sesión activa hoy.'); +$sesId = (int)$sesion['id']; + +// Resumen de estados +$stmt = $pdo->prepare("SELECT estado, COUNT(*) AS n FROM turnero_turnos WHERE sesion_id = ? GROUP BY estado"); +$stmt->execute([$sesId]); +$estados = []; +foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) $estados[$r['estado']] = (int)$r['n']; +$total = array_sum($estados); +$atendidos = ($estados['finalizado'] ?? 0); +$enEspera = ($estados['espera'] ?? 0) + ($estados['en_recepcion'] ?? 0) + ($estados['en_espera_lugar'] ?? 0) + ($estados['en_servicio'] ?? 0); +$ausentes = ($estados['ausente'] ?? 0); + +// Tiempos promedio +$tRow = $pdo->prepare( + "SELECT + ROUND(AVG(TIMESTAMPDIFF(SECOND, creado_at, COALESCE(inicio_recepcion_at, NOW())) / 60), 0) AS espera, + ROUND(AVG(TIMESTAMPDIFF(SECOND, inicio_lugar_at, COALESCE(fin_lugar_at, NOW())) / 60), 0) AS servicio, + ROUND(AVG(TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at) / 60), 0) AS total + FROM turnero_turnos WHERE sesion_id = ?" +); +$tRow->execute([$sesId]); +$t = $tRow->fetch(PDO::FETCH_ASSOC); + +// Facturación +$fRows = $pdo->prepare( + "SELECT ts.metodo_pago, SUM(ts.total_cobrado) AS total, COUNT(*) AS n + FROM turnero_solicitudes ts + JOIN turnero_turnos tt ON tt.id = ts.turno_id + WHERE tt.sesion_id = ? AND ts.total_cobrado > 0 + GROUP BY ts.metodo_pago" +); +$fRows->execute([$sesId]); +$fact = $fRows->fetchAll(PDO::FETCH_ASSOC); +$totalFact = array_sum(array_column($fact, 'total')); + +// Armar mensaje +$fecha = date('d/m/Y'); +$hora = date('H:i'); +$msg = "📊 *Informe del día - Lab. Ximena Caicedo*\n"; +$msg .= "📅 {$fecha} · {$hora}\n\n"; +$msg .= "👥 *Turnos:*\n"; +$msg .= "• Total: {$total}\n"; +$msg .= "• Finalizados: {$atendidos}\n"; +if ($enEspera) $msg .= "• En atención/espera: {$enEspera}\n"; +if ($ausentes) $msg .= "• Ausentes: {$ausentes}\n"; +$msg .= "\n"; + +if ($fact) { + $msg .= "💰 *Facturación:*\n"; + foreach ($fact as $f) { + $msg .= "• {$f['metodo_pago']}: $" . number_format((float)$f['total'], 0, ',', '.') . " ({$f['n']} pac.)\n"; + } + $msg .= "• *Total: $" . number_format($totalFact, 0, ',', '.') . "*\n\n"; +} + +$msg .= "⏱ *Tiempos promedio:*\n"; +$msg .= "• Espera hasta recepción: " . ($t['espera'] ?? '—') . " min\n"; +$msg .= "• Servicio en muestras: " . ($t['servicio'] ?? '—') . " min\n"; +$msg .= "• Total puerta a puerta: " . ($t['total'] ?? '—') . " min\n"; + +try { + $wa = new WhatsAppService('turnero'); + $wa->sendMessage($phone, $msg); + jsonOk(['mensaje' => "Informe enviado a {$phone}"]); +} catch (\Throwable $e) { + jsonError('Error al enviar: ' . $e->getMessage()); +} diff --git a/modules/turnero/api/export_excel.php b/modules/turnero/api/export_excel.php new file mode 100644 index 0000000..22a6047 --- /dev/null +++ b/modules/turnero/api/export_excel.php @@ -0,0 +1,108 @@ +prepare("SELECT id FROM turnero_sesiones WHERE fecha = ? LIMIT 1"); +$ses->execute([$hoy]); +$sesion = $ses->fetch(PDO::FETCH_ASSOC); + +$turnos = []; +if ($sesion) { + $sesId = (int)$sesion['id']; + $stmt = $pdo->prepare( + "SELECT t.codigo, p.codigo AS prioridad, t.estado, + COALESCE(sp.nombre_completo, t.paciente_nombre) AS paciente, + sp.tipo_documento AS tipo_doc, sp.numero_documento AS num_doc, + t.paciente_cel AS celular, + l.nombre AS lugar, + ur.full_name AS recepcionista, + ul.full_name AS bacteriologo, + ts.total_cobrado AS cobrado, ts.metodo_pago, + GROUP_CONCAT(DISTINCT et.nombre ORDER BY et.nombre SEPARATOR ', ') AS examenes, + ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, t.fin_recepcion_at)) / 60, 1) AS espera_min, + ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60, 1) AS servicio_min, + ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, t.fin_lugar_at) / 60, 1) AS total_min, + t.creado_at + FROM turnero_turnos t + JOIN turnero_prioridades p ON p.id = t.prioridad_id + LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id + LEFT JOIN turnero_solicitudes ts ON ts.turno_id = t.id + LEFT JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id + LEFT JOIN exam_tipos et ON et.id = tei.exam_tipo_id + LEFT JOIN lab_pacientes sp ON sp.id = ts.paciente_id + LEFT JOIN admin_users ur ON ur.id = t.atendido_recepcion_por + LEFT JOIN admin_users ul ON ul.id = t.atendido_lugar_por + WHERE t.sesion_id = ? + GROUP BY t.id ORDER BY t.numero ASC" + ); + $stmt->execute([$sesId]); + $turnos = $stmt->fetchAll(PDO::FETCH_ASSOC); +} + +$fecha = date('d/m/Y', strtotime($hoy)); +$archivo = 'informe-turnero-' . $hoy . '.xls'; + +header('Content-Type: application/vnd.ms-excel; charset=utf-8'); +header('Content-Disposition: attachment; filename="' . $archivo . '"'); +header('Cache-Control: no-cache'); + +$estados = [ + 'finalizado'=>'Finalizado','ausente'=>'Ausente','cancelado'=>'Cancelado', + 'en_servicio'=>'En servicio','en_espera_lugar'=>'Esp. lugar', + 'en_recepcion'=>'Recepción','espera'=>'Espera', +]; + +echo ''; +echo ' +'; +echo "

Informe Turnero - {$fecha}

"; +echo ''; +echo ' + + + + + + +'; + +foreach ($turnos as $t) { + $cls = $t['estado'] === 'finalizado' ? ' class="f"' : ($t['estado'] === 'ausente' ? ' class="a"' : ''); + $hora = $t['creado_at'] ? date('H:i', strtotime($t['creado_at'])) : ''; + $cobrado = $t['cobrado'] ? number_format((float)$t['cobrado'], 0, ',', '.') : ''; + $estadoLbl = $estados[$t['estado']] ?? $t['estado']; + echo " + + + + + + + + + + + + + + + + + + \n"; +} +echo '
CódigoPrioridadEstadoPacienteTipo DocDocumentoCelularLugarRecepcionistaBacteriólogoExámenesMétodo PagoCobradoEspera (min)Servicio (min)Total (min)Hora entrada
{$t['codigo']}{$t['prioridad']}{$estadoLbl}" . htmlspecialchars($t['paciente'] ?? '', ENT_QUOTES) . "{$t['tipo_doc']}{$t['num_doc']}{$t['celular']}" . htmlspecialchars($t['lugar'] ?? '', ENT_QUOTES) . "" . htmlspecialchars($t['recepcionista'] ?? '', ENT_QUOTES) . "" . htmlspecialchars($t['bacteriologo'] ?? '', ENT_QUOTES) . "" . htmlspecialchars($t['examenes'] ?? '', ENT_QUOTES) . "{$t['metodo_pago']}{$cobrado}{$t['espera_min']}{$t['servicio_min']}{$t['total_min']}{$hora}
'; diff --git a/modules/turnero/api/get_proyeccion.php b/modules/turnero/api/get_proyeccion.php new file mode 100644 index 0000000..e66c52b --- /dev/null +++ b/modules/turnero/api/get_proyeccion.php @@ -0,0 +1,65 @@ +prepare("SELECT id, inicio_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1"); +$ses->execute([$hoy]); +$sesion = $ses->fetch(PDO::FETCH_ASSOC); + +if (!$sesion) { + jsonOk(['labels'=>[],'real'=>[],'proyectado'=>[],'total_real'=>0,'total_proyectado'=>0]); +} +$sesId = (int)$sesion['id']; + +// Facturación por hora real +$stmt = $pdo->prepare( + "SELECT HOUR(t.creado_at) AS hora, + COALESCE(SUM(ts.total_cobrado), 0) AS total, + COUNT(DISTINCT t.id) AS turnos + FROM turnero_turnos t + JOIN turnero_solicitudes ts ON ts.turno_id = t.id + WHERE t.sesion_id = ? AND ts.total_cobrado > 0 + GROUP BY hora ORDER BY hora" +); +$stmt->execute([$sesId]); +$porHora = []; +foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) { + $porHora[(int)$r['hora']] = ['total' => (float)$r['total'], 'turnos' => (int)$r['turnos']]; +} + +$inicioH = $sesion['inicio_at'] ? (int)date('G', strtotime($sesion['inicio_at'])) : 6; +$horaAct = (int)date('G'); +$finDia = 17; // 5pm cierre estimado + +// Calcular tasa por hora basada en horas con datos +$totalReal = array_sum(array_column($porHora, 'total')); +$horasConDatos = max(1, $horaAct - $inicioH); +$tasaPorHora = $totalReal / $horasConDatos; + +$labels = $real = $proyectado = []; +for ($h = $inicioH; $h <= max($finDia, $horaAct); $h++) { + $labels[] = sprintf('%02d:00', $h); + $real[] = isset($porHora[$h]) ? round($porHora[$h]['total']) : 0; + $proyectado[] = ($h > $horaAct && $h <= $finDia) ? round($tasaPorHora) : null; +} + +$horasRestantes = max(0, $finDia - $horaAct); +$totalProyectado = round($totalReal + ($tasaPorHora * $horasRestantes)); + +jsonOk([ + 'labels' => $labels, + 'real' => $real, + 'proyectado' => $proyectado, + 'total_real' => $totalReal, + 'total_proyectado' => $totalProyectado, + 'tasa_por_hora' => round($tasaPorHora), +]); diff --git a/modules/turnero/views/dashboard.php b/modules/turnero/views/dashboard.php index 0cebfa3..12bfcf4 100644 --- a/modules/turnero/views/dashboard.php +++ b/modules/turnero/views/dashboard.php @@ -87,52 +87,75 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar'); #loadingOverlay { min-height:180px; display:flex; align-items:center; justify-content:center; } .empty-msg { color:#94a3b8; font-size:.9rem; text-align:center; padding:40px 0; } - /* ── Panel IA ── */ + /* ── Panel LIA ── */ #aiPanel { - position:fixed; bottom:0; right:24px; width:380px; z-index:1080; - box-shadow:0 -4px 32px rgba(0,0,0,.18); border-radius:16px 16px 0 0; + position:fixed; bottom:0; right:24px; width:400px; z-index:1080; + box-shadow:0 -8px 40px rgba(0,0,0,.22); border-radius:20px 20px 0 0; background:#fff; border:1px solid #e2e8f0; border-bottom:none; - transform:translateY(100%); transition:transform .3s ease; - display:flex; flex-direction:column; max-height:560px; + transform:translateY(100%); transition:transform .35s cubic-bezier(.4,0,.2,1); + display:flex; flex-direction:column; max-height:580px; } #aiPanel.open { transform:translateY(0); } #aiPanel.minimized .ai-body { display:none; } #aiPanel.minimized { max-height:none; } .ai-header { - background:linear-gradient(135deg, var(--brand-dark,#0d47a1), var(--brand,#1565c0)); - color:#fff; padding:12px 16px; border-radius:16px 16px 0 0; - display:flex; align-items:center; gap:8px; flex-shrink:0; cursor:pointer; user-select:none; + background:linear-gradient(135deg,#0d47a1,#1565c0,#0288d1); + color:#fff; padding:14px 18px; border-radius:20px 20px 0 0; + display:flex; align-items:center; gap:12px; flex-shrink:0; cursor:pointer; user-select:none; } - .ai-header .ai-title { flex:1; font-weight:700; font-size:.92rem; } - .ai-header .btn-close { filter:invert(1) brightness(2); } - .btn-tts { background:transparent; border:none; color:rgba(255,255,255,.55); cursor:pointer; padding:3px 6px; border-radius:6px; font-size:.85rem; transition:color .2s; } - .btn-tts.active { color:#fff; } + .lia-logo { + width:44px; height:44px; border-radius:50%; + background:rgba(255,255,255,.15); border:2px solid rgba(255,255,255,.4); + display:flex; align-items:center; justify-content:center; + font-size:1rem; font-weight:900; letter-spacing:-1px; flex-shrink:0; + transition:transform .3s; + } + .lia-logo:hover { transform:scale(1.08); } + .lia-logo .lia-i { color:#7dd3fc; } + .lia-logo.speaking { animation:lia-pulse 1s infinite; } + @keyframes lia-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(255,255,255,.4)} 50%{box-shadow:0 0 0 8px rgba(255,255,255,0)} } + .ai-title { font-weight:800; font-size:1.05rem; letter-spacing:.5px; } + .ai-header .btn-close { filter:invert(1) brightness(2); opacity:.7; } + .ai-header .btn-close:hover { opacity:1; } + .btn-tts { background:rgba(255,255,255,.15); border:1px solid rgba(255,255,255,.3); color:rgba(255,255,255,.7); cursor:pointer; padding:5px 9px; border-radius:8px; font-size:.88rem; transition:all .2s; } + .btn-tts.active { background:rgba(255,255,255,.25); color:#fff; } .ai-body { display:flex; flex-direction:column; flex:1; overflow:hidden; } - .ai-quick { display:flex; gap:6px; flex-wrap:wrap; padding:8px 12px; border-bottom:1px solid #f1f5f9; flex-shrink:0; } - .ai-chip { background:#f1f5f9; border:1px solid #e2e8f0; border-radius:99px; padding:4px 10px; font-size:.73rem; cursor:pointer; color:#475569; white-space:nowrap; transition:background .15s; } - .ai-chip:hover { background:#e2e8f0; } - .ai-messages { flex:1; overflow-y:auto; padding:12px 14px; display:flex; flex-direction:column; gap:8px; } - .ai-msg { max-width:90%; padding:8px 12px; border-radius:12px; font-size:.83rem; line-height:1.5; } - .ai-msg.user { background:var(--brand,#1565c0); color:#fff; align-self:flex-end; border-radius:12px 12px 2px 12px; } - .ai-msg.bot { background:#f1f5f9; color:#1e293b; align-self:flex-start; border-radius:12px 12px 12px 2px; } - .ai-msg.bot.typing { color:#94a3b8; font-style:italic; } - .ai-msg.bot strong { color:#0d47a1; } - .ai-msg.bot ul { margin:4px 0 4px 14px; padding:0; } - .ai-footer { padding:10px 12px; border-top:1px solid #e2e8f0; display:flex; gap:6px; flex-shrink:0; } - .ai-footer input { flex:1; border:1px solid #e2e8f0; border-radius:8px; padding:7px 12px; font-size:.85rem; outline:none; } - .ai-footer input:focus { border-color:var(--brand,#1565c0); } - .btn-mic { background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px; padding:7px 11px; cursor:pointer; color:#64748b; font-size:.85rem; transition:all .2s; } - .btn-mic.listening { background:#fee2e2; color:#dc2626; border-color:#fca5a5; animation:mic-pulse 1s infinite; } - @keyframes mic-pulse { 0%,100%{opacity:1} 50%{opacity:.5} } - .ai-footer .btn-send { background:var(--brand,#1565c0); color:#fff; border:none; border-radius:8px; padding:7px 14px; cursor:pointer; font-size:.85rem; } + .ai-quick { display:flex; gap:7px; flex-wrap:wrap; padding:10px 14px; border-bottom:1px solid #f1f5f9; flex-shrink:0; background:#fafbff; } + .ai-chip { background:#fff; border:1.5px solid #e2e8f0; border-radius:99px; padding:6px 13px; font-size:.82rem; cursor:pointer; color:#334155; white-space:nowrap; transition:all .15s; font-weight:500; } + .ai-chip:hover { background:#eff6ff; border-color:#93c5fd; color:#1d4ed8; } + .ai-messages { flex:1; overflow-y:auto; padding:14px 16px; display:flex; flex-direction:column; gap:10px; } + .ai-msg { max-width:92%; padding:10px 14px; border-radius:16px; font-size:.9rem; line-height:1.55; animation:msg-in .2s ease; } + @keyframes msg-in { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} } + .ai-msg.user { background:linear-gradient(135deg,#1565c0,#0288d1); color:#fff; align-self:flex-end; border-radius:16px 16px 2px 16px; } + .ai-msg.bot { background:#f1f5f9; color:#1e293b; align-self:flex-start; border-radius:16px 16px 16px 2px; } + .ai-msg.bot.typing::after { content:'●●●'; animation:dots 1.2s infinite; letter-spacing:2px; } + @keyframes dots { 0%,100%{opacity:.3} 50%{opacity:1} } + .ai-msg.bot strong { color:#1565c0; } + .ai-msg.bot ul { margin:4px 0 4px 16px; padding:0; } + .lia-wave { display:flex; align-items:center; justify-content:center; gap:4px; height:32px; } + .lia-wave span { width:4px; border-radius:2px; background:#1565c0; animation:wave .8s infinite ease-in-out; } + .lia-wave span:nth-child(1){height:8px;animation-delay:0s} + .lia-wave span:nth-child(2){height:16px;animation-delay:.1s} + .lia-wave span:nth-child(3){height:24px;animation-delay:.2s} + .lia-wave span:nth-child(4){height:16px;animation-delay:.3s} + .lia-wave span:nth-child(5){height:8px;animation-delay:.4s} + @keyframes wave { 0%,100%{transform:scaleY(.5);opacity:.6} 50%{transform:scaleY(1);opacity:1} } + .ai-footer { padding:10px 14px; border-top:1px solid #f1f5f9; display:flex; gap:8px; flex-shrink:0; background:#fafbff; } + .ai-footer input { flex:1; border:1.5px solid #e2e8f0; border-radius:10px; padding:9px 14px; font-size:.9rem; outline:none; background:#fff; } + .ai-footer input:focus { border-color:#1565c0; } + .btn-mic { background:#fff; border:1.5px solid #e2e8f0; border-radius:10px; padding:9px 13px; cursor:pointer; color:#64748b; font-size:.88rem; transition:all .2s; min-width:42px; } + .btn-mic.listening { background:#fee2e2; color:#dc2626; border-color:#fca5a5; } + .ai-footer .btn-send { background:linear-gradient(135deg,#1565c0,#0288d1); color:#fff; border:none; border-radius:10px; padding:9px 16px; cursor:pointer; font-size:.88rem; min-width:42px; } #btnAI { position:fixed; bottom:24px; right:24px; z-index:1079; - background:linear-gradient(135deg, var(--brand-dark,#0d47a1), var(--brand,#1565c0)); - color:#fff; border:none; border-radius:50%; width:52px; height:52px; - font-size:1.3rem; cursor:pointer; box-shadow:0 4px 18px rgba(0,0,0,.22); - transition:transform .2s; + background:linear-gradient(135deg,#0d47a1,#1565c0,#0288d1); + color:#fff; border:none; border-radius:50%; width:58px; height:58px; + font-size:.78rem; font-weight:900; letter-spacing:-.5px; + cursor:pointer; box-shadow:0 4px 20px rgba(21,101,192,.45); + transition:transform .2s,box-shadow .2s; + display:flex; align-items:center; justify-content:center; } - #btnAI:hover { transform:scale(1.08); } + #btnAI:hover { transform:scale(1.1); box-shadow:0 6px 28px rgba(21,101,192,.55); } #btnAI.hidden { display:none; } @media(max-width:600px) { #aiPanel { width:100%; right:0; border-radius:16px 16px 0 0; } } @@ -194,7 +217,13 @@ try { value="" max=""> + + + + +
- - Asistente del Turnero + +
+
LIA
+
Asistente Inteligente del Lab.
+
@@ -288,11 +351,13 @@ try {
-
-
¡Hola! Pregúntame sobre el turnero de hoy o usa los botones de arriba. También puedes hablar 🎤
+
+