This commit is contained in:
Lizandro Guarnizo
2026-06-16 19:09:59 -05:00
parent 7f2f998aa7
commit 8128616b63
4 changed files with 571 additions and 1 deletions
@@ -0,0 +1,6 @@
-- Agrega columnas de solicitud de transferencia a turnero_lugar_ocupacion
ALTER TABLE turnero_lugar_ocupacion
ADD COLUMN solicitud_user_id INT DEFAULT NULL,
ADD COLUMN solicitud_nombre VARCHAR(150) DEFAULT NULL,
ADD COLUMN solicitud_at DATETIME DEFAULT NULL,
ADD COLUMN solicitud_resultado ENUM('pendiente','denegada') DEFAULT NULL;
+209
View File
@@ -0,0 +1,209 @@
<?php
/**
* POST /modules/turnero/api/ocupar_lugar.php
*
* Gestiona la ocupación exclusiva de un puesto (recepción o muestras).
* Un puesto solo puede tener UN usuario activo.
*
* Actions:
* claim → reclamar el puesto (o renovar si ya es mío)
* ping → renovar heartbeat (cada 60 s)
* release → liberar el puesto
* solicitar → usuario B pide transferencia al ocupante A
* check_solicitud → A consulta si hay una solicitud pendiente
* denegar → A rechaza la solicitud
* aceptar → A acepta y libera el puesto
* check_transfer → B consulta si el puesto quedó libre o fue denegado
* disponibles → lista puestos del mismo tipo sin ocupante activo
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
$datos = inputJson();
$lugarId = (int)($datos['lugar_id'] ?? 0);
$action = $datos['action'] ?? 'claim';
if ($lugarId <= 0) jsonError('lugar_id inválido.');
$validActions = ['claim','ping','release','solicitar','check_solicitud','denegar','aceptar','check_transfer','disponibles'];
if (!in_array($action, $validActions, true)) jsonError('action inválida.');
$pdo = db();
$userId = (int)adminId();
$nombre = $_SESSION['admin_user']['full_name']
?? $_SESSION['admin_user']['username']
?? 'Usuario';
$timeout = 'NOW() - INTERVAL 2 MINUTE';
/* ── release ─────────────────────────────────────────────────── */
if ($action === 'release') {
$pdo->prepare(
"DELETE FROM turnero_lugar_ocupacion WHERE lugar_id = ? AND user_id = ?"
)->execute([$lugarId, $userId]);
jsonOk(['liberado' => true]);
}
/* ── ping ────────────────────────────────────────────────────── */
if ($action === 'ping') {
$pdo->prepare(
"UPDATE turnero_lugar_ocupacion SET ultimo_ping = NOW()
WHERE lugar_id = ? AND user_id = ?"
)->execute([$lugarId, $userId]);
jsonOk(['ok' => true]);
}
/* ── aceptar (ocupante A acepta la solicitud y libera) ───────── */
if ($action === 'aceptar') {
$pdo->prepare(
"DELETE FROM turnero_lugar_ocupacion WHERE lugar_id = ? AND user_id = ?"
)->execute([$lugarId, $userId]);
jsonOk(['liberado' => true]);
}
/* ── denegar (ocupante A rechaza) ────────────────────────────── */
if ($action === 'denegar') {
$pdo->prepare(
"UPDATE turnero_lugar_ocupacion
SET solicitud_resultado = 'denegada'
WHERE lugar_id = ? AND user_id = ?"
)->execute([$lugarId, $userId]);
jsonOk(['ok' => true]);
}
/* ── check_solicitud (A consulta si hay solicitud pendiente) ─── */
if ($action === 'check_solicitud') {
$st = $pdo->prepare(
"SELECT solicitud_user_id, solicitud_nombre, solicitud_at, solicitud_resultado
FROM turnero_lugar_ocupacion
WHERE lugar_id = ? AND user_id = ?"
);
$st->execute([$lugarId, $userId]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row || $row['solicitud_resultado'] !== 'pendiente') {
jsonOk(['hay_solicitud' => false]);
}
jsonOk([
'hay_solicitud' => true,
'solicitante' => $row['solicitud_nombre'],
'solicitud_at' => $row['solicitud_at'],
]);
}
/* ── check_transfer (B consulta si el puesto quedó libre) ───── */
if ($action === 'check_transfer') {
$st = $pdo->prepare(
"SELECT user_id, solicitud_user_id, solicitud_resultado
FROM turnero_lugar_ocupacion WHERE lugar_id = ?"
);
$st->execute([$lugarId]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) {
jsonOk(['disponible' => true]);
}
if ($row['solicitud_resultado'] === 'denegada') {
jsonOk(['disponible' => false, 'denegado' => true]);
}
jsonOk(['disponible' => false, 'denegado' => false]);
}
/* ── solicitar (B pide transferencia) ───────────────────────── */
if ($action === 'solicitar') {
// Limpiar expirados primero
$pdo->prepare(
"DELETE FROM turnero_lugar_ocupacion WHERE ultimo_ping < $timeout"
)->execute();
$st = $pdo->prepare(
"SELECT user_id, user_nombre, solicitud_at, solicitud_user_id
FROM turnero_lugar_ocupacion WHERE lugar_id = ?"
);
$st->execute([$lugarId]);
$ocupado = $st->fetch(PDO::FETCH_ASSOC);
if (!$ocupado || (int)$ocupado['user_id'] === $userId) {
// Ya libre o soy yo — reclamar directamente
$pdo->prepare(
"INSERT INTO turnero_lugar_ocupacion (lugar_id, user_id, user_nombre, ultimo_ping)
VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE user_id=?, user_nombre=?, ultimo_ping=NOW(),
solicitud_user_id=NULL, solicitud_nombre=NULL, solicitud_at=NULL, solicitud_resultado=NULL"
)->execute([$lugarId, $userId, $nombre, $userId, $nombre]);
jsonOk(['transferido' => true]);
}
// Guardar solicitud (o renovarla si ya existía de este mismo B)
$pdo->prepare(
"UPDATE turnero_lugar_ocupacion
SET solicitud_user_id = ?,
solicitud_nombre = ?,
solicitud_at = NOW(),
solicitud_resultado = 'pendiente'
WHERE lugar_id = ?"
)->execute([$userId, $nombre, $lugarId]);
jsonOk(['solicitado' => true, 'ocupado_por' => $ocupado['user_nombre']]);
}
/* ── disponibles (puestos del mismo tipo sin ocupante) ───────── */
if ($action === 'disponibles') {
$stLugar = $pdo->prepare("SELECT tipo FROM turnero_lugares WHERE id = ?");
$stLugar->execute([$lugarId]);
$lugar = $stLugar->fetch(PDO::FETCH_ASSOC);
if (!$lugar) jsonError('Lugar no encontrado.', 404);
$stDisp = $pdo->prepare(
"SELECT l.id, l.nombre
FROM turnero_lugares l
WHERE l.tipo = ? AND l.activo = 1 AND l.id != ?
AND NOT EXISTS (
SELECT 1 FROM turnero_lugar_ocupacion o
WHERE o.lugar_id = l.id
AND o.ultimo_ping >= $timeout
)
ORDER BY l.sort_order ASC"
);
$stDisp->execute([$lugar['tipo'], $lugarId]);
jsonOk(['disponibles' => $stDisp->fetchAll(PDO::FETCH_ASSOC)]);
}
/* ── claim ───────────────────────────────────────────────────── */
// Limpiar ocupaciones expiradas
$pdo->prepare(
"DELETE FROM turnero_lugar_ocupacion WHERE ultimo_ping < $timeout"
)->execute();
// Verificar si hay un ocupante
$st = $pdo->prepare(
"SELECT user_id, user_nombre, solicitud_user_id, solicitud_at, solicitud_resultado
FROM turnero_lugar_ocupacion WHERE lugar_id = ?"
);
$st->execute([$lugarId]);
$ocupado = $st->fetch(PDO::FETCH_ASSOC);
if ($ocupado && (int)$ocupado['user_id'] !== $userId) {
// Si hay una solicitud de este usuario pendiente hace más de 6s → auto-liberar
$esMiSolicitud = (int)($ocupado['solicitud_user_id'] ?? 0) === $userId;
$solicitudVieja = $ocupado['solicitud_at'] && (strtotime($ocupado['solicitud_at']) < time() - 6);
if ($esMiSolicitud && $solicitudVieja) {
$pdo->prepare("DELETE FROM turnero_lugar_ocupacion WHERE lugar_id = ?")
->execute([$lugarId]);
$ocupado = null; // caer al INSERT/UPDATE de abajo
} else {
jsonOk([
'disponible' => false,
'ocupado_por' => $ocupado['user_nombre'],
]);
}
}
// Reclamar o renovar
$pdo->prepare(
"INSERT INTO turnero_lugar_ocupacion (lugar_id, user_id, user_nombre, ultimo_ping)
VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE user_id=?, user_nombre=?, ultimo_ping=NOW(),
solicitud_user_id=NULL, solicitud_nombre=NULL, solicitud_at=NULL, solicitud_resultado=NULL"
)->execute([$lugarId, $userId, $nombre, $userId, $nombre]);
jsonOk(['disponible' => true]);
+177
View File
@@ -856,6 +856,183 @@ function mostrarToast(msg, type = 'info', duration = 2500) {
}
function mostrarError(msg) { mostrarToast(msg, 'error', 4000); }
function mostrarLlamando(cod) { mostrarToast('Llamando turno ' + cod + '…', 'info', 2000); }
/* ── Ocupación exclusiva del puesto ──────────────────────────── */
(function(_ID, _NOMBRE, _VIEW, _PARAM) {
if (!_ID) return;
function _e(s) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(s ?? '')));
return d.innerHTML;
}
function _post(body) {
return fetch(API + 'ocupar_lugar.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ lugar_id: _ID }, body)),
}).then(r => r.json());
}
function _redirigirSiguiente(disponibles) {
if (disponibles && disponibles.length) {
window.location.href = BASE_WA + 'erp.php?m=turnero&v=' + _VIEW + '&' + _PARAM + '=' + disponibles[0].id;
} else {
window.location.href = BASE_WA + 'erp.php?m=turnero';
}
}
/* Pantalla de bloqueo */
function _mostrarBloqueado(ocupadoPor) {
document.body.innerHTML =
'<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;' +
'min-height:100vh;background:#f8faff;font-family:sans-serif;gap:14px;text-align:center;padding:28px">' +
'<div style="font-size:4rem">🔒</div>' +
'<h2 style="color:#1565c0;margin:0">' + _e(_NOMBRE) + ' está abierta en otro equipo</h2>' +
'<p style="color:#555;font-size:1.05rem;max-width:440px;line-height:1.6">' +
'Actualmente usada por <strong>' + _e(ocupadoPor) + '</strong>.<br>' +
'Puedes solicitar que cierre su sesión o ir al siguiente puesto disponible.</p>' +
'<div style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center;margin-top:4px">' +
'<button id="_btn-solicitar" onclick="window._ocToma()" ' +
'style="padding:12px 26px;background:#1565c0;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer;font-weight:600">' +
'📢 Solicitar cierre en ese equipo</button>' +
'<button id="_btn-siguiente" onclick="window._ocSiguiente()" ' +
'style="padding:12px 26px;background:#f0f4ff;color:#1565c0;border:2px solid #1565c0;border-radius:8px;font-size:1rem;cursor:pointer;font-weight:600">' +
'➡ Ir al siguiente disponible</button>' +
'</div>' +
'<div id="_oc-estado" style="min-height:24px;color:#777;font-size:.95rem;margin-top:4px"></div>' +
'</div>';
}
function _setEstado(msg, color) {
const el = document.getElementById('_oc-estado');
if (el) el.innerHTML = '<span style="color:' + (color||'#555') + '">' + msg + '</span>';
}
/* Solicitar toma de control */
let _checkTransferTimer = null;
window._ocToma = async function() {
const btn = document.getElementById('_btn-solicitar');
if (btn) btn.disabled = true;
_setEstado('Enviando solicitud…', '#555');
try {
const j = await _post({ action: 'solicitar' });
if (!j.ok) { _setEstado('Error al enviar solicitud.', '#c62828'); if (btn) btn.disabled = false; return; }
if (j.transferido) { location.reload(); return; }
_setEstado('✉ Solicitud enviada. El otro equipo tiene 5 segundos para responder…', '#1565c0');
clearInterval(_checkTransferTimer);
_checkTransferTimer = setInterval(async () => {
try {
const r = await _post({ action: 'check_transfer' });
if (r.disponible) {
clearInterval(_checkTransferTimer);
_setEstado('✅ Puesto liberado. Tomando control…', '#2e7d32');
const rc = await _post({ action: 'claim' });
if (rc.disponible) location.reload();
else _setEstado('No se pudo tomar el control. Recargue la página.', '#c62828');
} else if (r.denegado) {
clearInterval(_checkTransferTimer);
_setEstado('❌ La solicitud fue denegada.', '#c62828');
if (btn) { btn.disabled = false; btn.textContent = '📢 Volver a solicitar'; }
}
} catch(_) {}
}, 1500);
} catch(e) {
_setEstado('Error de red: ' + e.message, '#c62828');
if (btn) btn.disabled = false;
}
};
/* Ir al siguiente disponible */
window._ocSiguiente = async function() {
const btn = document.getElementById('_btn-siguiente');
if (btn) btn.disabled = true;
_setEstado('Buscando puesto disponible…', '#555');
try {
const j = await _post({ action: 'disponibles' });
if (!j.ok || !j.disponibles.length) {
_setEstado('No hay otros puestos disponibles.', '#c62828');
if (btn) btn.disabled = false;
return;
}
_redirigirSiguiente(j.disponibles);
} catch(e) {
_setEstado('Error de red.', '#c62828');
if (btn) btn.disabled = false;
}
};
/* Modal de solicitud entrante (para el ocupante actual) */
let _modalActivo = false;
function _mostrarModalSolicitud(solicitante) {
if (_modalActivo) return;
_modalActivo = true;
const modal = document.createElement('div');
modal.style.cssText =
'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:99999;display:flex;align-items:center;justify-content:center';
modal.innerHTML =
'<div style="background:#fff;border-radius:14px;padding:32px 28px;max-width:420px;width:92%;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.25)">' +
'<div style="font-size:2.5rem">⚠️</div>' +
'<h3 style="color:#c62828;margin:10px 0 6px">Solicitud de transferencia</h3>' +
'<p style="color:#333;margin:0 0 6px"><strong>' + _e(solicitante) + '</strong> quiere usar este puesto.</p>' +
'<p style="color:#555;font-size:.95rem">Si no responde, se cerrará automáticamente en <strong id="_sol-cnt">5</strong> segundos.</p>' +
'<div style="display:flex;gap:10px;justify-content:center;margin-top:18px">' +
'<button id="_sol-aceptar" style="padding:10px 22px;background:#c62828;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer;font-weight:600">Aceptar y ceder</button>' +
'<button id="_sol-denegar" style="padding:10px 22px;background:#f5f5f5;color:#333;border:1px solid #ccc;border-radius:8px;font-size:1rem;cursor:pointer">Denegar</button>' +
'</div></div>';
document.body.appendChild(modal);
let secs = 5;
const tick = setInterval(() => {
secs--;
const el = document.getElementById('_sol-cnt');
if (el) el.textContent = secs;
if (secs <= 0) { clearInterval(tick); _liberar(); }
}, 1000);
async function _liberar() {
modal.remove();
await _post({ action: 'aceptar' }).catch(() => {});
try {
const j = await _post({ action: 'disponibles' });
_redirigirSiguiente(j.disponibles || []);
} catch(_) {
window.location.href = BASE_WA + 'erp.php?m=turnero';
}
}
document.getElementById('_sol-aceptar').onclick = () => { clearInterval(tick); _liberar(); };
document.getElementById('_sol-denegar').onclick = async () => {
clearInterval(tick);
modal.remove();
_modalActivo = false;
await _post({ action: 'denegar' }).catch(() => {});
};
}
/* Arranque */
async function _iniciar() {
try {
const j = await _post({ action: 'claim' });
if (!j.ok) return;
if (!j.disponible) { _mostrarBloqueado(j.ocupado_por); return; }
// Heartbeat cada 60 s
setInterval(() => _post({ action: 'ping' }).catch(() => {}), 60000);
// Polling de solicitudes entrantes cada 2 s
setInterval(async () => {
if (_modalActivo) return;
try {
const r = await _post({ action: 'check_solicitud' });
if (r.hay_solicitud) _mostrarModalSolicitud(r.solicitante);
} catch(_) {}
}, 2000);
} catch(_) {}
}
window.addEventListener('beforeunload', () => {
navigator.sendBeacon(API + 'ocupar_lugar.php',
new Blob([JSON.stringify({ lugar_id: _ID, action: 'release' })], { type: 'application/json' }));
});
_iniciar();
})(lugarId, LUGAR_NOMBRE, 'lugar', 'lugar_id');
</script>
<div class="rec-toast" id="lug-toast">
+179 -1
View File
@@ -525,7 +525,8 @@ let pollingColaId = null;
const API = '<?= BASE_URL ?>modules/turnero/api/';
const API_PAC = '<?= BASE_URL ?>api/lab/get_pacientes.php';
const BASE_WA = '<?= BASE_URL ?>';
const DESK_ID = <?= $deskId ?: 'null' ?>;
const DESK_ID = <?= $deskId ?: 'null' ?>;
const DESK_NOMBRE = '<?= addslashes($deskNombre ?? 'Recepción') ?>';
// ── Arranque ──────────────────────────────────────────────────
let pollingConsentimientosId = null;
@@ -1365,6 +1366,183 @@ function mostrarLlamando(codigo) {
mostrarToast('Llamando turno ' + codigo + '…', 'info', 2000);
}
/* ── Ocupación exclusiva del escritorio ──────────────────────── */
(function(_ID, _NOMBRE, _VIEW, _PARAM) {
if (!_ID) return;
function _e(s) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(s ?? '')));
return d.innerHTML;
}
function _post(body) {
return fetch(API + 'ocupar_lugar.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ lugar_id: _ID }, body)),
}).then(r => r.json());
}
function _redirigirSiguiente(disponibles) {
if (disponibles && disponibles.length) {
window.location.href = BASE_WA + 'erp.php?m=turnero&v=' + _VIEW + '&' + _PARAM + '=' + disponibles[0].id;
} else {
window.location.href = BASE_WA + 'erp.php?m=turnero';
}
}
/* Pantalla de bloqueo */
function _mostrarBloqueado(ocupadoPor) {
document.body.innerHTML =
'<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;' +
'min-height:100vh;background:#f8faff;font-family:sans-serif;gap:14px;text-align:center;padding:28px">' +
'<div style="font-size:4rem">🔒</div>' +
'<h2 style="color:#1565c0;margin:0">' + _e(_NOMBRE) + ' está abierta en otro equipo</h2>' +
'<p style="color:#555;font-size:1.05rem;max-width:440px;line-height:1.6">' +
'Actualmente usada por <strong>' + _e(ocupadoPor) + '</strong>.<br>' +
'Puedes solicitar que cierre su sesión o ir al siguiente escritorio disponible.</p>' +
'<div style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center;margin-top:4px">' +
'<button id="_btn-solicitar" onclick="window._ocToma()" ' +
'style="padding:12px 26px;background:#1565c0;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer;font-weight:600">' +
'📢 Solicitar cierre en ese equipo</button>' +
'<button id="_btn-siguiente" onclick="window._ocSiguiente()" ' +
'style="padding:12px 26px;background:#f0f4ff;color:#1565c0;border:2px solid #1565c0;border-radius:8px;font-size:1rem;cursor:pointer;font-weight:600">' +
'➡ Ir al siguiente disponible</button>' +
'</div>' +
'<div id="_oc-estado" style="min-height:24px;color:#777;font-size:.95rem;margin-top:4px"></div>' +
'</div>';
}
function _setEstado(msg, color) {
const el = document.getElementById('_oc-estado');
if (el) el.innerHTML = '<span style="color:' + (color||'#555') + '">' + msg + '</span>';
}
/* Solicitar toma de control */
let _checkTransferTimer = null;
window._ocToma = async function() {
const btn = document.getElementById('_btn-solicitar');
if (btn) btn.disabled = true;
_setEstado('Enviando solicitud…', '#555');
try {
const j = await _post({ action: 'solicitar' });
if (!j.ok) { _setEstado('Error al enviar solicitud.', '#c62828'); if (btn) btn.disabled = false; return; }
if (j.transferido) { location.reload(); return; }
_setEstado('✉ Solicitud enviada. El otro equipo tiene 5 segundos para responder…', '#1565c0');
clearInterval(_checkTransferTimer);
_checkTransferTimer = setInterval(async () => {
try {
const r = await _post({ action: 'check_transfer' });
if (r.disponible) {
clearInterval(_checkTransferTimer);
_setEstado('✅ Escritorio liberado. Tomando control…', '#2e7d32');
const rc = await _post({ action: 'claim' });
if (rc.disponible) location.reload();
else _setEstado('No se pudo tomar el control. Recargue la página.', '#c62828');
} else if (r.denegado) {
clearInterval(_checkTransferTimer);
_setEstado('❌ La solicitud fue denegada.', '#c62828');
if (btn) { btn.disabled = false; btn.textContent = '📢 Volver a solicitar'; }
}
} catch(_) {}
}, 1500);
} catch(e) {
_setEstado('Error de red: ' + e.message, '#c62828');
if (btn) btn.disabled = false;
}
};
/* Ir al siguiente disponible */
window._ocSiguiente = async function() {
const btn = document.getElementById('_btn-siguiente');
if (btn) btn.disabled = true;
_setEstado('Buscando escritorio disponible…', '#555');
try {
const j = await _post({ action: 'disponibles' });
if (!j.ok || !j.disponibles.length) {
_setEstado('No hay otros escritorios disponibles.', '#c62828');
if (btn) btn.disabled = false;
return;
}
_redirigirSiguiente(j.disponibles);
} catch(e) {
_setEstado('Error de red.', '#c62828');
if (btn) btn.disabled = false;
}
};
/* Modal de solicitud entrante (para el ocupante actual) */
let _modalActivo = false;
function _mostrarModalSolicitud(solicitante) {
if (_modalActivo) return;
_modalActivo = true;
const modal = document.createElement('div');
modal.style.cssText =
'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:99999;display:flex;align-items:center;justify-content:center';
modal.innerHTML =
'<div style="background:#fff;border-radius:14px;padding:32px 28px;max-width:420px;width:92%;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.25)">' +
'<div style="font-size:2.5rem">⚠️</div>' +
'<h3 style="color:#c62828;margin:10px 0 6px">Solicitud de transferencia</h3>' +
'<p style="color:#333;margin:0 0 6px"><strong>' + _e(solicitante) + '</strong> quiere usar este escritorio.</p>' +
'<p style="color:#555;font-size:.95rem">Si no responde, se cerrará automáticamente en <strong id="_sol-cnt">5</strong> segundos.</p>' +
'<div style="display:flex;gap:10px;justify-content:center;margin-top:18px">' +
'<button id="_sol-aceptar" style="padding:10px 22px;background:#c62828;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer;font-weight:600">Aceptar y ceder</button>' +
'<button id="_sol-denegar" style="padding:10px 22px;background:#f5f5f5;color:#333;border:1px solid #ccc;border-radius:8px;font-size:1rem;cursor:pointer">Denegar</button>' +
'</div></div>';
document.body.appendChild(modal);
let secs = 5;
const tick = setInterval(() => {
secs--;
const el = document.getElementById('_sol-cnt');
if (el) el.textContent = secs;
if (secs <= 0) { clearInterval(tick); _liberar(); }
}, 1000);
async function _liberar() {
modal.remove();
await _post({ action: 'aceptar' }).catch(() => {});
try {
const j = await _post({ action: 'disponibles' });
_redirigirSiguiente(j.disponibles || []);
} catch(_) {
window.location.href = BASE_WA + 'erp.php?m=turnero';
}
}
document.getElementById('_sol-aceptar').onclick = () => { clearInterval(tick); _liberar(); };
document.getElementById('_sol-denegar').onclick = async () => {
clearInterval(tick);
modal.remove();
_modalActivo = false;
await _post({ action: 'denegar' }).catch(() => {});
};
}
/* Arranque */
async function _iniciar() {
try {
const j = await _post({ action: 'claim' });
if (!j.ok) return;
if (!j.disponible) { _mostrarBloqueado(j.ocupado_por); return; }
// Heartbeat cada 60 s
setInterval(() => _post({ action: 'ping' }).catch(() => {}), 60000);
// Polling de solicitudes entrantes cada 2 s
setInterval(async () => {
if (_modalActivo) return;
try {
const r = await _post({ action: 'check_solicitud' });
if (r.hay_solicitud) _mostrarModalSolicitud(r.solicitante);
} catch(_) {}
}, 2000);
} catch(_) {}
}
window.addEventListener('beforeunload', () => {
navigator.sendBeacon(API + 'ocupar_lugar.php',
new Blob([JSON.stringify({ lugar_id: _ID, action: 'release' })], { type: 'application/json' }));
});
_iniciar();
})(DESK_ID, DESK_NOMBRE, 'recepcion', 'desk_id');
// ── Toast ────────────────────────────────────────────────────
let toastTimer = null;
function mostrarToast(msg, type = 'info', duration = 2500) {