updating display

This commit is contained in:
Flatlogic Bot 2026-03-31 09:50:11 +00:00
parent 560e915977
commit fd3feb7878
10 changed files with 2114 additions and 521 deletions

168
admin.php Normal file
View File

@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/queue_bootstrap.php';
qh_boot();
qh_admin_handle_request();
$clinics = qh_fetch_clinics();
$doctors = qh_fetch_doctors();
qh_page_start('admin', 'Admin configuration', 'Configure clinics, doctors, room numbers, and vitals requirements for the queue workflow.');
?>
<div class="container-xxl px-3 px-lg-4">
<section class="page-header-panel mb-4">
<div>
<span class="section-kicker">Admin / الإدارة</span>
<h1 class="section-title-xl mt-2">Clinic and doctor setup.</h1>
<p class="section-copy mb-0">This first iteration supports bilingual clinic names, a vitals-required flag, doctor-room assignments, and immediate use by reception.</p>
</div>
</section>
<div class="row g-4 mb-4">
<div class="col-xl-5">
<div class="panel-card h-100">
<h2 class="section-title mb-3">Add clinic / إضافة عيادة</h2>
<form method="post" class="vstack gap-3">
<input type="hidden" name="action" value="add_clinic">
<div>
<label class="form-label">Code</label>
<input class="form-control" type="text" name="code" maxlength="10" placeholder="GEN" required>
</div>
<div>
<label class="form-label">Name (English)</label>
<input class="form-control" type="text" name="name_en" placeholder="General Medicine" required>
</div>
<div>
<label class="form-label">Name (Arabic)</label>
<input class="form-control" type="text" name="name_ar" placeholder="الطب العام" required>
</div>
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label">Sort order</label>
<input class="form-control" type="number" name="sort_order" value="50" min="1">
</div>
<div class="col-sm-6 d-flex align-items-end">
<div class="form-check form-switch border rounded p-3 w-100 bg-light-subtle">
<input class="form-check-input" type="checkbox" role="switch" id="requiresVitalsCreate" name="requires_vitals" checked>
<label class="form-check-label" for="requiresVitalsCreate">Requires vitals first / يتطلب العلامات الحيوية أولاً</label>
</div>
</div>
</div>
<button class="btn btn-dark" type="submit">Save clinic</button>
</form>
</div>
</div>
<div class="col-xl-7">
<div class="panel-card h-100">
<h2 class="section-title mb-3">Add doctor / إضافة طبيب</h2>
<form method="post" class="row g-3 align-items-end">
<input type="hidden" name="action" value="add_doctor">
<div class="col-md-6">
<label class="form-label">Name (English)</label>
<input class="form-control" type="text" name="name_en" placeholder="Dr. Sarah Malik" required>
</div>
<div class="col-md-6">
<label class="form-label">Name (Arabic)</label>
<input class="form-control" type="text" name="name_ar" placeholder="د. سارة مالك" required>
</div>
<div class="col-md-5">
<label class="form-label">Clinic</label>
<select class="form-select" name="clinic_id" required>
<option value="">Choose clinic</option>
<?php foreach ($clinics as $clinic): ?>
<option value="<?= qh_h((string) $clinic['id']) ?>"><?= qh_h($clinic['name_en']) ?> / <?= qh_h($clinic['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Room</label>
<input class="form-control" type="text" name="room_number" placeholder="201" required>
</div>
<div class="col-md-2">
<label class="form-label">Order</label>
<input class="form-control" type="number" name="sort_order" value="50" min="1">
</div>
<div class="col-md-2">
<button class="btn btn-dark w-100" type="submit">Save</button>
</div>
</form>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-xl-5">
<div class="panel-card h-100">
<h2 class="section-title mb-3">Clinics / العيادات</h2>
<div class="vstack gap-3">
<?php foreach ($clinics as $clinic): ?>
<form method="post" class="list-row-form">
<input type="hidden" name="action" value="update_clinic">
<input type="hidden" name="clinic_id" value="<?= qh_h((string) $clinic['id']) ?>">
<div>
<div class="fw-semibold"><?= qh_h($clinic['name_en']) ?></div>
<div class="small text-secondary" lang="ar" dir="rtl"><?= qh_h($clinic['name_ar']) ?></div>
<div class="small text-secondary mt-1">Code <?= qh_h($clinic['code']) ?></div>
</div>
<div class="row g-2 align-items-center mt-1">
<div class="col-sm-6">
<label class="form-label small mb-1">Sort order</label>
<input class="form-control form-control-sm" type="number" name="sort_order" value="<?= qh_h((string) $clinic['sort_order']) ?>" min="1">
</div>
<div class="col-sm-6">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" role="switch" id="clinicSwitch<?= qh_h((string) $clinic['id']) ?>" name="requires_vitals" <?= (int) $clinic['requires_vitals'] === 1 ? 'checked' : '' ?>>
<label class="form-check-label small" for="clinicSwitch<?= qh_h((string) $clinic['id']) ?>">Vitals required</label>
</div>
</div>
</div>
<button class="btn btn-sm btn-outline-dark mt-3" type="submit">Update clinic</button>
</form>
<?php endforeach; ?>
</div>
</div>
</div>
<div class="col-xl-7">
<div class="panel-card h-100">
<h2 class="section-title mb-3">Doctors & rooms / الأطباء والغرف</h2>
<div class="vstack gap-3">
<?php foreach ($doctors as $doctor): ?>
<form method="post" class="list-row-form">
<input type="hidden" name="action" value="update_doctor">
<input type="hidden" name="doctor_id" value="<?= qh_h((string) $doctor['id']) ?>">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
<div>
<div class="fw-semibold"><?= qh_h($doctor['name_en']) ?></div>
<div class="small text-secondary" lang="ar" dir="rtl"><?= qh_h($doctor['name_ar']) ?></div>
</div>
<span class="room-badge">Current room <?= qh_h($doctor['room_number']) ?></span>
</div>
<div class="row g-3 align-items-end mt-1">
<div class="col-md-5">
<label class="form-label small">Clinic</label>
<select class="form-select form-select-sm" name="clinic_id" required>
<?php foreach ($clinics as $clinic): ?>
<option value="<?= qh_h((string) $clinic['id']) ?>" <?= (int) $clinic['id'] === (int) $doctor['clinic_id'] ? 'selected' : '' ?>><?= qh_h($clinic['name_en']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Room</label>
<input class="form-control form-control-sm" type="text" name="room_number" value="<?= qh_h($doctor['room_number']) ?>" required>
</div>
<div class="col-md-2">
<label class="form-label small">Order</label>
<input class="form-control form-control-sm" type="number" name="sort_order" value="<?= qh_h((string) $doctor['sort_order']) ?>" min="1">
</div>
<div class="col-md-2 d-grid">
<button class="btn btn-sm btn-outline-dark" type="submit">Save</button>
</div>
</div>
</form>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</div>
<?php qh_page_end(); ?>

View File

@ -1,403 +1,493 @@
body { :root {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); --bg: #f3f4f6;
background-size: 400% 400%; --surface: #ffffff;
animation: gradient 15s ease infinite; --surface-muted: #f8fafc;
color: #212529; --border: #d7dde6;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --border-strong: #c4ccd7;
font-size: 14px; --text: #111827;
margin: 0; --muted: #6b7280;
min-height: 100vh; --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 { html,
display: flex; 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; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; width: 2.25rem;
width: 100%; height: 2.25rem;
padding: 20px; border-radius: var(--radius-sm);
box-sizing: border-box; background: var(--text);
position: relative; color: #fff;
z-index: 1; font-size: 0.88rem;
}
@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);
font-weight: 700; font-weight: 700;
font-size: 1.1rem; letter-spacing: 0.04em;
display: flex;
justify-content: space-between;
align-items: center;
} }
.chat-messages { .brand-text {
flex: 1; font-size: 1rem;
overflow-y: auto; font-weight: 700;
padding: 1.5rem; }
.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; display: flex;
flex-direction: column; 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 */ .workflow-card:hover {
::-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;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2); border-color: var(--border-strong);
} }
/* Background Animations */ .workflow-step {
.bg-animations { display: inline-flex;
position: fixed; width: fit-content;
top: 0; padding: 0.25rem 0.55rem;
left: 0; border-radius: 999px;
width: 100%; background: var(--accent-soft);
height: 100%; color: var(--accent);
z-index: 0; font-size: 0.74rem;
overflow: hidden; font-weight: 700;
pointer-events: none; letter-spacing: 0.08em;
}
.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;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 1px;
} }
.table td { .workflow-card h3,
background: #fff; .ad-card h3 {
padding: 1rem; font-size: 1rem;
border: none; font-weight: 700;
margin: 0;
} }
.table tr td:first-child { border-radius: 12px 0 0 12px; } .workflow-card p,
.table tr td:last-child { border-radius: 0 12px 12px 0; } .ad-card p,
.empty-state span {
.form-group { color: var(--muted);
margin-bottom: 1.25rem; margin: 0;
} }
.form-group label { .bi-label {
display: block; display: inline-flex;
margin-bottom: 0.5rem; align-items: baseline;
flex-wrap: wrap;
gap: 0.15rem;
}
.label-ar {
font-size: 0.96em;
}
.status-badge {
font-weight: 600; font-weight: 600;
font-size: 0.9rem; padding: 0.48rem 0.62rem;
border-radius: 999px;
} }
.form-control { .empty-state {
width: 100%; display: grid;
padding: 0.75rem 1rem; place-items: center;
border: 1px solid rgba(0, 0, 0, 0.1); min-height: 220px;
border-radius: 12px; text-align: center;
background: #fff; padding: 1.5rem;
transition: all 0.3s ease; border: 1px dashed var(--border-strong);
box-sizing: border-box; border-radius: var(--radius-md);
background: var(--surface-muted);
gap: 0.4rem;
} }
.form-control:focus { .empty-state.compact,
outline: none; .display-empty {
border-color: #23a6d5; min-height: 150px;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
} }
.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; display: flex;
align-items: flex-start;
justify-content: space-between; 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; 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; gap: 1rem;
} }
.admin-card { .timeline-item {
background: rgba(255, 255, 255, 0.6); display: grid;
padding: 2rem; grid-template-columns: auto 1fr;
border-radius: 20px; gap: 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.5); align-items: start;
margin-bottom: 2.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
} }
.admin-card h3 { .timeline-dot {
margin-top: 0; width: 0.95rem;
margin-bottom: 1.5rem; height: 0.95rem;
font-weight: 700; border-radius: 50%;
background: #d1d5db;
border: 2px solid var(--surface);
margin-top: 0.18rem;
box-shadow: 0 0 0 1px var(--border);
} }
.btn-delete { .timeline-item.done .timeline-dot {
background: #dc3545; background: #1f4f78;
color: white; box-shadow: 0 0 0 1px #1f4f78;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
} }
.btn-add { .timeline-item.current .timeline-dot {
background: #212529; background: #f59e0b;
color: white; box-shadow: 0 0 0 1px #f59e0b;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
margin-top: 1rem;
} }
.btn-save { .detail-list {
background: #0088cc; display: grid;
color: white; gap: 0.85rem;
border: none; }
padding: 0.8rem 1.5rem;
border-radius: 12px; .detail-list div {
cursor: pointer; 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; font-weight: 600;
width: 100%;
transition: all 0.3s ease;
} }
.webhook-url { .form-control,
font-size: 0.85em; .form-select {
color: #555; border-color: var(--border-strong);
margin-top: 0.5rem; border-radius: var(--radius-sm);
padding-top: 0.72rem;
padding-bottom: 0.72rem;
box-shadow: none !important;
} }
.history-table-container { .form-control:focus,
overflow-x: auto; .form-select:focus,
background: rgba(255, 255, 255, 0.4); .btn:focus-visible,
padding: 1rem; .nav-link:focus-visible {
border-radius: 12px; border-color: var(--accent);
border: 1px solid rgba(255, 255, 255, 0.3); box-shadow: 0 0 0 0.2rem rgba(31, 79, 120, 0.12) !important;
} }
.history-table { .btn {
width: 100%; border-radius: var(--radius-sm);
padding: 0.7rem 1rem;
font-weight: 600;
} }
.history-table-time { .btn-sm {
width: 15%; padding: 0.42rem 0.72rem;
white-space: nowrap;
font-size: 0.85em;
color: #555;
} }
.history-table-user { .btn-dark {
width: 35%; background: var(--text);
background: rgba(255, 255, 255, 0.3); border-color: var(--text);
border-radius: 8px;
padding: 8px;
} }
.history-table-ai { .btn-outline-dark {
width: 50%; border-color: var(--border-strong);
background: rgba(255, 255, 255, 0.5); color: var(--text);
border-radius: 8px;
padding: 8px;
} }
.no-messages { .toast.app-toast {
text-align: center; min-width: 280px;
color: #777; }
}
@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;
}
}

