diff --git a/api/lab/save_config.php b/api/lab/save_config.php index d5c4f5e..5e9ea52 100644 --- a/api/lab/save_config.php +++ b/api/lab/save_config.php @@ -16,6 +16,7 @@ $permitidas = [ 'empresa_nombre', 'empresa_subtitulo', 'empresa_direccion', 'empresa_telefono', 'empresa_email', 'empresa_ciudad', 'doc_color', 'doc_logo_base64', 'doc_pie_pagina', + 'gemini_api_key', ]; $guardadas = 0; diff --git a/lab_configuracion.php b/lab_configuracion.php index 38a2f4d..5be4a92 100644 --- a/lab_configuracion.php +++ b/lab_configuracion.php @@ -165,6 +165,30 @@ require_once __DIR__ . '/shared/components/sidebar.php'; + +
+
Inteligencia Artificial — Google Gemini
+

+ El asistente IA del turnero usa Google Gemini Flash para responder preguntas + sobre el estado del día. Genera tu clave en + Google AI Studio. +

+
+ +
+ + +
+
El token se guarda cifrado y nunca se expone al navegador.
+
+
+ @@ -286,6 +310,7 @@ async function guardar() { doc_color: $('c-doc-color').value, doc_logo_base64: $('c-logo-base64').value, doc_pie_pagina: $('c-doc-pie').value.trim(), + gemini_api_key: $('c-gemini-key').value.trim(), }; if (!payload.empresa_nombre) { diff --git a/modules/turnero/api/ai_chat.php b/modules/turnero/api/ai_chat.php new file mode 100644 index 0000000..2ca4b3b --- /dev/null +++ b/modules/turnero/api/ai_chat.php @@ -0,0 +1,211 @@ + 800) jsonError('Pregunta demasiado larga', 400); + +$pdo = db(); + +// ── Clave Gemini ────────────────────────────────────────────── +$row = $pdo->query("SELECT valor FROM lab_config WHERE clave = 'gemini_api_key' LIMIT 1")->fetch(); +$apiKey = trim($row['valor'] ?? ''); +if (!$apiKey) jsonError('Token de IA no configurado. Ve a Configuración del laboratorio.', 503); + +// ── Contexto del día ────────────────────────────────────────── +$hoy = date('Y-m-d'); +$ctx = "Hoy es {$hoy}.\n"; + +$sesRow = $pdo->prepare("SELECT id, inicio_at, fin_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1"); +$sesRow->execute([$hoy]); +$ses = $sesRow->fetch(PDO::FETCH_ASSOC); + +if (!$ses) { + $ctx .= "No hay sesión de atención abierta hoy.\n"; +} else { + $sesId = (int)$ses['id']; + $ctx .= "Sesión " . ($ses['fin_at'] ? 'cerrada' : 'abierta') . ", inicio: " . ($ses['inicio_at'] ?? 'pendiente') . ".\n"; + + // Resumen de estados + $stmt = $pdo->prepare("SELECT estado, COUNT(*) AS n FROM turnero_turnos WHERE sesion_id = ? GROUP BY estado"); + $stmt->execute([$sesId]); + $stats = $stmt->fetchAll(PDO::FETCH_ASSOC); + $total = array_sum(array_column($stats, 'n')); + $ctx .= "Turnos hoy: {$total} total. "; + foreach ($stats as $s) { $ctx .= "{$s['estado']}: {$s['n']}. "; } + $ctx .= "\n"; + + // Tiempos promedio globales + $tRow = $pdo->prepare( + "SELECT + ROUND(AVG(TIMESTAMPDIFF(SECOND, creado_at, COALESCE(inicio_recepcion_at, NOW())) / 60), 1) AS espera_prom, + ROUND(AVG(TIMESTAMPDIFF(SECOND, inicio_recepcion_at, fin_recepcion_at) / 60), 1) AS recepcion_prom, + ROUND(AVG(TIMESTAMPDIFF(SECOND, inicio_lugar_at, COALESCE(fin_lugar_at, NOW())) / 60), 1) AS servicio_prom, + ROUND(AVG(TIMESTAMPDIFF(SECOND, creado_at, COALESCE(fin_lugar_at, NOW())) / 60), 1) AS total_prom + FROM turnero_turnos WHERE sesion_id = ?" + ); + $tRow->execute([$sesId]); + $tiempos = $tRow->fetch(PDO::FETCH_ASSOC); + $ctx .= "Tiempos promedio — espera: " . ($tiempos['espera_prom'] ?? '—') . "min, "; + $ctx .= "recepción: " . ($tiempos['recepcion_prom'] ?? '—') . "min, "; + $ctx .= "servicio: " . ($tiempos['servicio_prom'] ?? '—') . "min, "; + $ctx .= "total: " . ($tiempos['total_prom'] ?? '—') . "min.\n"; + + // Tiempos por recepcionista + $rRecep = $pdo->prepare( + "SELECT ur.full_name AS recepcionista, + COUNT(*) AS atendidos, + ROUND(AVG(TIMESTAMPDIFF(SECOND, t.inicio_recepcion_at, t.fin_recepcion_at) / 60), 1) AS prom_min + FROM turnero_turnos t + JOIN admin_users ur ON ur.id = t.atendido_recepcion_por + WHERE t.sesion_id = ? AND t.fin_recepcion_at IS NOT NULL + GROUP BY ur.id, ur.full_name ORDER BY prom_min DESC" + ); + $rRecep->execute([$sesId]); + $recepStats = $rRecep->fetchAll(PDO::FETCH_ASSOC); + if ($recepStats) { + $ctx .= "\nTiempo promedio por recepcionista:\n"; + foreach ($recepStats as $r) { + $ctx .= "- {$r['recepcionista']}: {$r['prom_min']} min/paciente ({$r['atendidos']} atendidos)\n"; + } + } + + // Facturación del día + $rFact = $pdo->prepare( + "SELECT SUM(ts.total_cobrado) AS total, COUNT(*) AS n, ts.metodo_pago + FROM turnero_solicitudes ts + JOIN turnero_turnos t ON t.id = ts.turno_id + WHERE t.sesion_id = ? AND ts.total_cobrado > 0 + GROUP BY ts.metodo_pago" + ); + $rFact->execute([$sesId]); + $factRows = $rFact->fetchAll(PDO::FETCH_ASSOC); + if ($factRows) { + $ctx .= "\nFacturación del día:\n"; + $totalFact = 0; + foreach ($factRows as $f) { + $ctx .= "- {$f['metodo_pago']}: $" . number_format((float)$f['total'], 0, ',', '.') . " ({$f['n']} atenciones)\n"; + $totalFact += (float)$f['total']; + } + $ctx .= "Total cobrado hoy: $" . number_format($totalFact, 0, ',', '.') . "\n"; + } + + // Franjas horarias (cada 2h) + $rFranjas = $pdo->prepare( + "SELECT FLOOR(HOUR(creado_at) / 2) * 2 AS franja, COUNT(*) AS n + FROM turnero_turnos WHERE sesion_id = ? + GROUP BY franja ORDER BY franja" + ); + $rFranjas->execute([$sesId]); + $franjas = $rFranjas->fetchAll(PDO::FETCH_ASSOC); + if ($franjas) { + $ctx .= "\nTurnos por franja horaria:\n"; + foreach ($franjas as $f) { + $h = (int)$f['franja']; + $ctx .= "- {$h}:00–" . ($h+2) . ":00 → {$f['n']} turnos\n"; + } + } + + // Detalle completo de turnos (máx 60) + $tList = $pdo->prepare( + "SELECT t.codigo, t.estado, p.codigo AS prioridad, + COALESCE(sp.nombre_completo, t.paciente_nombre) AS paciente, + sp.numero_documento AS doc, + l.nombre AS lugar, + ur.full_name AS recepcionista, + ul.full_name AS bacteriologo, + ts.total_cobrado AS cobrado, + ts.metodo_pago, + ROUND(TIMESTAMPDIFF(SECOND, t.inicio_recepcion_at, t.fin_recepcion_at) / 60, 1) AS min_recepcion, + ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60, 1) AS min_servicio, + ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.fin_lugar_at, NOW())) / 60, 1) AS min_total, + GROUP_CONCAT(DISTINCT et.nombre ORDER BY et.nombre SEPARATOR ', ') AS examenes + 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 LIMIT 60" + ); + $tList->execute([$sesId]); + $turnos = $tList->fetchAll(PDO::FETCH_ASSOC); + + if ($turnos) { + $ctx .= "\nDetalle de cada turno:\n"; + foreach ($turnos as $t) { + $ctx .= "- {$t['codigo']} [{$t['prioridad']}] {$t['paciente']}"; + if (!empty($t['doc'])) $ctx .= " (CC: {$t['doc']})"; + if (!empty($t['lugar'])) $ctx .= " → {$t['lugar']}"; + if (!empty($t['recepcionista'])) $ctx .= " | Recep: {$t['recepcionista']}"; + if (!empty($t['bacteriologo'])) $ctx .= " | Bact: {$t['bacteriologo']}"; + if (!empty($t['examenes'])) $ctx .= " | Exámenes: {$t['examenes']}"; + if ($t['min_recepcion'] !== null) $ctx .= " | T.recep: {$t['min_recepcion']}min"; + if ($t['min_servicio'] !== null) $ctx .= " | T.serv: {$t['min_servicio']}min"; + if ($t['min_total'] !== null) $ctx .= " | T.total: {$t['min_total']}min"; + if (!empty($t['cobrado'])) $ctx .= " | $" . number_format((float)$t['cobrado'], 0, ',', '.'); + if (!empty($t['metodo_pago'])) $ctx .= " ({$t['metodo_pago']})"; + $ctx .= " [{$t['estado']}]\n"; + } + } +} + +// ── Prompt ──────────────────────────────────────────────────── +$systemPrompt = << ['parts' => [['text' => $systemPrompt]]], + 'contents' => [ + ['role' => 'user', 'parts' => [['text' => $pregunta]]] + ], + 'generationConfig' => [ + 'temperature' => 0.3, + 'maxOutputTokens' => 600, + ], +], JSON_UNESCAPED_UNICODE); + +$ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_TIMEOUT => 20, + CURLOPT_SSL_VERIFYPEER => true, +]); +$raw = curl_exec($ch); +$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if (!$raw) jsonError('Sin respuesta del servicio de IA', 502); + +$gemini = json_decode($raw, true); +if ($code !== 200 || empty($gemini['candidates'][0]['content']['parts'][0]['text'])) { + $detail = $gemini['error']['message'] ?? 'Error desconocido'; + jsonError("Error IA: {$detail}", 502); +} + +$respuesta = $gemini['candidates'][0]['content']['parts'][0]['text']; +jsonOk(['respuesta' => $respuesta]); diff --git a/modules/turnero/api/get_dashboard.php b/modules/turnero/api/get_dashboard.php index f46fe96..52ab270 100644 --- a/modules/turnero/api/get_dashboard.php +++ b/modules/turnero/api/get_dashboard.php @@ -126,6 +126,8 @@ $stmtTurnos = $pdo->prepare( p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color, l.nombre AS lugar_nombre, s.nombre_completo AS paciente_bd, + s.numero_documento AS paciente_doc, + s.tipo_documento AS paciente_tipo_doc, rd.nombre AS recepcion_desk_nombre, ur.full_name AS atendido_recepcion_nombre, ul.full_name AS atendido_lugar_nombre, diff --git a/modules/turnero/views/dashboard.php b/modules/turnero/views/dashboard.php index 320c775..b573fc2 100644 --- a/modules/turnero/views/dashboard.php +++ b/modules/turnero/views/dashboard.php @@ -57,6 +57,12 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar'); .eb-finalizado { background:#f1f5f9; color:#475569; } .eb-ausente { background:#fee2e2; color:#991b1b; } .eb-cancelado { background:#f1f5f9; color:#9ca3af; } + .row-expandible { cursor:pointer; transition:background .15s; } + .row-expandible:hover { background: color-mix(in srgb, var(--brand,#1565c0) 5%, #fff) !important; } + .row-expandible.det-open { background: color-mix(in srgb, var(--brand,#1565c0) 8%, #fff) !important; } + .det-row { border-top:none !important; } + .det-row td { padding:0 !important; border-top:none !important; } + .det-inner { padding:12px 16px 14px; border-top:2px solid color-mix(in srgb, var(--brand,#1565c0) 20%, #e2e8f0); background:#f8fafc; } /* ── Sesión badge ── */ .sesion-open { background:#dcfce7; color:#15803d; } @@ -65,6 +71,42 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar'); /* ── Spinner / empty ── */ #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 ── */ + #aiPanel { + position:fixed; bottom:0; right:24px; width:360px; z-index:1080; + box-shadow:0 -4px 32px rgba(0,0,0,.18); border-radius:16px 16px 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:520px; + } + #aiPanel.open { transform:translateY(0); } + .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; + } + .ai-header .ai-title { flex:1; font-weight:700; font-size:.92rem; } + .ai-header .btn-close { filter:invert(1) brightness(2); } + .ai-messages { flex:1; overflow-y:auto; padding:12px 14px; display:flex; flex-direction:column; gap:8px; } + .ai-msg { max-width:88%; padding:8px 12px; border-radius:12px; font-size:.83rem; line-height:1.45; } + .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-footer { padding:10px 12px; border-top:1px solid #e2e8f0; display:flex; gap:8px; 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); } + .ai-footer .btn-send { background:var(--brand,#1565c0); color:#fff; border:none; border-radius:8px; padding:7px 14px; cursor:pointer; font-size:.85rem; } + #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; + } + #btnAI:hover { transform:scale(1.08); } + #btnAI.hidden { display:none; } + @media(max-width:600px) { #aiPanel { width:100%; right:0; border-radius:16px 16px 0 0; } }