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; } }