diff --git a/migrations/20260616_lugar_ocupacion_solicitud.sql b/migrations/20260616_lugar_ocupacion_solicitud.sql new file mode 100644 index 0000000..572ae38 --- /dev/null +++ b/migrations/20260616_lugar_ocupacion_solicitud.sql @@ -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; diff --git a/modules/turnero/api/ocupar_lugar.php b/modules/turnero/api/ocupar_lugar.php new file mode 100644 index 0000000..761ba85 --- /dev/null +++ b/modules/turnero/api/ocupar_lugar.php @@ -0,0 +1,209 @@ +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]); diff --git a/modules/turnero/views/lugar.php b/modules/turnero/views/lugar.php index 87ae3fa..6ffa473 100644 --- a/modules/turnero/views/lugar.php +++ b/modules/turnero/views/lugar.php @@ -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 = + '
' + + '
🔒
' + + '

' + _e(_NOMBRE) + ' está abierta en otro equipo

' + + '

' + + 'Actualmente usada por ' + _e(ocupadoPor) + '.
' + + 'Puedes solicitar que cierre su sesión o ir al siguiente puesto disponible.

' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; + } + function _setEstado(msg, color) { + const el = document.getElementById('_oc-estado'); + if (el) el.innerHTML = '' + msg + ''; + } + + /* 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 = + '
' + + '
⚠️
' + + '

Solicitud de transferencia

' + + '

' + _e(solicitante) + ' quiere usar este puesto.

' + + '

Si no responde, se cerrará automáticamente en 5 segundos.

' + + '
' + + '' + + '' + + '
'; + 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');
diff --git a/modules/turnero/views/recepcion.php b/modules/turnero/views/recepcion.php index 3bc6997..2cd7978 100644 --- a/modules/turnero/views/recepcion.php +++ b/modules/turnero/views/recepcion.php @@ -525,7 +525,8 @@ let pollingColaId = null; const API = 'modules/turnero/api/'; const API_PAC = 'api/lab/get_pacientes.php'; const BASE_WA = ''; -const DESK_ID = ; +const DESK_ID = ; +const DESK_NOMBRE = ''; // ── 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 = + '
' + + '
🔒
' + + '

' + _e(_NOMBRE) + ' está abierta en otro equipo

' + + '

' + + 'Actualmente usada por ' + _e(ocupadoPor) + '.
' + + 'Puedes solicitar que cierre su sesión o ir al siguiente escritorio disponible.

' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; + } + function _setEstado(msg, color) { + const el = document.getElementById('_oc-estado'); + if (el) el.innerHTML = '' + msg + ''; + } + + /* 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 = + '
' + + '
⚠️
' + + '

Solicitud de transferencia

' + + '

' + _e(solicitante) + ' quiere usar este escritorio.

' + + '

Si no responde, se cerrará automáticamente en 5 segundos.

' + + '
' + + '' + + '' + + '
'; + 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) {