View File

@ -1,39 +1,105 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const chatForm = document.getElementById('chat-form'); const clinicSelects = document.querySelectorAll('.js-clinic-select');
const chatInput = document.getElementById('chat-input'); clinicSelects.forEach((clinicSelect) => {
const chatMessages = document.getElementById('chat-messages'); const form = clinicSelect.closest('form');
if (!form) return;
const doctorSelect = form.querySelector('.js-doctor-select');
if (!doctorSelect) return;
const appendMessage = (text, sender) => { const syncDoctors = () => {
const msgDiv = document.createElement('div'); const clinicId = clinicSelect.value;
msgDiv.classList.add('message', sender); Array.from(doctorSelect.options).forEach((option) => {
msgDiv.textContent = text; if (!option.value) {
chatMessages.appendChild(msgDiv); option.hidden = false;
chatMessages.scrollTop = chatMessages.scrollHeight; return;
}; }
const visible = option.dataset.clinicId === clinicId;
chatForm.addEventListener('submit', async (e) => { option.hidden = !visible;
e.preventDefault(); if (!visible && option.selected) {
const message = chatInput.value.trim(); doctorSelect.value = '';
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 data = await response.json(); };
// Artificial delay for realism clinicSelect.addEventListener('change', syncDoctors);
setTimeout(() => { syncDoctors();
appendMessage(data.reply, 'bot'); });
}, 500);
} catch (error) { const ticketPrintButton = document.querySelector('.js-print-ticket');
console.error('Error:', error); if (ticketPrintButton) {
appendMessage("Sorry, something went wrong. Please try again.", 'bot'); 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);
}
}
}); });

