devsofttech_hms_ci4/public/App/Queue/index.html

594 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>OPD Token Tracker & Queue Display - HMS Mobile</title>
<link rel="manifest" href="/app/queue/manifest.json">
<meta name="theme-color" content="#0f172a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/assets/img/logo.png" type="image/png">
<link rel="apple-touch-icon" href="/assets/img/logo.png">
<!-- Google Fonts & Bootstrap 5 CSS & Icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body {
background-color: #0b1329;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 0;
user-select: none;
overflow: hidden;
color: #ffffff;
height: 100vh;
display: flex;
flex-direction: column;
}
/* Top TV Header Bar */
.tv-header {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
padding: 10px 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 100;
}
/* Split Columns Grid */
.tv-main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
height: calc(100vh - 105px);
gap: 16px;
padding: 16px;
box-sizing: border-box;
}
@media (max-width: 991px) {
.tv-main-grid {
grid-template-columns: 1fr;
overflow-y: auto;
height: auto;
}
}
/* Token List Tracker Column (Left) */
.tracker-column {
background: #ffffff;
border-radius: 16px;
box-shadow: 0 12px 30px rgba(0,0,0,0.3);
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
.tracker-header {
background: #0046ad;
color: #ffffff;
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 800;
font-size: 20px;
letter-spacing: 0.5px;
z-index: 10;
}
.tracker-viewport {
flex: 1;
overflow: hidden;
position: relative;
width: 100%;
}
.tracker-slider {
display: flex;
width: 100%;
height: 100%;
transition: transform 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}
.tracker-page-slide {
flex: 0 0 100%;
width: 100%;
padding: 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 10px;
justify-content: flex-start;
}
/* Individual Token Tracker Item Card */
.tracker-card {
border-radius: 12px;
padding: 12px 18px;
color: #ffffff;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: transform 0.2s ease;
cursor: pointer;
}
.tracker-card:focus, :focus-visible {
outline: 4px solid #f59e0b !important;
outline-offset: 2px !important;
transform: scale(1.02);
box-shadow: 0 0 20px rgba(245, 158, 11, 0.8) !important;
}
.card-blue { background: linear-gradient(90deg, #2563eb 0%, #1d4ed8 100%); }
.card-green { background: linear-gradient(90deg, #059669 0%, #047857 100%); }
.card-cyan { background: linear-gradient(90deg, #0284c7 0%, #0369a1 100%); }
.card-darkgreen { background: linear-gradient(90deg, #16a34a 0%, #15803d 100%); }
.card-red { background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%); }
.avatar-circle {
width: 44px;
height: 44px;
border-radius: 50%;
background: #ffffff;
color: #0f172a;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 800;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
overflow: hidden;
}
.token-number-box {
font-size: 42px;
font-weight: 900;
letter-spacing: -1px;
min-width: 80px;
text-align: right;
line-height: 1;
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.tracker-footer {
padding: 8px 16px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
/* Hospital Ads & Doctor Showcase Column (Right) */
.showcase-column {
background: #ffffff;
border-radius: 16px;
box-shadow: 0 12px 30px rgba(0,0,0,0.3);
overflow: hidden;
display: flex;
flex-direction: column;
color: #1e293b;
}
.showcase-header {
background: #0046ad;
color: #ffffff;
padding: 12px 20px;
font-weight: 800;
font-size: 20px;
letter-spacing: 0.5px;
text-align: center;
}
.showcase-body {
flex: 1;
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
background: radial-gradient(circle at center, #ffffff 0%, #f8fafc 100%);
}
.doctor-photo-frame {
width: 220px;
height: 250px;
border-radius: 16px;
border: 4px solid #ea580c;
box-shadow: 0 10px 25px rgba(234, 88, 12, 0.25);
object-fit: cover;
background: #f1f5f9;
margin-bottom: 20px;
}
.ad-banner-img {
max-width: 100%;
max-height: 260px;
border-radius: 16px;
object-fit: cover;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
margin-bottom: 20px;
}
/* Bottom Ticker Bar */
.tv-ticker {
background: #0f172a;
color: #f8fafc;
padding: 8px 16px;
font-size: 14px;
font-weight: 600;
border-top: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
white-space: nowrap;
}
.ticker-content {
display: inline-block;
animation: marquee 25s linear infinite;
}
@keyframes marquee {
0% { transform: translateX(100%); }
100% { transform: translateX(-100%); }
}
</style>
</head>
<body>
<!-- Top Header -->
<header class="tv-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3">
<img src="/assets/img/logo.png" alt="Hospital Logo" id="hdr_logo" style="height: 38px; width: auto; filter: brightness(0) invert(1);" onError="this.style.display='none'" />
<div>
<h5 class="mb-0 fw-bold text-white" id="hdr_hosp_name" style="font-size: 18px; letter-spacing: -0.3px;">
E-Atria Multispeciality Hospital
</h5>
<small class="text-light opacity-75" style="font-size: 11px;">
Live OPD Token Tracker &amp; Patient Calling Display
</small>
</div>
</div>
<div class="d-flex align-items-center gap-3">
<button id="btn_toggle_audio" class="btn btn-sm btn-danger rounded-circle p-2" style="width: 40px; height: 40px;" title="Toggle Voice Announcement">
<i id="icon_audio" class="bi bi-volume-up-fill fs-5"></i>
</button>
<div class="bg-dark text-white px-3 py-1.5 rounded-3 border border-secondary shadow-sm text-center">
<div class="fw-bold fs-6" id="tv_clock" style="font-family: monospace; letter-spacing: 1px;">00:00:00</div>
<div class="small opacity-75" style="font-size: 10px;">LIVE TIME</div>
</div>
</div>
</header>
<!-- Main Grid -->
<main class="tv-main-grid">
<!-- Left: Paginated Token Tracker List -->
<div class="tracker-column">
<div class="tracker-header">
<span><i class="bi bi-person-lines-fill me-2"></i> Token Tracker</span>
<span id="tracker_page_badge" class="badge bg-white text-primary rounded-pill px-3 py-1 fs-6">Page 1 of 1</span>
</div>
<!-- Horizontal Viewport Slider for Left Token Tracker -->
<div class="tracker-viewport">
<div class="tracker-slider" id="tracker_slider">
<!-- Dynamically Populated Slides (5 items per page) -->
</div>
</div>
<!-- Left Bottom Space Ad Banner -->
<div id="tracker_left_ad_container" class="px-2 py-1" style="display: none; background: #0f172a; color: #ffffff;">
<div id="tracker_left_ad_content" class="p-2 rounded d-flex align-items-center justify-content-between shadow-sm" style="background: linear-gradient(90deg, #1e293b 0%, #0f172a 100%); border: 1px solid rgba(255,255,255,0.15);">
<!-- Dynamic Left Bottom Ad Content -->
</div>
</div>
<!-- Page Dots Footer for Left Column -->
<div class="tracker-footer" id="tracker_dots_container">
<!-- Dynamically Populated Dots -->
</div>
</div>
<!-- Right: Hospital Showcase Carousel -->
<div class="showcase-column">
<div class="showcase-header" id="showcase_header">
<i class="bi bi-award-fill me-2"></i> Our Experts
</div>
<div class="showcase-body" id="showcase_body">
<!-- Dynamically Populated Slide -->
</div>
</div>
</main>
<!-- Bottom Ticker Bar -->
<div class="tv-ticker">
<div class="ticker-content" id="ticker_text">
<i class="bi bi-info-circle-fill text-warning me-2"></i>
Welcome to E-Atria Multispeciality Hospital. Please observe your token number on the screen. Voice announcements will call your turn. For assistance, contact the help desk. &nbsp;&nbsp;&bull;&nbsp;&nbsp; Emergency Helpline: +91-9876543210 &nbsp;&nbsp;&bull;&nbsp;&nbsp; 24/7 Pharmacy &amp; Ambulance Services Available
</div>
</div>
<!-- Universal Pure Vanilla JS script (Paginated Left-to-Right Carousel, Samsung TV compatible) -->
<script>
var audioEnabled = true;
var trackersData = [];
var adsData = [];
var currentAdIdx = 0;
var currentPageIdx = 0;
var itemsPerPage = 5; // 5 cards per page fit perfectly without cutoffs
var cardColors = ['card-blue', 'card-green', 'card-cyan', 'card-darkgreen', 'card-red'];
// Live Clock
function updateClock() {
var now = new Date();
var hours = String(now.getHours()).padStart(2, '0');
var minutes = String(now.getMinutes()).padStart(2, '0');
var seconds = String(now.getSeconds()).padStart(2, '0');
var clockEl = document.getElementById('tv_clock');
if (clockEl) clockEl.textContent = hours + ':' + minutes + ':' + seconds;
}
setInterval(updateClock, 1000);
updateClock();
// Speech Pronunciation Formatter (Expands abbreviations so TTS reads "Doctor", "Department", etc. cleanly)
function formatSpeechText(text) {
if (!text) return '';
var clean = text;
clean = clean.replace(/\bDr\.\s*/gi, 'Doctor ');
clean = clean.replace(/\bDr\s+/gi, 'Doctor ');
clean = clean.replace(/\bDept\.\s*/gi, 'Department ');
clean = clean.replace(/\bDept\s+/gi, 'Department ');
clean = clean.replace(/\bNo\.\s*/gi, 'Number ');
clean = clean.replace(/\bUSG\b/gi, 'Ultrasound');
return clean;
}
// Audio Announcement
function speakText(rawText) {
if (!audioEnabled) return;
var text = formatSpeechText(rawText);
try {
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel();
var msg = new SpeechSynthesisUtterance(text);
msg.rate = 0.9;
msg.lang = 'en-US';
window.speechSynthesis.speak(msg);
}
} catch (e) {
console.log('Audio speech not supported');
}
}
var btnAudio = document.getElementById('btn_toggle_audio');
var iconAudio = document.getElementById('icon_audio');
if (btnAudio) {
btnAudio.onclick = function() {
audioEnabled = !audioEnabled;
if (audioEnabled) {
btnAudio.className = 'btn btn-sm btn-danger rounded-circle p-2';
if (iconAudio) iconAudio.className = 'bi bi-volume-up-fill fs-5';
} else {
btnAudio.className = 'btn btn-sm btn-secondary rounded-circle p-2';
if (iconAudio) iconAudio.className = 'bi bi-volume-mute-fill fs-5';
}
};
}
// Render Paginated Left Token Tracker Slides (Horizontal Carousel)
function renderTrackers() {
var sliderEl = document.getElementById('tracker_slider');
var badgeEl = document.getElementById('tracker_page_badge');
var dotsEl = document.getElementById('tracker_dots_container');
if (!sliderEl) return;
var totalPages = Math.ceil(trackersData.length / itemsPerPage);
if (totalPages === 0) totalPages = 1;
if (currentPageIdx >= totalPages) currentPageIdx = 0;
if (badgeEl) {
badgeEl.textContent = 'Page ' + (currentPageIdx + 1) + ' of ' + totalPages;
}
var html = '';
for (var p = 0; p < totalPages; p++) {
html += '<div class="tracker-page-slide">';
var startIdx = p * itemsPerPage;
var endIdx = Math.min(startIdx + itemsPerPage, trackersData.length);
for (var i = startIdx; i < endIdx; i++) {
var item = trackersData[i];
var colorClass = cardColors[i % cardColors.length];
var iconHtml = item.icon
? '<i class="bi ' + item.icon + ' text-primary"></i>'
: (item.name ? item.name.charAt(0) : 'D');
html += '<div tabindex="0" class="tracker-card ' + colorClass + '" onclick="speakText(\'Token Number ' + item.token_no + ' for ' + item.name + '\')">';
html += ' <div class="d-flex align-items-center gap-3">';
html += ' <div class="avatar-circle">' + iconHtml + '</div>';
html += ' <div>';
html += ' <h5 class="mb-0 fw-extrabold" style="font-size:18px; font-weight:800;">' + item.name + '</h5>';
html += ' <small class="opacity-90" style="font-size:12px; font-weight:500;">' + item.specialization + ' &bull; <span class="badge bg-white bg-opacity-25 text-white">' + item.room + '</span></small>';
html += ' </div>';
html += ' </div>';
html += ' <div class="text-end">';
html += ' <div class="token-number-box">' + item.token_no + '</div>';
html += ' <small class="opacity-75" style="font-size:10px;">CURRENT TOKEN</small>';
html += ' </div>';
html += '</div>';
}
html += '</div>';
}
sliderEl.innerHTML = html;
sliderEl.style.transform = 'translateX(-' + (currentPageIdx * 100) + '%)';
// Render Dots Footer
if (dotsEl) {
var dotsHtml = '';
for (var d = 0; d < totalPages; d++) {
var activeStyle = (d === currentPageIdx) ? 'bg-primary' : 'bg-secondary opacity-50';
var widthStyle = (d === currentPageIdx) ? '24px' : '10px';
dotsHtml += '<button class="btn rounded-circle p-0 ' + activeStyle + '" style="width:' + widthStyle + '; height:10px; border-radius:10px;" onclick="goToTrackerPage(' + d + ')"></button>';
}
dotsEl.innerHTML = dotsHtml;
}
}
function goToTrackerPage(pageIdx) {
var totalPages = Math.ceil(trackersData.length / itemsPerPage);
if (totalPages === 0) totalPages = 1;
currentPageIdx = pageIdx % totalPages;
var sliderEl = document.getElementById('tracker_slider');
var badgeEl = document.getElementById('tracker_page_badge');
if (sliderEl) {
sliderEl.style.transform = 'translateX(-' + (currentPageIdx * 100) + '%)';
}
if (badgeEl) {
badgeEl.textContent = 'Page ' + (currentPageIdx + 1) + ' of ' + totalPages;
}
renderTrackers();
}
// Render Showcase Ad Slide
function renderShowcase() {
var headerEl = document.getElementById('showcase_header');
var bodyEl = document.getElementById('showcase_body');
if (!bodyEl || adsData.length === 0) return;
var ad = adsData[currentAdIdx];
if (headerEl) headerEl.innerHTML = '<i class="bi bi-award-fill me-2"></i> ' + (ad.title || 'Our Experts');
var html = '';
if (ad.type === 'doctor_profile') {
html += '<div class="d-flex flex-column align-items-center">';
html += ' <img src="' + ad.image_url + '" alt="Doctor" class="doctor-photo-frame" onerror="this.src=\'/assets/img/profile-img.jpg\'" />';
html += ' <h3 class="fw-extrabold text-dark mb-1" style="font-weight:800;">' + ad.doctor_name + '</h3>';
html += ' <h5 class="text-primary fw-bold mb-2" style="font-size:18px;">' + ad.specialization + '</h5>';
html += ' <h6 class="text-secondary fw-semibold mb-3" style="font-size:15px;">' + ad.designation + '</h6>';
html += ' <p class="text-muted small" style="font-size:13px; max-width:420px;">' + ad.description + '</p>';
html += '</div>';
} else {
html += '<div class="d-flex flex-column align-items-center">';
html += ' <img src="' + ad.image_url + '" alt="Ad Banner" class="ad-banner-img" onerror="this.src=\'/assets/img/slides-1.jpg\'" />';
html += ' <h3 class="fw-extrabold text-dark mb-1" style="font-weight:800;">' + ad.title + '</h3>';
html += ' <h5 class="text-success fw-bold mb-2" style="font-size:17px;">' + (ad.tagline || '') + '</h5>';
html += ' <p class="text-muted small" style="font-size:13px; max-width:420px;">' + ad.description + '</p>';
html += '</div>';
}
// Dot Indicators for Showcase
html += '<div class="d-flex gap-2 mt-4">';
for (var k = 0; k < adsData.length; k++) {
var activeStyle = (k === currentAdIdx) ? 'bg-primary' : 'bg-secondary opacity-50';
var widthStyle = (k === currentAdIdx) ? '24px' : '10px';
html += '<button class="btn rounded-circle p-0 ' + activeStyle + '" style="width:' + widthStyle + '; height:10px; border-radius:10px;" onclick="currentAdIdx=' + k + '; renderShowcase();"></button>';
}
html += '</div>';
bodyEl.innerHTML = html;
}
// Fetch Data from API
function loadQueueData() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/v1/opd/queue/live', true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
try {
var json = JSON.parse(xhr.responseText);
if (json.status === 1) {
if (json.trackers) trackersData = json.trackers;
if (json.ads) adsData = json.ads;
if (json.hospital_name) {
var hospEl = document.getElementById('hdr_hosp_name');
if (hospEl) hospEl.textContent = json.hospital_name;
}
if (json.footer_ticker) {
var tickerEl = document.getElementById('ticker_text');
if (tickerEl) {
tickerEl.innerHTML = '<i class="bi bi-info-circle-fill text-warning me-2"></i> ' + json.footer_ticker;
}
}
if (json.left_ad) {
var leftAdContainer = document.getElementById('tracker_left_ad_container');
var leftAdContent = document.getElementById('tracker_left_ad_content');
if (leftAdContainer && leftAdContent && json.left_ad.enabled) {
leftAdContainer.style.display = 'block';
if (json.left_ad.image_url) {
leftAdContent.innerHTML = '<img src="' + json.left_ad.image_url + '" style="max-height: 48px; width: 100%; object-fit: contain; border-radius: 6px;" />';
} else {
leftAdContent.innerHTML = '<div class="d-flex align-items-center gap-2 text-warning fw-bold small"><i class="bi bi-megaphone-fill fs-5"></i> <span>' + (json.left_ad.text || '24x7 Emergency Services Available') + '</span></div>';
}
} else if (leftAdContainer) {
leftAdContainer.style.display = 'none';
}
}
renderTrackers();
renderShowcase();
}
} catch (e) {
console.log('JSON parse error');
}
} else if (xhr.status === 403) {
// Access forbidden: wipe sensitive data and reload to show 403 screen
trackersData = [];
adsData = [];
window.location.reload();
}
}
};
xhr.send();
}
// Initial Load & Timers
loadQueueData();
setInterval(loadQueueData, 5000);
// Auto-advance Left Token Tracker Page every 7 seconds
setInterval(function() {
var totalPages = Math.ceil(trackersData.length / itemsPerPage);
if (totalPages > 1) {
currentPageIdx = (currentPageIdx + 1) % totalPages;
goToTrackerPage(currentPageIdx);
}
}, 7000);
// Auto-advance Right Showcase Carousel every 8 seconds
setInterval(function() {
if (adsData.length > 0) {
currentAdIdx = (currentAdIdx + 1) % adsData.length;
renderShowcase();
}
}, 8000);
// Register PWA Service Worker immediately
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/app/queue/sw.js', { scope: '/app/queue/' })
.then(function(reg) {
console.log('Queue PWA Service Worker Registered', reg);
})
.catch(function(err) {
console.log('Service Worker Registration Failed', err);
});
}
</script>
</body>
</html>