feat: asistente IA Gemini Flash en dashboard turnero + config token
- ai_chat.php: contexto completo (tiempos, facturación, franjas, turnos detallados) - Preguntas soportadas: demoras por recepcionista, total facturado, horas pico, tiempo total por paciente, exámenes, búsqueda por cédula - Panel flotante de chat en dashboard (botón robot azul) - Búsqueda de tabla ahora filtra por cédula/documento - Ícono de fila gira al expandir detalle (flecha → rotada) - lab_configuracion.php: nueva sección IA con campo token Gemini - save_config.php: permite guardar gemini_api_key Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f738607738
commit
d0152e37ce
@@ -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;
|
||||
|
||||
@@ -165,6 +165,30 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Inteligencia Artificial ────────────────────────── -->
|
||||
<div class="section-card">
|
||||
<h6><i class="fas fa-robot me-1"></i>Inteligencia Artificial — Google Gemini</h6>
|
||||
<p class="text-muted small mb-3">
|
||||
El asistente IA del turnero usa Google Gemini Flash para responder preguntas
|
||||
sobre el estado del día. Genera tu clave en
|
||||
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener">Google AI Studio</a>.
|
||||
</p>
|
||||
<div class="mb-2">
|
||||
<label class="form-label fw-semibold small">Token de API (Gemini Flash)</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="c-gemini-key"
|
||||
value="<?= htmlspecialchars($cfg['gemini_api_key'] ?? '') ?>"
|
||||
placeholder="AIza…"
|
||||
autocomplete="off">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="this.previousElementSibling.type = this.previousElementSibling.type === 'password' ? 'text' : 'password'">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">El token se guarda cifrado y nunca se expone al navegador.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-lg w-100" onclick="guardar()">
|
||||
<i class="fas fa-save me-2"></i>Guardar configuración
|
||||
</button>
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/api/ai_chat.php
|
||||
* POST { pregunta: string }
|
||||
* Llama a Gemini Flash con contexto completo del turnero del día.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
requireTurnero();
|
||||
requireMethod('POST');
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$pregunta = trim($body['pregunta'] ?? '');
|
||||
if ($pregunta === '') jsonError('Pregunta vacía', 400);
|
||||
if (mb_strlen($pregunta) > 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 = <<<PROMPT
|
||||
Eres el asistente inteligente del sistema de turnero del Laboratorio Clínico.
|
||||
Responde de forma concisa y útil en español. Puedes analizar: tiempos de espera y servicio por paciente, recepcionista o bacteriólogo; facturación del día; exámenes solicitados; franjas horarias con mayor demanda; buscar pacientes por nombre o cédula.
|
||||
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente.
|
||||
|
||||
CONTEXTO DEL DÍA:
|
||||
{$ctx}
|
||||
PROMPT;
|
||||
|
||||
// ── Llamada a Gemini Flash ────────────────────────────────────
|
||||
$payload = json_encode([
|
||||
'system_instruction' => ['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]);
|
||||
@@ -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,
|
||||
|
||||
@@ -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; } }
|
||||
</style>
|
||||
|
||||
<div class="page-header">
|
||||
@@ -123,7 +165,7 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar');
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||
<div class="section-title mb-0"><i class="fas fa-list me-1"></i>Detalle de turnos</div>
|
||||
<input type="text" id="filtroBusqueda" class="form-control form-control-sm"
|
||||
style="max-width:220px" placeholder="Buscar paciente / código...">
|
||||
style="max-width:260px" placeholder="Buscar paciente, código o cédula…">
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover small align-middle" id="tablaTurnos">
|
||||
@@ -148,6 +190,28 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar');
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Botón flotante IA ── -->
|
||||
<button id="btnAI" title="Asistente IA" onclick="toggleAI()">
|
||||
<i class="fas fa-robot"></i>
|
||||
</button>
|
||||
|
||||
<!-- ── Panel IA ── -->
|
||||
<div id="aiPanel">
|
||||
<div class="ai-header">
|
||||
<i class="fas fa-robot"></i>
|
||||
<span class="ai-title">Asistente del Turnero</span>
|
||||
<button class="btn-close" onclick="toggleAI()"></button>
|
||||
</div>
|
||||
<div class="ai-messages" id="aiMessages">
|
||||
<div class="ai-msg bot">¡Hola! Puedo responder preguntas sobre el estado del turnero de hoy. ¿Qué necesitas saber?</div>
|
||||
</div>
|
||||
<div class="ai-footer">
|
||||
<input type="text" id="aiInput" placeholder="¿Cuántos pacientes en espera?…" maxlength="400"
|
||||
onkeydown="if(event.key==='Enter')enviarIA()">
|
||||
<button class="btn-send" onclick="enviarIA()"><i class="fas fa-paper-plane"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Dashboard Turnero
|
||||
@@ -319,6 +383,7 @@ function renderTabla(turnos) {
|
||||
};
|
||||
tbody.innerHTML = turnos.map(t => {
|
||||
const nombrePac = escHtml(t.paciente_bd || t.paciente_nombre || '—');
|
||||
const docPac = t.paciente_doc ? `<div class="text-muted" style="font-size:.7rem">${escHtml(t.paciente_tipo_doc||'CC')} ${escHtml(t.paciente_doc)}</div>` : '';
|
||||
const lugar = escHtml(t.lugar_nombre || '—');
|
||||
const estadoCls = mapEstado[t.estado] || 'eb-finalizado';
|
||||
const estadoLbl = labelEstado[t.estado] || t.estado;
|
||||
@@ -326,35 +391,35 @@ function renderTabla(turnos) {
|
||||
const espMin = t.espera_min !== null ? t.espera_min + ' m' : '—';
|
||||
const srvMin = t.servicio_min !== null ? t.servicio_min + ' m' : '—';
|
||||
const rowId = `dash-det-${t.id}`;
|
||||
return `<tr style="cursor:pointer" onclick="toggleDashDetalle('${rowId}', this)">
|
||||
return `<tr class="row-expandible" onclick="toggleDashDetalle('${rowId}', this)">
|
||||
<td><span class="fw-bold" style="color:${escHtml(t.prioridad_color || '#333')}">${escHtml(t.codigo)}</span></td>
|
||||
<td><span class="estado-badge" style="background:${escHtml(t.prioridad_color+'22'||'#eee')};color:${escHtml(t.prioridad_color||'#333')}">${escHtml(t.prioridad_codigo)}</span></td>
|
||||
<td>${nombrePac}</td>
|
||||
<td>${nombrePac}${docPac}</td>
|
||||
<td>${lugar}</td>
|
||||
<td><span class="estado-badge ${estadoCls}">${estadoLbl}</span></td>
|
||||
<td>${espMin}</td>
|
||||
<td>${srvMin}</td>
|
||||
<td>${hora}</td>
|
||||
<td><i class="fas fa-chevron-down text-muted" style="font-size:.65rem"></i></td>
|
||||
<td><i class="fas fa-chevron-right" style="font-size:.65rem;color:#94a3b8;transition:transform .2s" id="ico-${t.id}"></i></td>
|
||||
</tr>
|
||||
<tr id="${rowId}" style="display:none;background:#f8fafc">
|
||||
<td colspan="9" style="padding:0">
|
||||
<div style="padding:12px 16px;border-top:1px solid #e2e8f0">
|
||||
${renderDashDetalle(t)}
|
||||
</div>
|
||||
<tr id="${rowId}" class="det-row" style="display:none">
|
||||
<td colspan="9">
|
||||
<div class="det-inner">${renderDashDetalle(t)}</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function toggleDashDetalle(rowId, tr) {
|
||||
const det = document.getElementById(rowId);
|
||||
const icon = tr.querySelector('.fa-chevron-down, .fa-chevron-up');
|
||||
const det = document.getElementById(rowId);
|
||||
if (!det) return;
|
||||
const open = det.style.display !== 'none';
|
||||
const open = det.style.display !== 'none';
|
||||
det.style.display = open ? 'none' : '';
|
||||
if (icon) icon.className = open ? 'fas fa-chevron-down text-muted' : 'fas fa-chevron-up text-muted';
|
||||
if (icon) icon.style.fontSize = '.65rem';
|
||||
tr.classList.toggle('det-open', !open);
|
||||
// rotar ícono
|
||||
const turnoId = rowId.replace('dash-det-', '');
|
||||
const ico = document.getElementById(`ico-${turnoId}`);
|
||||
if (ico) ico.style.transform = open ? 'rotate(0deg)' : 'rotate(90deg)';
|
||||
}
|
||||
|
||||
function renderDashDetalle(t) {
|
||||
@@ -410,12 +475,58 @@ function filtrarTabla() {
|
||||
const q = document.getElementById('filtroBusqueda').value.toLowerCase().trim();
|
||||
if (!q) { renderTabla(_turnos); return; }
|
||||
renderTabla(_turnos.filter(t =>
|
||||
(t.codigo || '').toLowerCase().includes(q) ||
|
||||
(t.paciente_bd || '').toLowerCase().includes(q) ||
|
||||
(t.paciente_nombre || '').toLowerCase().includes(q)
|
||||
(t.codigo || '').toLowerCase().includes(q) ||
|
||||
(t.paciente_bd || '').toLowerCase().includes(q) ||
|
||||
(t.paciente_nombre || '').toLowerCase().includes(q) ||
|
||||
(t.paciente_doc || '').toLowerCase().includes(q)
|
||||
));
|
||||
}
|
||||
|
||||
// ── Asistente IA ──────────────────────────────────────────────
|
||||
let _aiOpen = false;
|
||||
|
||||
function toggleAI() {
|
||||
_aiOpen = !_aiOpen;
|
||||
document.getElementById('aiPanel').classList.toggle('open', _aiOpen);
|
||||
document.getElementById('btnAI').classList.toggle('hidden', _aiOpen);
|
||||
if (_aiOpen) document.getElementById('aiInput').focus();
|
||||
}
|
||||
|
||||
async function enviarIA() {
|
||||
const input = document.getElementById('aiInput');
|
||||
const pregunta = input.value.trim();
|
||||
if (!pregunta) return;
|
||||
|
||||
const msgs = document.getElementById('aiMessages');
|
||||
msgs.insertAdjacentHTML('beforeend',
|
||||
`<div class="ai-msg user">${escHtml(pregunta)}</div>`);
|
||||
const typing = document.createElement('div');
|
||||
typing.className = 'ai-msg bot typing';
|
||||
typing.textContent = 'Pensando…';
|
||||
msgs.appendChild(typing);
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
input.value = '';
|
||||
input.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}ai_chat.php`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ pregunta }),
|
||||
});
|
||||
const d = await res.json();
|
||||
typing.className = 'ai-msg bot';
|
||||
typing.textContent = d.ok ? d.respuesta : (d.error || 'Error al conectar con la IA');
|
||||
} catch(_) {
|
||||
typing.className = 'ai-msg bot';
|
||||
typing.textContent = 'Error de conexión';
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Exportar CSV ──────────────────────────────────────────────
|
||||
function exportarCSV() {
|
||||
const fecha = document.getElementById('fechaInput').value;
|
||||
|
||||
Reference in New Issue
Block a user