94
display.php Normal file
View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/queue_bootstrap.php';
qh_boot();
$activeCalls = qh_fetch_tickets(['called', 'in_progress'], null, 6);
$queueOverview = qh_queue_overview();
qh_page_start('display', 'General display board', 'Public hospital queue display with bilingual callouts, text-to-speech, and an ads pane.');
?>
<div class="container-fluid px-3 px-lg-4 py-2" data-auto-refresh="20">
<section class="display-shell">
<div class="display-main panel-card p-4">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-4">
<div>
<div class="section-kicker">General display / الشاشة العامة</div>
<h1 class="section-title-xl mt-2 mb-1">Now serving</h1>
<p class="section-copy mb-0">Latest calls are read aloud in English and Arabic when supported by the browser.</p>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap justify-content-end">
<button type="button" class="btn btn-dark btn-sm js-fullscreen-toggle" aria-pressed="false">Full display / عرض كامل</button>
<div class="live-clock js-live-clock"><?= qh_h(date('H:i')) ?></div>
</div>
</div>
<?php if ($activeCalls): ?>
<div class="vstack gap-3">
<?php foreach ($activeCalls as $ticket): $speech = qh_call_message($ticket); ?>
<article class="announcement-card" data-announcement-key="<?= qh_h((string) $ticket['id']) ?>-<?= qh_h((string) strtotime((string) $ticket['called_at'])) ?>" data-announcement-en="<?= qh_h($speech['en']) ?>" data-announcement-ar="<?= qh_h($speech['ar']) ?>">
<div>
<div class="ticket-number large"><?= qh_h($ticket['ticket_number']) ?></div>
<div class="display-meta"><?= qh_h($ticket['doctor_name_en'] ?? 'Doctor') ?> · Room <?= qh_h($ticket['doctor_room'] ?? '--') ?></div>
</div>
<div class="text-end">
<?= qh_status_badge($ticket['status']) ?>
<div class="small text-secondary mt-2"><?= qh_format_datetime($ticket['called_at'] ?? $ticket['updated_at']) ?></div>
</div>
</article>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty-state display-empty">
<strong>No live calls right now.</strong>
<span>When a doctor presses “Call patient”, the ticket will appear here and play on TTS.</span>
</div>
<?php endif; ?>
<div class="panel-subsection mt-4">
<h2 class="section-title mb-3">Queue by clinic / الطابور حسب العيادة</h2>
<div class="row g-3">
<?php foreach ($queueOverview as $row): ?>
<div class="col-md-6 col-xl-4">
<div class="mini-overview-card">
<div class="fw-semibold"><?= qh_h($row['name_en']) ?></div>
<div class="small text-secondary" lang="ar" dir="rtl"><?= qh_h($row['name_ar']) ?></div>
<div class="d-flex gap-2 mt-3 flex-wrap">
<span class="table-pill warning">Vitals <?= qh_h((string) $row['vitals_waiting']) ?></span>
<span class="table-pill info">Doctor <?= qh_h((string) $row['doctor_waiting']) ?></span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<aside class="display-ads panel-card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<div class="section-kicker">Ads & notices / الإعلانات</div>
<h2 class="section-title mb-1">Patient information</h2>
</div>
<span class="badge rounded-pill text-bg-dark">Looping</span>
</div>
<div class="ad-card mb-3">
<div class="ad-tag">Service</div>
<h3>Lab packages & wellness checks</h3>
<p>Ask reception about bundled blood tests, diabetes follow-up, and annual screenings.</p>
</div>
<div class="ad-card mb-3">
<div class="ad-tag">Reminder</div>
<h3>Keep your ticket visible</h3>
<p>We announce ticket numbers on this screen and by voice. Stay near your department area.</p>
</div>
<div class="ad-card">
<div class="ad-tag">Wayfinding</div>
<h3>Pharmacy & billing</h3>
<p>Completed visits can proceed to the pharmacy and billing desk near the main exit.</p>
</div>
</aside>
</section>
</div>
<?php qh_page_end(); ?>

