From fd3feb787827212d2d4cccea80b8e5119803a07d Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 31 Mar 2026 09:50:11 +0000 Subject: [PATCH] updating display --- admin.php | 168 +++++++++ assets/css/custom.css | 778 +++++++++++++++++++++++------------------- assets/js/main.js | 132 +++++-- display.php | 94 +++++ doctor.php | 108 ++++++ index.php | 346 +++++++++++-------- nursing.php | 64 ++++ queue_bootstrap.php | 715 ++++++++++++++++++++++++++++++++++++++ reception.php | 123 +++++++ ticket.php | 107 ++++++ 10 files changed, 2114 insertions(+), 521 deletions(-) create mode 100644 admin.php create mode 100644 display.php create mode 100644 doctor.php create mode 100644 nursing.php create mode 100644 queue_bootstrap.php create mode 100644 reception.php create mode 100644 ticket.php diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..a6be8d1 --- /dev/null +++ b/admin.php @@ -0,0 +1,168 @@ + +
+
+
+ Admin / الإدارة +

Clinic and doctor setup.

+

This first iteration supports bilingual clinic names, a vitals-required flag, doctor-room assignments, and immediate use by reception.

+
+
+ +
+
+
+

Add clinic / إضافة عيادة

+
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ +
+
+
+
+
+

Add doctor / إضافة طبيب

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ +
+
+
+

Clinics / العيادات

+
+ +
+ + +
+
+
+
Code
+
+
+
+ + +
+
+
+ > + +
+
+
+ +
+ +
+
+
+
+
+

Doctors & rooms / الأطباء والغرف

