39414-vm/display.php
2026-04-26 10:01:23 +00:00

482 lines
24 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/queue_bootstrap.php';
qh_boot();
$activeCalls = qh_fetch_tickets(['called', 'in_progress', 'nursing_called'], null, 8);
$queueOverview = qh_queue_overview();
function qh_display_ads_column_exists(PDO $pdo, string $column): bool
{
static $cache = [];
if (array_key_exists($column, $cache)) {
return $cache[$column];
}
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'hospital_ads' AND COLUMN_NAME = ?"
);
$stmt->execute([DB_NAME, $column]);
return $cache[$column] = ((int) $stmt->fetchColumn() > 0);
}
$activeAds = [];
try {
$pdo = db();
$hasMediaType = qh_display_ads_column_exists($pdo, 'media_type');
$hasMediaPath = qh_display_ads_column_exists($pdo, 'media_path');
if ($hasMediaType && $hasMediaPath) {
$stmt = $pdo->query(
"SELECT id, title,
COALESCE(NULLIF(media_type, ''), 'video') AS media_type,
COALESCE(NULLIF(media_path, ''), NULLIF(video_path, '')) AS media_path
FROM hospital_ads
WHERE is_active = 1
ORDER BY sort_order ASC, id DESC"
);
} elseif ($hasMediaPath) {
$stmt = $pdo->query(
"SELECT id, title, 'video' AS media_type,
COALESCE(NULLIF(media_path, ''), NULLIF(video_path, '')) AS media_path
FROM hospital_ads
WHERE is_active = 1
ORDER BY sort_order ASC, id DESC"
);
} else {
$stmt = $pdo->query(
"SELECT id, title, 'video' AS media_type, video_path AS media_path
FROM hospital_ads
WHERE is_active = 1
ORDER BY sort_order ASC, id DESC"
);
}
if ($stmt) {
foreach ($stmt->fetchAll() as $ad) {
$mediaPath = (string) ($ad['media_path'] ?? '');
if ($mediaPath === '') {
continue;
}
$activeAds[] = [
'id' => (int) ($ad['id'] ?? 0),
'title' => (string) ($ad['title'] ?? ''),
'type' => (($ad['media_type'] ?? 'video') === 'image') ? 'image' : 'video',
'path' => $mediaPath,
'duration' => 10,
];
}
}
} catch (Throwable $e) {
// Table might not exist yet, safe to ignore
}
$activeNews = [];
try {
$stmt = db()->query("SELECT phrase FROM hospital_news WHERE is_active = 1 ORDER BY sort_order ASC, id DESC");
if ($stmt) {
$activeNews = $stmt->fetchAll(PDO::FETCH_COLUMN);
}
} catch (Throwable $e) {
// Table might not exist yet
}
qh_page_start(
'display',
qh_t('General display board', 'لوحة العرض العامة'),
qh_t('Public queue display.', 'شاشة طوابير عامة.')
);
?>
<style>
/* Remove top spacing from the shell wrapper */
main.app-shell {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
@keyframes highlightPulse {
0% { transform: scale(1); box-shadow: var(--shadow); border-color: transparent; background-color: var(--surface); }
50% { transform: scale(1.08); box-shadow: 0 0 40px rgba(255, 235, 59, 0.9); border-color: #ffb300; background-color: #ffeb3b; }
100% { transform: scale(1); box-shadow: var(--shadow); border-color: transparent; background-color: var(--surface); }
}
.blinking-ticket,
.announcement-current-turn {
animation: highlightPulse 1.5s ease-in-out infinite;
z-index: 10;
position: relative;
background-image: none !important; /* Ensure background-color animation works against shorthand */
}
.announcement-queued-turn {
position: relative;
border-color: rgba(13, 110, 253, 0.28) !important;
background: linear-gradient(135deg, rgba(13, 110, 253, 0.08), rgba(13, 202, 240, 0.12));
box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.08), 0 14px 30px rgba(13, 110, 253, 0.10);
transform: translateY(-2px);
transition: box-shadow 0.25s ease, transform 0.25s ease, background 0.25s ease, border-color 0.25s ease;
}
.announcement-queued-turn::after {
content: 'Next';
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
color: #0b5ed7;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 6px 16px rgba(13, 110, 253, 0.12);
}
html[lang='ar'] .announcement-queued-turn::after {
content: 'التالي';
left: 0.5rem;
right: auto;
}
</style>
<div class="container-fluid px-0 px-lg-0 py-0 m-0" data-auto-refresh="3" style="min-height: 100vh; display: flex; flex-direction: column; ">
<!-- Top Header for Display Board -->
<header class="d-flex justify-content-between align-items-center bg-white py-2 px-3 shadow-sm border-0">
<div class="d-flex align-items-center gap-3">
<?php if ($logoUrl = qh_hospital_logo_url()): ?>
<img src="<?= qh_h($logoUrl) ?>" alt="<?= qh_h(qh_hospital_name()) ?>" style="height: 50px;">
<?php else: ?>
<div class="bg-primary text-white rounded d-flex align-items-center justify-content-center fs-6 fw-bold" style="width: 50px; height: 50px;">
<?= qh_h(qh_hospital_brand_initials()) ?>
</div>
<?php endif; ?>
<div>
<h1 class="h3 mb-0 fw-bold text-primary"><?= qh_h(qh_hospital_name()) ?></h1>
<?php if ($tagline = qh_hospital_tagline()): ?>
<div class="text-muted fw-semibold"><?= qh_h($tagline) ?></div>
<?php endif; ?>
</div>
</div>
<div class="d-flex align-items-center gap-3">
<div class="fs-6 fw-bold text-dark js-live-clock"><?= qh_h(date('H:i')) ?></div>
<button type="button" class="btn btn-outline-secondary btn-sm shadow-sm me-2 d-flex align-items-center gap-1 js-audio-toggle" id="globalAudioToggle" title="<?= qh_h(qh_t('Toggle Audio', 'تبديل الصوت')) ?>">
<i class="bi bi-megaphone"></i> <span class="d-none d-sm-inline"><?= qh_h(qh_t('Sound', 'الصوت')) ?></span>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm shadow-sm d-flex align-items-center gap-1 js-fullscreen-toggle" aria-pressed="false" data-label-enter="<?= qh_h(qh_t('Fullscreen', 'ملء الشاشة')) ?>" data-label-exit="<?= qh_h(qh_t('Exit fullscreen', 'إنهاء ملء الشاشة')) ?>"><i class="bi bi-arrows-fullscreen"></i> <span class="d-none d-sm-inline"><?= qh_h(qh_t('Fullscreen', 'ملء الشاشة')) ?></span></button>
</div>
</header>
<?php $pb = !empty($activeNews) ? "padding-bottom: 4rem !important;" : "padding-bottom: 1.5rem !important;"; ?>
<div class="row g-4 m-0 mt-2 px-3 px-lg-4 pt-0 flex-grow-1" style="<?= $pb ?>">
<div class="col-xl-8 col-lg-7 d-flex flex-column gap-1" id="queueSections">
<div class="card shadow-sm border-0 flex-grow-1">
<div class="card-header bg-white border-bottom py-2 d-flex justify-content-between align-items-center">
<div>
<h3 class="h5 mb-0 text-gray-800 fw-bold"><?= qh_h(qh_t('Now Serving', 'يتم الآن النداء')) ?></h3>
</div>
</div>
<div class="card-body p-1 bg-light">
<?php if ($activeCalls): ?>
<div class="row g-3 row-cols-2 row-cols-lg-5">
<?php foreach ($activeCalls as $ticket): $speech = qh_call_message($ticket);
$isNew = abs(time() - strtotime((string) $ticket["called_at"])) <= 15;
$blinkClass = $isNew ? 'blinking-ticket' : '';
?>
<div class="col">
<div class="card shadow-sm h-100 announcement-card <?= $blinkClass ?>" data-announcement-key="<?= qh_h((string) $ticket['id']) ?>-<?= qh_h((string) strtotime((string) $ticket['called_at'])) ?>" data-announcement-en="<?= qh_h($speech['speech_en'] ?? $speech['en']) ?>" data-announcement-ar="<?= qh_h($speech['speech_ar'] ?? $speech['ar']) ?>">
<div class="card-body w-100 text-center p-1 d-flex flex-column align-items-center justify-content-center">
<div class="fs-3 fw-bold text-primary mb-1"><?= qh_h($ticket['ticket_number']) ?></div>
<?php if ($ticket['status'] === 'nursing_called'): ?>
<div class="small text-muted mb-1 text-truncate w-100" title="<?= qh_h(qh_t('Nursing Station', 'محطة التمريض')) ?>"><?= qh_h(qh_t('Nursing Station', 'محطة التمريض')) ?></div>
<div class="bg-warning text-dark rounded px-2 py-1 small fw-bold mt-1 mb-2">
<?= qh_h(qh_t('Vitals', 'العلامات الحيوية')) ?>
</div>
<?php else: ?>
<div class="small text-muted mb-1 text-truncate w-100" title="<?= qh_h(qh_name($ticket, 'clinic_name')) ?>"><?= qh_h(qh_name($ticket, 'clinic_name')) ?></div>
<div class="bg-primary text-white rounded px-2 py-1 small fw-bold mt-1 mb-2">
<?= qh_h(qh_t('Room', 'غرفة')) ?> <?= qh_h($ticket['doctor_room'] ?? '--') ?>
</div>
<?php endif; ?>
<div class="text-muted mt-auto" style="font-size: 0.65rem;">
<?= qh_format_datetime($ticket['called_at'] ?? $ticket['updated_at']) ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="d-flex align-items-center justify-content-center h-100 min-vh-50 text-muted">
<div class="text-center">
<i class="bi bi-display display-1 mb-3 opacity-50"></i>
<h3><?= qh_h(qh_t('No active calls right now.', 'لا توجد نداءات نشطة حالياً.')) ?></h3>
<p class="lead opacity-75"><?= qh_h(qh_t('Please wait for your ticket number.', 'يرجى الانتظار حتى يتم نداء رقم تذكرتك.')) ?></p>
</div>
</div>
<?php endif; ?>
</div>
</div>
<div class="card shadow-sm border-0">
<div class="card-header bg-white border-bottom py-2">
<h6 class="mb-0 font-weight-bold text-dark"><?= qh_h(qh_t('Queue by Clinic', 'الإنتظار حسب العيادة')) ?></h5>
</div>
<div class="card-body p-1 bg-light">
<div class="row g-3 row-cols-2 row-cols-lg-5">
<?php foreach ($queueOverview as $row): ?>
<div class="col">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-1 d-flex flex-column justify-content-center">
<h6 class="fw-bold text-primary text-center mb-2 text-truncate" title="<?= qh_h(qh_name($row)) ?>"><?= qh_h(qh_name($row)) ?></h6>
<?php if ((int) ($row['requires_vitals'] ?? 0) === 1): ?>
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted fw-semibold lh-sm" style="font-size: 0.7rem;"><?= qh_h(qh_t('Vitals Wait', 'غرفة المعاينة الأولية')) ?></span>
<span class="badge bg-warning text-dark rounded-pill px-2"><?= qh_h((string) $row['vitals_waiting']) ?></span>
</div>
<?php endif; ?>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted fw-semibold lh-sm" style="font-size: 0.7rem;"><?= qh_h(qh_t('Room Wait', 'انتظار الغرف')) ?></span>
<span class="badge bg-info text-dark rounded-pill px-2"><?= qh_h((string) $row['doctor_waiting']) ?></span>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-lg-5">
<div class="sticky-top" style="top: 1rem; height: 60vh;">
<?php if (!empty($activeAds)): ?>
<div class="card shadow-sm border-0 h-100 bg-black overflow-hidden d-flex justify-content-center align-items-center">
<div class="w-100 h-100 position-relative bg-black">
<video id="adsVideoPlayer" class="w-100 h-100 object-fit-contain d-none" muted playsinline></video>
<img id="adsImagePlayer" class="w-100 h-100 object-fit-contain d-none" src="" alt="<?= qh_h(qh_t('Hospital advertisement', 'إعلان المستشفى')) ?>">
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const playlist = <?= json_encode($activeAds, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
const player = document.getElementById('adsVideoPlayer');
const image = document.getElementById('adsImagePlayer');
const defaultImageAlt = "<?= qh_h(qh_t('Hospital advertisement', 'إعلان المستشفى')) ?>";
let imageTimer = null;
let savedType = sessionStorage.getItem('adsMediaType') || 'video';
let currentIdx = parseInt(sessionStorage.getItem('adsMediaIndex') || sessionStorage.getItem('adsVideoIndex') || '0', 10);
let currentTime = parseFloat(sessionStorage.getItem('adsVideoTime') || '0');
let imageStartedAt = parseInt(sessionStorage.getItem('adsImageStartedAt') || '0', 10);
if (!player || !image || playlist.length === 0) {
return;
}
if (isNaN(currentIdx) || currentIdx < 0 || currentIdx >= playlist.length) {
currentIdx = 0;
}
if (isNaN(currentTime) || currentTime < 0) {
currentTime = 0;
}
if (isNaN(imageStartedAt) || imageStartedAt < 0) {
imageStartedAt = 0;
}
const persistIndex = function() {
sessionStorage.setItem('adsMediaIndex', String(currentIdx));
sessionStorage.setItem('adsVideoIndex', String(currentIdx));
};
const clearImageTimer = function() {
if (imageTimer) {
clearTimeout(imageTimer);
imageTimer = null;
}
};
const showAudioPrompt = function() {
if (window.audioPromptShown || window.localStorage.getItem('hospitalQueue:audioEnabled') === 'false') {
return;
}
window.audioPromptShown = true;
const prompt = document.createElement('div');
prompt.style.position = 'fixed';
prompt.style.bottom = '20px';
prompt.style.left = '50%';
prompt.style.transform = 'translateX(-50%)';
prompt.style.zIndex = '9999';
prompt.style.backgroundColor = '#dc3545';
prompt.style.color = 'white';
prompt.style.padding = '15px 30px';
prompt.style.borderRadius = '50px';
prompt.style.cursor = 'pointer';
prompt.style.display = 'flex';
prompt.style.alignItems = 'center';
prompt.style.gap = '15px';
prompt.style.boxShadow = '0 10px 25px rgba(0,0,0,0.5)';
const msg = "<?= qh_h(qh_t('Sound blocked by TV/Browser. Click here or press OK on remote to enable.', 'تم حظر الصوت بواسطة المتصفح/التلفاز. انقر هنا أو اضغط OK في الريموت للتفعيل.')) ?>";
prompt.innerHTML = '<i class="bi bi-volume-mute-fill fs-3"></i> <span class="fs-6 fw-bold">' + msg + '</span>';
const enableSound = function(ev) {
if (ev && ev.type === 'keydown' && ev.key !== 'Enter' && ev.key !== ' ') return;
player.muted = false;
player.play().catch(e => console.warn('Still cannot play:', e));
if (document.body.contains(prompt)) {
document.body.removeChild(prompt);
}
if (ev && ev.type === 'keydown') {
document.body.click();
}
};
prompt.addEventListener('click', enableSound);
document.body.appendChild(prompt);
document.addEventListener('keydown', enableSound);
document.addEventListener('click', function(ev2) {
if (document.body.contains(prompt) && ev2.target !== prompt && !prompt.contains(ev2.target)) {
enableSound(ev2);
}
}, { once: true });
};
const playVideo = function() {
player.play().catch(function(e) {
console.error('Error playing video:', e);
if (!player.muted) {
player.muted = true;
player.play().catch(err => console.error('Fallback play failed:', err));
}
showAudioPrompt();
});
};
const showNextItem = function() {
clearImageTimer();
currentIdx = (currentIdx + 1) % playlist.length;
currentTime = 0;
imageStartedAt = 0;
savedType = 'video';
renderCurrent(true);
};
const renderCurrent = function(resetPosition) {
const item = playlist[currentIdx] || playlist[0];
persistIndex();
clearImageTimer();
sessionStorage.setItem('adsMediaType', item.type);
savedType = item.type;
if (item.type === 'image') {
player.pause();
player.removeAttribute('src');
player.load();
player.classList.add('d-none');
image.classList.remove('d-none');
image.src = item.path;
image.alt = item.title || defaultImageAlt;
const durationMs = Math.max(parseInt(item.duration || 10, 10), 3) * 1000;
let remainingMs = durationMs;
const canResumeImage = !resetPosition && imageStartedAt > 0;
if (!canResumeImage) {
imageStartedAt = Date.now();
} else {
const elapsedMs = Date.now() - imageStartedAt;
if (elapsedMs >= durationMs) {
showNextItem();
return;
}
remainingMs = durationMs - elapsedMs;
}
sessionStorage.setItem('adsImageStartedAt', String(imageStartedAt));
sessionStorage.setItem('adsVideoTime', '0');
imageTimer = window.setTimeout(showNextItem, remainingMs);
return;
}
image.classList.add('d-none');
player.classList.remove('d-none');
imageStartedAt = 0;
sessionStorage.removeItem('adsImageStartedAt');
const resumeTime = (!resetPosition && currentTime > 0) ? currentTime : 0;
player.loop = playlist.length === 1;
player.src = item.path;
player.load();
player.addEventListener('loadedmetadata', function onLoadedMetadata() {
if (resumeTime > 0 && resumeTime < player.duration) {
player.currentTime = resumeTime;
}
playVideo();
}, { once: true });
if (window.localStorage.getItem('hospitalQueue:audioEnabled') !== 'false') {
player.muted = false;
}
};
player.addEventListener('ended', function() {
if (playlist.length > 1) {
showNextItem();
}
});
window.addEventListener('beforeunload', function() {
persistIndex();
sessionStorage.setItem('adsMediaType', savedType);
if (savedType === 'video') {
sessionStorage.setItem('adsVideoTime', String(player.currentTime || 0));
} else {
sessionStorage.setItem('adsVideoTime', '0');
sessionStorage.setItem('adsImageStartedAt', String(imageStartedAt || Date.now()));
}
});
renderCurrent(false);
});
</script>
</div>
<?php else: ?>
<div class="card shadow-sm border-0 h-100 bg-primary text-white">
<div class="card-header border-bottom border-light border-opacity-25 py-2 bg-transparent d-flex justify-content-between align-items-center">
<h5 class="mb-0 font-weight-bold text-white"><i class="bi bi-info-circle me-2"></i><?= qh_h(qh_t('Information', 'معلومات')) ?></h5>
<span class="badge bg-white text-primary rounded-pill small"><?= qh_h(qh_t('Notices', 'تنبيهات')) ?></span>
</div>
<div class="card-body p-1 d-flex flex-column gap-1">
<div class="border border-light border-opacity-25 rounded p-1 bg-white bg-opacity-10">
<div class="badge bg-light text-primary mb-2 px-2 py-1"><?= qh_h(qh_t('Service', 'خدمة')) ?></div>
<h5 class="text-white fw-bold"><?= qh_h(qh_t('Lab packages & checks', 'باقات المختبر والفحوصات')) ?></h5>
<p class="mb-0 text-white text-opacity-75"><?= qh_h(qh_t('Ask reception about bundled blood tests, diabetes follow-up, and annual screenings.', 'اسأل الاستقبال عن باقات تحاليل الدم، ومتابعة السكري، والفحوصات السنوية.')) ?></p>
</div>
<div class="border border-light border-opacity-25 rounded p-1 bg-white bg-opacity-10">
<div class="badge bg-light text-primary mb-2 px-2 py-1"><?= qh_h(qh_t('Reminder', 'تذكير')) ?></div>
<h5 class="text-white fw-bold"><?= qh_h(qh_t('Keep your ticket visible', 'احتفظ بتذكرتك ظاهرة')) ?></h5>
<p class="mb-0 text-white text-opacity-75"><?= qh_h(qh_t('We announce ticket numbers on this screen and by voice. Stay near your department area.', 'نعلن أرقام التذاكر على هذه الشاشة وبالصوت. يرجى البقاء قرب منطقة القسم الخاص بك.')) ?></p>
</div>
<div class="border border-light border-opacity-25 rounded p-1 bg-white bg-opacity-10">
<div class="badge bg-light text-primary mb-2 px-2 py-1"><?= qh_h(qh_t('Wayfinding', 'الإرشاد')) ?></div>
<h5 class="text-white fw-bold"><?= qh_h(qh_t('Pharmacy & billing', 'الصيدلية والمحاسبة')) ?></h5>
<p class="mb-0 text-white text-opacity-75"><?= qh_h(qh_t('Completed visits can proceed to the pharmacy and billing desk near the main exit.', 'بعد انتهاء الزيارة يمكن التوجه إلى الصيدلية ومكتب المحاسبة قرب المخرج الرئيسي.')) ?></p>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php if (!empty($activeNews)): ?>
<div class="news-ticker-container">
<div class="news-ticker-content">
<?php foreach ($activeNews as $news): ?>
<span class="news-ticker-item"><?= qh_h($news) ?></span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php qh_page_end(); ?>