108
doctor.php Normal file
View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/queue_bootstrap.php';
qh_boot();
qh_doctor_handle_request();
$doctors = qh_fetch_doctors();
$selectedDoctorId = isset($_GET['doctor_id']) ? (int) $_GET['doctor_id'] : ($doctors[0]['id'] ?? 0);
$selectedDoctor = $selectedDoctorId ? qh_fetch_doctor($selectedDoctorId) : null;
$doctorQueue = $selectedDoctorId ? qh_fetch_tickets(['ready_for_doctor', 'called', 'in_progress'], $selectedDoctorId, 20) : [];
qh_page_start('doctor', 'Doctor room control', 'Doctor queue page for calling patients, starting visits, and closing tickets.');
?>
<div class="container-xxl px-3 px-lg-4">
<section class="page-header-panel mb-4">
<div>
<span class="section-kicker">Doctor / الطبيب</span>
<h1 class="section-title-xl mt-2">Call the next patient and update status.</h1>
<p class="section-copy mb-0">The display page announces called tickets in English and Arabic using the browsers text-to-speech engine.</p>
</div>
</section>
<div class="panel-card mb-4">
<form method="get" class="row g-3 align-items-end">
<div class="col-lg-5">
<label class="form-label">Doctor room / غرفة الطبيب</label>
<select class="form-select" name="doctor_id" onchange="this.form.submit()">
<?php foreach ($doctors as $doctor): ?>
<option value="<?= qh_h((string) $doctor['id']) ?>" <?= (int) $doctor['id'] === $selectedDoctorId ? 'selected' : '' ?>><?= qh_h($doctor['name_en']) ?> · <?= qh_h($doctor['clinic_name_en'] ?? '') ?> · Room <?= qh_h($doctor['room_number']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php if ($selectedDoctor): ?>
<div class="col-lg-7">
<div class="doctor-spotlight">
<div>
<div class="fw-semibold"><?= qh_h($selectedDoctor['name_en']) ?></div>
<div class="small text-secondary" lang="ar" dir="rtl"><?= qh_h($selectedDoctor['name_ar']) ?></div>
</div>
<span class="room-badge">Room <?= qh_h($selectedDoctor['room_number']) ?></span>
</div>
</div>
<?php endif; ?>
</form>
</div>
<div class="panel-card">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div>
<h2 class="section-title mb-1">Doctor queue / طابور الطبيب</h2>
<p class="section-copy mb-0">Ready patients, live calls, and in-room consultations for the selected doctor.</p>
</div>
<a class="btn btn-sm btn-outline-dark" href="display.php" target="_blank" rel="noopener">Preview public display</a>
</div>
<?php if ($doctorQueue): ?>
<div class="vstack gap-3">
<?php foreach ($doctorQueue as $ticket): ?>
<div class="queue-row">
<div class="queue-row-head">
<div>
<div class="ticket-code"><?= qh_h($ticket['ticket_number']) ?></div>
<div class="fw-semibold"><?= qh_h($ticket['patient_name']) ?></div>
<div class="small text-secondary">Vitals: <?= qh_h($ticket['vitals_notes'] ?: 'Not recorded / غير مسجل') ?></div>
</div>
<div class="d-flex flex-column align-items-end gap-2">
<?= qh_status_badge($ticket['status']) ?>
<a class="btn btn-sm btn-outline-dark" href="ticket.php?id=<?= qh_h((string) $ticket['id']) ?>">Ticket detail</a>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mt-3">
<form method="post">
<input type="hidden" name="ticket_id" value="<?= qh_h((string) $ticket['id']) ?>">
<input type="hidden" name="doctor_id" value="<?= qh_h((string) $selectedDoctorId) ?>">
<input type="hidden" name="action" value="call_ticket">
<button class="btn btn-dark btn-sm" type="submit">Call patient</button>
</form>
<form method="post">
<input type="hidden" name="ticket_id" value="<?= qh_h((string) $ticket['id']) ?>">
<input type="hidden" name="doctor_id" value="<?= qh_h((string) $selectedDoctorId) ?>">
<input type="hidden" name="action" value="start_visit">
<button class="btn btn-outline-dark btn-sm" type="submit">Start visit</button>
</form>
<form method="post">
<input type="hidden" name="ticket_id" value="<?= qh_h((string) $ticket['id']) ?>">
<input type="hidden" name="doctor_id" value="<?= qh_h((string) $selectedDoctorId) ?>">
<input type="hidden" name="action" value="complete_ticket">
<button class="btn btn-outline-success btn-sm" type="submit">Mark done</button>
</form>
<form method="post">
<input type="hidden" name="ticket_id" value="<?= qh_h((string) $ticket['id']) ?>">
<input type="hidden" name="doctor_id" value="<?= qh_h((string) $selectedDoctorId) ?>">
<input type="hidden" name="action" value="mark_no_show">
<button class="btn btn-outline-danger btn-sm" type="submit">No-show</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty-state">
<strong>No patients in this doctor queue.</strong>
<span>Issue a ticket at reception or complete vitals in nursing to fill this room.</span>
</div>
<?php endif; ?>
</div>
</div>
<?php qh_page_end(); ?>

346
index.php
View File

@ -1,150 +1,208 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
@ini_set('display_errors', '1'); require_once __DIR__ . '/queue_bootstrap.php';
@error_reporting(E_ALL); qh_boot();
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION; $stats = qh_dashboard_stats();
$now = date('Y-m-d H:i:s'); $overview = qh_queue_overview();
$recentTickets = qh_fetch_tickets(['waiting_vitals', 'ready_for_doctor', 'called', 'in_progress'], null, 8);
$calledTickets = qh_fetch_tickets(['called', 'in_progress'], null, 4);
qh_page_start('home', 'Hospital queue operations', 'Bilingual hospital queue dashboard with queue status, staff entry points, and public display controls.');
?> ?>
<!doctype html> <div class="container-xxl px-3 px-lg-4">
<html lang="en"> <section class="hero-panel mb-4 mb-lg-5">
<head> <div class="row g-4 align-items-center">
<meta charset="utf-8" /> <div class="col-lg-7">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <span class="section-kicker">Hospital queue system / نظام الطوابير</span>
<title>New Style</title> <h1 class="display-title mt-3 mb-3">One bilingual workflow for reception, nursing, doctors, and the public screen.</h1>
<?php <p class="lead text-secondary mb-4">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.</p>
// Read project preview data from environment <div class="d-flex flex-wrap gap-2">
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? ''; <a class="btn btn-dark" href="reception.php">Issue ticket / إصدار تذكرة</a>
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; <a class="btn btn-outline-dark" href="display.php">Open TV display / فتح الشاشة العامة</a>
?> </div>
<?php if ($projectDescription): ?> </div>
<!-- Meta description --> <div class="col-lg-5">
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' /> <div class="hero-card stack-card h-100">
<!-- Open Graph meta tags --> <div class="small text-uppercase text-secondary fw-semibold mb-2">Today / اليوم</div>
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" /> <div class="row g-3">
<!-- Twitter meta tags --> <div class="col-6">
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" /> <div class="metric-card">
<?php endif; ?> <div class="metric-value"><?= qh_h((string) $stats['issued_today']) ?></div>
<?php if ($projectImageUrl): ?> <div class="metric-label">Issued / المُصدرة</div>
<!-- Open Graph image --> </div>
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> </div>
<!-- Twitter image --> <div class="col-6">
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <div class="metric-card">
<?php endif; ?> <div class="metric-value"><?= qh_h((string) $stats['waiting_vitals']) ?></div>
<link rel="preconnect" href="https://fonts.googleapis.com"> <div class="metric-label">Vitals / الحيوية</div>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> </div>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet"> </div>
<style> <div class="col-6">
:root { <div class="metric-card">
--bg-color-start: #6a11cb; <div class="metric-value"><?= qh_h((string) $stats['ready_for_doctor']) ?></div>
--bg-color-end: #2575fc; <div class="metric-label">Ready / جاهز</div>
--text-color: #ffffff; </div>
--card-bg-color: rgba(255, 255, 255, 0.01); </div>
--card-border-color: rgba(255, 255, 255, 0.1); <div class="col-6">
} <div class="metric-card">
body { <div class="metric-value"><?= qh_h((string) $stats['active_rooms']) ?></div>
margin: 0; <div class="metric-label">Active rooms / الغرف النشطة</div>
font-family: 'Inter', sans-serif; </div>
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); </div>
color: var(--text-color); </div>
display: flex; </div>
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div> </div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</div> </div>
</main> </section>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC) <section class="row g-4 mb-4 mb-lg-5">
</footer> <div class="col-xl-8">
</body> <div class="panel-card h-100">
</html> <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div>
<h2 class="section-title mb-1">Live queue overview / نظرة مباشرة على الطابور</h2>
<p class="section-copy mb-0">Clinic-level demand for vitals, doctor readiness, and active calls.</p>
</div>
<a class="btn btn-sm btn-outline-dark" href="admin.php">Manage clinics & doctors</a>
</div>
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead>
<tr>
<th>Clinic / العيادة</th>
<th>Vitals wait</th>
<th>Doctor wait</th>
<th>Active calls</th>
<th>Total today</th>
</tr>
</thead>
<tbody>
<?php foreach ($overview as $row): ?>
<tr>
<td>
<div class="fw-semibold"><?= qh_h($row['name_en']) ?></div>
<div class="small text-secondary" lang="ar" dir="rtl"><?= qh_h($row['name_ar']) ?></div>
</td>
<td><span class="table-pill warning"><?= qh_h((string) $row['vitals_waiting']) ?></span></td>
<td><span class="table-pill info"><?= qh_h((string) $row['doctor_waiting']) ?></span></td>
<td><span class="table-pill dark"><?= qh_h((string) $row['active_calls']) ?></span></td>
<td><?= qh_h((string) $row['total_today']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="panel-card h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="section-title mb-1">Current calls / النداءات الحالية</h2>
<p class="section-copy mb-0">Patients already called to a doctor room.</p>
</div>
</div>
<?php if ($calledTickets): ?>
<div class="vstack gap-3">
<?php foreach ($calledTickets as $ticket): ?>
<div class="call-strip">
<div>
<div class="ticket-code"><?= qh_h($ticket['ticket_number']) ?></div>
<div class="small text-secondary"><?= qh_h($ticket['doctor_name_en'] ?? 'Unassigned') ?> · Room <?= qh_h($ticket['doctor_room'] ?? '--') ?></div>
</div>
<?= qh_status_badge($ticket['status']) ?>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty-state compact">
<strong>No active calls yet.</strong>
<span>Use the doctor page to call the next patient.</span>
</div>
<?php endif; ?>
</div>
</div>
</section>
<section class="row g-4 mb-4 mb-lg-5">
<div class="col-md-6 col-xl-3">
<a class="workflow-card h-100" href="admin.php">
<span class="workflow-step">01</span>
<h3>Admin config</h3>
<p>Manage clinics, vitals requirement, doctors, and room assignments.</p>
</a>
</div>
<div class="col-md-6 col-xl-3">
<a class="workflow-card h-100" href="reception.php">
<span class="workflow-step">02</span>
<h3>Reception issue</h3>
<p>Create a single bilingual ticket and route it to vitals or directly to the doctor.</p>
</a>
</div>
<div class="col-md-6 col-xl-3">
<a class="workflow-card h-100" href="nursing.php">
<span class="workflow-step">03</span>
<h3>Nursing handoff</h3>
<p>Capture vitals, note key measurements, and release the patient to the doctor queue.</p>
</a>
</div>
<div class="col-md-6 col-xl-3">
<a class="workflow-card h-100" href="doctor.php">
<span class="workflow-step">04</span>
<h3>Doctor call</h3>
<p>Call, start, and complete visits while the TV display announces the latest ticket.</p>
</a>
</div>
</section>
<section class="panel-card">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div>
<h2 class="section-title mb-1">Recent patient flow / آخر حركة للمرضى</h2>
<p class="section-copy mb-0">Each ticket is linked to a detail page showing its current stage.</p>
</div>
<a class="btn btn-sm btn-outline-dark" href="reception.php">Create new ticket</a>
</div>
<?php if ($recentTickets): ?>
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead>
<tr>
<th>Ticket</th>
<th>Patient</th>
<th>Clinic</th>
<th>Doctor / Room</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($recentTickets as $ticket): ?>
<tr>
<td class="fw-semibold"><?= qh_h($ticket['ticket_number']) ?></td>
<td>
<div><?= qh_h($ticket['patient_name']) ?></div>
<div class="small text-secondary"><?= strtoupper(qh_h($ticket['language_pref'])) ?></div>
</td>
<td>
<div><?= qh_h($ticket['clinic_name_en'] ?? '—') ?></div>
<div class="small text-secondary" lang="ar" dir="rtl"><?= qh_h($ticket['clinic_name_ar'] ?? '') ?></div>
</td>
<td><?= qh_h($ticket['doctor_name_en'] ?? '—') ?> <span class="text-secondary">· <?= qh_h($ticket['doctor_room'] ?? '--') ?></span></td>
<td><?= qh_status_badge($ticket['status']) ?></td>
<td class="text-end"><a class="btn btn-sm btn-outline-dark" href="ticket.php?id=<?= qh_h((string) $ticket['id']) ?>">View detail</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="empty-state">
<strong>No active tickets yet.</strong>
<span>Start from the reception desk to issue the first patient ticket.</span>
</div>
<?php endif; ?>
</section>
</div>
<?php qh_page_end(); ?>

64
nursing.php Normal file
View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/queue_bootstrap.php';
qh_boot();
qh_nursing_handle_request();
$waitingTickets = qh_fetch_tickets(['waiting_vitals'], null, 20);
qh_page_start('nursing', 'Nursing vitals queue', 'Nursing queue for clinics that require vitals before the doctor visit.');
?>
<div class="container-xxl px-3 px-lg-4">
<section class="page-header-panel mb-4">
<div>
<span class="section-kicker">Nursing / التمريض</span>
<h1 class="section-title-xl mt-2">Capture vitals and release to doctor.</h1>
<p class="section-copy mb-0">Only tickets from clinics marked “requires vitals” arrive here. Once notes are saved, the same ticket continues to the doctor queue.</p>
</div>
</section>
<div class="panel-card">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div>
<h2 class="section-title mb-1">Waiting for vitals / بانتظار العلامات الحيوية</h2>
<p class="section-copy mb-0">Add a short clinical note to transfer the patient to the assigned doctor.</p>
</div>
<span class="badge text-bg-warning px-3 py-2"><?= qh_h((string) count($waitingTickets)) ?> patients</span>
</div>
<?php if ($waitingTickets): ?>
<div class="vstack gap-3">
<?php foreach ($waitingTickets as $ticket): ?>
<div class="queue-row">
<div class="queue-row-head">
<div>
<div class="ticket-code"><?= qh_h($ticket['ticket_number']) ?></div>
<div class="fw-semibold"><?= qh_h($ticket['patient_name']) ?></div>
<div class="small text-secondary"><?= qh_h($ticket['clinic_name_en'] ?? '') ?> → <?= qh_h($ticket['doctor_name_en'] ?? '') ?> · Room <?= qh_h($ticket['doctor_room'] ?? '--') ?></div>
</div>
<div class="d-flex flex-column align-items-end gap-2">
<?= qh_status_badge($ticket['status']) ?>
<a class="btn btn-sm btn-outline-dark" href="ticket.php?id=<?= qh_h((string) $ticket['id']) ?>">Ticket detail</a>
</div>
</div>
<form method="post" class="row g-3 align-items-end mt-2">
<input type="hidden" name="ticket_id" value="<?= qh_h((string) $ticket['id']) ?>">
<div class="col-lg-9">
<label class="form-label">Vitals note / ملاحظة العلامات الحيوية</label>
<input class="form-control" type="text" name="vitals_notes" placeholder="BP 120/80 · Temp 36.8 · Weight 68kg" required>
</div>
<div class="col-lg-3 d-grid">
<button class="btn btn-dark" type="submit">Send to doctor</button>
</div>
</form>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty-state">
<strong>No patients waiting for vitals.</strong>
<span>Tickets from vitals-required clinics will appear here automatically after issue.</span>
</div>
<?php endif; ?>
</div>
</div>
<?php qh_page_end(); ?>

715
queue_bootstrap.php Normal file
View File

@ -0,0 +1,715 @@
<?php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/db/config.php';
function qh_boot(): void
{
static $booted = false;
if ($booted) {
return;
}
qh_ensure_schema();
qh_seed_demo_data();
$booted = true;
}
function qh_ensure_schema(): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS hospital_queue_records (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_type VARCHAR(20) NOT NULL,
code VARCHAR(20) DEFAULT NULL,
name_en VARCHAR(120) DEFAULT NULL,
name_ar VARCHAR(120) DEFAULT NULL,
clinic_id INT UNSIGNED DEFAULT NULL,
doctor_id INT UNSIGNED DEFAULT NULL,
room_number VARCHAR(20) DEFAULT NULL,
requires_vitals TINYINT(1) NOT NULL DEFAULT 0,
patient_name VARCHAR(120) DEFAULT NULL,
language_pref VARCHAR(5) DEFAULT 'en',
ticket_number VARCHAR(20) DEFAULT NULL,
status VARCHAR(30) DEFAULT 'draft',
vitals_notes TEXT DEFAULT NULL,
display_note VARCHAR(255) DEFAULT NULL,
sort_order INT NOT NULL DEFAULT 0,
called_at DATETIME DEFAULT NULL,
served_at DATETIME DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_item_type (item_type),
INDEX idx_status (status),
INDEX idx_clinic_id (clinic_id),
INDEX idx_doctor_id (doctor_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL;
db()->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"><span class="label-en">%2$s</span><span class="label-sep"> / </span><span class="label-ar" lang="ar" dir="rtl">%3$s</span></%1$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 '<!doctype html>';
echo '<html lang="en">';
echo '<head>';
echo ' <meta charset="utf-8">';
echo ' <meta name="viewport" content="width=device-width, initial-scale=1">';
echo ' <title>' . qh_h($fullTitle) . '</title>';
echo ' <meta name="description" content="' . qh_h($description) . '">';
if ($projectDescription) {
echo ' <meta property="og:description" content="' . qh_h((string) $projectDescription) . '">';
echo ' <meta property="twitter:description" content="' . qh_h((string) $projectDescription) . '">';
}
if ($projectImageUrl) {
echo ' <meta property="og:image" content="' . qh_h((string) $projectImageUrl) . '">';
echo ' <meta property="twitter:image" content="' . qh_h((string) $projectImageUrl) . '">';
}
echo ' <meta name="theme-color" content="#f3f4f6">';
echo ' <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">';
echo ' <link rel="stylesheet" href="assets/css/custom.css?v=' . $assetVersionCss . '">';
echo '</head>';
echo '<body class="' . qh_h($bodyClass) . '" data-page="' . qh_h($activePage) . '">';
if ($activePage !== 'display') {
qh_render_nav($activePage);
}
echo '<main class="app-shell py-4 py-lg-5">';
qh_render_flash();
}
function qh_page_end(): void
{
$assetVersionJs = qh_asset_version('assets/js/main.js');
echo '</main>';
echo '<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>';
echo '<script src="assets/js/main.js?v=' . $assetVersionJs . '"></script>';
echo '</body></html>';
}
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 '<header class="border-bottom bg-white sticky-top shadow-sm">';
echo ' <nav class="navbar navbar-expand-lg">';
echo ' <div class="container-fluid container-xxl px-3 px-lg-4">';
echo ' <a class="navbar-brand d-flex flex-column gap-0" href="index.php">';
echo ' <span class="brand-mark">HQ</span>';
echo ' <span class="brand-text">' . qh_h(qh_project_name()) . '</span>';
echo ' </a>';
echo ' <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#appNav" aria-controls="appNav" aria-expanded="false" aria-label="Toggle navigation">';
echo ' <span class="navbar-toggler-icon"></span>';
echo ' </button>';
echo ' <div class="collapse navbar-collapse" id="appNav">';
echo ' <ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2 mt-3 mt-lg-0">';
foreach ($links as $key => $link) {
$activeClass = $key === $activePage ? ' active' : '';
echo ' <li class="nav-item"><a class="nav-link' . $activeClass . '" href="' . qh_h($link['href']) . '">' . $link['label'] . '</a></li>';
}
echo ' </ul>';
echo ' </div>';
echo ' </div>';
echo ' </nav>';
echo '</header>';
}
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 '<div class="toast-container position-fixed top-0 end-0 p-3">';
echo ' <div class="toast app-toast js-app-toast text-bg-' . qh_h($toastType) . ' border-0" role="status" aria-live="polite" aria-atomic="true">';
echo ' <div class="d-flex">';
echo ' <div class="toast-body">' . qh_h((string) $flash['message']) . '</div>';
echo ' <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>';
echo ' </div>';
echo ' </div>';
echo '</div>';
}
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 '<span class="badge text-bg-' . qh_h($meta['class']) . ' status-badge">' . qh_label($meta['en'], $meta['ar']) . '</span>';
}
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');
}
}

