updating display
This commit is contained in:
parent
560e915977
commit
fd3feb7878
168
admin.php
Normal file
168
admin.php
Normal 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(); ?>
|
||||
@ -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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
94
display.php
Normal file
94
display.php
Normal 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
108
doctor.php
Normal 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 browser’s 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
346
index.php
@ -1,150 +1,208 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
require_once __DIR__ . '/queue_bootstrap.php';
|
||||
qh_boot();
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$stats = qh_dashboard_stats();
|
||||
$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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<?php endif; ?>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
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 class="container-xxl px-3 px-lg-4">
|
||||
<section class="hero-panel mb-4 mb-lg-5">
|
||||
<div class="row g-4 align-items-center">
|
||||
<div class="col-lg-7">
|
||||
<span class="section-kicker">Hospital queue system / نظام الطوابير</span>
|
||||
<h1 class="display-title mt-3 mb-3">One bilingual workflow for reception, nursing, doctors, and the public screen.</h1>
|
||||
<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>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-dark" href="reception.php">Issue ticket / إصدار تذكرة</a>
|
||||
<a class="btn btn-outline-dark" href="display.php">Open TV display / فتح الشاشة العامة</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="hero-card stack-card h-100">
|
||||
<div class="small text-uppercase text-secondary fw-semibold mb-2">Today / اليوم</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<div class="metric-card">
|
||||
<div class="metric-value"><?= qh_h((string) $stats['issued_today']) ?></div>
|
||||
<div class="metric-label">Issued / المُصدرة</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="metric-card">
|
||||
<div class="metric-value"><?= qh_h((string) $stats['waiting_vitals']) ?></div>
|
||||
<div class="metric-label">Vitals / الحيوية</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="metric-card">
|
||||
<div class="metric-value"><?= qh_h((string) $stats['ready_for_doctor']) ?></div>
|
||||
<div class="metric-label">Ready / جاهز</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="metric-card">
|
||||
<div class="metric-value"><?= qh_h((string) $stats['active_rooms']) ?></div>
|
||||
<div class="metric-label">Active rooms / الغرف النشطة</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
</section>
|
||||
|
||||
<section class="row g-4 mb-4 mb-lg-5">
|
||||
<div class="col-xl-8">
|
||||
<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">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
64
nursing.php
Normal 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
715
queue_bootstrap.php
Normal 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
123
reception.php
Normal 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">Today’s 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
107
ticket.php
Normal 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(); ?>
|
||||
Loading…
x
Reference in New Issue
Block a user