diff --git a/modules/turnero/api/save_tv_video.php b/modules/turnero/api/save_tv_video.php
new file mode 100644
index 0000000..4a62644
--- /dev/null
+++ b/modules/turnero/api/save_tv_video.php
@@ -0,0 +1,127 @@
+ false, 'error' => 'No autenticado']);
+ exit;
+}
+
+$pdo = Database::getInstance()->getConnection();
+$uploadDir = dirname(__DIR__, 3) . '/uploads/turnero/';
+$publicBase = rtrim(BASE_URL, '/') . '/uploads/turnero/';
+
+// ── Crear carpeta si no existe ────────────────────────────────
+if (!is_dir($uploadDir)) {
+ if (!mkdir($uploadDir, 0755, true)) {
+ echo json_encode(['ok' => false, 'error' => 'No se pudo crear el directorio de uploads']);
+ exit;
+ }
+}
+
+// ── Acción: borrar ────────────────────────────────────────────
+$rawBody = file_get_contents('php://input');
+$bodyJson = $rawBody ? json_decode($rawBody, true) : null;
+
+if (isset($bodyJson['accion']) && $bodyJson['accion'] === 'borrar') {
+ // Obtener ruta guardada
+ $stmt = $pdo->prepare("SELECT valor FROM lab_config WHERE clave = 'turnero_tv_video' LIMIT 1");
+ $stmt->execute();
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if ($row && $row['valor']) {
+ // Derivar nombre de archivo de la URL guardada
+ $filename = basename(parse_url($row['valor'], PHP_URL_PATH));
+ $filepath = $uploadDir . $filename;
+ if ($filepath && is_file($filepath)) {
+ @unlink($filepath);
+ }
+ }
+
+ // Borrar de la BD
+ $pdo->prepare("DELETE FROM lab_config WHERE clave = 'turnero_tv_video'")->execute();
+ echo json_encode(['ok' => true]);
+ exit;
+}
+
+// ── Acción: subir ──────────────────────────────────────────────
+if (!isset($_FILES['video'])) {
+ echo json_encode(['ok' => false, 'error' => 'No se recibió ningún archivo']);
+ exit;
+}
+
+$file = $_FILES['video'];
+
+if ($file['error'] !== UPLOAD_ERR_OK) {
+ $errores = [
+ UPLOAD_ERR_INI_SIZE => 'El archivo supera upload_max_filesize en php.ini',
+ UPLOAD_ERR_FORM_SIZE => 'El archivo supera MAX_FILE_SIZE del formulario',
+ UPLOAD_ERR_PARTIAL => 'El archivo se subió parcialmente',
+ UPLOAD_ERR_NO_FILE => 'No se seleccionó ningún archivo',
+ UPLOAD_ERR_NO_TMP_DIR => 'Falta la carpeta temporal',
+ UPLOAD_ERR_CANT_WRITE => 'Error al escribir el archivo',
+ ];
+ echo json_encode(['ok' => false, 'error' => $errores[$file['error']] ?? 'Error de subida desconocido']);
+ exit;
+}
+
+// Validar tamaño (máx 500 MB)
+$maxBytes = 500 * 1024 * 1024;
+if ($file['size'] > $maxBytes) {
+ echo json_encode(['ok' => false, 'error' => 'El video supera el límite de 500 MB']);
+ exit;
+}
+
+// Validar tipo MIME (solo MP4 y WebM)
+$allowedMime = ['video/mp4', 'video/webm', 'video/ogg'];
+$finfo = new finfo(FILEINFO_MIME_TYPE);
+$mime = $finfo->file($file['tmp_name']);
+if (!in_array($mime, $allowedMime, true)) {
+ echo json_encode(['ok' => false, 'error' => 'Solo se permiten videos MP4 o WebM']);
+ exit;
+}
+
+// Definir extensión segura desde el MIME real
+$extMap = ['video/mp4' => 'mp4', 'video/webm' => 'webm', 'video/ogg' => 'ogv'];
+$ext = $extMap[$mime];
+
+// Borrar video anterior si existe
+foreach (['mp4', 'webm', 'ogv'] as $oldExt) {
+ $oldPath = $uploadDir . 'tv_video.' . $oldExt;
+ if (is_file($oldPath)) {
+ @unlink($oldPath);
+ }
+}
+
+$destFilename = 'tv_video.' . $ext;
+$destPath = $uploadDir . $destFilename;
+
+if (!move_uploaded_file($file['tmp_name'], $destPath)) {
+ echo json_encode(['ok' => false, 'error' => 'No se pudo guardar el archivo']);
+ exit;
+}
+
+$publicUrl = $publicBase . $destFilename;
+
+// Guardar URL en lab_config (upsert)
+$stmt = $pdo->prepare(
+ "INSERT INTO lab_config (clave, valor) VALUES ('turnero_tv_video', ?)
+ ON DUPLICATE KEY UPDATE valor = VALUES(valor)"
+);
+$stmt->execute([$publicUrl]);
+
+echo json_encode(['ok' => true, 'url' => $publicUrl]);
diff --git a/modules/turnero/views/configuracion.php b/modules/turnero/views/configuracion.php
index aef9685..1956594 100644
--- a/modules/turnero/views/configuracion.php
+++ b/modules/turnero/views/configuracion.php
@@ -6,6 +6,7 @@
* TAB 2: Exámenes y Consentimientos
* TAB 3: Prioridades
* TAB 4: Sesión y WhatsApp
+ * TAB 5: Pantalla TV (video de fondo)
*/
require_once __DIR__ . '/../../../config/config.php';
@@ -159,6 +160,11 @@ $tab = $_GET['tab'] ?? 'lugares';
Sesión y WhatsApp
+
+
+ Pantalla TV
+
+
@@ -706,6 +712,55 @@ $tab = $_GET['tab'] ?? 'lugares';
+
+
+
+
+
+ Video de fondo / publicidad
+ El video se reproduce en bucle y sin sonido como fondo en la pantalla TV del turnero. Formatos admitidos: MP4, WebM. Máximo 500 MB.
+
+
+ >
+
Video actual:
+
+
+
+
+
+
+
+ >
+
+
+
+
+
+
+
+
@@ -942,6 +997,85 @@ async function cambiarSesion(accion, sesionId) {
} catch (e) { toast('Error de conexión', 'error'); }
}
+// ─────────────────────────────────────────────────────────────
+// TAB 5: PANTALLA TV
+// ─────────────────────────────────────────────────────────────
+(function () {
+ const inp = document.getElementById('tv-file-input');
+ const drop = document.getElementById('tv-drop');
+ if (!inp) return;
+
+ // Drag-and-drop visual
+ drop.addEventListener('dragover', e => { e.preventDefault(); drop.style.borderColor = '#3b82f6'; });
+ drop.addEventListener('dragleave', () => { drop.style.borderColor = '#cbd5e1'; });
+ drop.addEventListener('drop', e => {
+ e.preventDefault(); drop.style.borderColor = '#cbd5e1';
+ const f = e.dataTransfer.files[0];
+ if (f) subirTvVideo(f);
+ });
+ inp.addEventListener('change', () => { if (inp.files[0]) subirTvVideo(inp.files[0]); });
+})();
+
+function subirTvVideo(file) {
+ const allowedMime = ['video/mp4', 'video/webm', 'video/ogg'];
+ if (!allowedMime.includes(file.type)) { toast('Solo se permiten MP4 o WebM', 'error'); return; }
+ if (file.size > 500 * 1024 * 1024) { toast('El video supera el límite de 500 MB', 'error'); return; }
+
+ const wrap = document.getElementById('tv-progress-wrap');
+ const bar = document.getElementById('tv-progress-bar');
+ const pct = document.getElementById('tv-progress-pct');
+ const lbl = document.getElementById('tv-progress-lbl');
+ wrap.style.display = '';
+
+ const fd = new FormData();
+ fd.append('video', file);
+
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', API + 'save_tv_video.php');
+
+ xhr.upload.addEventListener('progress', e => {
+ if (!e.lengthComputable) return;
+ const p = Math.round(e.loaded / e.total * 100);
+ bar.style.width = p + '%';
+ pct.textContent = p + '%';
+ });
+
+ xhr.addEventListener('load', () => {
+ wrap.style.display = 'none';
+ try {
+ const json = JSON.parse(xhr.responseText);
+ if (!json.ok) { toast(json.error || 'Error al subir', 'error'); return; }
+ toast('Video guardado correctamente');
+ // Mostrar preview y ocultar zona de subida
+ document.getElementById('tv-preview').src = json.url;
+ document.getElementById('tv-preview-wrap').style.display = '';
+ document.getElementById('tv-upload-wrap').style.display = 'none';
+ } catch(e) { toast('Respuesta inesperada del servidor', 'error'); }
+ });
+
+ xhr.addEventListener('error', () => { wrap.style.display = 'none'; toast('Error de conexión', 'error'); });
+
+ lbl.textContent = 'Subiendo ' + file.name + '…';
+ bar.style.width = '0%';
+ xhr.send(fd);
+}
+
+async function borrarTvVideo() {
+ if (!confirm('¿Quitar el video de la pantalla TV?')) return;
+ try {
+ const res = await fetch(API + 'save_tv_video.php', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ accion: 'borrar' })
+ });
+ const json = await res.json();
+ if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
+ toast('Video eliminado');
+ document.getElementById('tv-preview-wrap').style.display = 'none';
+ document.getElementById('tv-upload-wrap').style.display = '';
+ document.getElementById('tv-preview').src = '';
+ } catch(e) { toast('Error de conexión', 'error'); }
+}
+
async function guardarWhatsApp() {
const template = document.getElementById('wa-template')?.value.trim();
const lang = document.getElementById('wa-lang')?.value.trim();
diff --git a/modules/turnero/views/display_global.php b/modules/turnero/views/display_global.php
index 2650b85..c51e981 100644
--- a/modules/turnero/views/display_global.php
+++ b/modules/turnero/views/display_global.php
@@ -4,13 +4,16 @@ $_dispCfg = [];
try {
$__pdo = Database::getInstance()->getConnection();
$__rows = $__pdo->query(
- "SELECT clave, valor FROM lab_config WHERE clave IN ('empresa_nombre','doc_logo_base64','doc_color')"
+ "SELECT clave, valor FROM lab_config WHERE clave IN ('empresa_nombre','doc_logo_base64','doc_color','turnero_tv_video')"
)->fetchAll(PDO::FETCH_KEY_PAIR);
$_dispCfg = $__rows ?: [];
} catch (\Throwable $_) {}
$_labNombre = htmlspecialchars($_dispCfg['empresa_nombre'] ?? 'Sistema de Turnos');
$_labLogo = $_dispCfg['doc_logo_base64'] ?? '';
$_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '') ? $_dispCfg['doc_color'] : '#1565c0';
+$_tvVideo = $_dispCfg['turnero_tv_video'] ?? '';
+// Validar que sea una URL del mismo origen (solo rutas relativas o mismo dominio)
+if ($_tvVideo && !preg_match('#^https?://#', $_tvVideo)) { $_tvVideo = ''; }
?>
@@ -303,6 +306,13 @@ $_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '')
@keyframes r-pop { 0%{transform:translate(-50%,-50%) scale(1);background:var(--pc,var(--brand));opacity:.38} 100%{transform:translate(-50%,-50%) scale(50);background:var(--pc,var(--brand));opacity:0} }
/* Cola dark */
.pg-cola { background: rgba(255,255,255,.022); border-top: 1px solid var(--border); }
+ /* Video de fondo TV */
+ .tv-bg-video {
+ position: absolute; inset: 0; width: 100%; height: 100%;
+ object-fit: cover; opacity: .14; z-index: 0; pointer-events: none;
+ }
+ .pg-body { position: relative; }
+ .pg-body > *:not(.tv-bg-video) { position: relative; z-index: 1; }
.cola-lbl { color: var(--brand); }
.cola-sep { width: 1px; height: 12px; background: var(--border); flex-shrink: 0; }
.cola-chip { background: rgba(255,255,255,.04); font-size: .68rem; }
@@ -382,6 +392,11 @@ $_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '')