feat(dashboard): LIA voz+saludo, proyección Chart.js, Excel, informe WA
- LIA: saludo de voz al abrir (1x/día), auto-escucha 3s, olas animadas - Gráfica de barras (Chart.js) con facturación real + proyección lineal - Exportar a Excel (.xls con HTML table, se abre directo en Excel) - Botón "Informe WA": envía resumen del día por WhatsApp a número elegido - Diseño accesible: texto más grande, botones claros para usuario adulto Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a8752b9e9d
commit
34d0fed21b
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
/**
|
||||
* POST enviar_informe_dia.php
|
||||
* Envía por WhatsApp un resumen del día al número indicado.
|
||||
* Body JSON: { phone: string }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
require_once __DIR__ . '/../../../services/WhatsAppService.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
$body = inputJson();
|
||||
$phone = preg_replace('/[^0-9]/', '', $body['phone'] ?? '');
|
||||
if (strlen($phone) < 7) jsonError('Número de teléfono inválido.');
|
||||
|
||||
$pdo = db();
|
||||
$hoy = date('Y-m-d');
|
||||
|
||||
$ses = $pdo->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());
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
/**
|
||||
* GET export_excel.php
|
||||
* Exporta el resumen del día como .xls (HTML table, abre en Excel).
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireTurnero();
|
||||
requireMethod('GET');
|
||||
|
||||
$pdo = db();
|
||||
$hoy = trim($_GET['fecha'] ?? date('Y-m-d'));
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $hoy)) $hoy = date('Y-m-d');
|
||||
|
||||
$ses = $pdo->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 '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">';
|
||||
echo '<head><meta charset="UTF-8">
|
||||
<style>
|
||||
th { background:#1565c0; color:#fff; font-weight:bold; }
|
||||
td,th { border:1px solid #ccc; padding:4px 8px; font-size:11px; }
|
||||
.f { color:green; font-weight:bold; }
|
||||
.a { color:red; }
|
||||
</style></head><body>';
|
||||
echo "<h2>Informe Turnero - {$fecha}</h2>";
|
||||
echo '<table>';
|
||||
echo '<tr>
|
||||
<th>Código</th><th>Prioridad</th><th>Estado</th>
|
||||
<th>Paciente</th><th>Tipo Doc</th><th>Documento</th><th>Celular</th>
|
||||
<th>Lugar</th><th>Recepcionista</th><th>Bacteriólogo</th>
|
||||
<th>Exámenes</th><th>Método Pago</th><th>Cobrado</th>
|
||||
<th>Espera (min)</th><th>Servicio (min)</th><th>Total (min)</th>
|
||||
<th>Hora entrada</th>
|
||||
</tr>';
|
||||
|
||||
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 "<tr{$cls}>
|
||||
<td>{$t['codigo']}</td>
|
||||
<td>{$t['prioridad']}</td>
|
||||
<td>{$estadoLbl}</td>
|
||||
<td>" . htmlspecialchars($t['paciente'] ?? '', ENT_QUOTES) . "</td>
|
||||
<td>{$t['tipo_doc']}</td>
|
||||
<td>{$t['num_doc']}</td>
|
||||
<td>{$t['celular']}</td>
|
||||
<td>" . htmlspecialchars($t['lugar'] ?? '', ENT_QUOTES) . "</td>
|
||||
<td>" . htmlspecialchars($t['recepcionista'] ?? '', ENT_QUOTES) . "</td>
|
||||
<td>" . htmlspecialchars($t['bacteriologo'] ?? '', ENT_QUOTES) . "</td>
|
||||
<td>" . htmlspecialchars($t['examenes'] ?? '', ENT_QUOTES) . "</td>
|
||||
<td>{$t['metodo_pago']}</td>
|
||||
<td>{$cobrado}</td>
|
||||
<td>{$t['espera_min']}</td>
|
||||
<td>{$t['servicio_min']}</td>
|
||||
<td>{$t['total_min']}</td>
|
||||
<td>{$hora}</td>
|
||||
</tr>\n";
|
||||
}
|
||||
echo '</table></body></html>';
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* GET get_proyeccion.php
|
||||
* Facturación real por hora + proyección lineal para el resto del día.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireTurnero();
|
||||
requireMethod('GET');
|
||||
|
||||
$pdo = db();
|
||||
$hoy = trim($_GET['fecha'] ?? date('Y-m-d'));
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $hoy)) $hoy = date('Y-m-d');
|
||||
|
||||
$ses = $pdo->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),
|
||||
]);
|
||||
@@ -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; } }
|
||||
</style>
|
||||
@@ -194,7 +217,13 @@ try {
|
||||
value="<?= date('Y-m-d') ?>" max="<?= date('Y-m-d') ?>">
|
||||
</div>
|
||||
<button class="btn btn-outline-success btn-sm" id="btnExport" title="Exportar CSV del día">
|
||||
<i class="fas fa-file-csv me-1"></i>Exportar CSV
|
||||
<i class="fas fa-file-csv me-1"></i>CSV
|
||||
</button>
|
||||
<button class="btn btn-outline-success btn-sm" id="btnExcel" title="Exportar Excel del día">
|
||||
<i class="fas fa-file-excel me-1"></i>Excel
|
||||
</button>
|
||||
<button class="btn btn-outline-success btn-sm" id="btnWAInforme" title="Enviar informe por WhatsApp" data-bs-toggle="modal" data-bs-target="#modalWAInforme">
|
||||
<i class="fab fa-whatsapp me-1"></i>Informe WA
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="cargarDatos()" title="Refrescar">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
@@ -215,6 +244,15 @@ try {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proyección de ventas -->
|
||||
<div class="section-card" id="sectionProyeccion" style="display:none">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<div class="section-title mb-0"><i class="fas fa-chart-line me-1"></i>Facturación y proyección del día</div>
|
||||
<span id="proyeccionResumen" class="small text-muted"></span>
|
||||
</div>
|
||||
<canvas id="chartProyeccion" height="90"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Consentimientos (solo si hay) -->
|
||||
<div class="section-card" id="sectionConsent" style="display:none">
|
||||
<div class="section-title"><i class="fas fa-file-signature me-1"></i>Consentimientos del día</div>
|
||||
@@ -267,15 +305,40 @@ try {
|
||||
|
||||
<?php if ($_iaActiva): ?>
|
||||
<!-- ── Botón flotante IA ── -->
|
||||
<button id="btnAI" title="Asistente IA" onclick="toggleAI()">
|
||||
<i class="fas fa-robot"></i>
|
||||
<button id="btnAI" title="Abrir LIA - Asistente Inteligente" onclick="toggleAI()">
|
||||
L<span style="color:#7dd3fc">I</span>A
|
||||
</button>
|
||||
|
||||
<!-- ── Modal informe WA ── -->
|
||||
<div class="modal fade" id="modalWAInforme" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5 class="modal-title"><i class="fab fa-whatsapp me-2 text-success"></i>Enviar informe por WA</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label small fw-semibold">Número WhatsApp (con código país)</label>
|
||||
<input type="tel" id="waInformePhone" class="form-control" placeholder="573001234567">
|
||||
<div class="form-text">Ej: 573001234567 (57 = Colombia)</div>
|
||||
<div id="waInformeMsg" class="mt-2 small"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-success" onclick="enviarInformeWA()"><i class="fab fa-whatsapp me-1"></i>Enviar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Panel IA ── -->
|
||||
<div id="aiPanel">
|
||||
<div class="ai-header" onclick="minimizarIA(event)">
|
||||
<i class="fas fa-robot"></i>
|
||||
<span class="ai-title">Asistente del Turnero</span>
|
||||
<div class="lia-logo" id="liaLogo">
|
||||
<span>L</span><span class="lia-i">I</span><span>A</span>
|
||||
</div>
|
||||
<div style="flex:1;line-height:1.1">
|
||||
<div class="ai-title">LIA</div>
|
||||
<div style="font-size:.68rem;opacity:.75;font-weight:400">Asistente Inteligente del Lab.</div>
|
||||
</div>
|
||||
<button id="btnTTS" class="btn-tts active" onclick="event.stopPropagation();toggleTTS()" title="Activar/silenciar voz">
|
||||
<i class="fas fa-volume-up"></i>
|
||||
</button>
|
||||
@@ -288,11 +351,13 @@ try {
|
||||
<button class="ai-chip" onclick="enviarRapido('¿Cuáles son los tiempos promedio de hoy?')">⏱ Tiempos</button>
|
||||
<button class="ai-chip" onclick="enviarRapido('¿Cuántos pacientes están en espera ahora mismo?')">⏳ En espera</button>
|
||||
</div>
|
||||
<div class="ai-messages" id="aiMessages">
|
||||
<div class="ai-msg bot">¡Hola! Pregúntame sobre el turnero de hoy o usa los botones de arriba. También puedes hablar 🎤</div>
|
||||
<div class="ai-messages" id="aiMessages"></div>
|
||||
<div id="liaListening" style="display:none;text-align:center;padding:10px 0">
|
||||
<div class="lia-wave"><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div style="font-size:.75rem;color:#64748b;margin-top:4px">Escuchando…</div>
|
||||
</div>
|
||||
<div class="ai-footer">
|
||||
<input type="text" id="aiInput" placeholder="Escribe o toca el micrófono…" maxlength="400"
|
||||
<input type="text" id="aiInput" placeholder="Escribe o habla con LIA…" maxlength="400"
|
||||
onkeydown="if(event.key==='Enter')enviarIA()">
|
||||
<button class="btn-mic" id="btnMic" onclick="iniciarVoz()" title="Hablar"><i class="fas fa-microphone"></i></button>
|
||||
<button class="btn-send" onclick="enviarIA()"><i class="fas fa-paper-plane"></i></button>
|
||||
@@ -313,9 +378,14 @@ let _autoReloadId = null;
|
||||
// ── Entrada ──────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
cargarDatos();
|
||||
document.getElementById('fechaInput').addEventListener('change', cargarDatos);
|
||||
document.getElementById('fechaInput').addEventListener('change', () => {
|
||||
cargarDatos();
|
||||
cargarProyeccion(document.getElementById('fechaInput').value);
|
||||
});
|
||||
document.getElementById('filtroBusqueda').addEventListener('input', filtrarTabla);
|
||||
document.getElementById('btnExport').addEventListener('click', exportarCSV);
|
||||
document.getElementById('btnExcel').addEventListener('click', exportarExcel);
|
||||
cargarProyeccion(document.getElementById('fechaInput').value);
|
||||
|
||||
// Auto-refresh cada 30s solo si la fecha es hoy
|
||||
_autoReloadId = setInterval(() => {
|
||||
@@ -593,10 +663,11 @@ function filtrarTabla() {
|
||||
// ── Asistente IA ──────────────────────────────────────────────
|
||||
let _aiOpen = false;
|
||||
|
||||
// ── IA helpers ───────────────────────────────────────────────
|
||||
// ── LIA helpers ──────────────────────────────────────────────
|
||||
let _aiMinimized = false;
|
||||
let _ttsOn = true;
|
||||
let _recog = null;
|
||||
let _chartProy = null;
|
||||
|
||||
function mdToHtml(md) {
|
||||
let s = md
|
||||
@@ -626,7 +697,7 @@ function toggleTTS() {
|
||||
if (!_ttsOn) window.speechSynthesis?.cancel();
|
||||
}
|
||||
|
||||
function iniciarVoz() {
|
||||
function iniciarVoz(autoEnviar = true) {
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SR) { alert('Tu navegador no soporta reconocimiento de voz (usa Chrome)'); return; }
|
||||
const btn = document.getElementById('btnMic');
|
||||
@@ -634,12 +705,22 @@ function iniciarVoz() {
|
||||
_recog = new SR();
|
||||
_recog.lang = 'es-CO'; _recog.interimResults = false;
|
||||
btn.classList.add('listening');
|
||||
document.getElementById('liaListening').style.display = 'block';
|
||||
_recog.onresult = e => {
|
||||
document.getElementById('aiInput').value = e.results[0][0].transcript;
|
||||
enviarIA();
|
||||
const texto = e.results[0][0].transcript;
|
||||
document.getElementById('aiInput').value = texto;
|
||||
if (autoEnviar) enviarIA();
|
||||
};
|
||||
_recog.onend = () => {
|
||||
btn.classList.remove('listening');
|
||||
document.getElementById('liaListening').style.display = 'none';
|
||||
_recog = null;
|
||||
};
|
||||
_recog.onerror= () => {
|
||||
btn.classList.remove('listening');
|
||||
document.getElementById('liaListening').style.display = 'none';
|
||||
_recog = null;
|
||||
};
|
||||
_recog.onend = () => { btn.classList.remove('listening'); _recog = null; };
|
||||
_recog.onerror= () => { btn.classList.remove('listening'); _recog = null; };
|
||||
_recog.start();
|
||||
}
|
||||
|
||||
@@ -655,12 +736,64 @@ function minimizarIA(e) {
|
||||
document.getElementById('aiPanel').classList.toggle('minimized', _aiMinimized);
|
||||
}
|
||||
|
||||
function _liaGreet() {
|
||||
const hoy = new Date().toISOString().slice(0,10);
|
||||
const llave = 'lia_greeted_' + hoy;
|
||||
if (localStorage.getItem(llave)) return;
|
||||
localStorage.setItem(llave, '1');
|
||||
const msg = 'Hola, soy LIA 👋 ¿En qué te puedo asistir hoy?';
|
||||
const msgs = document.getElementById('aiMessages');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ai-msg bot';
|
||||
el.textContent = msg;
|
||||
msgs.appendChild(el);
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
document.getElementById('liaLogo').classList.add('speaking');
|
||||
hablarIA(msg);
|
||||
// Auto-escuchar 3 segundos después del saludo
|
||||
setTimeout(() => {
|
||||
document.getElementById('liaLogo').classList.remove('speaking');
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (SR) {
|
||||
const btn = document.getElementById('btnMic');
|
||||
btn.classList.add('listening');
|
||||
document.getElementById('liaListening').style.display = 'block';
|
||||
const r = new SR();
|
||||
r.lang = 'es-CO'; r.interimResults = false;
|
||||
let oyo = false;
|
||||
r.onresult = e => {
|
||||
oyo = true;
|
||||
document.getElementById('aiInput').value = e.results[0][0].transcript;
|
||||
enviarIA();
|
||||
};
|
||||
r.onend = () => {
|
||||
btn.classList.remove('listening');
|
||||
document.getElementById('liaListening').style.display = 'none';
|
||||
if (!oyo) {
|
||||
// no habló — mostrar hint
|
||||
const h = document.createElement('div');
|
||||
h.className = 'ai-msg bot';
|
||||
h.innerHTML = 'Toca 🎤 para hablar o escribe tu pregunta.';
|
||||
document.getElementById('aiMessages').appendChild(h);
|
||||
document.getElementById('aiMessages').scrollTop = 99999;
|
||||
}
|
||||
};
|
||||
r.onerror = () => { btn.classList.remove('listening'); document.getElementById('liaListening').style.display = 'none'; };
|
||||
setTimeout(() => r.start(), 3000); // esperar que el TTS termine
|
||||
setTimeout(() => { try { r.stop(); } catch(_){} }, 9000); // max 6s de escucha
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function toggleAI() {
|
||||
_aiOpen = !_aiOpen;
|
||||
if (!_aiOpen) { _aiMinimized = false; document.getElementById('aiPanel').classList.remove('minimized'); }
|
||||
document.getElementById('aiPanel').classList.toggle('open', _aiOpen);
|
||||
document.getElementById('btnAI').classList.toggle('hidden', _aiOpen);
|
||||
if (_aiOpen && !_aiMinimized) document.getElementById('aiInput').focus();
|
||||
if (_aiOpen && !_aiMinimized) {
|
||||
document.getElementById('aiInput').focus();
|
||||
setTimeout(_liaGreet, 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function enviarIA() {
|
||||
@@ -699,17 +832,91 @@ async function enviarIA() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Exportar CSV ──────────────────────────────────────────────
|
||||
// ── Exportar CSV / Excel ──────────────────────────────────────
|
||||
function exportarCSV() {
|
||||
const fecha = document.getElementById('fechaInput').value;
|
||||
window.location.href = `${API}export_csv.php?fecha=${fecha}`;
|
||||
}
|
||||
function exportarExcel() {
|
||||
const fecha = document.getElementById('fechaInput').value;
|
||||
window.location.href = `${API}export_excel.php?fecha=${fecha}`;
|
||||
}
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────
|
||||
// ── Informe por WhatsApp ──────────────────────────────────────
|
||||
async function enviarInformeWA() {
|
||||
const phone = (document.getElementById('waInformePhone').value || '').replace(/\D/g,'');
|
||||
const msg = document.getElementById('waInformeMsg');
|
||||
if (!phone || phone.length < 7) { msg.innerHTML = '<span class="text-danger">Número inválido</span>'; return; }
|
||||
msg.innerHTML = '<span class="text-muted"><i class="fas fa-spinner fa-spin me-1"></i>Enviando…</span>';
|
||||
try {
|
||||
const res = await fetch(`${API}enviar_informe_dia.php`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ phone })
|
||||
});
|
||||
const d = await res.json();
|
||||
msg.innerHTML = d.ok
|
||||
? `<span class="text-success"><i class="fas fa-check me-1"></i>${d.mensaje}</span>`
|
||||
: `<span class="text-danger">${d.error}</span>`;
|
||||
} catch(_) { msg.innerHTML = '<span class="text-danger">Error de conexión</span>'; }
|
||||
}
|
||||
|
||||
// ── Gráfica proyección ─────────────────────────────────────────
|
||||
async function cargarProyeccion(fecha) {
|
||||
const sec = document.getElementById('sectionProyeccion');
|
||||
try {
|
||||
const r = await fetch(`${API}get_proyeccion.php?fecha=${fecha}`);
|
||||
const d = await r.json();
|
||||
if (!d.ok || !d.labels.length) { sec.style.display = 'none'; return; }
|
||||
sec.style.display = '';
|
||||
const fmt = v => v == null ? null : '$' + Intl.NumberFormat('es-CO').format(v);
|
||||
document.getElementById('proyeccionResumen').textContent =
|
||||
`Real: ${fmt(d.total_real)} · Proyectado: ${fmt(d.total_proyectado)}`;
|
||||
const ctx = document.getElementById('chartProyeccion').getContext('2d');
|
||||
if (_chartProy) _chartProy.destroy();
|
||||
_chartProy = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: d.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Facturado',
|
||||
data: d.real,
|
||||
backgroundColor: 'rgba(21,101,192,.75)',
|
||||
borderRadius: 6,
|
||||
},
|
||||
{
|
||||
label: 'Proyectado',
|
||||
data: d.proyectado,
|
||||
backgroundColor: 'rgba(2,136,209,.3)',
|
||||
borderRadius: 6,
|
||||
borderDash: [4,4],
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position:'top', labels:{ font:{size:12} } },
|
||||
tooltip: {
|
||||
callbacks: { label: ctx => ' $' + Intl.NumberFormat('es-CO').format(ctx.raw ?? 0) }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { callback: v => '$' + Intl.NumberFormat('es-CO',{notation:'compact'}).format(v) }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch(_) { sec.style.display = 'none'; }
|
||||
}
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────
|
||||
function escHtml(str) {
|
||||
const d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(String(str ?? '')));
|
||||
return d.innerHTML;
|
||||
}
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<?php Layout::close(); ?>
|
||||
|
||||
Reference in New Issue
Block a user