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),
|
||||
]);
|
||||
Reference in New Issue
Block a user