123
reception.php Normal file
View File

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/queue_bootstrap.php';
qh_boot();
qh_reception_handle_request();
$clinics = qh_fetch_clinics();
$doctors = qh_fetch_doctors();
$currentTicket = isset($_GET['ticket_id']) ? qh_fetch_ticket((int) $_GET['ticket_id']) : null;
$todayTickets = qh_fetch_tickets(['waiting_vitals', 'ready_for_doctor', 'called', 'in_progress', 'done', 'no_show'], null, 12);
qh_page_start('reception', 'Reception ticket issuance', 'Reception desk ticket issue flow with one ticket that follows the patient through vitals and doctor visit.');
?>
<div class="container-xxl px-3 px-lg-4">
<section class="page-header-panel mb-4">
<div>
<span class="section-kicker">Reception / الاستقبال</span>
<h1 class="section-title-xl mt-2">Issue one ticket for the full visit.</h1>
<p class="section-copy mb-0">Reception decides the clinic and doctor once. The system automatically routes the patient to vitals first only when the selected clinic requires it.</p>
</div>
</section>
<div class="row g-4">
<div class="col-xl-5">
<div class="panel-card h-100">
<h2 class="section-title mb-3">New patient ticket / تذكرة مريض جديدة</h2>
<form method="post" class="vstack gap-3" novalidate>
<div>
<label class="form-label">Patient name / اسم المريض</label>
<input class="form-control" type="text" name="patient_name" placeholder="Maha Ali" required>
</div>
<div>
<label class="form-label">Clinic / العيادة</label>
<select class="form-select js-clinic-select" name="clinic_id" required>
<option value="">Choose clinic</option>
<?php foreach ($clinics as $clinic): ?>
<option value="<?= qh_h((string) $clinic['id']) ?>"><?= qh_h($clinic['name_en']) ?> / <?= qh_h($clinic['name_ar']) ?><?= (int) $clinic['requires_vitals'] === 1 ? ' · Vitals first' : '' ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">Doctor / الطبيب</label>
<select class="form-select js-doctor-select" name="doctor_id" required>
<option value="">Choose doctor</option>
<?php foreach ($doctors as $doctor): ?>
<option value="<?= qh_h((string) $doctor['id']) ?>" data-clinic-id="<?= qh_h((string) $doctor['clinic_id']) ?>">
<?= qh_h($doctor['name_en']) ?> / <?= qh_h($doctor['name_ar']) ?> · Room <?= qh_h($doctor['room_number']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">Preferred language / اللغة المفضلة</label>
<select class="form-select" name="language_pref">
<option value="en">English</option>
<option value="ar">العربية</option>
</select>
</div>
<button class="btn btn-dark" type="submit">Issue ticket / إصدار التذكرة</button>
</form>
</div>
</div>
<div class="col-xl-7">
<?php if ($currentTicket): ?>
<div class="panel-card ticket-card mb-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<div class="section-kicker">Issued ticket / التذكرة الصادرة</div>
<div class="ticket-number mt-2"><?= qh_h($currentTicket['ticket_number']) ?></div>
<div class="mt-2 fw-semibold"><?= qh_h($currentTicket['patient_name']) ?></div>
<div class="text-secondary"><?= qh_h($currentTicket['clinic_name_en'] ?? '') ?> · <?= qh_h($currentTicket['doctor_name_en'] ?? '') ?> · Room <?= qh_h($currentTicket['doctor_room'] ?? '--') ?></div>
</div>
<div class="d-flex flex-column align-items-lg-end gap-2">
<?= qh_status_badge($currentTicket['status']) ?>
<button class="btn btn-outline-dark btn-sm js-print-ticket" type="button">Print ticket</button>
</div>
</div>
<hr>
<div class="row g-3 small">
<div class="col-md-4"><strong>Issued:</strong><br><?= qh_format_datetime($currentTicket['created_at']) ?></div>
<div class="col-md-4"><strong>Language:</strong><br><?= strtoupper(qh_h($currentTicket['language_pref'])) ?></div>
<div class="col-md-4"><strong>Next stop:</strong><br><?= (int) $currentTicket['clinic_requires_vitals'] === 1 ? 'Nursing vitals / التمريض' : 'Doctor waiting / انتظار الطبيب' ?></div>
</div>
</div>
<?php endif; ?>
<div class="panel-card h-100">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div>
<h2 class="section-title mb-1">Todays tickets / تذاكر اليوم</h2>
<p class="section-copy mb-0">The latest issued tickets and where they currently are in the visit flow.</p>
</div>
</div>
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead>
<tr>
<th>Ticket</th>
<th>Patient</th>
<th>Clinic</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($todayTickets as $ticket): ?>
<tr>
<td class="fw-semibold"><?= qh_h($ticket['ticket_number']) ?></td>
<td><?= qh_h($ticket['patient_name']) ?></td>
<td><?= qh_h($ticket['clinic_name_en'] ?? '—') ?></td>
<td><?= qh_status_badge($ticket['status']) ?></td>
<td class="text-end"><a class="btn btn-sm btn-outline-dark" href="ticket.php?id=<?= qh_h((string) $ticket['id']) ?>">View</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<?php qh_page_end(); ?>

107
ticket.php Normal file
View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/queue_bootstrap.php';
qh_boot();
$ticketId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
$ticket = $ticketId > 0 ? qh_fetch_ticket($ticketId) : null;
qh_page_start('home', 'Ticket detail', 'Detailed patient ticket timeline for the hospital queue workflow.');
?>
<div class="container-lg px-3 px-lg-4">
<section class="page-header-panel mb-4">
<div>
<span class="section-kicker">Ticket detail / تفاصيل التذكرة</span>
<h1 class="section-title-xl mt-2">Track one patient through the visit.</h1>
<p class="section-copy mb-0">This view confirms the assigned clinic, doctor, room, vitals notes, and current status.</p>
</div>
</section>
<?php if (!$ticket): ?>
<div class="panel-card">
<div class="empty-state">
<strong>Ticket not found.</strong>
<span>Return to reception and choose a valid ticket.</span>
</div>
</div>
<?php else: ?>
<div class="panel-card mb-4">
<div class="d-flex justify-content-between flex-wrap gap-3 align-items-start">
<div>
<div class="ticket-number"><?= qh_h($ticket['ticket_number']) ?></div>
<div class="mt-2 fw-semibold"><?= qh_h($ticket['patient_name']) ?></div>
<div class="text-secondary"><?= qh_h($ticket['clinic_name_en'] ?? '') ?> · <?= qh_h($ticket['doctor_name_en'] ?? '') ?> · Room <?= qh_h($ticket['doctor_room'] ?? '--') ?></div>
</div>
<div class="d-flex flex-column gap-2 align-items-lg-end">
<?= qh_status_badge($ticket['status']) ?>
<span class="small text-secondary">Preferred language: <?= strtoupper(qh_h($ticket['language_pref'])) ?></span>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<div class="panel-card h-100">
<h2 class="section-title mb-3">Visit timeline / خط سير الزيارة</h2>
<div class="timeline-list">
<div class="timeline-item done">
<div class="timeline-dot"></div>
<div>
<div class="fw-semibold">Ticket issued / تم إصدار التذكرة</div>
<div class="small text-secondary"><?= qh_format_datetime($ticket['created_at']) ?></div>
</div>
</div>
<?php if ((int) $ticket['clinic_requires_vitals'] === 1): ?>
<div class="timeline-item <?= in_array($ticket['status'], ['ready_for_doctor', 'called', 'in_progress', 'done', 'no_show'], true) ? 'done' : 'current' ?>">
<div class="timeline-dot"></div>
<div>
<div class="fw-semibold">Nursing vitals / العلامات الحيوية</div>
<div class="small text-secondary"><?= qh_h($ticket['vitals_notes'] ?: 'Waiting for nursing input.') ?></div>
</div>
</div>
<?php endif; ?>
<div class="timeline-item <?= in_array($ticket['status'], ['called', 'in_progress', 'done', 'no_show'], true) ? 'done' : 'current' ?>">
<div class="timeline-dot"></div>
<div>
<div class="fw-semibold">Ready for doctor / جاهز للطبيب</div>
<div class="small text-secondary">Assigned to <?= qh_h($ticket['doctor_name_en'] ?? 'Doctor') ?>, room <?= qh_h($ticket['doctor_room'] ?? '--') ?></div>
</div>
</div>
<div class="timeline-item <?= in_array($ticket['status'], ['in_progress', 'done', 'no_show'], true) ? 'done' : (($ticket['status'] === 'called') ? 'current' : '') ?>">
<div class="timeline-dot"></div>
<div>
<div class="fw-semibold">Called to room / تم النداء للغرفة</div>
<div class="small text-secondary"><?= qh_format_datetime($ticket['called_at']) ?></div>
</div>
</div>
<div class="timeline-item <?= in_array($ticket['status'], ['done', 'no_show'], true) ? 'done' : '' ?>">
<div class="timeline-dot"></div>
<div>
<div class="fw-semibold">Visit closed / إغلاق الزيارة</div>
<div class="small text-secondary"><?= $ticket['status'] === 'done' ? 'Completed successfully.' : ($ticket['status'] === 'no_show' ? 'Marked as no-show.' : 'Still active.') ?></div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="panel-card h-100">
<h2 class="section-title mb-3">Details / التفاصيل</h2>
<dl class="detail-list mb-0">
<div><dt>Clinic</dt><dd><?= qh_h($ticket['clinic_name_en'] ?? '—') ?></dd></div>
<div><dt>Doctor</dt><dd><?= qh_h($ticket['doctor_name_en'] ?? '—') ?></dd></div>
<div><dt>Room</dt><dd><?= qh_h($ticket['doctor_room'] ?? '--') ?></dd></div>
<div><dt>Vitals note</dt><dd><?= qh_h($ticket['vitals_notes'] ?: 'Not captured yet.') ?></dd></div>
<div><dt>Last note</dt><dd><?= qh_h($ticket['display_note'] ?: '—') ?></dd></div>
</dl>
<div class="d-flex flex-wrap gap-2 mt-4">
<a class="btn btn-outline-dark btn-sm" href="reception.php?ticket_id=<?= qh_h((string) $ticket['id']) ?>">Back to reception</a>
<a class="btn btn-outline-dark btn-sm" href="doctor.php?doctor_id=<?= qh_h((string) ($ticket['doctor_id'] ?? 0)) ?>">Open doctor queue</a>
<a class="btn btn-outline-dark btn-sm" href="display.php">Open display</a>
</div>
</div>
</div>
</div>
<?php endif; ?>
</div>
<?php qh_page_end(); ?>