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:
Lizandro Guarnizo
2026-06-23 20:28:43 -05:00
co-authored by Claude Sonnet 4.6
parent f738607738
commit d0152e37ce
5 changed files with 367 additions and 17 deletions
+128 -17
View File
@@ -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;