up
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
/**
|
||||
* Helpers compartidos para los endpoints del módulo Turnero.
|
||||
* Incluido por cada endpoint de modules/turnero/api/.
|
||||
*/
|
||||
|
||||
ob_start();
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
|
||||
error_reporting(E_ERROR | E_PARSE);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('html_errors', '0');
|
||||
|
||||
register_shutdown_function(function () {
|
||||
$err = error_get_last();
|
||||
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
ob_clean();
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => 'Error interno: ' . $err['message']]);
|
||||
}
|
||||
});
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Funciones de respuesta ───────────────────────────────────
|
||||
|
||||
function jsonOk(array $payload = [], string $mensaje = ''): void
|
||||
{
|
||||
ob_clean();
|
||||
$resp = ['ok' => true, 'success' => true];
|
||||
if ($mensaje) {
|
||||
$resp['message'] = $mensaje;
|
||||
}
|
||||
echo json_encode(array_merge($resp, $payload), JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function jsonError(string $mensaje, int $code = 400): void
|
||||
{
|
||||
ob_clean();
|
||||
http_response_code($code);
|
||||
echo json_encode(['ok' => false, 'success' => false, 'error' => $mensaje], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function requireMethod(string $method): void
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== strtoupper($method)) {
|
||||
jsonError('Método no permitido', 405);
|
||||
}
|
||||
}
|
||||
|
||||
function inputJson(): array
|
||||
{
|
||||
static $parsed = null;
|
||||
if ($parsed === null) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$parsed = json_decode($raw, true) ?? [];
|
||||
}
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
// ── Autenticación (kiosko y display son públicos) ────────────
|
||||
|
||||
/**
|
||||
* Exige sesión activa. Para endpoints de login requerido.
|
||||
*/
|
||||
function requireAuth(): void
|
||||
{
|
||||
if (empty($_SESSION['admin_user']['id'])) {
|
||||
jsonError('No autenticado', 401);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exige sesión + módulo turnero.
|
||||
*/
|
||||
function requireTurnero(): void
|
||||
{
|
||||
requireAuth();
|
||||
$modules = $_SESSION['admin_user']['modules'] ?? [];
|
||||
$roleId = $_SESSION['admin_user']['role_id'] ?? null;
|
||||
|
||||
// Superadmin / admin sin role_id = acceso total
|
||||
if (empty($roleId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array('turnero', (array) $modules, true)) {
|
||||
jsonError('Sin acceso al módulo Turnero', 403);
|
||||
}
|
||||
}
|
||||
|
||||
function adminId(): ?int
|
||||
{
|
||||
return isset($_SESSION['admin_user']['id'])
|
||||
? (int) $_SESSION['admin_user']['id']
|
||||
: null;
|
||||
}
|
||||
|
||||
// ── Helper BD ────────────────────────────────────────────────
|
||||
|
||||
function db(): PDO
|
||||
{
|
||||
return Database::getInstance()->getConnection();
|
||||
}
|
||||
|
||||
// ── Motor de cola ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Obtiene o crea la sesión del día actual.
|
||||
* Devuelve el id de la sesión.
|
||||
*/
|
||||
function obtenerOCrearSesionHoy(): int
|
||||
{
|
||||
$pdo = db();
|
||||
$hoy = date('Y-m-d');
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id FROM turnero_sesiones WHERE fecha = ? LIMIT 1');
|
||||
$stmt->execute([$hoy]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($row) {
|
||||
return (int) $row['id'];
|
||||
}
|
||||
|
||||
// Crear sesión del día
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO turnero_sesiones (fecha, abierto_por, inicio_at) VALUES (?, ?, NOW())'
|
||||
);
|
||||
$stmt->execute([$hoy, adminId()]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera el siguiente número correlativo de turno para una sesión.
|
||||
* Usa bloqueo a nivel de fila para evitar duplicados en concurrencia.
|
||||
*/
|
||||
function siguienteNumero(int $sesionId): int
|
||||
{
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COALESCE(MAX(numero), 0) + 1 AS siguiente
|
||||
FROM turnero_turnos
|
||||
WHERE sesion_id = ?
|
||||
FOR UPDATE'
|
||||
);
|
||||
$stmt->execute([$sesionId]);
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Selecciona el siguiente turno a llamar según el motor de prioridades.
|
||||
*
|
||||
* @param string $estado Estado que deben tener los turnos en cola
|
||||
* @param int|null $lugarId Si no es null, filtra por lugar_destino_id
|
||||
* @return array|null Fila de turnero_turnos o null si cola vacía
|
||||
*/
|
||||
function siguienteTurnoEnCola(string $estado, ?int $lugarId = null): ?array
|
||||
{
|
||||
$pdo = db();
|
||||
|
||||
if ($lugarId !== null) {
|
||||
$sql = '
|
||||
SELECT t.*, p.orden_peso, p.codigo AS prioridad_codigo, p.color AS prioridad_color
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.estado = ?
|
||||
AND t.lugar_destino_id = ?
|
||||
ORDER BY p.orden_peso ASC, t.creado_at ASC
|
||||
LIMIT 1
|
||||
';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$estado, $lugarId]);
|
||||
} else {
|
||||
$sql = '
|
||||
SELECT t.*, p.orden_peso, p.codigo AS prioridad_codigo, p.color AS prioridad_color
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.estado = ?
|
||||
ORDER BY p.orden_peso ASC, t.creado_at ASC
|
||||
LIMIT 1
|
||||
';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$estado]);
|
||||
}
|
||||
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emite un evento SSE a todos los clientes suscritos escribiendo
|
||||
* un flag en la tabla turnero_sesiones (campo sse_ping_at).
|
||||
* Los clientes hacen polling de ese campo o escuchan el SSE directamente.
|
||||
*
|
||||
* Implementación ligera: actualiza timestamp en sesión para que
|
||||
* sse_turno.php lo detecte y reenvíe el estado.
|
||||
*/
|
||||
function notificarSSE(int $sesionId): void
|
||||
{
|
||||
try {
|
||||
db()->prepare(
|
||||
'UPDATE turnero_sesiones SET sse_ping_at = NOW() WHERE id = ?'
|
||||
)->execute([$sesionId]);
|
||||
} catch (\Throwable $e) {
|
||||
// No es crítico si falla; el SSE continuará con polling normal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /modules/turnero/api/cambiar_estado.php
|
||||
* Avanza o cancela el estado de un turno.
|
||||
* Requiere login + módulo turnero.
|
||||
*
|
||||
* Body JSON:
|
||||
* turno_id int requerido
|
||||
* nuevo_estado string requerido (ver transiciones válidas abajo)
|
||||
* lugar_id int requerido cuando nuevo_estado = "en_espera_lugar"
|
||||
*
|
||||
* Transiciones permettidas:
|
||||
* espera → en_recepcion
|
||||
* en_recepcion → en_espera_lugar, ausente, cancelado
|
||||
* en_espera_lugar → en_servicio, ausente, cancelado
|
||||
* en_servicio → finalizado, ausente, cancelado
|
||||
* (cualquier) → ausente, cancelado
|
||||
*
|
||||
* Bloqueo especial:
|
||||
* en_espera_lugar → en_servicio requiere que todos los consentimientos
|
||||
* del turno estén en estado 'firmado' o 'rechazado'.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
$datos = inputJson();
|
||||
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
|
||||
$nuevoEstado = isset($datos['nuevo_estado']) ? trim((string) $datos['nuevo_estado']) : '';
|
||||
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : null;
|
||||
|
||||
// ── Validación básica ─────────────────────────────────────────
|
||||
if ($turnoId <= 0) jsonError('turno_id inválido.');
|
||||
if ($nuevoEstado === '') jsonError('nuevo_estado es requerido.');
|
||||
|
||||
// Mapa de transiciones válidas [estado_actual => [estados_destino_permitidos]]
|
||||
const TRANSICIONES = [
|
||||
'espera' => ['en_recepcion', 'ausente', 'cancelado'],
|
||||
'en_recepcion' => ['en_espera_lugar', 'ausente', 'cancelado'],
|
||||
'en_espera_lugar' => ['en_servicio', 'ausente', 'cancelado'],
|
||||
'en_servicio' => ['finalizado', 'ausente', 'cancelado'],
|
||||
];
|
||||
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
// Leer turno actual con lock
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT t.*, p.codigo AS prioridad_codigo
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.id = ?
|
||||
FOR UPDATE'
|
||||
);
|
||||
$stmt->execute([$turnoId]);
|
||||
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$turno) {
|
||||
$pdo->rollBack();
|
||||
jsonError('Turno no encontrado.', 404);
|
||||
}
|
||||
|
||||
$estadoActual = $turno['estado'];
|
||||
|
||||
// Estados finales no modificables
|
||||
if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) {
|
||||
$pdo->rollBack();
|
||||
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
|
||||
}
|
||||
|
||||
// Verificar que la transición sea válida
|
||||
$permitidos = TRANSICIONES[$estadoActual] ?? [];
|
||||
if (!in_array($nuevoEstado, $permitidos, true)) {
|
||||
$pdo->rollBack();
|
||||
jsonError(
|
||||
"Transición '{$estadoActual}' → '{$nuevoEstado}' no está permitida. "
|
||||
. "Transiciones válidas: " . implode(', ', $permitidos),
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
// ── Regla especial: en_espera_lugar → en_servicio ─────────
|
||||
if ($nuevoEstado === 'en_servicio') {
|
||||
$cStmt = $pdo->prepare(
|
||||
"SELECT COUNT(*) FROM turnero_consentimientos
|
||||
WHERE turno_id = ?
|
||||
AND estado NOT IN ('firmado', 'rechazado')"
|
||||
);
|
||||
$cStmt->execute([$turnoId]);
|
||||
$pendientes = (int) $cStmt->fetchColumn();
|
||||
|
||||
if ($pendientes > 0) {
|
||||
$pdo->rollBack();
|
||||
jsonError(
|
||||
"Hay {$pendientes} consentimiento(s) pendiente(s). "
|
||||
. "El turno no puede pasar a 'en_servicio' hasta que todos estén firmados o rechazados.",
|
||||
422
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Construir UPDATE dinámico ─────────────────────────────
|
||||
$sets = ['estado = ?'];
|
||||
$binds = [$nuevoEstado];
|
||||
|
||||
switch ($nuevoEstado) {
|
||||
case 'en_recepcion':
|
||||
$sets[] = 'llamado_recepcion_at = COALESCE(llamado_recepcion_at, NOW())';
|
||||
$sets[] = 'inicio_recepcion_at = COALESCE(inicio_recepcion_at, NOW())';
|
||||
$sets[] = 'atendido_recepcion_por = COALESCE(atendido_recepcion_por, ?)';
|
||||
$binds[] = adminId();
|
||||
break;
|
||||
|
||||
case 'en_espera_lugar':
|
||||
// Requiere lugar_id
|
||||
if (!$lugarId) {
|
||||
$pdo->rollBack();
|
||||
jsonError('lugar_id es obligatorio para pasar a en_espera_lugar.', 422);
|
||||
}
|
||||
$sets[] = 'fin_recepcion_at = COALESCE(fin_recepcion_at, NOW())';
|
||||
$sets[] = 'lugar_destino_id = ?';
|
||||
$binds[] = $lugarId;
|
||||
$sets[] = 'atendido_recepcion_por = COALESCE(atendido_recepcion_por, ?)';
|
||||
$binds[] = adminId();
|
||||
break;
|
||||
|
||||
case 'en_servicio':
|
||||
$sets[] = 'llamado_lugar_at = COALESCE(llamado_lugar_at, NOW())';
|
||||
$sets[] = 'inicio_lugar_at = COALESCE(inicio_lugar_at, NOW())';
|
||||
$sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)';
|
||||
$binds[] = adminId();
|
||||
break;
|
||||
|
||||
case 'finalizado':
|
||||
$sets[] = 'fin_lugar_at = COALESCE(fin_lugar_at, NOW())';
|
||||
$sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)';
|
||||
$binds[] = adminId();
|
||||
break;
|
||||
|
||||
case 'ausente':
|
||||
case 'cancelado':
|
||||
// Sin timestamps adicionales; simplemente cambio de estado
|
||||
break;
|
||||
}
|
||||
|
||||
$sets[] = 'actualizado_at = NOW()';
|
||||
$sql = 'UPDATE turnero_turnos SET ' . implode(', ', $sets) . ' WHERE id = ?';
|
||||
$binds[] = $turnoId;
|
||||
|
||||
$pdo->prepare($sql)->execute($binds);
|
||||
$pdo->commit();
|
||||
|
||||
// Refrescar y devolver turno actualizado
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT t.*, p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.id = ?'
|
||||
);
|
||||
$stmt->execute([$turnoId]);
|
||||
$turnoActualizado = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
notificarSSE((int) $turnoActualizado['sesion_id']);
|
||||
|
||||
jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'");
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
jsonError('Error al cambiar estado: ' . $e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /modules/turnero/api/create_solicitud.php
|
||||
* Crea la solicitud interna de un turno: vincula paciente, lugar y exámenes.
|
||||
* Además devuelve qué consentimientos serán necesarios (sin enviarlos aún).
|
||||
* Requiere login + módulo turnero.
|
||||
*
|
||||
* Body JSON:
|
||||
* turno_id int requerido
|
||||
* paciente_id int requerido
|
||||
* lugar_id int requerido
|
||||
* exam_tipo_ids int[] requerido (al menos 1)
|
||||
* total_cobrado float opcional
|
||||
* metodo_pago string opcional
|
||||
* observaciones string opcional
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
$datos = inputJson();
|
||||
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
|
||||
$pacienteId = isset($datos['paciente_id']) ? (int) $datos['paciente_id'] : 0;
|
||||
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : 0;
|
||||
$examIds = isset($datos['exam_tipo_ids']) && is_array($datos['exam_tipo_ids'])
|
||||
? array_filter(array_map('intval', $datos['exam_tipo_ids'])) : [];
|
||||
$total = isset($datos['total_cobrado']) && $datos['total_cobrado'] !== null
|
||||
? (float) $datos['total_cobrado'] : null;
|
||||
$metodoPago = isset($datos['metodo_pago']) ? trim((string) $datos['metodo_pago']) : null;
|
||||
$obs = isset($datos['observaciones']) ? trim((string) $datos['observaciones']) : null;
|
||||
|
||||
// ── Validaciones ──────────────────────────────────────────────
|
||||
if ($turnoId <= 0) jsonError('turno_id inválido.');
|
||||
if ($pacienteId <= 0) jsonError('paciente_id inválido.');
|
||||
if ($lugarId <= 0) jsonError('lugar_id inválido.');
|
||||
if (empty($examIds)) jsonError('Debe seleccionar al menos un examen.');
|
||||
|
||||
$metodosValidos = ['efectivo', 'transferencia', 'tarjeta', 'eps', 'cortesia', ''];
|
||||
if ($metodoPago && !in_array($metodoPago, $metodosValidos, true)) {
|
||||
jsonError('metodo_pago inválido.');
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
// Verificar que el turno existe y está en recepción
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT id, estado FROM turnero_turnos WHERE id = ? FOR UPDATE"
|
||||
);
|
||||
$stmt->execute([$turnoId]);
|
||||
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$turno) {
|
||||
$pdo->rollBack();
|
||||
jsonError('Turno no encontrado.', 404);
|
||||
}
|
||||
if (!in_array($turno['estado'], ['en_recepcion', 'espera'], true)) {
|
||||
$pdo->rollBack();
|
||||
jsonError("El turno está en estado '{$turno['estado']}' y no se puede editar su solicitud.", 422);
|
||||
}
|
||||
|
||||
// Verificar que el lugar existe
|
||||
$stmt = $pdo->prepare("SELECT id FROM turnero_lugares WHERE id = ? AND activo = 1");
|
||||
$stmt->execute([$lugarId]);
|
||||
if (!$stmt->fetch()) {
|
||||
$pdo->rollBack();
|
||||
jsonError('Lugar destino no encontrado o inactivo.', 404);
|
||||
}
|
||||
|
||||
// Verificar que todos los exam_tipo_ids existen
|
||||
$in = implode(',', array_fill(0, count($examIds), '?'));
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos WHERE id IN ({$in}) AND activo = 1");
|
||||
$stmt->execute($examIds);
|
||||
if ((int)$stmt->fetchColumn() !== count($examIds)) {
|
||||
$pdo->rollBack();
|
||||
jsonError('Uno o más exámenes no existen o están inactivos.', 422);
|
||||
}
|
||||
|
||||
// ── Insertar o reemplazar solicitud (DELETE + INSERT para poder re-guardar) ──
|
||||
$stmt = $pdo->prepare("SELECT id FROM turnero_solicitudes WHERE turno_id = ?");
|
||||
$stmt->execute([$turnoId]);
|
||||
$solAnterior = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($solAnterior) {
|
||||
// Eliminar ítemes anteriores (CASCADE borrará los turnero_examen_items)
|
||||
$pdo->prepare("DELETE FROM turnero_solicitudes WHERE turno_id = ?")->execute([$turnoId]);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO turnero_solicitudes
|
||||
(turno_id, paciente_id, lugar_id, total_cobrado, metodo_pago, observaciones, creado_por)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->execute([
|
||||
$turnoId, $pacienteId, $lugarId,
|
||||
$total, $metodoPago ?: null, $obs ?: null, adminId(),
|
||||
]);
|
||||
$solicitudId = (int) $pdo->lastInsertId();
|
||||
|
||||
// ── Insertar ítemes de examen ──────────────────────────────
|
||||
$stmtItem = $pdo->prepare(
|
||||
"INSERT INTO turnero_examen_items (solicitud_id, exam_tipo_id) VALUES (?, ?)"
|
||||
);
|
||||
foreach ($examIds as $examId) {
|
||||
$stmtItem->execute([$solicitudId, $examId]);
|
||||
}
|
||||
|
||||
// ── Calcular consentimientos necesarios (sin crearlos todavía) ──
|
||||
// Deduplica: varios exámenes que apuntan al mismo formulario → 1 solo ítem.
|
||||
$in = implode(',', array_fill(0, count($examIds), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT DISTINCT etc.formulario_id, f.nombre AS formulario_nombre
|
||||
FROM exam_tipo_consentimientos etc
|
||||
JOIN lab_formularios f ON f.id = etc.formulario_id
|
||||
WHERE etc.exam_tipo_id IN ({$in})"
|
||||
);
|
||||
$stmt->execute($examIds);
|
||||
$consentimientosRequeridos = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Agregar estado actual de cada consentimiento (si ya fue enviado antes)
|
||||
foreach ($consentimientosRequeridos as &$c) {
|
||||
$stC = $pdo->prepare(
|
||||
"SELECT estado FROM turnero_consentimientos WHERE turno_id = ? AND formulario_id = ?"
|
||||
);
|
||||
$stC->execute([$turnoId, $c['formulario_id']]);
|
||||
$row = $stC->fetch(PDO::FETCH_ASSOC);
|
||||
$c['estado'] = $row['estado'] ?? 'pendiente';
|
||||
}
|
||||
unset($c);
|
||||
|
||||
$pdo->commit();
|
||||
notificarSSE(obtenerOCrearSesionHoy());
|
||||
|
||||
// Recuperar solicitud completa
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT s.*,
|
||||
p.full_name AS paciente_nombre,
|
||||
l.nombre AS lugar_nombre
|
||||
FROM turnero_solicitudes s
|
||||
JOIN lab_pacientes l2 ON l2.id = s.lugar_id -- placeholder; join real abajo
|
||||
-- joins reales:
|
||||
LEFT JOIN lab_pacientes p ON p.id = s.paciente_id
|
||||
LEFT JOIN turnero_lugares l ON l.id = s.lugar_id
|
||||
WHERE s.id = ?"
|
||||
);
|
||||
// Simplificado para evitar error de sintaxis de comentarios SQL embebidos:
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT s.id, s.turno_id, s.paciente_id, s.lugar_id, s.total_cobrado,
|
||||
s.metodo_pago, s.observaciones, s.creado_at,
|
||||
p.full_name AS paciente_nombre,
|
||||
p.documento AS paciente_documento,
|
||||
l.nombre AS lugar_nombre
|
||||
FROM turnero_solicitudes s
|
||||
LEFT JOIN lab_pacientes p ON p.id = s.paciente_id
|
||||
LEFT JOIN turnero_lugares l ON l.id = s.lugar_id
|
||||
WHERE s.id = ?"
|
||||
);
|
||||
$stmt->execute([$solicitudId]);
|
||||
$solicitud = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
jsonOk([
|
||||
'solicitud' => $solicitud,
|
||||
'consentimientos_requeridos' => $consentimientosRequeridos,
|
||||
], 'Solicitud guardada correctamente');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
jsonError('Error al crear solicitud: ' . $e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /modules/turnero/api/create_turno.php
|
||||
* Genera un nuevo turno desde el kiosko o recepción.
|
||||
* No requiere login (kiosko es público).
|
||||
*
|
||||
* Body JSON:
|
||||
* prioridad_codigo string requerido "A"|"B"|"C"|"D"|"E"|"F"
|
||||
* paciente_nombre string opcional
|
||||
* paciente_cel string opcional
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
|
||||
// Este endpoint es público (kiosko no requiere login)
|
||||
// Solo validamos CSRF básico: debe ser POST con JSON
|
||||
|
||||
$datos = inputJson();
|
||||
|
||||
// ── Validación ────────────────────────────────────────────────
|
||||
$prioCodigo = strtoupper(trim($datos['prioridad_codigo'] ?? ''));
|
||||
if (!in_array($prioCodigo, ['A', 'B', 'C', 'D', 'E', 'F'], true)) {
|
||||
jsonError('prioridad_codigo inválido. Use A, B, C, D, E o F.');
|
||||
}
|
||||
|
||||
$pacienteNombre = substr(trim($datos['paciente_nombre'] ?? ''), 0, 150);
|
||||
$pacienteCel = preg_replace('/[^0-9+\- ]/', '', $datos['paciente_cel'] ?? '');
|
||||
$pacienteCel = substr($pacienteCel, 0, 20);
|
||||
|
||||
// ── Lógica principal (dentro de transacción para evitar race conditions) ──
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$sesionId = obtenerOCrearSesionHoy();
|
||||
$numero = siguienteNumero($sesionId);
|
||||
|
||||
// Buscar prioridad_id
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id FROM turnero_prioridades WHERE codigo = ? AND activo = 1 LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$prioCodigo]);
|
||||
$prioridad = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$prioridad) {
|
||||
$pdo->rollBack();
|
||||
jsonError("La prioridad '$prioCodigo' no existe o no está activa.", 422);
|
||||
}
|
||||
|
||||
$prioridadId = (int) $prioridad['id'];
|
||||
|
||||
// Código visual: "A001", "E042", etc.
|
||||
$codigo = $prioCodigo . str_pad($numero, 3, '0', STR_PAD_LEFT);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO turnero_turnos
|
||||
(sesion_id, numero, codigo, prioridad_id, paciente_nombre, paciente_cel,
|
||||
estado, creado_at)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, "espera", NOW())'
|
||||
);
|
||||
$stmt->execute([
|
||||
$sesionId,
|
||||
$numero,
|
||||
$codigo,
|
||||
$prioridadId,
|
||||
$pacienteNombre ?: null,
|
||||
$pacienteCel ?: null,
|
||||
]);
|
||||
|
||||
$turnoId = (int) $pdo->lastInsertId();
|
||||
$pdo->commit();
|
||||
|
||||
notificarSSE($sesionId);
|
||||
|
||||
// Contar posición en la cola
|
||||
$stmt = $pdo->prepare(
|
||||
'
|
||||
SELECT COUNT(*) AS posicion
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.estado = "espera"
|
||||
AND t.sesion_id = ?
|
||||
AND (
|
||||
p.orden_peso < (SELECT orden_peso FROM turnero_prioridades WHERE id = ?)
|
||||
OR (
|
||||
p.orden_peso = (SELECT orden_peso FROM turnero_prioridades WHERE id = ?)
|
||||
AND t.creado_at < (SELECT creado_at FROM turnero_turnos WHERE id = ?)
|
||||
)
|
||||
)
|
||||
'
|
||||
);
|
||||
$stmt->execute([$sesionId, $prioridadId, $prioridadId, $turnoId]);
|
||||
$posicion = (int) $stmt->fetchColumn() + 1;
|
||||
|
||||
jsonOk([
|
||||
'turno' => [
|
||||
'id' => $turnoId,
|
||||
'codigo' => $codigo,
|
||||
'numero' => $numero,
|
||||
'prioridad_codigo' => $prioCodigo,
|
||||
'posicion_cola' => $posicion,
|
||||
'sesion_id' => $sesionId,
|
||||
],
|
||||
], "Turno $codigo asignado correctamente");
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
jsonError('Error al crear el turno: ' . $e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/api/export_csv.php
|
||||
* GET ?fecha=YYYY-MM-DD → descarga CSV con todos los turnos del día
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
requireTurnero();
|
||||
|
||||
$fecha = trim($_GET['fecha'] ?? '');
|
||||
if ($fecha === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $fecha)) {
|
||||
$fecha = date('Y-m-d');
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// Buscar sesión del día
|
||||
$stmtS = $pdo->prepare('SELECT id FROM turnero_sesiones WHERE fecha = ? LIMIT 1');
|
||||
$stmtS->execute([$fecha]);
|
||||
$sesion = $stmtS->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$sesion) {
|
||||
// Sin sesión: CSV vacío con encabezado
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="turnero_' . $fecha . '.csv"');
|
||||
echo "\xEF\xBB\xBF"; // BOM UTF-8 para Excel
|
||||
echo "Sin datos para la fecha {$fecha}\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$sesionId = (int)$sesion['id'];
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT
|
||||
t.codigo,
|
||||
t.numero,
|
||||
p.codigo AS prioridad,
|
||||
p.nombre AS prioridad_nombre,
|
||||
COALESCE(s.nombre_completo, t.paciente_nombre) AS paciente,
|
||||
s.numero_documento,
|
||||
s.tipo_documento,
|
||||
t.paciente_cel AS celular,
|
||||
l.nombre AS lugar,
|
||||
t.estado,
|
||||
t.creado_at,
|
||||
t.llamado_recepcion_at,
|
||||
t.inicio_recepcion_at,
|
||||
t.fin_recepcion_at,
|
||||
t.llamado_lugar_at,
|
||||
t.inicio_lugar_at,
|
||||
t.fin_lugar_at,
|
||||
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, t.fin_recepcion_at, NOW())) / 60.0, 1)
|
||||
AS espera_recepcion_min,
|
||||
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1)
|
||||
AS servicio_lugar_min,
|
||||
sol.total_cobrado,
|
||||
sol.metodo_pago,
|
||||
GROUP_CONCAT(et.nombre ORDER BY et.nombre SEPARATOR ' | ') AS examenes,
|
||||
SUM(CASE WHEN tc.estado = 'firmado' THEN 1 ELSE 0 END) AS consent_firmados,
|
||||
SUM(CASE WHEN tc.estado = 'pendiente' THEN 1 ELSE 0 END) AS consent_pendientes
|
||||
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 sol ON sol.turno_id = t.id
|
||||
LEFT JOIN lab_pacientes s ON s.id = sol.paciente_id
|
||||
LEFT JOIN turnero_examen_items tei ON tei.solicitud_id = sol.id
|
||||
LEFT JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||
LEFT JOIN turnero_consentimientos tc ON tc.turno_id = t.id
|
||||
WHERE t.sesion_id = ?
|
||||
GROUP BY t.id
|
||||
ORDER BY t.numero ASC"
|
||||
);
|
||||
$stmt->execute([$sesionId]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ── Encabezados CSV ───────────────────────────────────────────
|
||||
$headers = [
|
||||
'Código', 'Número', 'Prioridad', 'Prioridad nombre',
|
||||
'Paciente', 'Documento', 'Tipo doc.', 'Celular',
|
||||
'Lugar', 'Estado',
|
||||
'Creado', 'Llamado recepción', 'Inicio recepción', 'Fin recepción',
|
||||
'Llamado lugar', 'Inicio lugar', 'Fin lugar',
|
||||
'Espera recepción (min)', 'Servicio lugar (min)',
|
||||
'Total cobrado', 'Método pago',
|
||||
'Exámenes',
|
||||
'Consentimientos firmados', 'Consentimientos pendientes',
|
||||
];
|
||||
|
||||
$fields = [
|
||||
'codigo', 'numero', 'prioridad', 'prioridad_nombre',
|
||||
'paciente', 'numero_documento', 'tipo_documento', 'celular',
|
||||
'lugar', 'estado',
|
||||
'creado_at', 'llamado_recepcion_at', 'inicio_recepcion_at', 'fin_recepcion_at',
|
||||
'llamado_lugar_at', 'inicio_lugar_at', 'fin_lugar_at',
|
||||
'espera_recepcion_min', 'servicio_lugar_min',
|
||||
'total_cobrado', 'metodo_pago',
|
||||
'examenes',
|
||||
'consent_firmados', 'consent_pendientes',
|
||||
];
|
||||
|
||||
// ── Output ────────────────────────────────────────────────────
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="turnero_' . $fecha . '.csv"');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
echo "\xEF\xBB\xBF"; // BOM UTF-8 para Excel
|
||||
|
||||
fputcsv($out, $headers, ',', '"');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$line = [];
|
||||
foreach ($fields as $f) {
|
||||
$v = $row[$f] ?? '';
|
||||
// Formatear fechas: quitar segundos fraccionarios si los hay
|
||||
if ($v && in_array($f, ['creado_at','llamado_recepcion_at','inicio_recepcion_at','fin_recepcion_at','llamado_lugar_at','inicio_lugar_at','fin_lugar_at'])) {
|
||||
$v = $v ? date('d/m/Y H:i:s', strtotime($v)) : '';
|
||||
}
|
||||
$line[] = $v ?? '';
|
||||
}
|
||||
fputcsv($out, $line, ',', '"');
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
exit;
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /modules/turnero/api/get_cola.php
|
||||
* Devuelve el estado de la cola para pantallas TV y kiosko.
|
||||
* No requiere autenticación (acceso público).
|
||||
*
|
||||
* Query params:
|
||||
* area string requerido "recepcion" | "lugar"
|
||||
* lugar_id int requerido si area = "lugar"
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
$area = isset($_GET['area']) ? trim($_GET['area']) : '';
|
||||
$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null;
|
||||
|
||||
if (!in_array($area, ['recepcion', 'lugar'], true)) {
|
||||
jsonError('area debe ser "recepcion" o "lugar".');
|
||||
}
|
||||
if ($area === 'lugar' && !$lugarId) {
|
||||
jsonError('lugar_id es obligatorio cuando area = "lugar".');
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// ── Sesión de hoy ─────────────────────────────────────────────
|
||||
$sesionId = obtenerOCrearSesionHoy();
|
||||
|
||||
// ── Cola según área ───────────────────────────────────────────
|
||||
if ($area === 'recepcion') {
|
||||
$estadosCola = ["'espera'", "'en_recepcion'"];
|
||||
$filtroLugar = '';
|
||||
$bindsCola = [];
|
||||
} else {
|
||||
$estadosCola = ["'en_espera_lugar'", "'en_servicio'"];
|
||||
$filtroLugar = 'AND t.lugar_destino_id = ?';
|
||||
$bindsCola = [$lugarId];
|
||||
}
|
||||
|
||||
$inEstados = implode(', ', $estadosCola);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id,
|
||||
t.codigo,
|
||||
t.numero,
|
||||
t.estado,
|
||||
t.paciente_nombre,
|
||||
t.creado_at,
|
||||
t.llamado_recepcion_at,
|
||||
t.llamado_lugar_at,
|
||||
p.codigo AS prioridad_codigo,
|
||||
p.nombre AS prioridad_nombre,
|
||||
p.color AS prioridad_color,
|
||||
p.orden_peso
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.sesion_id = ?
|
||||
AND t.estado IN ({$inEstados})
|
||||
{$filtroLugar}
|
||||
ORDER BY p.orden_peso ASC, t.creado_at ASC"
|
||||
);
|
||||
array_unshift($bindsCola, $sesionId);
|
||||
$stmt->execute($bindsCola);
|
||||
$cola = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ── Turno actualmente en pantalla (último llamado) ────────────
|
||||
$campoLlamado = $area === 'recepcion' ? 'llamado_recepcion_at' : 'llamado_lugar_at';
|
||||
$estadoActivo = $area === 'recepcion' ? "'en_recepcion'" : "'en_servicio'";
|
||||
$bindActivo = $area === 'lugar' ? [$sesionId, $lugarId] : [$sesionId];
|
||||
$filtroActivo = $area === 'lugar' ? 'AND t.lugar_destino_id = ?' : '';
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id,
|
||||
t.codigo,
|
||||
t.numero,
|
||||
t.estado,
|
||||
t.paciente_nombre,
|
||||
t.{$campoLlamado} AS llamado_at,
|
||||
p.codigo AS prioridad_codigo,
|
||||
p.nombre AS prioridad_nombre,
|
||||
p.color AS prioridad_color
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.sesion_id = ?
|
||||
AND t.estado = {$estadoActivo}
|
||||
{$filtroActivo}
|
||||
ORDER BY t.{$campoLlamado} DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute($bindActivo);
|
||||
$activo = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
|
||||
// ── Estadísticas de la sesión ─────────────────────────────────
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(estado = 'espera') AS en_espera,
|
||||
SUM(estado = 'en_recepcion') AS en_recepcion,
|
||||
SUM(estado = 'en_espera_lugar') AS en_espera_lugar,
|
||||
SUM(estado = 'en_servicio') AS en_servicio,
|
||||
SUM(estado = 'finalizado') AS finalizados,
|
||||
SUM(estado IN ('ausente','cancelado')) AS no_atendidos,
|
||||
SEC_TO_TIME(
|
||||
AVG(
|
||||
CASE WHEN fin_lugar_at IS NOT NULL AND creado_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at)
|
||||
ELSE NULL END
|
||||
)
|
||||
) AS tiempo_promedio_atencion
|
||||
FROM turnero_turnos
|
||||
WHERE sesion_id = ?"
|
||||
);
|
||||
$stmt->execute([$sesionId]);
|
||||
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
jsonOk([
|
||||
'sesion_id' => $sesionId,
|
||||
'area' => $area,
|
||||
'lugar_id' => $lugarId,
|
||||
'activo' => $activo,
|
||||
'cola' => $cola,
|
||||
'stats' => $stats,
|
||||
'timestamp' => date('c'),
|
||||
]);
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /modules/turnero/api/get_consentimientos.php
|
||||
* Retorna el estado actualizado de los consentimientos de un turno.
|
||||
* Usado por lugar.php (polling cada 5 s) y recepcion.php.
|
||||
* No requiere autenticación (las pantallas internas ya están protegidas).
|
||||
*
|
||||
* Query params:
|
||||
* turno_id int requerido
|
||||
* incluir_solicitud int opcional 1 → también retorna solicitud, paciente y exámenes
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
$turnoId = isset($_GET['turno_id']) ? (int) $_GET['turno_id'] : 0;
|
||||
$incluirSolicitud = isset($_GET['incluir_solicitud']) ? (bool)(int)$_GET['incluir_solicitud'] : false;
|
||||
|
||||
if ($turnoId <= 0) jsonError('turno_id inválido.');
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// ── Verificar que el turno existe ─────────────────────────────
|
||||
$stmt = $pdo->prepare("SELECT id, estado FROM turnero_turnos WHERE id = ?");
|
||||
$stmt->execute([$turnoId]);
|
||||
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$turno) jsonError('Turno no encontrado.', 404);
|
||||
|
||||
// ── Consentimientos ───────────────────────────────────────────
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT tc.id,
|
||||
tc.turno_id,
|
||||
tc.formulario_id,
|
||||
tc.token,
|
||||
tc.estado,
|
||||
tc.enviado_at,
|
||||
tc.firmado_at,
|
||||
f.nombre AS formulario_nombre
|
||||
FROM turnero_consentimientos tc
|
||||
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||
WHERE tc.turno_id = ?
|
||||
ORDER BY tc.id ASC"
|
||||
);
|
||||
$stmt->execute([$turnoId]);
|
||||
$consentimientos = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$respuesta = [
|
||||
'turno_id' => $turnoId,
|
||||
'turno_estado' => $turno['estado'],
|
||||
'consentimientos' => $consentimientos,
|
||||
];
|
||||
|
||||
// ── Datos adicionales si solicita solicitud ───────────────────
|
||||
if ($incluirSolicitud) {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT s.id, s.turno_id, s.paciente_id, s.lugar_id,
|
||||
s.total_cobrado, s.metodo_pago, s.observaciones, s.creado_at,
|
||||
l.nombre AS lugar_nombre
|
||||
FROM turnero_solicitudes s
|
||||
LEFT JOIN turnero_lugares l ON l.id = s.lugar_id
|
||||
WHERE s.turno_id = ?
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$turnoId]);
|
||||
$solicitud = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
|
||||
$paciente = null;
|
||||
$examenes = [];
|
||||
|
||||
if ($solicitud) {
|
||||
// Paciente
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT id, full_name, tipo_documento, documento,
|
||||
fecha_nacimiento, telefono, celular
|
||||
FROM lab_pacientes
|
||||
WHERE id = ?"
|
||||
);
|
||||
$stmt->execute([$solicitud['paciente_id']]);
|
||||
$paciente = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
|
||||
// Exámenes
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT et.id, et.codigo, et.nombre, et.categoria
|
||||
FROM turnero_examen_items tei
|
||||
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||
WHERE tei.solicitud_id = ?
|
||||
ORDER BY et.categoria, et.nombre"
|
||||
);
|
||||
$stmt->execute([$solicitud['id']]);
|
||||
$examenes = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
$respuesta['solicitud'] = $solicitud;
|
||||
$respuesta['paciente'] = $paciente;
|
||||
$respuesta['examenes'] = $examenes;
|
||||
}
|
||||
|
||||
jsonOk($respuesta);
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/api/get_dashboard.php
|
||||
* GET ?fecha=YYYY-MM-DD (por defecto = hoy)
|
||||
*
|
||||
* Devuelve métricas del día para el dashboard del turnero:
|
||||
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
|
||||
* - desglose por prioridad
|
||||
* - desglose por lugar
|
||||
* - lista de turnos del día (para tabla detalle)
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
requireTurnero();
|
||||
requireMethod('GET');
|
||||
|
||||
$fecha = trim($_GET['fecha'] ?? '');
|
||||
if ($fecha === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $fecha)) {
|
||||
$fecha = date('Y-m-d');
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// ── 1. Sesión del día ─────────────────────────────────────────
|
||||
$stmtSesion = $pdo->prepare(
|
||||
'SELECT id, inicio_at, fin_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1'
|
||||
);
|
||||
$stmtSesion->execute([$fecha]);
|
||||
$sesion = $stmtSesion->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$sesion) {
|
||||
jsonOk([
|
||||
'fecha' => $fecha,
|
||||
'sesion' => null,
|
||||
'resumen' => ['total' => 0, 'atendidos' => 0, 'en_espera' => 0,
|
||||
'ausentes' => 0, 'cancelados' => 0, 'tiempo_promedio_min' => null],
|
||||
'por_prioridad' => [],
|
||||
'por_lugar' => [],
|
||||
'turnos' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
$sesionId = (int)$sesion['id'];
|
||||
|
||||
// ── 2. Resumen global ─────────────────────────────────────────
|
||||
$stmtRes = $pdo->prepare(
|
||||
"SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(estado IN ('finalizado','en_servicio')) AS atendidos,
|
||||
SUM(estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
|
||||
SUM(estado = 'ausente') AS ausentes,
|
||||
SUM(estado = 'cancelado') AS cancelados,
|
||||
ROUND(
|
||||
AVG(
|
||||
CASE
|
||||
WHEN inicio_recepcion_at IS NOT NULL AND creado_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, creado_at, inicio_recepcion_at) / 60.0
|
||||
END
|
||||
), 1
|
||||
) AS tiempo_espera_promedio_min,
|
||||
ROUND(
|
||||
AVG(
|
||||
CASE
|
||||
WHEN fin_lugar_at IS NOT NULL AND inicio_lugar_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0
|
||||
END
|
||||
), 1
|
||||
) AS tiempo_servicio_promedio_min
|
||||
FROM turnero_turnos
|
||||
WHERE sesion_id = ?"
|
||||
);
|
||||
$stmtRes->execute([$sesionId]);
|
||||
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// ── 3. Desglose por prioridad ─────────────────────────────────
|
||||
$stmtPri = $pdo->prepare(
|
||||
"SELECT p.codigo, p.nombre, p.color,
|
||||
COUNT(t.id) AS total,
|
||||
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
|
||||
SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS pendientes,
|
||||
SUM(t.estado = 'ausente') AS ausentes
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.sesion_id = ?
|
||||
GROUP BY p.id
|
||||
ORDER BY p.orden_peso ASC"
|
||||
);
|
||||
$stmtPri->execute([$sesionId]);
|
||||
$porPrioridad = $stmtPri->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ── 4. Desglose por lugar ─────────────────────────────────────
|
||||
$stmtLug = $pdo->prepare(
|
||||
"SELECT l.nombre AS lugar,
|
||||
COUNT(t.id) AS total,
|
||||
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
|
||||
SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
|
||||
ROUND(AVG(
|
||||
CASE
|
||||
WHEN t.fin_lugar_at IS NOT NULL AND t.inicio_lugar_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60.0
|
||||
END
|
||||
), 1) AS tiempo_servicio_promedio_min
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||
WHERE t.sesion_id = ?
|
||||
GROUP BY l.id
|
||||
ORDER BY l.sort_order ASC, l.nombre ASC"
|
||||
);
|
||||
$stmtLug->execute([$sesionId]);
|
||||
$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ── 5. Lista de turnos del día ────────────────────────────────
|
||||
$stmtTurnos = $pdo->prepare(
|
||||
"SELECT t.id, t.codigo, t.numero, t.estado,
|
||||
t.paciente_nombre, t.paciente_cel,
|
||||
t.creado_at, t.inicio_recepcion_at, t.fin_recepcion_at,
|
||||
t.inicio_lugar_at, t.fin_lugar_at,
|
||||
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,
|
||||
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, NOW())) / 60.0, 1) AS espera_min,
|
||||
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) AS servicio_min
|
||||
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 lab_pacientes s ON s.id = ts.paciente_id
|
||||
WHERE t.sesion_id = ?
|
||||
ORDER BY t.numero ASC"
|
||||
);
|
||||
$stmtTurnos->execute([$sesionId]);
|
||||
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ── 6. Consentimientos del día ────────────────────────────────
|
||||
$stmtcons = $pdo->prepare(
|
||||
"SELECT tc.estado, COUNT(*) AS cnt
|
||||
FROM turnero_consentimientos tc
|
||||
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||
WHERE t.sesion_id = ?
|
||||
GROUP BY tc.estado"
|
||||
);
|
||||
$stmtcons->execute([$sesionId]);
|
||||
$consentStats = [];
|
||||
foreach ($stmtcons->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$consentStats[$r['estado']] = (int)$r['cnt'];
|
||||
}
|
||||
|
||||
jsonOk([
|
||||
'fecha' => $fecha,
|
||||
'sesion' => [
|
||||
'id' => $sesionId,
|
||||
'inicio' => $sesion['inicio_at'] ? date('H:i', strtotime($sesion['inicio_at'])) : null,
|
||||
'fin' => $sesion['fin_at'] ? date('H:i', strtotime($sesion['fin_at'])) : null,
|
||||
'abierta' => $sesion['fin_at'] === null,
|
||||
],
|
||||
'resumen' => $resumen,
|
||||
'por_prioridad' => $porPrioridad,
|
||||
'por_lugar' => $porLugar,
|
||||
'consent_stats' => $consentStats,
|
||||
'turnos' => $turnos,
|
||||
]);
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /modules/turnero/api/llamar_turno.php
|
||||
* Llama el siguiente turno de una cola con el motor de prioridades.
|
||||
* Requiere login + módulo turnero.
|
||||
*
|
||||
* Body JSON:
|
||||
* area string requerido "recepcion" | "lugar"
|
||||
* lugar_id int requerido si area = "lugar"
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
$datos = inputJson();
|
||||
$area = $datos['area'] ?? '';
|
||||
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : null;
|
||||
|
||||
// ── Validación ────────────────────────────────────────────────
|
||||
if (!in_array($area, ['recepcion', 'lugar'], true)) {
|
||||
jsonError('area debe ser "recepcion" o "lugar".');
|
||||
}
|
||||
if ($area === 'lugar' && !$lugarId) {
|
||||
jsonError('lugar_id es obligatorio cuando area = "lugar".');
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
if ($area === 'recepcion') {
|
||||
// Cola recepción: turnos en estado 'espera' sin filtro de lugar
|
||||
$turno = siguienteTurnoEnCola('espera');
|
||||
|
||||
if (!$turno) {
|
||||
$pdo->rollBack();
|
||||
jsonOk(['turno' => null], 'Cola de recepción vacía');
|
||||
}
|
||||
|
||||
// Actualizar estado + timestamp
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE turnero_turnos
|
||||
SET estado = "en_recepcion",
|
||||
llamado_recepcion_at = NOW(),
|
||||
inicio_recepcion_at = NOW(),
|
||||
atendido_recepcion_por = ?
|
||||
WHERE id = ? AND estado = "espera"'
|
||||
);
|
||||
$stmt->execute([adminId(), $turno['id']]);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
// Otro proceso lo tomó primero (race condition)
|
||||
$pdo->rollBack();
|
||||
jsonError('El turno ya fue tomado por otro operador. Intente de nuevo.', 409);
|
||||
}
|
||||
|
||||
} else {
|
||||
// Cola de un Lugar específico: turnos 'en_espera_lugar' asignados a ese lugar
|
||||
$turno = siguienteTurnoEnCola('en_espera_lugar', $lugarId);
|
||||
|
||||
if (!$turno) {
|
||||
$pdo->rollBack();
|
||||
jsonOk(['turno' => null], 'Cola del lugar vacía');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE turnero_turnos
|
||||
SET estado = "en_servicio",
|
||||
llamado_lugar_at = NOW(),
|
||||
inicio_lugar_at = NOW(),
|
||||
atendido_lugar_por = ?
|
||||
WHERE id = ? AND estado = "en_espera_lugar"'
|
||||
);
|
||||
$stmt->execute([adminId(), $turno['id']]);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
$pdo->rollBack();
|
||||
jsonError('El turno ya fue tomado por otro operador. Intente de nuevo.', 409);
|
||||
}
|
||||
}
|
||||
|
||||
// Refrescar datos del turno actualizado
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT t.*,
|
||||
p.codigo AS prioridad_codigo,
|
||||
p.nombre AS prioridad_nombre,
|
||||
p.color AS prioridad_color
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.id = ?'
|
||||
);
|
||||
$stmt->execute([$turno['id']]);
|
||||
$turnoActualizado = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$pdo->commit();
|
||||
notificarSSE((int) $turnoActualizado['sesion_id']);
|
||||
|
||||
jsonOk(['turno' => $turnoActualizado], "Turno {$turnoActualizado['codigo']} llamado");
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
jsonError('Error al llamar turno: ' . $e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/api/save_ex_tipo.php
|
||||
* GET ?id=X → devuelve datos de un tipo de examen (incluyendo formulario_id vinculado)
|
||||
* POST {codigo, nombre, categoria, formulario_id, activo} → crear
|
||||
* POST {id, codigo, nombre, categoria, formulario_id, activo} → actualizar
|
||||
* POST {id, _delete: true} → eliminar
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
requireTurnero();
|
||||
|
||||
// ── GET: devolver datos de un tipo de examen ─────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if (!$id) jsonError('ID requerido');
|
||||
|
||||
$stmt = db()->prepare(
|
||||
'SELECT et.*,
|
||||
(SELECT formulario_id FROM exam_tipo_consentimientos
|
||||
WHERE exam_tipo_id = et.id LIMIT 1) AS formulario_id
|
||||
FROM exam_tipos et
|
||||
WHERE et.id = ?'
|
||||
);
|
||||
$stmt->execute([$id]);
|
||||
$data = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$data) jsonError('Examen no encontrado', 404);
|
||||
jsonOk(['data' => $data]);
|
||||
}
|
||||
|
||||
requireMethod('POST');
|
||||
$input = inputJson();
|
||||
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$delete = !empty($input['_delete']);
|
||||
|
||||
// ── ELIMINAR ─────────────────────────────────────────────────
|
||||
if ($delete) {
|
||||
if (!$id) jsonError('ID requerido');
|
||||
// Bloquear si tiene ítems en solicitudes activas
|
||||
$stmt = db()->prepare(
|
||||
'SELECT COUNT(*) FROM turnero_examen_items WHERE exam_tipo_id = ?'
|
||||
);
|
||||
$stmt->execute([$id]);
|
||||
if ($stmt->fetchColumn() > 0) {
|
||||
jsonError('No se puede eliminar: hay solicitudes que incluyen este examen. Desactívelo en su lugar.');
|
||||
}
|
||||
// Eliminar relación de consentimiento primero (FK)
|
||||
db()->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$id]);
|
||||
db()->prepare('DELETE FROM exam_tipos WHERE id = ?')->execute([$id]);
|
||||
jsonOk([], 'Examen eliminado');
|
||||
}
|
||||
|
||||
// ── CREAR / ACTUALIZAR ───────────────────────────────────────
|
||||
$codigo = strtoupper(trim($input['codigo'] ?? ''));
|
||||
$nombre = trim($input['nombre'] ?? '');
|
||||
$categoria = trim($input['categoria'] ?? '');
|
||||
$formId = (int)($input['formulario_id'] ?? 0) ?: null;
|
||||
$activo = (int)($input['activo'] ?? 1);
|
||||
|
||||
if ($codigo === '') jsonError('El código es requerido');
|
||||
if ($nombre === '') jsonError('El nombre es requerido');
|
||||
if (strlen($codigo) > 20) jsonError('El código no puede tener más de 20 caracteres');
|
||||
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
if ($id) {
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE exam_tipos SET codigo=?, nombre=?, categoria=?, activo=? WHERE id=?'
|
||||
);
|
||||
$stmt->execute([$codigo, $nombre, $categoria ?: null, $activo, $id]);
|
||||
$examId = $id;
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO exam_tipos (codigo, nombre, categoria, activo) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$codigo, $nombre, $categoria ?: null, $activo]);
|
||||
$examId = (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
// Actualizar relación formulario ↔ examen
|
||||
$pdo->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$examId]);
|
||||
if ($formId) {
|
||||
$pdo->prepare(
|
||||
'INSERT INTO exam_tipo_consentimientos (exam_tipo_id, formulario_id) VALUES (?, ?)'
|
||||
)->execute([$examId, $formId]);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
jsonOk(['id' => $examId], $id ? 'Examen actualizado' : 'Examen creado');
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
// Código SQLSTATE 23000 = violación de unicidad
|
||||
if ($e->getCode() === '23000') {
|
||||
jsonError('Ya existe un examen con ese código');
|
||||
}
|
||||
jsonError('Error interno: ' . $e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/api/save_lugar.php
|
||||
* GET ?id=X → devuelve datos de un lugar
|
||||
* POST {nombre, descripcion, sort_order, activo} → crear
|
||||
* POST {id, nombre, descripcion, sort_order, activo} → actualizar
|
||||
* POST {id, _delete: true} → eliminar
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
requireTurnero();
|
||||
|
||||
// ── GET: devolver datos de un lugar ──────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if (!$id) jsonError('ID requerido');
|
||||
$row = db()->prepare('SELECT * FROM turnero_lugares WHERE id = ?');
|
||||
$row->execute([$id]);
|
||||
$data = $row->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$data) jsonError('Lugar no encontrado', 404);
|
||||
jsonOk(['data' => $data]);
|
||||
}
|
||||
|
||||
requireMethod('POST');
|
||||
$input = inputJson();
|
||||
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$delete = !empty($input['_delete']);
|
||||
|
||||
// ── ELIMINAR ─────────────────────────────────────────────────
|
||||
if ($delete) {
|
||||
if (!$id) jsonError('ID requerido');
|
||||
// Bloquear si tiene turnos asignados
|
||||
$stmt = db()->prepare('SELECT COUNT(*) FROM turnero_turnos WHERE lugar_destino_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
if ($stmt->fetchColumn() > 0) {
|
||||
jsonError('No se puede eliminar: tiene turnos asignados. Desactívelo en su lugar.');
|
||||
}
|
||||
$stmt = db()->prepare('DELETE FROM turnero_lugares WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
jsonOk([], 'Lugar eliminado');
|
||||
}
|
||||
|
||||
// ── CREAR / ACTUALIZAR ───────────────────────────────────────
|
||||
$nombre = trim($input['nombre'] ?? '');
|
||||
$desc = trim($input['descripcion'] ?? '');
|
||||
$sortOrder = (int)($input['sort_order'] ?? 99);
|
||||
$activo = (int)($input['activo'] ?? 1);
|
||||
|
||||
if ($nombre === '') jsonError('El nombre es requerido');
|
||||
if ($sortOrder < 1) jsonError('El orden debe ser mayor a 0');
|
||||
|
||||
if ($id) {
|
||||
$stmt = db()->prepare(
|
||||
'UPDATE turnero_lugares SET nombre=?, descripcion=?, sort_order=?, activo=? WHERE id=?'
|
||||
);
|
||||
$stmt->execute([$nombre, $desc ?: null, $sortOrder, $activo, $id]);
|
||||
jsonOk(['id' => $id], 'Lugar actualizado');
|
||||
} else {
|
||||
$stmt = db()->prepare(
|
||||
'INSERT INTO turnero_lugares (nombre, descripcion, sort_order, activo) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$nombre, $desc ?: null, $sortOrder, $activo]);
|
||||
jsonOk(['id' => (int)db()->lastInsertId()], 'Lugar creado');
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/api/save_prioridad.php
|
||||
* POST {id, nombre, color, orden_peso, activo} → actualizar prioridad existente
|
||||
* (Las prioridades no se crean ni eliminan vía API — se gestionan en la migración)
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
requireTurnero();
|
||||
requireMethod('POST');
|
||||
|
||||
$input = inputJson();
|
||||
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$nombre = trim($input['nombre'] ?? '');
|
||||
$color = trim($input['color'] ?? '#6b7280');
|
||||
$ordenPeso = (int)($input['orden_peso'] ?? 1);
|
||||
$activo = (int)($input['activo'] ?? 1);
|
||||
|
||||
if (!$id) jsonError('ID requerido');
|
||||
if ($nombre === '') jsonError('El nombre es requerido');
|
||||
if ($ordenPeso < 1) jsonError('El orden debe ser mayor a 0');
|
||||
if (!preg_match('/^#[0-9a-f]{6}$/i', $color)) jsonError('Color hexadecimal inválido (formato: #RRGGBB)');
|
||||
|
||||
$stmt = db()->prepare(
|
||||
'UPDATE turnero_prioridades SET nombre=?, color=?, orden_peso=?, activo=? WHERE id=?'
|
||||
);
|
||||
$stmt->execute([$nombre, $color, $ordenPeso, $activo, $id]);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
jsonError('Prioridad no encontrada', 404);
|
||||
}
|
||||
|
||||
jsonOk(['id' => $id], 'Prioridad actualizada');
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /modules/turnero/api/send_consentimiento.php
|
||||
* Genera y/o reenvía consentimientos informados por WhatsApp para un turno.
|
||||
*
|
||||
* Lógica:
|
||||
* 1. Lee los turnero_examen_items del turno.
|
||||
* 2. Para cada exam_tipo_id busca en exam_tipo_consentimientos qué formulario_id requiere.
|
||||
* 3. Deduplica: varios exámenes que apunten al mismo formulario → 1 solo envío.
|
||||
* 4. Por cada formulario distinto:
|
||||
* a. Si ya existe un turnero_consentimientos con ese (turno_id, formulario_id) → reutiliza / reenvía.
|
||||
* b. Si no existe: genera token UUID, inserta con estado 'pendiente'.
|
||||
* 5. Envía mensaje WhatsApp al celular del turno / paciente con enlace de firma.
|
||||
* 6. Actualiza estado → 'enviado' y registra enviado_at.
|
||||
*
|
||||
* Body JSON:
|
||||
* turno_id int requerido
|
||||
*
|
||||
* Requiere login + módulo turnero.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
require_once __DIR__ . '/../../../services/WhatsAppService.php';
|
||||
|
||||
$datos = inputJson();
|
||||
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
|
||||
|
||||
if ($turnoId <= 0) jsonError('turno_id inválido.');
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// ── 1. Cargar turno y su solicitud ────────────────────────────
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id, t.codigo, t.paciente_nombre, t.paciente_cel,
|
||||
t.estado,
|
||||
s.id AS solicitud_id,
|
||||
s.paciente_id,
|
||||
p.telefono AS pac_telefono,
|
||||
p.celular AS pac_celular,
|
||||
p.full_name AS pac_nombre
|
||||
FROM turnero_turnos t
|
||||
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
|
||||
LEFT JOIN lab_pacientes p ON p.id = s.paciente_id
|
||||
WHERE t.id = ?"
|
||||
);
|
||||
$stmt->execute([$turnoId]);
|
||||
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$turno) jsonError('Turno no encontrado.', 404);
|
||||
if (!$turno['solicitud_id']) jsonError('El turno no tiene solicitud registrada. Guarde la solicitud primero.', 422);
|
||||
|
||||
// Determinar celular de contacto (preferencia: paciente → kiosko)
|
||||
$celular = $turno['pac_celular'] ?? $turno['pac_telefono'] ?? $turno['paciente_cel'] ?? null;
|
||||
if (!$celular) {
|
||||
jsonError('El paciente no tiene número de celular registrado. Actualice el paciente antes de enviar.', 422);
|
||||
}
|
||||
|
||||
// Normalizar celular (quitar espacios / guiones, agregar +57 si no tiene código)
|
||||
$celular = preg_replace('/[\s\-\.]/', '', $celular);
|
||||
if (!str_starts_with($celular, '+')) {
|
||||
// Asumir Colombia si no tiene prefijo internacional
|
||||
$celular = '+57' . ltrim($celular, '0');
|
||||
}
|
||||
|
||||
// ── 2. Obtener exámenes de la solicitud ───────────────────────
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT exam_tipo_id FROM turnero_examen_items WHERE solicitud_id = ?"
|
||||
);
|
||||
$stmt->execute([$turno['solicitud_id']]);
|
||||
$examIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
if (empty($examIds)) {
|
||||
jsonError('La solicitud no tiene exámenes registrados.', 422);
|
||||
}
|
||||
|
||||
// ── 3. Obtener formularios de consentimiento (deduplicados) ───
|
||||
$in = implode(',', array_fill(0, count($examIds), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT DISTINCT etc.formulario_id, f.nombre AS formulario_nombre
|
||||
FROM exam_tipo_consentimientos etc
|
||||
JOIN lab_formularios f ON f.id = etc.formulario_id
|
||||
WHERE etc.exam_tipo_id IN ({$in})"
|
||||
);
|
||||
$stmt->execute($examIds);
|
||||
$formulariosRequeridos = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($formulariosRequeridos)) {
|
||||
jsonOk(['consentimientos' => [], 'mensaje' => 'Ningún examen requiere consentimiento.']);
|
||||
}
|
||||
|
||||
// ── 4. Generar / recuperar tokens UUID ────────────────────────
|
||||
function generarUuid(): string
|
||||
{
|
||||
return sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0x0fff) | 0x4000,
|
||||
mt_rand(0, 0x3fff) | 0x8000,
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||
);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
$consentimientosResultado = [];
|
||||
$erroresEnvio = [];
|
||||
|
||||
try {
|
||||
$stmtBuscar = $pdo->prepare(
|
||||
"SELECT id, token, estado FROM turnero_consentimientos
|
||||
WHERE turno_id = ? AND formulario_id = ?"
|
||||
);
|
||||
$stmtInsertar = $pdo->prepare(
|
||||
"INSERT INTO turnero_consentimientos (turno_id, formulario_id, token, estado)
|
||||
VALUES (?, ?, ?, 'pendiente')"
|
||||
);
|
||||
$stmtActualizar = $pdo->prepare(
|
||||
"UPDATE turnero_consentimientos
|
||||
SET estado = 'enviado', enviado_at = NOW()
|
||||
WHERE id = ?"
|
||||
);
|
||||
|
||||
$wa = new WhatsAppService();
|
||||
// URL base del sistema (para generar el enlace de firma)
|
||||
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http')
|
||||
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
|
||||
// Si BASE_URL está definida, úsala
|
||||
if (defined('BASE_URL')) {
|
||||
$baseUrl = rtrim(BASE_URL, '/');
|
||||
}
|
||||
|
||||
foreach ($formulariosRequeridos as $form) {
|
||||
$formularioId = (int) $form['formulario_id'];
|
||||
$formularioNom = $form['formulario_nombre'];
|
||||
|
||||
// Buscar si ya existe
|
||||
$stmtBuscar->execute([$turnoId, $formularioId]);
|
||||
$existente = $stmtBuscar->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($existente) {
|
||||
$consentId = (int) $existente['id'];
|
||||
$token = $existente['token'];
|
||||
// Si ya está firmado/rechazado, no reenviar
|
||||
if (in_array($existente['estado'], ['firmado', 'rechazado'], true)) {
|
||||
$consentimientosResultado[] = [
|
||||
'id' => $consentId,
|
||||
'formulario_id' => $formularioId,
|
||||
'formulario_nombre'=> $formularioNom,
|
||||
'estado' => $existente['estado'],
|
||||
'enviado' => false,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
$token = generarUuid();
|
||||
$stmtInsertar->execute([$turnoId, $formularioId, $token]);
|
||||
$consentId = (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
// ── Construir enlace de firma ──────────────────────────
|
||||
$enlaceFirma = $baseUrl . '/ver_formulario_enviado.php?token=' . urlencode($token);
|
||||
|
||||
// ── Nombre del paciente para el mensaje ───────────────
|
||||
$nombrePac = $turno['pac_nombre'] ?? $turno['paciente_nombre'] ?? 'Paciente';
|
||||
|
||||
// ── Enviar mensaje WhatsApp ────────────────────────────
|
||||
// Intentamos enviar con template "consentimiento_turno".
|
||||
// Si el template no existe, enviamos texto plano como fallback.
|
||||
$enviado = false;
|
||||
try {
|
||||
$wa->sendTemplateMessage(
|
||||
$celular,
|
||||
'consentimiento_turno',
|
||||
'es',
|
||||
// Parámetros del body: {{1}} = nombre, {{2}} = nombre formulario, {{3}} = código turno
|
||||
[
|
||||
htmlspecialchars($nombrePac, ENT_QUOTES),
|
||||
htmlspecialchars($formularioNom, ENT_QUOTES),
|
||||
$turno['codigo'],
|
||||
],
|
||||
// Parámetro del header o botón URL (URL del enlace)
|
||||
[$enlaceFirma]
|
||||
);
|
||||
$enviado = true;
|
||||
} catch (\Throwable $eTemplate) {
|
||||
// Fallback: enviar mensaje de texto plano
|
||||
try {
|
||||
$mensajeTexto = "Hola {$nombrePac}, le informamos que para su turno *{$turno['codigo']}* "
|
||||
. "debe firmar el siguiente consentimiento informado:\n\n"
|
||||
. "*{$formularioNom}*\n\n"
|
||||
. "Puede firmarlo en el siguiente enlace:\n{$enlaceFirma}\n\n"
|
||||
. "Si ya firmó este documento presencial, ignore este mensaje.";
|
||||
$wa->sendTextMessage($celular, $mensajeTexto);
|
||||
$enviado = true;
|
||||
} catch (\Throwable $eTexto) {
|
||||
$erroresEnvio[] = "Formulario '{$formularioNom}': " . $eTexto->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($enviado) {
|
||||
$stmtActualizar->execute([$consentId]);
|
||||
$estado = 'enviado';
|
||||
} else {
|
||||
$estado = 'pendiente';
|
||||
}
|
||||
|
||||
$consentimientosResultado[] = [
|
||||
'id' => $consentId,
|
||||
'formulario_id' => $formularioId,
|
||||
'formulario_nombre' => $formularioNom,
|
||||
'token' => $token,
|
||||
'estado' => $estado,
|
||||
'enviado' => $enviado,
|
||||
'enlace_firma' => $enlaceFirma,
|
||||
];
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
$msgExtra = !empty($erroresEnvio)
|
||||
? ' (Advertencias: ' . implode('; ', $erroresEnvio) . ')'
|
||||
: '';
|
||||
|
||||
jsonOk(
|
||||
['consentimientos' => $consentimientosResultado],
|
||||
count(array_filter($consentimientosResultado, fn($c) => $c['enviado']))
|
||||
. ' consentimiento(s) enviado(s) por WhatsApp' . $msgExtra
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
jsonError('Error al procesar consentimientos: ' . $e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/api/sesion_turno.php
|
||||
*
|
||||
* POST {accion: 'abrir'} → Crear/abrir sesión de hoy
|
||||
* POST {accion: 'cerrar', sesion_id: X} → Cerrar sesión activa
|
||||
* POST {accion: 'reabrir', sesion_id: X} → Reabrir sesión (borra fin_at)
|
||||
* POST {accion: 'config_wa', template, lang} → Guardar config WhatsApp turnero
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
requireTurnero();
|
||||
requireMethod('POST');
|
||||
|
||||
$input = inputJson();
|
||||
$accion = trim($input['accion'] ?? '');
|
||||
|
||||
if (!in_array($accion, ['abrir', 'cerrar', 'reabrir', 'config_wa'], true)) {
|
||||
jsonError('Acción no válida');
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
$uid = adminId();
|
||||
|
||||
// ── ABRIR sesión del día ─────────────────────────────────────
|
||||
if ($accion === 'abrir') {
|
||||
$hoy = date('Y-m-d');
|
||||
// Verificar si ya existe
|
||||
$stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1');
|
||||
$stmt->execute([$hoy]);
|
||||
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($existing) {
|
||||
if ($existing['fin_at'] === null) {
|
||||
jsonError('Ya hay una sesión abierta para hoy');
|
||||
}
|
||||
// Re-abrir (fue cerrada)
|
||||
$pdo->prepare('UPDATE turnero_sesiones SET fin_at=NULL, cerrado_por=NULL WHERE id=?')
|
||||
->execute([$existing['id']]);
|
||||
jsonOk(['sesion_id' => $existing['id']], 'Sesión del día reabierta');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO turnero_sesiones (fecha, abierto_por, inicio_at) VALUES (?, ?, NOW())'
|
||||
);
|
||||
$stmt->execute([$hoy, $uid]);
|
||||
jsonOk(['sesion_id' => (int)$pdo->lastInsertId()], 'Sesión del día abierta');
|
||||
}
|
||||
|
||||
// ── CERRAR sesión ────────────────────────────────────────────
|
||||
if ($accion === 'cerrar') {
|
||||
$sesionId = (int)($input['sesion_id'] ?? 0);
|
||||
if (!$sesionId) jsonError('sesion_id requerido');
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE id = ?');
|
||||
$stmt->execute([$sesionId]);
|
||||
$s = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$s) jsonError('Sesión no encontrada', 404);
|
||||
if ($s['fin_at']) jsonError('La sesión ya está cerrada');
|
||||
|
||||
$pdo->prepare('UPDATE turnero_sesiones SET fin_at=NOW(), cerrado_por=? WHERE id=?')
|
||||
->execute([$uid, $sesionId]);
|
||||
jsonOk(['sesion_id' => $sesionId], 'Sesión cerrada correctamente');
|
||||
}
|
||||
|
||||
// ── REABRIR sesión ───────────────────────────────────────────
|
||||
if ($accion === 'reabrir') {
|
||||
$sesionId = (int)($input['sesion_id'] ?? 0);
|
||||
if (!$sesionId) jsonError('sesion_id requerido');
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE id = ?');
|
||||
$stmt->execute([$sesionId]);
|
||||
$s = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$s) jsonError('Sesión no encontrada', 404);
|
||||
|
||||
$pdo->prepare('UPDATE turnero_sesiones SET fin_at=NULL, cerrado_por=NULL WHERE id=?')
|
||||
->execute([$sesionId]);
|
||||
jsonOk(['sesion_id' => $sesionId], 'Sesión reabierta');
|
||||
}
|
||||
|
||||
// ── CONFIG WHATSAPP ──────────────────────────────────────────
|
||||
if ($accion === 'config_wa') {
|
||||
$template = trim($input['template'] ?? '');
|
||||
$lang = trim($input['lang'] ?? 'es_CO');
|
||||
|
||||
if ($template === '') jsonError('El nombre de plantilla es requerido');
|
||||
if (!preg_match('/^[a-z0-9_]{1,64}$/i', $template)) {
|
||||
jsonError('Nombre de plantilla inválido — solo letras, números y guiones bajos');
|
||||
}
|
||||
if (!preg_match('/^[a-z]{2}_[A-Z]{2}$/i', $lang)) {
|
||||
jsonError('Código de idioma inválido (formato: es_CO)');
|
||||
}
|
||||
|
||||
// Upsert en lab_config
|
||||
$upsert = $pdo->prepare(
|
||||
'INSERT INTO lab_config (clave, valor) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE valor = VALUES(valor)'
|
||||
);
|
||||
$upsert->execute(['turnero_wa_template', $template]);
|
||||
$upsert->execute(['turnero_wa_lang', $lang]);
|
||||
|
||||
jsonOk([], 'Configuración WhatsApp guardada');
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /modules/turnero/api/sse_turno.php
|
||||
* Server-Sent Events — actualiza pantallas TV y kiosko en tiempo real.
|
||||
* No requiere autenticación (acceso público).
|
||||
*
|
||||
* Query params:
|
||||
* area string opcional "recepcion" | "lugar" (default: "recepcion")
|
||||
* lugar_id int requerido si area = "lugar"
|
||||
*
|
||||
* Eventos emitidos:
|
||||
* cola_update — estado completo de la cola cada vez que haya cambio o cada 30 s
|
||||
* ping — comentario de keep-alive cada 15 s
|
||||
*/
|
||||
|
||||
// ── Asegurar que la columna sse_ping_at exista (idempotente) ──
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
$pdo = db();
|
||||
$pdo->exec(
|
||||
"ALTER TABLE turnero_sesiones
|
||||
ADD COLUMN IF NOT EXISTS sse_ping_at DATETIME NULL DEFAULT NULL"
|
||||
);
|
||||
|
||||
// ── Headers SSE ───────────────────────────────────────────────
|
||||
@set_time_limit(0);
|
||||
@ini_set('output_buffering', 'off');
|
||||
@ini_set('zlib.output_compression', false);
|
||||
|
||||
header('Content-Type: text/event-stream; charset=UTF-8');
|
||||
header('Cache-Control: no-store, no-cache');
|
||||
header('X-Accel-Buffering: no'); // Nginx: deshabilitar buffering
|
||||
header('Connection: keep-alive');
|
||||
|
||||
// ── Parámetros ────────────────────────────────────────────────
|
||||
$area = isset($_GET['area']) ? trim($_GET['area']) : 'recepcion';
|
||||
$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null;
|
||||
|
||||
if (!in_array($area, ['recepcion', 'lugar'], true)) {
|
||||
$area = 'recepcion';
|
||||
}
|
||||
|
||||
// ── Helpers de emisión ────────────────────────────────────────
|
||||
function sseEvent(string $event, mixed $payload): void
|
||||
{
|
||||
echo "event: {$event}\n";
|
||||
echo 'data: ' . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n";
|
||||
flush();
|
||||
}
|
||||
|
||||
function ssePing(): void
|
||||
{
|
||||
echo ": ping\n\n";
|
||||
flush();
|
||||
}
|
||||
|
||||
// ── Función que arma el snapshot de la cola ───────────────────
|
||||
function buildSnapshot(PDO $pdo, int $sesionId, string $area, ?int $lugarId): array
|
||||
{
|
||||
if ($area === 'recepcion') {
|
||||
$estadosCola = ["'espera'", "'en_recepcion'"];
|
||||
$filtroLugar = '';
|
||||
$bindsCola = [$sesionId];
|
||||
$estadoActivo = "'en_recepcion'";
|
||||
$campoLlamado = 'llamado_recepcion_at';
|
||||
$bindActivo = [$sesionId];
|
||||
$filtroActivo = '';
|
||||
} else {
|
||||
$estadosCola = ["'en_espera_lugar'", "'en_servicio'"];
|
||||
$filtroLugar = 'AND t.lugar_destino_id = ?';
|
||||
$bindsCola = [$sesionId, $lugarId];
|
||||
$estadoActivo = "'en_servicio'";
|
||||
$campoLlamado = 'llamado_lugar_at';
|
||||
$bindActivo = [$sesionId, $lugarId];
|
||||
$filtroActivo = 'AND t.lugar_destino_id = ?';
|
||||
}
|
||||
|
||||
$inEstados = implode(', ', $estadosCola);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id, t.codigo, t.numero, t.estado, t.paciente_nombre, t.creado_at,
|
||||
p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre,
|
||||
p.color AS prioridad_color, p.orden_peso
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.sesion_id = ?
|
||||
AND t.estado IN ({$inEstados})
|
||||
{$filtroLugar}
|
||||
ORDER BY p.orden_peso ASC, t.creado_at ASC"
|
||||
);
|
||||
$stmt->execute($bindsCola);
|
||||
$cola = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id, t.codigo, t.numero, t.estado, t.paciente_nombre,
|
||||
t.{$campoLlamado} AS llamado_at,
|
||||
p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre,
|
||||
p.color AS prioridad_color
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.sesion_id = ?
|
||||
AND t.estado = {$estadoActivo}
|
||||
{$filtroActivo}
|
||||
ORDER BY t.{$campoLlamado} DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute($bindActivo);
|
||||
$activo = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
|
||||
return [
|
||||
'activo' => $activo,
|
||||
'cola' => $cola,
|
||||
'timestamp' => date('c'),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Loop principal ────────────────────────────────────────────
|
||||
$sesionId = obtenerOCrearSesionHoy();
|
||||
$lastPing = null; // sse_ping_at de la última iteración
|
||||
$lastKeepalive = time();
|
||||
$lastEmit = 0;
|
||||
|
||||
// Emitir estado inicial inmediatamente
|
||||
$snapshot = buildSnapshot($pdo, $sesionId, $area, $lugarId);
|
||||
sseEvent('cola_update', $snapshot);
|
||||
|
||||
while (!connection_aborted()) {
|
||||
sleep(2);
|
||||
|
||||
// Leer el último sse_ping_at de la sesión
|
||||
$stmt = $pdo->prepare('SELECT sse_ping_at FROM turnero_sesiones WHERE id = ?');
|
||||
$stmt->execute([$sesionId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$pingActual = $row['sse_ping_at'] ?? null;
|
||||
|
||||
// Emitir si hubo cambio (notificarSSE actualiza sse_ping_at) o cada 30 s
|
||||
$ahora = time();
|
||||
$forzar = ($ahora - $lastEmit) >= 30;
|
||||
|
||||
if ($pingActual !== $lastPing || $forzar) {
|
||||
$snapshot = buildSnapshot($pdo, $sesionId, $area, $lugarId);
|
||||
sseEvent('cola_update', $snapshot);
|
||||
$lastPing = $pingActual;
|
||||
$lastEmit = $ahora;
|
||||
}
|
||||
|
||||
// Keep-alive cada 15 s
|
||||
if (($ahora - $lastKeepalive) >= 15) {
|
||||
ssePing();
|
||||
$lastKeepalive = $ahora;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user