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 '
+ | Código | Prioridad | Estado |
+ Paciente | Tipo Doc | Documento | Celular |
+ Lugar | Recepcionista | Bacteriólogo |
+ Exámenes | Método Pago | Cobrado |
+ Espera (min) | Servicio (min) | Total (min) |
+ Hora entrada |
+
';
+
+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 "
+ | {$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} |
+
\n";
+}
+echo '
';
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="= date('Y-m-d') ?>" max="= date('Y-m-d') ?>">
+
+