+
+ +
+ + +
+
+
+
+
+ Current room +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+
+
+ diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..da46c50 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,493 @@ -body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; - margin: 0; - min-height: 100vh; +:root { + --bg: #f3f4f6; + --surface: #ffffff; + --surface-muted: #f8fafc; + --border: #d7dde6; + --border-strong: #c4ccd7; + --text: #111827; + --muted: #6b7280; + --accent: #1f4f78; + --accent-soft: #e8f0f6; + --warning-soft: #fff4d6; + --info-soft: #dceef8; + --success-soft: #dff3e7; + --danger-soft: #fde4e4; + --shadow: 0 10px 30px rgba(15, 23, 42, 0.06); + --radius-sm: 0.45rem; + --radius-md: 0.7rem; + --radius-lg: 0.95rem; + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; } -.main-wrapper { - display: flex; +html, +body { + min-height: 100%; +} + +body { + background: var(--bg); + color: var(--text); + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: -0.01em; +} + +a { + color: inherit; + text-decoration: none; +} + +a:hover { + color: inherit; +} + +.navbar { + min-height: 72px; +} + +.navbar-brand { + gap: 0.1rem; + text-decoration: none; +} + +.brand-mark { + display: inline-flex; align-items: center; justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; -} - -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } -} - -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - overflow: hidden; -} - -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); + width: 2.25rem; + height: 2.25rem; + border-radius: var(--radius-sm); + background: var(--text); + color: #fff; + font-size: 0.88rem; font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; - align-items: center; + letter-spacing: 0.04em; } -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; +.brand-text { + font-size: 1rem; + font-weight: 700; +} + +.nav-link { + color: var(--muted); + font-weight: 500; + padding: 0.65rem 0.8rem !important; + border-radius: var(--radius-sm); +} + +.nav-link:hover, +.nav-link.active { + background: #eef2f6; + color: var(--text); +} + +.app-shell { + min-height: calc(100vh - 72px); +} + +.hero-panel, +.page-header-panel, +.panel-card, +.workflow-card, +.ticket-card, +.queue-row, +.mini-overview-card, +.ad-card, +.hero-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); +} + +.hero-panel, +.page-header-panel, +.panel-card, +.hero-card { + padding: clamp(1.15rem, 2vw, 1.75rem); +} + +.section-kicker { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + font-weight: 700; +} + +.display-title, +.section-title-xl { + font-size: clamp(1.85rem, 3vw, 2.65rem); + line-height: 1.06; + letter-spacing: -0.03em; + font-weight: 700; + max-width: 14ch; +} + +.section-title { + font-size: 1.05rem; + font-weight: 700; +} + +.section-copy, +.lead { + color: var(--muted); + max-width: 64ch; +} + +.metric-card { + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.95rem; +} + +.metric-value { + font-size: 1.85rem; + font-weight: 700; + line-height: 1; +} + +.metric-label { + margin-top: 0.45rem; + color: var(--muted); + font-size: 0.85rem; +} + +.table > :not(caption) > * > * { + padding: 0.95rem 0.85rem; + vertical-align: middle; +} + +.table thead th { + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + background: transparent; +} + +.table-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.35rem; + padding: 0.28rem 0.55rem; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 700; + border: 1px solid transparent; +} + +.table-pill.warning { background: var(--warning-soft); color: #8a5a00; } +.table-pill.info { background: var(--info-soft); color: #165a75; } +.table-pill.dark { background: #e7edf3; color: #334155; } + +.call-strip, +.doctor-spotlight { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.95rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-muted); +} + +.ticket-code { + font-size: 1.15rem; + font-weight: 700; + letter-spacing: 0.04em; +} + +.ticket-number { + font-size: 2.15rem; + font-weight: 700; + letter-spacing: 0.05em; +} + +.ticket-number.large { + font-size: clamp(2.25rem, 4vw, 3.75rem); +} + +.workflow-card { display: flex; flex-direction: column; - gap: 1.25rem; + gap: 0.8rem; + padding: 1.2rem; + min-height: 100%; + transition: transform 0.2s ease, border-color 0.2s ease; } -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); -} - -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; -} - -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; -} - -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.chat-input-area form { - display: flex; - gap: 0.75rem; -} - -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.chat-input-area button:hover { - background: #000; +.workflow-card:hover { transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); + border-color: var(--border-strong); } -/* Background Animations */ -.bg-animations { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; -} - -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); -} - -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); -} - -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; -} - -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; -} - -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.header-link { - font-size: 14px; - color: #fff; - text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); - position: relative; - z-index: 1; -} - -.admin-container h1 { - margin-top: 0; - color: #212529; - font-weight: 800; -} - -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; -} - -.table th { - background: transparent; - border: none; - padding: 1rem; - color: #6c757d; - font-weight: 600; +.workflow-step { + display: inline-flex; + width: fit-content; + padding: 0.25rem 0.55rem; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.08em; text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; } -.table td { - background: #fff; - padding: 1rem; - border: none; +.workflow-card h3, +.ad-card h3 { + font-size: 1rem; + font-weight: 700; + margin: 0; } -.table tr td:first-child { border-radius: 12px 0 0 12px; } -.table tr td:last-child { border-radius: 0 12px 12px 0; } - -.form-group { - margin-bottom: 1.25rem; +.workflow-card p, +.ad-card p, +.empty-state span { + color: var(--muted); + margin: 0; } -.form-group label { - display: block; - margin-bottom: 0.5rem; +.bi-label { + display: inline-flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.15rem; +} + +.label-ar { + font-size: 0.96em; +} + +.status-badge { font-weight: 600; - font-size: 0.9rem; + padding: 0.48rem 0.62rem; + border-radius: 999px; } -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; +.empty-state { + display: grid; + place-items: center; + min-height: 220px; + text-align: center; + padding: 1.5rem; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-md); + background: var(--surface-muted); + gap: 0.4rem; } -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); +.empty-state.compact, +.display-empty { + min-height: 150px; } -.header-container { +.list-row-form { + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-muted); +} + +.queue-row { + padding: 1rem; +} + +.queue-row-head { display: flex; + align-items: flex-start; justify-content: space-between; - align-items: center; + gap: 1rem; + flex-wrap: wrap; } -.header-links { +.room-badge, +.ad-tag { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-muted); + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.display-shell { + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.9fr); + gap: 1rem; + min-height: calc(100vh - 132px); +} + +.announcement-card { display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.2rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-muted); +} + +.display-meta, +.live-clock { + color: var(--muted); +} + +.live-clock { + font-size: 1.4rem; + font-weight: 700; +} + +.panel-subsection { + border-top: 1px solid var(--border); + padding-top: 1.25rem; +} + +.mini-overview-card, +.ad-card { + padding: 1rem; + background: var(--surface-muted); +} + +.ticket-card hr { + border-color: var(--border); +} + +.timeline-list { + display: grid; gap: 1rem; } -.admin-card { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.5); - margin-bottom: 2.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.05); +.timeline-item { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.85rem; + align-items: start; } -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; - font-weight: 700; +.timeline-dot { + width: 0.95rem; + height: 0.95rem; + border-radius: 50%; + background: #d1d5db; + border: 2px solid var(--surface); + margin-top: 0.18rem; + box-shadow: 0 0 0 1px var(--border); } -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; +.timeline-item.done .timeline-dot { + background: #1f4f78; + box-shadow: 0 0 0 1px #1f4f78; } -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; +.timeline-item.current .timeline-dot { + background: #f59e0b; + box-shadow: 0 0 0 1px #f59e0b; } -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; +.detail-list { + display: grid; + gap: 0.85rem; +} + +.detail-list div { + display: grid; + gap: 0.15rem; +} + +.detail-list dt { + color: var(--muted); + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.detail-list dd { + margin: 0; font-weight: 600; - width: 100%; - transition: all 0.3s ease; } -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; +.form-control, +.form-select { + border-color: var(--border-strong); + border-radius: var(--radius-sm); + padding-top: 0.72rem; + padding-bottom: 0.72rem; + box-shadow: none !important; } -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); +.form-control:focus, +.form-select:focus, +.btn:focus-visible, +.nav-link:focus-visible { + border-color: var(--accent); + box-shadow: 0 0 0 0.2rem rgba(31, 79, 120, 0.12) !important; } -.history-table { - width: 100%; +.btn { + border-radius: var(--radius-sm); + padding: 0.7rem 1rem; + font-weight: 600; } -.history-table-time { - width: 15%; - white-space: nowrap; - font-size: 0.85em; - color: #555; +.btn-sm { + padding: 0.42rem 0.72rem; } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; +.btn-dark { + background: var(--text); + border-color: var(--text); } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; +.btn-outline-dark { + border-color: var(--border-strong); + color: var(--text); } -.no-messages { - text-align: center; - color: #777; -} \ No newline at end of file +.toast.app-toast { + min-width: 280px; +} + +@media (max-width: 991.98px) { + .display-shell { + grid-template-columns: 1fr; + } + + .display-title, + .section-title-xl { + max-width: none; + } +} + +@media (max-width: 767.98px) { + .queue-row-head, + .announcement-card, + .call-strip, + .doctor-spotlight { + flex-direction: column; + align-items: flex-start; + } + + .ticket-number.large { + font-size: 2.5rem; + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..aaf51f7 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,105 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + const clinicSelects = document.querySelectorAll('.js-clinic-select'); + clinicSelects.forEach((clinicSelect) => { + const form = clinicSelect.closest('form'); + if (!form) return; + const doctorSelect = form.querySelector('.js-doctor-select'); + if (!doctorSelect) return; - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; - }; - - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; - - appendMessage(message, 'visitor'); - chatInput.value = ''; - - try { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) + const syncDoctors = () => { + const clinicId = clinicSelect.value; + Array.from(doctorSelect.options).forEach((option) => { + if (!option.value) { + option.hidden = false; + return; + } + const visible = option.dataset.clinicId === clinicId; + option.hidden = !visible; + if (!visible && option.selected) { + doctorSelect.value = ''; + } }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); - } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + }; + + clinicSelect.addEventListener('change', syncDoctors); + syncDoctors(); + }); + + const ticketPrintButton = document.querySelector('.js-print-ticket'); + if (ticketPrintButton) { + ticketPrintButton.addEventListener('click', () => window.print()); + } + + document.querySelectorAll('.js-app-toast').forEach((toastNode) => { + if (window.bootstrap && bootstrap.Toast) { + bootstrap.Toast.getOrCreateInstance(toastNode, { delay: 3200 }).show(); } }); + + const liveClock = document.querySelector('.js-live-clock'); + if (liveClock) { + const renderClock = () => { + const now = new Date(); + liveClock.textContent = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + renderClock(); + window.setInterval(renderClock, 1000 * 30); + } + + const page = document.body.dataset.page; + if (page === 'display') { + const fullscreenButton = document.querySelector('.js-fullscreen-toggle'); + const syncFullscreenButton = () => { + if (!fullscreenButton) return; + const isFullscreen = !!document.fullscreenElement; + fullscreenButton.textContent = isFullscreen ? 'Exit full display / إنهاء العرض الكامل' : 'Full display / عرض كامل'; + fullscreenButton.setAttribute('aria-pressed', isFullscreen ? 'true' : 'false'); + }; + + if (fullscreenButton && document.fullscreenEnabled) { + fullscreenButton.addEventListener('click', async () => { + try { + if (document.fullscreenElement) { + await document.exitFullscreen(); + } else { + await document.documentElement.requestFullscreen(); + } + } catch (error) { + console.warn('Fullscreen toggle failed', error); + } + }); + document.addEventListener('fullscreenchange', syncFullscreenButton); + syncFullscreenButton(); + } else if (fullscreenButton) { + fullscreenButton.hidden = true; + } + + const cards = Array.from(document.querySelectorAll('.announcement-card')); + const latest = cards[0]; + if (latest && 'speechSynthesis' in window) { + const announcementKey = latest.dataset.announcementKey || ''; + const storedKey = window.localStorage.getItem('hospitalQueue:lastAnnouncement') || ''; + if (announcementKey && announcementKey !== storedKey) { + const speakText = (text, lang) => { + if (!text) return; + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = lang; + const voices = window.speechSynthesis.getVoices(); + const preferredVoice = voices.find((voice) => voice.lang.toLowerCase().startsWith(lang.slice(0, 2))); + if (preferredVoice) utterance.voice = preferredVoice; + window.speechSynthesis.speak(utterance); + }; + + window.speechSynthesis.cancel(); + speakText(latest.dataset.announcementEn || '', 'en-US'); + window.setTimeout(() => speakText(latest.dataset.announcementAr || '', 'ar-SA'), 1750); + window.localStorage.setItem('hospitalQueue:lastAnnouncement', announcementKey); + } + } + + const autoRefreshSeconds = parseInt(document.querySelector('[data-auto-refresh]')?.dataset.autoRefresh || '0', 10); + if (autoRefreshSeconds > 0) { + window.setTimeout(() => window.location.reload(), autoRefreshSeconds * 1000); + } + } }); diff --git a/display.php b/display.php new file mode 100644 index 0000000..ca69f73 --- /dev/null +++ b/display.php @@ -0,0 +1,94 @@ + +
+
+
+
+
+
General display / الشاشة العامة
+

Now serving

+

Latest calls are read aloud in English and Arabic when supported by the browser.

+
+
+ +
+
+
+ + +
+ +
+
+
+
· Room
+
+
+ +
+
+
+ +
+ +
+ No live calls right now. + When a doctor presses “Call patient”, the ticket will appear here and play on TTS. +
+ + +
+

Queue by clinic / الطابور حسب العيادة

+
+ +
+
+
+
+
+ Vitals + Doctor +
+
+
+ +
+
+
+ + +
+
+ diff --git a/doctor.php b/doctor.php new file mode 100644 index 0000000..a09d81c --- /dev/null +++ b/doctor.php @@ -0,0 +1,108 @@ + +
+
+
+ Doctor / الطبيب +

Call the next patient and update status.

+

The display page announces called tickets in English and Arabic using the browser’s text-to-speech engine.

+
+
+ +
+
+
+ + +
+ +
+
+
+
+
+
+ Room +
+
+ +
+
+ +
+
+
+

Doctor queue / طابور الطبيب

+

Ready patients, live calls, and in-room consultations for the selected doctor.

+
+ Preview public display +
+ + +
+ +
+
+
+
+
+
Vitals:
+
+ +
+
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+
+ +
+ +
+ No patients in this doctor queue. + Issue a ticket at reception or complete vitals in nursing to fill this room. +
+ +
+
+ diff --git a/index.php b/index.php index 7205f3d..6120431 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,208 @@ - - - - - - New Style - - - - - - - - - - - - - - - - - - - - - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… +
+
+
+
+ Hospital queue system / نظام الطوابير +

One bilingual workflow for reception, nursing, doctors, and the public screen.

+

Issue one ticket, route it through optional vitals, call patients to the right room, and announce the latest calls with browser text-to-speech on the general display.

+ +
+
+
+
Today / اليوم
+
+
+
+
+
Issued / المُصدرة
+
+
+
+
+
+
Vitals / الحيوية
+
+
+
+
+
+
Ready / جاهز
+
+
+
+
+
+
Active rooms / الغرف النشطة
+
+
+
+
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

-
- - - + + +
+
+
+
+
+

Live queue overview / نظرة مباشرة على الطابور

+

Clinic-level demand for vitals, doctor readiness, and active calls.

+
+ Manage clinics & doctors +
+
+ + + + + + + + + + + + + + + + + + + + + +
Clinic / العيادةVitals waitDoctor waitActive callsTotal today
+
+
+
+
+
+
+
+
+
+
+

Current calls / النداءات الحالية

+

Patients already called to a doctor room.

+
+
+ +
+ +
+
+
+
· Room
+
+ +
+ +
+ +
+ No active calls yet. + Use the doctor page to call the next patient. +
+ +
+
+
+ +
+ + + + +
+ +
+
+
+

Recent patient flow / آخر حركة للمرضى

+

Each ticket is linked to a detail page showing its current stage.

+
+ Create new ticket +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
TicketPatientClinicDoctor / RoomStatus
+
+
+
+
+
+
· View detail
+
+ +
+ No active tickets yet. + Start from the reception desk to issue the first patient ticket. +
+ +
+ + diff --git a/nursing.php b/nursing.php new file mode 100644 index 0000000..022aa36 --- /dev/null +++ b/nursing.php @@ -0,0 +1,64 @@ + +
+
+
+ Nursing / التمريض +

Capture vitals and release to doctor.

+

Only tickets from clinics marked “requires vitals” arrive here. Once notes are saved, the same ticket continues to the doctor queue.

+
+
+ +
+
+
+

Waiting for vitals / بانتظار العلامات الحيوية

+

Add a short clinical note to transfer the patient to the assigned doctor.

+
+ patients +
+ + +
+ +
+
+
+
+
+
· Room
+
+ +
+
+ +
+ + +
+
+ +
+
+
+ +
+ +
+ No patients waiting for vitals. + Tickets from vitals-required clinics will appear here automatically after issue. +
+ +
+
+ diff --git a/queue_bootstrap.php b/queue_bootstrap.php new file mode 100644 index 0000000..9e8988e --- /dev/null +++ b/queue_bootstrap.php @@ -0,0 +1,715 @@ +exec($sql); +} + +function qh_seed_demo_data(): void +{ + $pdo = db(); + + $clinicCount = (int) $pdo->query("SELECT COUNT(*) FROM hospital_queue_records WHERE item_type = 'clinic'")->fetchColumn(); + if ($clinicCount === 0) { + $insertClinic = $pdo->prepare( + 'INSERT INTO hospital_queue_records (item_type, code, name_en, name_ar, requires_vitals, sort_order, status) + VALUES (\'clinic\', :code, :name_en, :name_ar, :requires_vitals, :sort_order, \'active\')' + ); + + $clinics = [ + ['code' => 'GEN', 'name_en' => 'General Medicine', 'name_ar' => 'الطب العام', 'requires_vitals' => 1, 'sort_order' => 10], + ['code' => 'PED', 'name_en' => 'Pediatrics', 'name_ar' => 'طب الأطفال', 'requires_vitals' => 0, 'sort_order' => 20], + ['code' => 'CAR', 'name_en' => 'Cardiology', 'name_ar' => 'أمراض القلب', 'requires_vitals' => 1, 'sort_order' => 30], + ]; + + foreach ($clinics as $clinic) { + $insertClinic->execute($clinic); + } + } + + $clinicMap = []; + foreach (qh_fetch_clinics() as $clinic) { + $clinicMap[$clinic['code']] = $clinic['id']; + } + + $doctorCount = (int) $pdo->query("SELECT COUNT(*) FROM hospital_queue_records WHERE item_type = 'doctor'")->fetchColumn(); + if ($doctorCount === 0 && $clinicMap !== []) { + $insertDoctor = $pdo->prepare( + 'INSERT INTO hospital_queue_records (item_type, name_en, name_ar, clinic_id, room_number, sort_order, status) + VALUES (\'doctor\', :name_en, :name_ar, :clinic_id, :room_number, :sort_order, \'active\')' + ); + + $doctors = [ + ['name_en' => 'Dr. Sarah Malik', 'name_ar' => 'د. سارة مالك', 'clinic_id' => $clinicMap['GEN'] ?? null, 'room_number' => '201', 'sort_order' => 10], + ['name_en' => 'Dr. Omar Nasser', 'name_ar' => 'د. عمر ناصر', 'clinic_id' => $clinicMap['GEN'] ?? null, 'room_number' => '202', 'sort_order' => 20], + ['name_en' => 'Dr. Lina Haddad', 'name_ar' => 'د. لينا حداد', 'clinic_id' => $clinicMap['PED'] ?? null, 'room_number' => '103', 'sort_order' => 30], + ['name_en' => 'Dr. Ahmad Kareem', 'name_ar' => 'د. أحمد كريم', 'clinic_id' => $clinicMap['CAR'] ?? null, 'room_number' => '305', 'sort_order' => 40], + ]; + + foreach ($doctors as $doctor) { + if ($doctor['clinic_id']) { + $insertDoctor->execute($doctor); + } + } + } + + $ticketCount = (int) $pdo->query("SELECT COUNT(*) FROM hospital_queue_records WHERE item_type = 'ticket'")->fetchColumn(); + if ($ticketCount === 0) { + $doctors = qh_fetch_doctors(); + $doctorByClinic = []; + foreach ($doctors as $doctor) { + $doctorByClinic[$doctor['clinic_id']][] = $doctor; + } + + foreach (qh_fetch_clinics() as $clinic) { + if (empty($doctorByClinic[$clinic['id']])) { + continue; + } + $assignedDoctor = $doctorByClinic[$clinic['id']][0]; + qh_create_ticket( + $clinic['name_en'] === 'General Medicine' ? 'Maha Ali' : 'Yousef Karim', + (int) $clinic['id'], + (int) $assignedDoctor['id'], + $clinic['name_en'] === 'General Medicine' ? 'ar' : 'en', + $clinic['requires_vitals'] ? 'waiting_vitals' : 'ready_for_doctor' + ); + if ($clinic['name_en'] !== 'General Medicine') { + break; + } + } + } +} + +function qh_h(?string $value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +function qh_project_name(string $fallback = 'Hospital Queue'): string +{ + $candidate = trim((string) ($_SERVER['PROJECT_NAME'] ?? $_SERVER['PROJECT_TITLE'] ?? '')); + return $candidate !== '' ? $candidate : $fallback; +} + +function qh_project_description(string $fallback = 'Bilingual hospital queue workflow for reception, nursing, doctors, and the public display.'): string +{ + $candidate = trim((string) ($_SERVER['PROJECT_DESCRIPTION'] ?? '')); + return $candidate !== '' ? $candidate : $fallback; +} + +function qh_asset_version(string $relativePath): int +{ + $fullPath = __DIR__ . '/' . ltrim($relativePath, '/'); + return is_file($fullPath) ? (int) filemtime($fullPath) : time(); +} + +function qh_label(string $en, string $ar, string $wrapper = 'span'): string +{ + $tag = preg_replace('/[^a-z0-9]/i', '', $wrapper) ?: 'span'; + return sprintf( + '<%1$s class="bi-label">%2$s / %3$s', + $tag, + qh_h($en), + qh_h($ar) + ); +} + +function qh_page_start(string $activePage, string $pageTitle, string $metaDescription = ''): void +{ + $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? qh_project_description(); + $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; + $fullTitle = $pageTitle . ' · ' . qh_project_name(); + $description = $metaDescription !== '' ? $metaDescription : qh_project_description(); + $bodyClass = 'page-' . preg_replace('/[^a-z0-9\-]/i', '-', $activePage); + $assetVersionCss = qh_asset_version('assets/css/custom.css'); + $assetVersionJs = qh_asset_version('assets/js/main.js'); + + echo ''; + echo ''; + echo ''; + echo ' '; + echo ' '; + echo ' ' . qh_h($fullTitle) . ''; + echo ' '; + if ($projectDescription) { + echo ' '; + echo ' '; + } + if ($projectImageUrl) { + echo ' '; + echo ' '; + } + echo ' '; + echo ' '; + echo ' '; + echo ''; + echo ''; + if ($activePage !== 'display') { + qh_render_nav($activePage); + } + echo '
'; + qh_render_flash(); +} + +function qh_page_end(): void +{ + $assetVersionJs = qh_asset_version('assets/js/main.js'); + echo '
'; + echo ''; + echo ''; + echo ''; +} + +function qh_render_nav(string $activePage): void +{ + $links = [ + 'home' => ['href' => 'index.php', 'label' => qh_label('Operations', 'العمليات')], + 'admin' => ['href' => 'admin.php', 'label' => qh_label('Admin', 'الإدارة')], + 'reception' => ['href' => 'reception.php', 'label' => qh_label('Reception', 'الاستقبال')], + 'nursing' => ['href' => 'nursing.php', 'label' => qh_label('Nursing', 'التمريض')], + 'doctor' => ['href' => 'doctor.php', 'label' => qh_label('Doctor', 'الطبيب')], + 'display' => ['href' => 'display.php', 'label' => qh_label('Display', 'الشاشة العامة')], + ]; + + echo '
'; + echo ' '; + echo '
'; +} + +function qh_set_flash(string $type, string $message): void +{ + $_SESSION['flash'] = ['type' => $type, 'message' => $message]; +} + +function qh_render_flash(): void +{ + if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) { + return; + } + + $flash = $_SESSION['flash']; + unset($_SESSION['flash']); + + $typeMap = [ + 'success' => 'success', + 'danger' => 'danger', + 'warning' => 'warning', + 'info' => 'primary', + ]; + $toastType = $typeMap[$flash['type']] ?? 'primary'; + + echo '
'; + echo '
'; + echo '
'; + echo '
' . qh_h((string) $flash['message']) . '
'; + echo ' '; + echo '
'; + echo '
'; + echo '
'; +} + +function qh_redirect(string $location): void +{ + header('Location: ' . $location); + exit; +} + +function qh_fetch_clinics(): array +{ + $stmt = db()->query("SELECT * FROM hospital_queue_records WHERE item_type = 'clinic' ORDER BY sort_order ASC, name_en ASC"); + return $stmt->fetchAll(); +} + +function qh_fetch_doctors(?int $clinicId = null): array +{ + if ($clinicId) { + $stmt = db()->prepare( + "SELECT d.*, c.name_en AS clinic_name_en, c.name_ar AS clinic_name_ar, c.code AS clinic_code + FROM hospital_queue_records d + LEFT JOIN hospital_queue_records c ON c.id = d.clinic_id AND c.item_type = 'clinic' + WHERE d.item_type = 'doctor' AND d.clinic_id = :clinic_id + ORDER BY d.sort_order ASC, d.name_en ASC" + ); + $stmt->execute(['clinic_id' => $clinicId]); + return $stmt->fetchAll(); + } + + $stmt = db()->query( + "SELECT d.*, c.name_en AS clinic_name_en, c.name_ar AS clinic_name_ar, c.code AS clinic_code + FROM hospital_queue_records d + LEFT JOIN hospital_queue_records c ON c.id = d.clinic_id AND c.item_type = 'clinic' + WHERE d.item_type = 'doctor' + ORDER BY c.sort_order ASC, d.sort_order ASC, d.name_en ASC" + ); + return $stmt->fetchAll(); +} + +function qh_fetch_ticket(int $ticketId): ?array +{ + $stmt = db()->prepare( + "SELECT t.*, c.name_en AS clinic_name_en, c.name_ar AS clinic_name_ar, c.code AS clinic_code, c.requires_vitals AS clinic_requires_vitals, + d.name_en AS doctor_name_en, d.name_ar AS doctor_name_ar, d.room_number AS doctor_room + FROM hospital_queue_records t + LEFT JOIN hospital_queue_records c ON c.id = t.clinic_id AND c.item_type = 'clinic' + LEFT JOIN hospital_queue_records d ON d.id = t.doctor_id AND d.item_type = 'doctor' + WHERE t.item_type = 'ticket' AND t.id = :ticket_id + LIMIT 1" + ); + $stmt->execute(['ticket_id' => $ticketId]); + $ticket = $stmt->fetch(); + return $ticket ?: null; +} + +function qh_fetch_tickets(array $statuses = [], ?int $doctorId = null, ?int $limit = null): array +{ + $sql = "SELECT t.*, c.name_en AS clinic_name_en, c.name_ar AS clinic_name_ar, c.code AS clinic_code, c.requires_vitals AS clinic_requires_vitals, + d.name_en AS doctor_name_en, d.name_ar AS doctor_name_ar, d.room_number AS doctor_room + FROM hospital_queue_records t + LEFT JOIN hospital_queue_records c ON c.id = t.clinic_id AND c.item_type = 'clinic' + LEFT JOIN hospital_queue_records d ON d.id = t.doctor_id AND d.item_type = 'doctor' + WHERE t.item_type = 'ticket'"; + + $params = []; + + if ($statuses !== []) { + $statusPlaceholders = []; + foreach ($statuses as $index => $status) { + $key = 'status_' . $index; + $statusPlaceholders[] = ':' . $key; + $params[$key] = $status; + } + $sql .= ' AND t.status IN (' . implode(', ', $statusPlaceholders) . ')'; + } + + if ($doctorId) { + $sql .= ' AND t.doctor_id = :doctor_id'; + $params['doctor_id'] = $doctorId; + } + + $sql .= ' ORDER BY COALESCE(t.called_at, t.created_at) DESC, t.id DESC'; + + if ($limit) { + $sql .= ' LIMIT ' . (int) $limit; + } + + $stmt = db()->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); +} + +function qh_queue_overview(): array +{ + $sql = "SELECT c.id, c.name_en, c.name_ar, c.code, + SUM(CASE WHEN t.status = 'waiting_vitals' THEN 1 ELSE 0 END) AS vitals_waiting, + SUM(CASE WHEN t.status = 'ready_for_doctor' THEN 1 ELSE 0 END) AS doctor_waiting, + SUM(CASE WHEN t.status IN ('called', 'in_progress') THEN 1 ELSE 0 END) AS active_calls, + COUNT(t.id) AS total_today + FROM hospital_queue_records c + LEFT JOIN hospital_queue_records t + ON t.clinic_id = c.id + AND t.item_type = 'ticket' + AND DATE(t.created_at) = CURDATE() + WHERE c.item_type = 'clinic' + GROUP BY c.id, c.name_en, c.name_ar, c.code + ORDER BY c.sort_order ASC, c.name_en ASC"; + + return db()->query($sql)->fetchAll(); +} + +function qh_dashboard_stats(): array +{ + $pdo = db(); + return [ + 'issued_today' => (int) $pdo->query("SELECT COUNT(*) FROM hospital_queue_records WHERE item_type = 'ticket' AND DATE(created_at) = CURDATE()")->fetchColumn(), + 'waiting_vitals' => (int) $pdo->query("SELECT COUNT(*) FROM hospital_queue_records WHERE item_type = 'ticket' AND status = 'waiting_vitals'")->fetchColumn(), + 'ready_for_doctor' => (int) $pdo->query("SELECT COUNT(*) FROM hospital_queue_records WHERE item_type = 'ticket' AND status = 'ready_for_doctor'")->fetchColumn(), + 'active_rooms' => (int) $pdo->query("SELECT COUNT(DISTINCT doctor_id) FROM hospital_queue_records WHERE item_type = 'ticket' AND status IN ('called', 'in_progress') AND doctor_id IS NOT NULL")->fetchColumn(), + ]; +} + +function qh_create_ticket(string $patientName, int $clinicId, int $doctorId, string $languagePref = 'en', ?string $forcedStatus = null): int +{ + $pdo = db(); + $clinic = qh_fetch_clinic($clinicId); + if (!$clinic) { + throw new RuntimeException('Clinic not found.'); + } + + $doctor = qh_fetch_doctor($doctorId); + if (!$doctor || (int) $doctor['clinic_id'] !== $clinicId) { + throw new RuntimeException('Doctor does not belong to the selected clinic.'); + } + + $ticketNumber = qh_generate_ticket_number($clinic['code']); + $status = $forcedStatus ?: ((int) $clinic['requires_vitals'] === 1 ? 'waiting_vitals' : 'ready_for_doctor'); + + $stmt = $pdo->prepare( + "INSERT INTO hospital_queue_records + (item_type, clinic_id, doctor_id, patient_name, language_pref, ticket_number, status, display_note) + VALUES + ('ticket', :clinic_id, :doctor_id, :patient_name, :language_pref, :ticket_number, :status, :display_note)" + ); + $stmt->execute([ + 'clinic_id' => $clinicId, + 'doctor_id' => $doctorId, + 'patient_name' => $patientName, + 'language_pref' => in_array($languagePref, ['en', 'ar'], true) ? $languagePref : 'en', + 'ticket_number' => $ticketNumber, + 'status' => $status, + 'display_note' => $status === 'waiting_vitals' + ? 'Proceed to nursing vitals first.' + : 'Wait for your doctor call on the public screen.', + ]); + + return (int) $pdo->lastInsertId(); +} + +function qh_generate_ticket_number(string $clinicCode): string +{ + $prefix = strtoupper(substr(preg_replace('/[^A-Z0-9]/i', '', $clinicCode), 0, 3)); + $stmt = db()->prepare( + "SELECT COUNT(*) + FROM hospital_queue_records + WHERE item_type = 'ticket' + AND ticket_number LIKE :prefix" + ); + $stmt->execute(['prefix' => $prefix . '-%']); + $next = ((int) $stmt->fetchColumn()) + 1; + return $prefix . '-' . str_pad((string) $next, 3, '0', STR_PAD_LEFT); +} + +function qh_fetch_clinic(int $clinicId): ?array +{ + $stmt = db()->prepare("SELECT * FROM hospital_queue_records WHERE item_type = 'clinic' AND id = :id LIMIT 1"); + $stmt->execute(['id' => $clinicId]); + $row = $stmt->fetch(); + return $row ?: null; +} + +function qh_fetch_doctor(int $doctorId): ?array +{ + $stmt = db()->prepare("SELECT * FROM hospital_queue_records WHERE item_type = 'doctor' AND id = :id LIMIT 1"); + $stmt->execute(['id' => $doctorId]); + $row = $stmt->fetch(); + return $row ?: null; +} + +function qh_status_meta(string $status): array +{ + $map = [ + 'waiting_vitals' => ['class' => 'warning', 'en' => 'Waiting for vitals', 'ar' => 'بانتظار العلامات الحيوية'], + 'ready_for_doctor' => ['class' => 'info', 'en' => 'Ready for doctor', 'ar' => 'جاهز للطبيب'], + 'called' => ['class' => 'primary', 'en' => 'Called', 'ar' => 'تم النداء'], + 'in_progress' => ['class' => 'secondary', 'en' => 'In consultation', 'ar' => 'داخل العيادة'], + 'done' => ['class' => 'success', 'en' => 'Completed', 'ar' => 'مكتمل'], + 'no_show' => ['class' => 'danger', 'en' => 'No-show', 'ar' => 'لم يحضر'], + ]; + + return $map[$status] ?? ['class' => 'light', 'en' => ucfirst(str_replace('_', ' ', $status)), 'ar' => $status]; +} + +function qh_status_badge(string $status): string +{ + $meta = qh_status_meta($status); + return '' . qh_label($meta['en'], $meta['ar']) . ''; +} + +function qh_call_message(array $ticket): array +{ + $ticketNumber = $ticket['ticket_number'] ?? '---'; + $doctorNameEn = $ticket['doctor_name_en'] ?? 'Doctor'; + $doctorNameAr = $ticket['doctor_name_ar'] ?? 'الطبيب'; + $room = $ticket['doctor_room'] ?? '--'; + + return [ + 'en' => sprintf('Ticket %s, please proceed to room %s for %s.', $ticketNumber, $room, $doctorNameEn), + 'ar' => sprintf('رقم التذكرة %s، يرجى التوجه إلى الغرفة %s إلى %s.', $ticketNumber, $room, $doctorNameAr), + ]; +} + +function qh_format_datetime(?string $value): string +{ + if (!$value) { + return '—'; + } + $timestamp = strtotime($value); + return $timestamp ? date('M d, Y H:i', $timestamp) : '—'; +} + +function qh_require_post(): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + qh_set_flash('danger', 'Invalid request method.'); + qh_redirect('index.php'); + } +} + +function qh_admin_handle_request(): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + return; + } + + $action = $_POST['action'] ?? ''; + $pdo = db(); + + try { + if ($action === 'add_clinic') { + $code = strtoupper(trim((string) ($_POST['code'] ?? ''))); + $nameEn = trim((string) ($_POST['name_en'] ?? '')); + $nameAr = trim((string) ($_POST['name_ar'] ?? '')); + $requiresVitals = isset($_POST['requires_vitals']) ? 1 : 0; + if ($code === '' || $nameEn === '' || $nameAr === '') { + throw new InvalidArgumentException('Please complete the clinic code and bilingual names.'); + } + $stmt = $pdo->prepare( + "INSERT INTO hospital_queue_records (item_type, code, name_en, name_ar, requires_vitals, sort_order, status) + VALUES ('clinic', :code, :name_en, :name_ar, :requires_vitals, :sort_order, 'active')" + ); + $stmt->execute([ + 'code' => substr($code, 0, 10), + 'name_en' => $nameEn, + 'name_ar' => $nameAr, + 'requires_vitals' => $requiresVitals, + 'sort_order' => (int) ($_POST['sort_order'] ?? 50), + ]); + qh_set_flash('success', 'Clinic saved successfully.'); + } elseif ($action === 'update_clinic') { + $clinicId = (int) ($_POST['clinic_id'] ?? 0); + $stmt = $pdo->prepare( + "UPDATE hospital_queue_records + SET requires_vitals = :requires_vitals, sort_order = :sort_order + WHERE item_type = 'clinic' AND id = :clinic_id" + ); + $stmt->execute([ + 'requires_vitals' => isset($_POST['requires_vitals']) ? 1 : 0, + 'sort_order' => (int) ($_POST['sort_order'] ?? 50), + 'clinic_id' => $clinicId, + ]); + qh_set_flash('success', 'Clinic settings updated.'); + } elseif ($action === 'add_doctor') { + $nameEn = trim((string) ($_POST['name_en'] ?? '')); + $nameAr = trim((string) ($_POST['name_ar'] ?? '')); + $clinicId = (int) ($_POST['clinic_id'] ?? 0); + $roomNumber = trim((string) ($_POST['room_number'] ?? '')); + if ($nameEn === '' || $nameAr === '' || $clinicId <= 0 || $roomNumber === '') { + throw new InvalidArgumentException('Please complete the doctor form before saving.'); + } + $stmt = $pdo->prepare( + "INSERT INTO hospital_queue_records (item_type, name_en, name_ar, clinic_id, room_number, sort_order, status) + VALUES ('doctor', :name_en, :name_ar, :clinic_id, :room_number, :sort_order, 'active')" + ); + $stmt->execute([ + 'name_en' => $nameEn, + 'name_ar' => $nameAr, + 'clinic_id' => $clinicId, + 'room_number' => $roomNumber, + 'sort_order' => (int) ($_POST['sort_order'] ?? 50), + ]); + qh_set_flash('success', 'Doctor profile saved.'); + } elseif ($action === 'update_doctor') { + $doctorId = (int) ($_POST['doctor_id'] ?? 0); + $clinicId = (int) ($_POST['clinic_id'] ?? 0); + $roomNumber = trim((string) ($_POST['room_number'] ?? '')); + $stmt = $pdo->prepare( + "UPDATE hospital_queue_records + SET clinic_id = :clinic_id, room_number = :room_number, sort_order = :sort_order + WHERE item_type = 'doctor' AND id = :doctor_id" + ); + $stmt->execute([ + 'clinic_id' => $clinicId, + 'room_number' => $roomNumber, + 'sort_order' => (int) ($_POST['sort_order'] ?? 50), + 'doctor_id' => $doctorId, + ]); + qh_set_flash('success', 'Doctor assignment updated.'); + } + } catch (Throwable $exception) { + qh_set_flash('danger', $exception->getMessage()); + } + + qh_redirect('admin.php'); +} + +function qh_reception_handle_request(): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + return; + } + + try { + $patientName = trim((string) ($_POST['patient_name'] ?? '')); + $clinicId = (int) ($_POST['clinic_id'] ?? 0); + $doctorId = (int) ($_POST['doctor_id'] ?? 0); + $languagePref = trim((string) ($_POST['language_pref'] ?? 'en')); + + if ($patientName === '' || $clinicId <= 0 || $doctorId <= 0) { + throw new InvalidArgumentException('Please complete patient name, clinic, and doctor.'); + } + + $ticketId = qh_create_ticket($patientName, $clinicId, $doctorId, $languagePref); + qh_set_flash('success', 'Ticket issued successfully.'); + qh_redirect('reception.php?ticket_id=' . $ticketId); + } catch (Throwable $exception) { + qh_set_flash('danger', $exception->getMessage()); + qh_redirect('reception.php'); + } +} + +function qh_nursing_handle_request(): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + return; + } + + try { + $ticketId = (int) ($_POST['ticket_id'] ?? 0); + $vitalsNotes = trim((string) ($_POST['vitals_notes'] ?? '')); + if ($ticketId <= 0 || $vitalsNotes === '') { + throw new InvalidArgumentException('Please add a short vitals note before sending the patient forward.'); + } + + $stmt = db()->prepare( + "UPDATE hospital_queue_records + SET vitals_notes = :vitals_notes, + status = 'ready_for_doctor', + display_note = 'Vitals completed. Wait for doctor call.' + WHERE item_type = 'ticket' AND id = :ticket_id AND status = 'waiting_vitals'" + ); + $stmt->execute([ + 'vitals_notes' => $vitalsNotes, + 'ticket_id' => $ticketId, + ]); + qh_set_flash('success', 'Vitals captured and patient moved to doctor queue.'); + } catch (Throwable $exception) { + qh_set_flash('danger', $exception->getMessage()); + } + + qh_redirect('nursing.php'); +} + +function qh_doctor_handle_request(): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + return; + } + + try { + $ticketId = (int) ($_POST['ticket_id'] ?? 0); + $doctorId = (int) ($_POST['doctor_id'] ?? 0); + $action = trim((string) ($_POST['action'] ?? '')); + $ticket = qh_fetch_ticket($ticketId); + if (!$ticket || $doctorId <= 0 || (int) $ticket['doctor_id'] !== $doctorId) { + throw new InvalidArgumentException('That ticket is not available for the selected doctor.'); + } + + if ($action === 'call_ticket') { + $message = qh_call_message($ticket); + $stmt = db()->prepare( + "UPDATE hospital_queue_records + SET status = 'called', called_at = NOW(), display_note = :display_note + WHERE item_type = 'ticket' AND id = :ticket_id" + ); + $stmt->execute([ + 'display_note' => $message['en'], + 'ticket_id' => $ticketId, + ]); + qh_set_flash('success', 'Patient call pushed to the public display.'); + } elseif ($action === 'start_visit') { + $stmt = db()->prepare( + "UPDATE hospital_queue_records + SET status = 'in_progress', display_note = 'Patient is now in consultation.' + WHERE item_type = 'ticket' AND id = :ticket_id" + ); + $stmt->execute(['ticket_id' => $ticketId]); + qh_set_flash('success', 'Consultation started.'); + } elseif ($action === 'complete_ticket') { + $stmt = db()->prepare( + "UPDATE hospital_queue_records + SET status = 'done', served_at = NOW(), display_note = 'Visit completed.' + WHERE item_type = 'ticket' AND id = :ticket_id" + ); + $stmt->execute(['ticket_id' => $ticketId]); + qh_set_flash('success', 'Visit marked as completed.'); + } elseif ($action === 'mark_no_show') { + $stmt = db()->prepare( + "UPDATE hospital_queue_records + SET status = 'no_show', served_at = NOW(), display_note = 'Marked as no-show.' + WHERE item_type = 'ticket' AND id = :ticket_id" + ); + $stmt->execute(['ticket_id' => $ticketId]); + qh_set_flash('warning', 'Patient marked as no-show.'); + } + + qh_redirect('doctor.php?doctor_id=' . $doctorId); + } catch (Throwable $exception) { + qh_set_flash('danger', $exception->getMessage()); + qh_redirect('doctor.php'); + } +} diff --git a/reception.php b/reception.php new file mode 100644 index 0000000..66e699d --- /dev/null +++ b/reception.php @@ -0,0 +1,123 @@ + +
+
+
+ Reception / الاستقبال +

Issue one ticket for the full visit.

+

Reception decides the clinic and doctor once. The system automatically routes the patient to vitals first only when the selected clinic requires it.

+
+
+ +
+
+
+

New patient ticket / تذكرة مريض جديدة

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+ +
+
+
+
Issued ticket / التذكرة الصادرة
+
+
+
· · Room
+
+
+ + +
+
+
+
+
Issued:
+
Language:
+
Next stop:
+
+
+ + +
+
+
+

Today’s tickets / تذاكر اليوم

+

The latest issued tickets and where they currently are in the visit flow.

+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
TicketPatientClinicStatus
View
+
+
+
+
+
+ diff --git a/ticket.php b/ticket.php new file mode 100644 index 0000000..55724b6 --- /dev/null +++ b/ticket.php @@ -0,0 +1,107 @@ + 0 ? qh_fetch_ticket($ticketId) : null; + +qh_page_start('home', 'Ticket detail', 'Detailed patient ticket timeline for the hospital queue workflow.'); +?> +
+
+
+ Ticket detail / تفاصيل التذكرة +

Track one patient through the visit.

+

This view confirms the assigned clinic, doctor, room, vitals notes, and current status.

+
+
+ + +
+
+ Ticket not found. + Return to reception and choose a valid ticket. +
+
+ +
+
+
+
+
+
· · Room
+
+
+ + Preferred language: +
+
+
+ +
+
+
+

Visit timeline / خط سير الزيارة

+
+
+
+
+
Ticket issued / تم إصدار التذكرة
+
+
+
+ +
+
+
+
Nursing vitals / العلامات الحيوية
+
+
+
+ +
+
+
+
Ready for doctor / جاهز للطبيب
+
Assigned to , room
+
+
+
+
+
+
Called to room / تم النداء للغرفة
+
+
+
+
+
+
+
Visit closed / إغلاق الزيارة
+
+
+
+
+
+
+
+
+

Details / التفاصيل

+
+
Clinic
+
Doctor
+
Room
+
Vitals note
+
Last note
+
+ +
+
+
+ +
+