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 = '= 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 =
+ '
' +
+ '
🔒
' +
+ '
' + _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) {