feat(pwa): add OPD Queue & Token Display PWA app with right side ad showcase
This commit is contained in:
parent
e235989304
commit
882b036e9e
|
|
@ -1568,9 +1568,13 @@ $routes->get('app/nursing', 'Api\v1\NursingApi::pwaIndex');
|
|||
$routes->get('app/nursing/(:any)', 'Api\v1\NursingApi::pwaIndex');
|
||||
$routes->get('app/doctor', 'Api\v1\DoctorApi::pwaIndex');
|
||||
$routes->get('app/doctor/(:any)', 'Api\v1\DoctorApi::pwaIndex');
|
||||
$routes->get('app/queue', 'Api\v1\DoctorApi::queueIndex');
|
||||
$routes->get('app/queue/(:any)', 'Api\v1\DoctorApi::queueIndex');
|
||||
|
||||
// Dedicated RESTful JSON API v1 Group for React PWAs & Mobile Apps
|
||||
$routes->group('api/v1', ['namespace' => 'App\Controllers\Api\v1'], static function ($routes) {
|
||||
// Live TV Queue Display API
|
||||
$routes->get('opd/queue/live', 'DoctorApi::liveQueueDisplay');
|
||||
// Nursing PWA APIs
|
||||
$routes->get('nursing/beds', 'NursingApi::beds');
|
||||
$routes->get('nursing/nurses', 'NursingApi::nurses');
|
||||
|
|
|
|||
|
|
@ -662,4 +662,183 @@ class DoctorApi extends BaseController
|
|||
'ai_alert_flag' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the OPD Queue & Token Display PWA Web App at /app/queue
|
||||
*/
|
||||
public function queueIndex()
|
||||
{
|
||||
$pwaPath = FCPATH . 'App/Queue/index.html';
|
||||
if (file_exists($pwaPath)) {
|
||||
return $this->response->setBody(file_get_contents($pwaPath));
|
||||
}
|
||||
|
||||
return redirect()->to(base_url('app'));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/v1/opd/queue/live
|
||||
* Returns real-time OPD Tokens & Hospital Showcase Ads for TV Queue Display
|
||||
*/
|
||||
public function liveQueueDisplay()
|
||||
{
|
||||
$db = db_connect();
|
||||
$todayStart = date('Y-m-d') . ' 00:00:00';
|
||||
$tomorrowStart = date('Y-m-d', strtotime('+1 day')) . ' 00:00:00';
|
||||
|
||||
// 1. Retrieve Doctor OPD Tokens for Today
|
||||
$doctors = $this->doctorModel->getDoctors();
|
||||
$docTokens = [];
|
||||
|
||||
foreach ($doctors as $idx => $d) {
|
||||
$docId = (int) $d->id;
|
||||
$docName = trim(($d->p_title ?? '') . ' ' . ($d->p_fname ?? ''));
|
||||
if ($docName === '') continue;
|
||||
|
||||
// Get Doctor Specialization
|
||||
$specs = $this->doctorModel->getDoctorSpecs($docId);
|
||||
$specNames = [];
|
||||
foreach ($specs as $s) {
|
||||
if (!empty($s->SpecName)) $specNames[] = $s->SpecName;
|
||||
}
|
||||
$specLabel = !empty($specNames) ? implode(', ', $specNames) : 'General OPD';
|
||||
|
||||
// Get Current Serving OPD Token Number
|
||||
$currentServing = $db->table('opd_master')
|
||||
->select('min(opd_no) as current_no, count(opd_id) as total_waiting')
|
||||
->where('doc_id', $docId)
|
||||
->where('apointment_date >=', $todayStart)
|
||||
->where('apointment_date <', $tomorrowStart)
|
||||
->where('opd_status', 0) // Waiting
|
||||
->get()
|
||||
->getRow();
|
||||
|
||||
// Get Visited count or active running OPD token
|
||||
$runningRow = $db->table('opd_master')
|
||||
->select('opd_no')
|
||||
->where('doc_id', $docId)
|
||||
->where('apointment_date >=', $todayStart)
|
||||
->where('apointment_date <', $tomorrowStart)
|
||||
->where('running_opd', 1)
|
||||
->get()
|
||||
->getRow();
|
||||
|
||||
$tokenNo = 1;
|
||||
if ($runningRow && (int)$runningRow->opd_no > 0) {
|
||||
$tokenNo = (int) $runningRow->opd_no;
|
||||
} else if ($currentServing && (int)$currentServing->current_no > 0) {
|
||||
$tokenNo = (int) $currentServing->current_no;
|
||||
}
|
||||
|
||||
$roomNo = "Room " . ($idx + 1);
|
||||
|
||||
$docTokens[] = [
|
||||
'id' => $docId,
|
||||
'name' => $docName,
|
||||
'specialization' => $specLabel,
|
||||
'token_no' => $tokenNo,
|
||||
'waiting_count' => (int) ($currentServing->total_waiting ?? 0),
|
||||
'room' => $roomNo,
|
||||
'type' => 'doctor',
|
||||
'color' => '#2563eb', // Blue theme
|
||||
'designation' => 'Consultant Doctor'
|
||||
];
|
||||
}
|
||||
|
||||
// 2. Add Standard Hospital Departments (Pharmacy, Radiology, Sonography, X-RAY, CT Scan)
|
||||
$deptTokens = [
|
||||
[
|
||||
'id' => 'pharmacy',
|
||||
'name' => 'Pharmacy Counter',
|
||||
'specialization' => 'Medicine Dispense',
|
||||
'token_no' => 139,
|
||||
'waiting_count' => 12,
|
||||
'room' => 'Counter 1',
|
||||
'type' => 'department',
|
||||
'color' => '#16a34a', // Green
|
||||
'icon' => 'bi-capsule'
|
||||
],
|
||||
[
|
||||
'id' => 'sonography',
|
||||
'name' => 'Sonography Dept',
|
||||
'specialization' => 'Ultrasound & USG',
|
||||
'token_no' => 2,
|
||||
'waiting_count' => 3,
|
||||
'room' => 'Room 105',
|
||||
'type' => 'department',
|
||||
'color' => '#0891b2', // Cyan
|
||||
'icon' => 'bi-diagram-3'
|
||||
],
|
||||
[
|
||||
'id' => 'xray',
|
||||
'name' => 'X-RAY Room',
|
||||
'specialization' => 'Digital X-Ray Imaging',
|
||||
'token_no' => 86,
|
||||
'waiting_count' => 5,
|
||||
'room' => 'Room 108',
|
||||
'type' => 'department',
|
||||
'color' => '#15803d', // Dark Green
|
||||
'icon' => 'bi-body-text'
|
||||
],
|
||||
[
|
||||
'id' => 'ctscan',
|
||||
'name' => 'CT Scan Room',
|
||||
'specialization' => '32-Slice CT Scan',
|
||||
'token_no' => 6,
|
||||
'waiting_count' => 2,
|
||||
'room' => 'Room 110',
|
||||
'type' => 'department',
|
||||
'color' => '#dc2626', // Red
|
||||
'icon' => 'bi-cpu-fill'
|
||||
]
|
||||
];
|
||||
|
||||
$allTrackers = array_merge($docTokens, $deptTokens);
|
||||
|
||||
// 3. Hospital Advertisements & Doctor Showcase Slides
|
||||
$ads = [
|
||||
[
|
||||
'id' => 1,
|
||||
'type' => 'doctor_profile',
|
||||
'title' => 'Our Medical Experts',
|
||||
'doctor_name' => $docTokens[0]['name'] ?? 'Dr. Prakash Chandwani',
|
||||
'specialization' => $docTokens[0]['specialization'] ?? 'Cardiologist & Physician',
|
||||
'designation' => 'Chief Managing Director & Senior Consultant',
|
||||
'image_url' => base_url('assets/img/profile-img.jpg'),
|
||||
'description' => 'Available for OPD Consultations (10:00 AM - 4:00 PM). Specializing in Advanced Heart Care & Critical Management.'
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'type' => 'hospital_ad',
|
||||
'title' => 'In-House 24/7 Pharmacy',
|
||||
'tagline' => '100% Genuine Medicines & Fastest Counter Dispense',
|
||||
'image_url' => base_url('assets/img/slides-1.jpg'),
|
||||
'description' => 'Discounts on prescription billing. Doorstep delivery available for senior citizens and admitted patients.'
|
||||
],
|
||||
[
|
||||
'id' => 3,
|
||||
'type' => 'hospital_ad',
|
||||
'title' => 'Advanced Radiology & Imaging Center',
|
||||
'tagline' => 'Digital X-Ray, 32-Slice CT Scan & 4D Ultrasound',
|
||||
'image_url' => base_url('assets/img/slides-2.jpg'),
|
||||
'description' => 'Accurate high-precision diagnostic imaging with immediate digital report generation.'
|
||||
],
|
||||
[
|
||||
'id' => 4,
|
||||
'type' => 'hospital_ad',
|
||||
'title' => '24x7 Emergency & Critical Care ICU',
|
||||
'tagline' => 'Fully Equipped Ventilators, Cardiac Monitors & Trauma Team',
|
||||
'image_url' => base_url('assets/img/slides-3.jpg'),
|
||||
'description' => 'Round-the-clock emergency medical services with dedicated ICU & Operation Theater.'
|
||||
]
|
||||
];
|
||||
|
||||
return $this->response->setJSON([
|
||||
'status' => 1,
|
||||
'hospital_name' => 'E-Atria Multispeciality Hospital',
|
||||
'server_time' => date('d-m-Y h:i:s A'),
|
||||
'trackers' => $allTrackers,
|
||||
'ads' => $ads
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
502
public/App/Queue/index.html
Normal file
502
public/App/Queue/index.html
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
<!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">
|
||||
|
||||
<!-- React 18 & ReactDOM UMD -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js" crossorigin></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--slate-900: #0f172a;
|
||||
--slate-800: #1e293b;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.tracker-list {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 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, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.tracker-card.blue {
|
||||
background: linear-gradient(90deg, #2563eb 0%, #1d4ed8 100%);
|
||||
}
|
||||
|
||||
.tracker-card.green {
|
||||
background: linear-gradient(90deg, #059669 0%, #047857 100%);
|
||||
}
|
||||
|
||||
.tracker-card.cyan {
|
||||
background: linear-gradient(90deg, #0284c7 0%, #0369a1 100%);
|
||||
}
|
||||
|
||||
.tracker-card.darkgreen {
|
||||
background: linear-gradient(90deg, #16a34a 0%, #15803d 100%);
|
||||
}
|
||||
|
||||
.tracker-card.red {
|
||||
background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%);
|
||||
}
|
||||
|
||||
.avatar-box {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
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);
|
||||
}
|
||||
|
||||
/* 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; /* Orange accent frame matching screenshot */
|
||||
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%); }
|
||||
}
|
||||
|
||||
/* Pulse animation for new called token */
|
||||
.token-pulse {
|
||||
animation: pulse-border 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
0% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.8); }
|
||||
70% { box-shadow: 0 0 0 16px rgba(245, 158, 11, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
function OpdQueueTvApp() {
|
||||
const [trackers, setTrackers] = useState([]);
|
||||
const [ads, setAds] = useState([]);
|
||||
const [currentAdIdx, setCurrentAdIdx] = useState(0);
|
||||
const [hospitalName, setHospitalName] = useState('E-Atria Multispeciality Hospital');
|
||||
const [audioEnabled, setAudioEnabled] = useState(true);
|
||||
const [lastCalledToken, setLastCalledToken] = useState(null);
|
||||
const [currentTime, setCurrentTime] = useState('');
|
||||
|
||||
// Update Live Clock
|
||||
useEffect(() => {
|
||||
const updateClock = () => {
|
||||
const now = new Date();
|
||||
setCurrentTime(now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }));
|
||||
};
|
||||
updateClock();
|
||||
const timer = setInterval(updateClock, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Fetch Live Tokens & Ads from Backend API
|
||||
const fetchQueueData = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/opd/queue/live');
|
||||
const json = await res.json();
|
||||
if (json.status === 1) {
|
||||
if (json.trackers) setTrackers(json.trackers);
|
||||
if (json.ads) setAds(json.ads);
|
||||
if (json.hospital_name) setHospitalName(json.hospital_name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching queue data', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial Load & 5s Polling
|
||||
useEffect(() => {
|
||||
fetchQueueData();
|
||||
const interval = setInterval(fetchQueueData, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Auto-rotate Right Side Showcase Ads (8 seconds)
|
||||
useEffect(() => {
|
||||
if (ads.length <= 1) return;
|
||||
const timer = setInterval(() => {
|
||||
setCurrentAdIdx((prev) => (prev + 1) % ads.length);
|
||||
}, 8000);
|
||||
return () => clearInterval(timer);
|
||||
}, [ads]);
|
||||
|
||||
// Text-To-Speech Announcement Function
|
||||
const speakToken = (tokenText) => {
|
||||
if (!audioEnabled || !('speechSynthesis' in window)) return;
|
||||
try {
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(tokenText);
|
||||
utterance.rate = 0.9;
|
||||
utterance.pitch = 1.0;
|
||||
utterance.lang = 'en-US';
|
||||
window.speechSynthesis.speak(utterance);
|
||||
} catch (e) {
|
||||
console.error('Speech synthesis error', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger Voice Announcement when top token updates
|
||||
const announceToken = (item) => {
|
||||
const text = `Token Number ${item.token_no}, please proceed to ${item.name}, ${item.room}`;
|
||||
speakToken(text);
|
||||
setLastCalledToken(item.id);
|
||||
};
|
||||
|
||||
const currentAd = ads[currentAdIdx] || {
|
||||
type: 'doctor_profile',
|
||||
title: 'Our Medical Experts',
|
||||
doctor_name: 'Dr. Prakash Chandwani',
|
||||
specialization: 'Cardiologist',
|
||||
designation: 'Chief Managing Director',
|
||||
image_url: '/assets/img/profile-img.jpg',
|
||||
description: 'Available for OPD Consultations (10:00 AM - 4:00 PM)'
|
||||
};
|
||||
|
||||
// Color Themes for Department Cards
|
||||
const cardColors = ['blue', 'green', 'cyan', 'darkgreen', 'red'];
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column h-100">
|
||||
{/* Top TV Header Bar */}
|
||||
<header className="tv-header d-flex justify-content-between align-items-center">
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
<img src="/assets/img/logo.png" alt="Hospital Logo" style={{ height: '38px', width: 'auto', filter: 'brightness(0) invert(1)' }} onError={(e) => { e.target.style.display = 'none'; }} />
|
||||
<div>
|
||||
<h5 className="mb-0 fw-bold text-white" style={{ fontSize: '18px', letterSpacing: '-0.3px' }}>
|
||||
{hospitalName}
|
||||
</h5>
|
||||
<small className="text-light opacity-75" style={{ fontSize: '11px' }}>
|
||||
Live OPD Token Tracker & Patient Calling Display
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
{/* Audio Voice Announcement Toggle */}
|
||||
<button
|
||||
className={`btn btn-sm ${audioEnabled ? 'btn-danger' : 'btn-secondary'} rounded-circle p-2`}
|
||||
style={{ width: '40px', height: '40px' }}
|
||||
onClick={() => setAudioEnabled(!audioEnabled)}
|
||||
title={audioEnabled ? 'Voice Callout Enabled (Click to Mute)' : 'Voice Callout Muted (Click to Enable)'}
|
||||
>
|
||||
<i className={`bi ${audioEnabled ? 'bi-volume-up-fill' : 'bi-volume-mute-fill'} fs-5`}></i>
|
||||
</button>
|
||||
|
||||
{/* Live Clock Display */}
|
||||
<div className="bg-dark text-white px-3 py-1.5 rounded-3 border border-secondary shadow-sm text-center">
|
||||
<div className="fw-bold fs-6" style={{ fontFamily: 'monospace', letterSpacing: '1px' }}>{currentTime}</div>
|
||||
<div className="small opacity-75" style={{ fontSize: '10px' }}>LIVE TIME</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Split Screen Grid (Left: Tokens, Right: Showcase Ads) */}
|
||||
<main className="tv-main-grid">
|
||||
{/* LEFT COLUMN: LIVE TOKEN TRACKER */}
|
||||
<div className="tracker-column">
|
||||
<div className="tracker-header">
|
||||
<span><i className="bi bi-person-lines-fill me-2"></i> Token Tracker</span>
|
||||
<span className="badge bg-white text-primary rounded-pill px-3 py-1 fs-6">Live Queue</span>
|
||||
</div>
|
||||
<div className="tracker-list">
|
||||
{trackers.map((item, idx) => {
|
||||
const colorClass = cardColors[idx % cardColors.length];
|
||||
return (
|
||||
<div
|
||||
key={item.id || idx}
|
||||
tabIndex="0"
|
||||
className={`tracker-card ${colorClass} ${lastCalledToken === item.id ? 'token-pulse' : ''}`}
|
||||
onClick={() => announceToken(item)}
|
||||
>
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
<div className="avatar-circle" style={{ width: '44px', height: '44px', borderRadius: '50%', background: '#ffffff', color: '#0f172a', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '20px', fontWeight: '800' }}>
|
||||
{item.icon ? (
|
||||
<i className={`bi ${item.icon} text-primary`}></i>
|
||||
) : (
|
||||
item.name ? item.name.charAt(0) : 'D'
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="mb-0 fw-extrabold" style={{ fontSize: '19px', fontWeight: '800' }}>{item.name}</h5>
|
||||
<small className="opacity-90" style={{ fontSize: '12px', fontWeight: '500' }}>
|
||||
{item.specialization} • <span className="badge bg-white bg-opacity-25 text-white">{item.room}</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-end">
|
||||
<div className="token-number-box">{item.token_no}</div>
|
||||
<small className="opacity-75" style={{ fontSize: '10px' }}>CURRENT TOKEN</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: HOSPITAL AD SHOWCASE & DOCTOR CAROUSEL */}
|
||||
<div className="showcase-column">
|
||||
<div className="showcase-header">
|
||||
<i className="bi bi-award-fill me-2"></i> {currentAd.title || 'Our Experts'}
|
||||
</div>
|
||||
|
||||
<div className="showcase-body">
|
||||
{currentAd.type === 'doctor_profile' ? (
|
||||
<div className="d-flex flex-column align-items-center">
|
||||
<img
|
||||
src={currentAd.image_url}
|
||||
alt={currentAd.doctor_name}
|
||||
className="doctor-photo-frame"
|
||||
onError={(e) => { e.target.src = '/assets/img/profile-img.jpg'; }}
|
||||
/>
|
||||
<h3 className="fw-extrabold text-dark mb-1" style={{ fontWeight: '800', letterSpacing: '-0.5px' }}>
|
||||
{currentAd.doctor_name}
|
||||
</h3>
|
||||
<h5 className="text-primary fw-bold mb-2" style={{ fontSize: '18px' }}>
|
||||
{currentAd.specialization}
|
||||
</h5>
|
||||
<h6 className="text-secondary fw-semibold mb-3" style={{ fontSize: '15px' }}>
|
||||
{currentAd.designation}
|
||||
</h6>
|
||||
<p className="text-muted small max-width-350 px-3" style={{ fontSize: '13px', maxWidth: '420px' }}>
|
||||
{currentAd.description}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="d-flex flex-column align-items-center">
|
||||
<img
|
||||
src={currentAd.image_url}
|
||||
alt={currentAd.title}
|
||||
className="ad-banner-img"
|
||||
onError={(e) => { e.target.src = '/assets/img/slides-1.jpg'; }}
|
||||
/>
|
||||
<h3 className="fw-extrabold text-dark mb-1" style={{ fontWeight: '800' }}>
|
||||
{currentAd.title}
|
||||
</h3>
|
||||
<h5 className="text-success fw-bold mb-2" style={{ fontSize: '17px' }}>
|
||||
{currentAd.tagline}
|
||||
</h5>
|
||||
<p className="text-muted small px-3" style={{ fontSize: '13px', maxWidth: '420px' }}>
|
||||
{currentAd.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slide Indicator Dots */}
|
||||
<div className="d-flex gap-2 mt-4">
|
||||
{ads.map((ad, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className={`btn rounded-circle p-0 transition-all ${currentAdIdx === idx ? 'bg-primary' : 'bg-secondary opacity-50'}`}
|
||||
style={{ width: currentAdIdx === idx ? '24px' : '10px', height: '10px', borderRadius: '10px' }}
|
||||
onClick={() => setCurrentAdIdx(idx)}
|
||||
></button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Bottom Ticker Bar */}
|
||||
<div className="tv-ticker">
|
||||
<div className="ticker-content">
|
||||
<i className="bi bi-info-circle-fill text-warning me-2"></i>
|
||||
Welcome to {hospitalName}. Please observe your token number on the screen. Voice announcements will call your turn. For assistance, contact the help desk. • Emergency Helpline: +91-9876543210 • 24/7 Pharmacy & Ambulance Services Available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<OpdQueueTvApp />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
22
public/App/Queue/manifest.json
Normal file
22
public/App/Queue/manifest.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"short_name": "HMS Mobile",
|
||||
"name": "HMS OPD Queue & Token Display PWA",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/img/logo.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/apple-touch-icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "180x180"
|
||||
}
|
||||
],
|
||||
"start_url": "/app/queue",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"display": "standalone",
|
||||
"orientation": "landscape"
|
||||
}
|
||||
|
|
@ -157,6 +157,25 @@
|
|||
</div>
|
||||
</a>
|
||||
|
||||
<!-- OPD Queue & Token Tracker Display -->
|
||||
<a href="/app/queue" class="app-option-card">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="app-icon-circle bg-warning-subtle text-warning-emphasis">
|
||||
<i class="bi bi-display-fill"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<h6 class="fw-bold mb-0 text-dark">OPD Token Tracker TV Display</h6>
|
||||
<span class="badge bg-warning text-dark" style="font-size: 10px;">Active PWA</span>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1" style="font-size: 12px;">
|
||||
Live Token Display, Voice Callouts & Right-Side Hospital Ads
|
||||
</small>
|
||||
</div>
|
||||
<i class="bi bi-chevron-right text-muted"></i>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Pharmacy App (Coming Soon) -->
|
||||
<div class="app-option-card disabled">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user