diff --git a/admin.php b/admin.php index a6be8d1..b7f1f3a 100644 --- a/admin.php +++ b/admin.php @@ -4,163 +4,158 @@ require_once __DIR__ . '/queue_bootstrap.php'; qh_boot(); qh_admin_handle_request(); +$stats = qh_admin_stats(); $clinics = qh_fetch_clinics(); $doctors = qh_fetch_doctors(); +$recentClinics = array_slice($clinics, 0, 4); +$recentDoctors = array_slice($doctors, 0, 5); +$profile = qh_fetch_hospital_profile(); -qh_page_start('admin', 'Admin configuration', 'Configure clinics, doctors, room numbers, and vitals requirements for the queue workflow.'); +qh_page_start( + 'admin', + qh_t('Admin overview', 'نظرة عامة للإدارة'), + qh_t('Structured admin overview with separate pages for clinics and doctors.', 'نظرة عامة منظمة للإدارة مع صفحات مستقلة للعيادات والأطباء.') +); ?>
-
-
- Admin / الإدارة -

Clinic and doctor setup.

-

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

-
-
+
+ -
-
-
-

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

-
- -
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- - -
-
-
- -
-
-
-
-
-

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

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

+

+
+
-
-
-
-

Clinics / العيادات

-
- -
- - +
+
+
+ +

+

+
+
+ +
+

+

+

+

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

+
+ +
+
+
-
-
-
Code
+
+
-
-
- - -
-
-
- > - + +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +

+
+ +
+ +
+ +
+ +
+
+
+
:
+
-
- - - -
-
-
-
-
-

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

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

+
+ +
+ +
+ +
+ +
+
+
+
+
+
- Current room -
-
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
+ +
+ +
diff --git a/admin_clinics.php b/admin_clinics.php new file mode 100644 index 0000000..8409903 --- /dev/null +++ b/admin_clinics.php @@ -0,0 +1,188 @@ + 0 ? qh_fetch_clinic($editId) : null; + +if ($editId > 0 && $editClinic === null) { + qh_set_flash('warning', qh_t('The requested clinic record was not found.', 'لم يتم العثور على سجل العيادة المطلوب.')); + qh_redirect('admin_clinics.php'); +} + +$toLower = static function (string $value): string { + return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value); +}; + +if ($search !== '') { + $needle = $toLower($search); + $clinics = array_values(array_filter($clinics, static function (array $clinic) use ($needle, $toLower): bool { + $haystack = implode(' ', [ + (string) ($clinic['code'] ?? ''), + (string) ($clinic['name_en'] ?? ''), + (string) ($clinic['name_ar'] ?? ''), + (int) ($clinic['requires_vitals'] ?? 0) === 1 ? 'vitals first' : 'direct doctor', + ]); + return str_contains($toLower($haystack), $needle); + })); +} + +qh_page_start( + 'admin', + qh_t('Clinic management', 'إدارة العيادات'), + qh_t('Professional clinic directory with search, edit, and delete actions.', 'دليل احترافي للعيادات مع البحث وخيارات التعديل والحذف.') +); +?> +
+
+ + +
+
+
+ +
+

+

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

+

+
+
+
+ + +
+ +
+ + + + + + + + + + + + + (int) $clinic['id'], 'q' => $search]); ?> + + + + + + + + + +
+
+
+
+
+ +
+ + + + +
+
+
+
+ +
+ +
+
+
+ +

+

+
+
+ +
+ + + + + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ > + +
+
+
+ +
+ + + + +
+
+
+
+
+
+
+ diff --git a/admin_doctors.php b/admin_doctors.php new file mode 100644 index 0000000..356e4ab --- /dev/null +++ b/admin_doctors.php @@ -0,0 +1,196 @@ + 0 ? qh_fetch_doctor($editId) : null; + +if ($editId > 0 && $editDoctor === null) { + qh_set_flash('warning', qh_t('The requested doctor record was not found.', 'لم يتم العثور على سجل الطبيب المطلوب.')); + qh_redirect('admin_doctors.php'); +} + +$toLower = static function (string $value): string { + return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value); +}; + +if ($search !== '') { + $needle = $toLower($search); + $doctors = array_values(array_filter($doctors, static function (array $doctor) use ($needle, $toLower): bool { + $haystack = implode(' ', [ + (string) ($doctor['name_en'] ?? ''), + (string) ($doctor['name_ar'] ?? ''), + (string) ($doctor['clinic_name_en'] ?? ''), + (string) ($doctor['clinic_name_ar'] ?? ''), + (string) ($doctor['room_number'] ?? ''), + ]); + return str_contains($toLower($haystack), $needle); + })); +} + +qh_page_start( + 'admin', + qh_t('Doctor management', 'إدارة الأطباء'), + qh_t('Professional doctor directory with search, edit, and delete actions.', 'دليل احترافي للأطباء مع البحث وخيارات التعديل والحذف.') +); +?> +
+
+ + +
+
+
+ +
+

+

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

+

+
+
+
+ + +
+ +
+ + + + + + + + + + + + + (int) $doctor['id'], 'q' => $search]); ?> + + + + + + + + + +
+
+
+
+
+ +
+ + + + +
+
+
+
+ +
+ +
+
+
+ +

+

+
+
+ + +
+ + +
+ + + + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + + + +
+
+ +
+
+
+
+
+ diff --git a/admin_hospital.php b/admin_hospital.php new file mode 100644 index 0000000..773f062 --- /dev/null +++ b/admin_hospital.php @@ -0,0 +1,243 @@ + +
+
+ + +
+
+
+ +
+

+

+
+ +
+
+ + <?= qh_h(qh_hospital_name()) ?> + + + +
+
+
+

+ +

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

+

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

+

+
+
+ +
+
+ +
+ + +

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

+
+
+ +
+
+ + + <?= qh_h(qh_t('Hospital logo preview', 'معاينة شعار المستشفى')) ?> + +
+ +
+
+ + + <?= qh_h(qh_t('Favicon preview', 'معاينة الأيقونة')) ?> + +
+ +
+
+
+
+
+
+
+
+ diff --git a/assets/css/custom.css b/assets/css/custom.css index da46c50..ecf9684 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,18 +1,20 @@ :root { - --bg: #f3f4f6; + --bg: #eef6f7; --surface: #ffffff; - --surface-muted: #f8fafc; - --border: #d7dde6; - --border-strong: #c4ccd7; - --text: #111827; - --muted: #6b7280; - --accent: #1f4f78; - --accent-soft: #e8f0f6; + --surface-muted: #f7fbfc; + --border: #d5e3e8; + --border-strong: #b7ccd5; + --text: #183b4d; + --ink: #183b4d; + --muted: #61788a; + --accent: #0f8b8d; + --accent-strong: #16697a; + --accent-soft: #e1f4f3; --warning-soft: #fff4d6; --info-soft: #dceef8; --success-soft: #dff3e7; --danger-soft: #fde4e4; - --shadow: 0 10px 30px rgba(15, 23, 42, 0.06); + --shadow: 0 18px 40px rgba(15, 64, 75, 0.08); --radius-sm: 0.45rem; --radius-md: 0.7rem; --radius-lg: 0.95rem; @@ -54,6 +56,11 @@ a:hover { text-decoration: none; } +.app-header { + background: rgba(255, 255, 255, 0.88); + backdrop-filter: blur(14px); +} + .brand-mark { display: inline-flex; align-items: center; @@ -61,11 +68,12 @@ a:hover { width: 2.25rem; height: 2.25rem; border-radius: var(--radius-sm); - background: var(--text); + background: linear-gradient(135deg, var(--accent-strong) 0%, var(--accent) 100%); color: #fff; font-size: 0.88rem; font-weight: 700; letter-spacing: 0.04em; + box-shadow: 0 12px 20px rgba(15, 139, 141, 0.22); } .brand-text { @@ -73,6 +81,36 @@ a:hover { font-weight: 700; } + +.brand-copy { + display: inline-flex; + flex-direction: column; + gap: 0.05rem; + min-width: 0; +} + +.brand-subtext { + color: var(--muted); + font-size: 0.78rem; + line-height: 1.2; + max-width: 34ch; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.brand-mark-image { + overflow: hidden; + padding: 0; + background: #ffffff; +} + +.brand-mark-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + .nav-link { color: var(--muted); font-weight: 500; @@ -82,8 +120,8 @@ a:hover { .nav-link:hover, .nav-link.active { - background: #eef2f6; - color: var(--text); + background: var(--accent-soft); + color: var(--accent-strong); } .app-shell { @@ -491,3 +529,633 @@ a:hover { font-size: 2.5rem; } } + + +.admin-layout { + display: grid; + grid-template-columns: minmax(220px, 248px) minmax(0, 1fr); + gap: 1.25rem; + align-items: start; +} + +.admin-sidebar-column { + min-width: 0; +} + +.admin-sidebar { + position: sticky; + top: 5.9rem; + display: grid; + gap: 1rem; +} + +.admin-sidebar-top { + display: grid; + gap: 0.35rem; +} + +.admin-sidebar-nav { + display: grid; + gap: 0.3rem; + padding: 0.35rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: linear-gradient(180deg, #ffffff 0%, #f8fbfc 100%); +} + +.admin-sidebar-link { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.7rem; + padding: 0.72rem 0.8rem; + border: 1px solid transparent; + border-radius: 0.95rem; + background: transparent; + color: var(--muted); + text-decoration: none; + font-weight: 700; + line-height: 1.2; + transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease; +} + +.admin-sidebar-link:hover { + color: var(--accent-strong); + border-color: rgba(15, 139, 141, 0.14); + background: rgba(15, 139, 141, 0.08); +} + +.admin-sidebar-link-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.75rem; + border: 1px solid var(--border); + background: var(--surface-muted); + color: var(--accent-strong); + flex-shrink: 0; +} + +.admin-sidebar-link-icon svg { + width: 1rem; + height: 1rem; +} + +.admin-sidebar-link-text { + display: inline-flex; + align-items: center; + min-width: 0; +} + +.admin-sidebar-meta { + display: grid; + gap: 0.75rem; +} + +.admin-mini-stat { + padding: 0.95rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); +} + +.admin-mini-stat-value { + display: block; + font-size: 1.4rem; + font-weight: 800; + line-height: 1.1; +} + +.admin-mini-stat-label { + display: block; + margin-top: 0.3rem; + color: var(--muted); + font-size: 0.85rem; +} + +.admin-content-stack { + display: grid; + gap: 1.25rem; + min-width: 0; +} + +.admin-hero-panel { + background: linear-gradient(135deg, #ffffff 0%, #eef4f9 100%); +} + +.admin-section-card { + scroll-margin-top: 6.75rem; +} + +.admin-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.admin-overview-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; +} + +.admin-overview-card { + border: 1px solid var(--border); + border-radius: var(--radius-md); +} + +.admin-overview-card strong { + display: block; + margin-top: 0.9rem; + font-size: clamp(1.8rem, 3vw, 2.35rem); + line-height: 1; +} + +.admin-overview-card p { + margin-top: 0.65rem; +} + +.admin-switch-card { + padding: 0.9rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-muted); +} + +.admin-form-note { + color: var(--muted); + font-size: 0.92rem; +} + +.admin-list-form { + display: grid; + gap: 0.4rem; +} + +.admin-list-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.admin-inline-switch { + padding: 0.65rem 0.85rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: #ffffff; +} + +@media (max-width: 1199.98px) { + .admin-overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 991.98px) { + .admin-layout { + grid-template-columns: 1fr; + } + + .admin-sidebar { + position: static; + } +} + +@media (max-width: 767.98px) { + .admin-overview-grid { + grid-template-columns: 1fr; + } + + .admin-list-head { + flex-direction: column; + align-items: flex-start; + } + + .admin-sidebar-link { + padding-inline: 0.75rem; + } +} + + +/* language split updates */ +.lang-switcher { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.lang-switch-link { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.45rem 0.85rem; + border-radius: 999px; + border: 1px solid var(--border-strong); + background: rgba(255, 255, 255, 0.7); + color: var(--ink); + font-size: 0.84rem; + font-weight: 700; + text-decoration: none; +} + +.lang-switch-link.active { + background: var(--ink); + border-color: var(--ink); + color: #fff; +} + +.locale-chip { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.8rem; + border-radius: 999px; + background: rgba(17, 24, 39, 0.08); + color: var(--ink); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +html[dir="rtl"] body { + direction: rtl; +} + +html[dir="rtl"] .section-title, +html[dir="rtl"] .section-title-xl, +html[dir="rtl"] .section-copy, +html[dir="rtl"] .display-title, +html[dir="rtl"] .form-label, +html[dir="rtl"] th, +html[dir="rtl"] td, +html[dir="rtl"] .navbar-brand, +html[dir="rtl"] .admin-sidebar, +html[dir="rtl"] .empty-state, +html[dir="rtl"] .mini-overview-card, +html[dir="rtl"] .ticket-card, +html[dir="rtl"] .timeline-item, +html[dir="rtl"] .detail-list { + text-align: right; +} + +html[dir="rtl"] .navbar-nav { + padding-right: 0; +} + +html[dir="rtl"] .toast-container { + left: 0; + right: auto; +} + +html[dir="rtl"] .admin-list-head, +html[dir="rtl"] .call-strip, +html[dir="rtl"] .announcement-card, +html[dir="rtl"] .admin-sidebar-link { + flex-direction: row-reverse; +} + +html[dir="rtl"] .timeline-item { + grid-template-columns: 1fr 18px; +} + +html[dir="rtl"] .timeline-dot { + justify-self: center; +} + + +.btn-dark { + background: linear-gradient(135deg, var(--accent-strong) 0%, var(--accent) 100%); + border-color: var(--accent-strong); +} + +.btn-dark:hover, +.btn-dark:focus { + background: linear-gradient(135deg, #135464 0%, #0b7f81 100%); + border-color: #135464; +} + +.btn-outline-dark { + color: var(--accent-strong); + border-color: rgba(22, 105, 122, 0.28); +} + +.btn-outline-dark:hover, +.btn-outline-dark:focus, +.btn-outline-dark:active { + color: #fff; + background: var(--accent-strong); + border-color: var(--accent-strong); +} + +.admin-sidebar-link.active { + color: var(--accent-strong); + border-color: rgba(15, 139, 141, 0.24); + background: linear-gradient(135deg, #ffffff 0%, #ebf8f8 100%); + box-shadow: inset 0 0 0 1px rgba(15, 139, 141, 0.08); +} + +.admin-sidebar-link.active .admin-sidebar-link-icon { + border-color: rgba(15, 139, 141, 0.18); + background: rgba(15, 139, 141, 0.12); +} + +.admin-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.25rem; +} + +.admin-card-grid-triple { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.admin-card-grid-secondary { + align-items: start; +} + +.admin-link-card { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.admin-summary-row { + 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); +} + +.admin-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.admin-search-form { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1 1 32rem; + flex-wrap: wrap; +} + +.admin-search-form .form-control { + flex: 1 1 16rem; +} + +.admin-directory-layout { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.95fr); + gap: 1.25rem; + align-items: start; +} + +.admin-table-card, +.admin-form-card { + min-width: 0; +} + +.admin-table-actions { + display: flex; + align-items: center; + gap: 0.55rem; + flex-wrap: wrap; +} + +.admin-table-actions form { + margin: 0; +} + +.admin-count-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 0.85rem; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent-strong); + font-size: 0.82rem; + font-weight: 700; +} + +.admin-table tbody tr:hover { + background: rgba(15, 139, 141, 0.04); +} + +@media (max-width: 1199.98px) { + .admin-card-grid, + .admin-directory-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 767.98px) { + .admin-summary-row, + .admin-toolbar, + .admin-search-form, + .admin-table-actions { + flex-direction: column; + align-items: stretch; + } +} + +html[dir="rtl"] .admin-summary-row, +html[dir="rtl"] .admin-toolbar, +html[dir="rtl"] .admin-table-actions { + flex-direction: row-reverse; +} + + +.hospital-hero-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + flex-wrap: wrap; +} + +.hospital-brand-preview { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.1rem; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(225,244,243,0.9) 100%); + min-width: min(100%, 24rem); +} + +.hospital-brand-mark, +.hospital-preview-logo { + width: 4.5rem; + height: 4.5rem; + border-radius: 1.1rem; + background: linear-gradient(135deg, var(--accent-strong) 0%, var(--accent) 100%); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.3rem; + font-weight: 800; + box-shadow: 0 12px 26px rgba(15, 139, 141, 0.22); + overflow: hidden; +} + +.hospital-brand-mark img, +.hospital-preview-logo img, +.hospital-asset-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.hospital-color-pills { + display: flex; + align-items: center; + gap: 0.55rem; + flex-wrap: wrap; +} + +.hospital-color-pill { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.75rem; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + font-size: 0.82rem; + font-weight: 700; + color: var(--accent-strong); +} + +.hospital-color-swatch { + width: 0.9rem; + height: 0.9rem; + border-radius: 999px; + border: 1px solid rgba(0, 0, 0, 0.08); +} + +.hospital-profile-layout { + grid-template-columns: minmax(0, 1.45fr) minmax(300px, 0.95fr); +} + +.hospital-preview-card, +.hospital-overview-card { + min-width: 0; +} + +.hospital-preview-stack { + display: grid; + gap: 1rem; +} + +.hospital-preview-header { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--surface-muted); +} + +.hospital-detail-list { + display: grid; + gap: 0.85rem; +} + +.hospital-detail-list div { + padding: 0.9rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: #fff; +} + +.hospital-detail-list dt { + margin-bottom: 0.25rem; + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.hospital-detail-list dd { + margin: 0; + color: var(--text); +} + +.hospital-asset-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.hospital-asset-box { + padding: 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--surface-muted); +} + +.hospital-asset-image { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + margin-top: 0.9rem; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: #fff; +} + +.hospital-asset-favicon { + max-width: 6rem; + aspect-ratio: 1; +} + +@media (max-width: 1199.98px) { + .admin-card-grid-triple, + .hospital-profile-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 767.98px) { + .hospital-brand-preview, + .hospital-preview-header { + flex-direction: column; + align-items: flex-start; + } + + .hospital-asset-grid { + grid-template-columns: 1fr; + } + + .brand-subtext { + white-space: normal; + } +} + +html[dir="rtl"] .hospital-brand-preview, +html[dir="rtl"] .hospital-preview-header { + flex-direction: row-reverse; +} + +html[dir="rtl"] .hospital-detail-list dt, +html[dir="rtl"] .hospital-detail-list dd { + text-align: right; +} diff --git a/assets/js/main.js b/assets/js/main.js index aaf51f7..51760ac 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -36,23 +36,25 @@ document.addEventListener('DOMContentLoaded', () => { } }); + const locale = document.body.dataset.locale || 'en'; 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' }); + liveClock.textContent = now.toLocaleTimeString(locale === 'ar' ? 'ar-SA' : 'en-US', { hour: '2-digit', minute: '2-digit' }); }; renderClock(); window.setInterval(renderClock, 1000 * 30); } - const page = document.body.dataset.page; - if (page === 'display') { + if (document.body.dataset.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.textContent = isFullscreen + ? (fullscreenButton.dataset.labelExit || 'Exit full display') + : (fullscreenButton.dataset.labelEnter || 'Full display'); fullscreenButton.setAttribute('aria-pressed', isFullscreen ? 'true' : 'false'); }; @@ -78,22 +80,20 @@ document.addEventListener('DOMContentLoaded', () => { const latest = cards[0]; if (latest && 'speechSynthesis' in window) { const announcementKey = latest.dataset.announcementKey || ''; - const storedKey = window.localStorage.getItem('hospitalQueue:lastAnnouncement') || ''; + const storageKey = `hospitalQueue:lastAnnouncement:${locale}`; + const storedKey = window.localStorage.getItem(storageKey) || ''; 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); - }; - + const text = locale === 'ar' ? (latest.dataset.announcementAr || '') : (latest.dataset.announcementEn || ''); + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = locale === 'ar' ? 'ar-SA' : 'en-US'; + const voices = window.speechSynthesis.getVoices(); + const preferredVoice = voices.find((voice) => voice.lang.toLowerCase().startsWith(locale === 'ar' ? 'ar' : 'en')); + if (preferredVoice) utterance.voice = preferredVoice; window.speechSynthesis.cancel(); - speakText(latest.dataset.announcementEn || '', 'en-US'); - window.setTimeout(() => speakText(latest.dataset.announcementAr || '', 'ar-SA'), 1750); - window.localStorage.setItem('hospitalQueue:lastAnnouncement', announcementKey); + if (text) { + window.speechSynthesis.speak(utterance); + window.localStorage.setItem(storageKey, announcementKey); + } } } diff --git a/display.php b/display.php index ca69f73..6de797b 100644 --- a/display.php +++ b/display.php @@ -6,19 +6,23 @@ 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.'); +qh_page_start( + 'display', + qh_t('General display board', 'لوحة العرض العامة'), + qh_t('Public queue display with separated English and Arabic modes.', 'شاشة طوابير عامة مع وضعي عرض منفصلين بالعربية والإنجليزية.') +); ?>
-
General display / الشاشة العامة
-

Now serving

-

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

+
+

+

- +
@@ -29,7 +33,7 @@ qh_page_start('display', 'General display board', 'Public hospital queue display
-
· Room
+
·
@@ -40,22 +44,21 @@ qh_page_start('display', 'General display board', 'Public hospital queue display
- No live calls right now. - When a doctor presses “Call patient”, the ticket will appear here and play on TTS. + +
-

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

+

-
-
+
- Vitals - Doctor + +
@@ -67,26 +70,26 @@ qh_page_start('display', 'General display board', 'Public hospital queue display
diff --git a/doctor.php b/doctor.php index a09d81c..18a3d7d 100644 --- a/doctor.php +++ b/doctor.php @@ -5,104 +5,92 @@ 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) : []; +$selectedDoctorId = isset($_GET['doctor_id']) ? (int) $_GET['doctor_id'] : (int) ($doctors[0]['id'] ?? 0); +$selectedDoctor = $selectedDoctorId > 0 ? qh_fetch_doctor($selectedDoctorId) : null; +$doctorQueue = $selectedDoctorId > 0 ? 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.'); +qh_page_start( + 'doctor', + qh_t('Doctor room queue', 'طابور غرفة الطبيب'), + qh_t('Doctor workspace with separate English and Arabic page views.', 'مساحة عمل الطبيب مع صفحات عربية وإنجليزية منفصلة.') +); ?>
- Doctor / الطبيب -

Call the next patient and update status.

-

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

+ +
+

+

-
-
-
- - -
- -
-
-
-
-
+
+
+
+ + + + + +
+
+
- Room -
-
- - -
- -
-
-
-

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

-

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

+ + +
- Preview public display
- -
- -
-
-
-
-
-
Vitals:
-
- -
-
-
- - - - -
-
- - - - -
-
- - - - -
-
- - - - -
-
+
+
+
+
+

+

- + + + +
+ + +
+ +
+
+
+
+
+
·
+
+
+ + +
+
+
+
+
+
+
+
+
+ +
+ +
+ + +
+
- -
- No patients in this doctor queue. - Issue a ticket at reception or complete vitals in nursing to fill this room. -
- +
diff --git a/index.php b/index.php index 6120431..bf84f41 100644 --- a/index.php +++ b/index.php @@ -8,48 +8,33 @@ $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.'); +qh_page_start( + 'home', + qh_t('Hospital queue operations', 'عمليات طابور المستشفى'), + qh_t('English operations dashboard for the hospital queue workflow.', 'لوحة عمليات عربية لمسار طابور المستشفى.') +); ?>
- Hospital queue system / نظام الطوابير -

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

-

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

+ +
+

+

-
Today / اليوم
+
-
-
-
-
Issued / المُصدرة
-
-
-
-
-
-
Vitals / الحيوية
-
-
-
-
-
-
Ready / جاهز
-
-
-
-
-
-
Active rooms / الغرف النشطة
-
-
+
+
+
+
@@ -61,29 +46,26 @@ qh_page_start('home', 'Hospital queue operations', 'Bilingual hospital queue das
-

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

-

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

+

+

- Manage clinics & doctors +
- - - - - + + + + + - + @@ -97,19 +79,15 @@ qh_page_start('home', 'Hospital queue operations', 'Bilingual hospital queue das
-
-
-

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

-

Patients already called to a doctor room.

-
-
+

+

-
· Room
+
·
@@ -117,8 +95,8 @@ qh_page_start('home', 'Hospital queue operations', 'Bilingual hospital queue das
- No active calls yet. - Use the doctor page to call the next patient. + +
@@ -126,81 +104,64 @@ qh_page_start('home', 'Hospital queue operations', 'Bilingual hospital queue das
- - - - + '01', 'title' => qh_t('Reception issues one ticket', 'الاستقبال يصدر تذكرة واحدة'), 'copy' => qh_t('Choose the clinic and doctor once at the front desk.', 'يتم اختيار العيادة والطبيب مرة واحدة من مكتب الاستقبال.')], + ['step' => '02', 'title' => qh_t('Optional vitals step', 'خطوة العلامات الحيوية عند الحاجة'), 'copy' => qh_t('Only clinics that require vitals route through nursing first.', 'فقط العيادات التي تتطلب العلامات الحيوية تمر أولاً على التمريض.')], + ['step' => '03', 'title' => qh_t('Doctor calls the patient', 'الطبيب ينادي المريض'), 'copy' => qh_t('The doctor room page pushes the next call to the display.', 'صفحة غرفة الطبيب ترسل النداء التالي إلى الشاشة.')], + ['step' => '04', 'title' => qh_t('Display announces the call', 'الشاشة تعلن النداء'), 'copy' => qh_t('Patients see the latest ticket and room assignment immediately.', 'يرى المرضى آخر تذكرة ورقم الغرفة مباشرة.')], + ]; + ?> + +
+
+ +

+

+
+
+
-

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

-

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

+

+

- Create new ticket +
Clinic / العيادةVitals waitDoctor waitActive callsTotal today
-
-
-
- - - - - + + + + + - - - - - - - - - - + + + + + + + + + +
TicketPatientClinicDoctor / RoomStatus
-
-
-
-
-
-
· View detail
-
- No active tickets yet. - Start from the reception desk to issue the first patient ticket. +
+ +
diff --git a/nursing.php b/nursing.php index 022aa36..56c889f 100644 --- a/nursing.php +++ b/nursing.php @@ -5,58 +5,64 @@ 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.'); + +qh_page_start( + 'nursing', + qh_t('Nursing vitals queue', 'طابور التمريض للعلامات الحيوية'), + qh_t('Nursing workspace with separate English and Arabic views.', 'مساحة عمل التمريض مع فصل بين الواجهة العربية والإنجليزية.') +); ?>
- Nursing / التمريض -

Capture vitals and release to doctor.

-

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

+ +
+

+

-

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

-

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

+

+

- patients +
-
-
+
+
-
-
· Room
+
+
· ·
- -
+
- - + +
- +
-
+
- No patients waiting for vitals. - Tickets from vitals-required clinics will appear here automatically after issue. + +
diff --git a/queue_bootstrap.php b/queue_bootstrap.php index 9e8988e..3e78a3a 100644 --- a/queue_bootstrap.php +++ b/queue_bootstrap.php @@ -16,6 +16,7 @@ function qh_boot(): void qh_ensure_schema(); qh_seed_demo_data(); + qh_seed_hospital_profile(); $booted = true; } @@ -51,6 +52,31 @@ CREATE TABLE IF NOT EXISTS hospital_queue_records ( SQL; db()->exec($sql); + + $profileSql = <<exec($profileSql); } function qh_seed_demo_data(): void @@ -128,6 +154,40 @@ function qh_seed_demo_data(): void } } +function qh_seed_hospital_profile(): void +{ + $exists = (int) db()->query('SELECT COUNT(*) FROM hospital_profile_settings')->fetchColumn(); + if ($exists > 0) { + return; + } + + $stmt = db()->prepare( + 'INSERT INTO hospital_profile_settings + (id, name_en, name_ar, short_name, tagline_en, tagline_ar, phone, email, website, address_en, address_ar, working_hours_en, working_hours_ar, logo_url, favicon_url, primary_color, secondary_color) + VALUES + (1, :name_en, :name_ar, :short_name, :tagline_en, :tagline_ar, :phone, :email, :website, :address_en, :address_ar, :working_hours_en, :working_hours_ar, :logo_url, :favicon_url, :primary_color, :secondary_color)' + ); + + $stmt->execute([ + 'name_en' => qh_project_name('Hospital Queue Center'), + 'name_ar' => 'مركز إدارة الطوابير', + 'short_name' => 'HQC', + 'tagline_en' => 'Organized patient flow, queue control, and staff coordination.', + 'tagline_ar' => 'تنظيم تدفق المرضى وإدارة الطوابير وتنسيق عمل الطاقم.', + 'phone' => '', + 'email' => '', + 'website' => '', + 'address_en' => '', + 'address_ar' => '', + 'working_hours_en' => 'Sun–Thu · 8:00 AM – 8:00 PM', + 'working_hours_ar' => 'الأحد – الخميس · 8:00 ص – 8:00 م', + 'logo_url' => '', + 'favicon_url' => '', + 'primary_color' => '#0f8b8d', + 'secondary_color' => '#16697a', + ]); +} + function qh_h(?string $value): string { return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); @@ -151,29 +211,313 @@ function qh_asset_version(string $relativePath): int return is_file($fullPath) ? (int) filemtime($fullPath) : time(); } + +function qh_locale(): string +{ + static $locale = null; + if ($locale !== null) { + return $locale; + } + + $requested = strtolower(trim((string) ($_GET['lang'] ?? ''))); + if (in_array($requested, ['en', 'ar'], true)) { + $_SESSION['qh_lang'] = $requested; + } + + $sessionLocale = strtolower(trim((string) ($_SESSION['qh_lang'] ?? 'en'))); + $locale = in_array($sessionLocale, ['en', 'ar'], true) ? $sessionLocale : 'en'; + + return $locale; +} + +function qh_is_ar(): bool +{ + return qh_locale() === 'ar'; +} + +function qh_t(string $en, string $ar): string +{ + return qh_is_ar() ? $ar : $en; +} + +function qh_locale_label(?string $locale = null): string +{ + $locale = $locale ?: qh_locale(); + return $locale === 'ar' ? 'العربية' : 'English'; +} + +function qh_other_locale(): string +{ + return qh_is_ar() ? 'en' : 'ar'; +} + +function qh_url(string $path, array $params = []): string +{ + $target = basename($path); + $params['lang'] = $params['lang'] ?? qh_locale(); + $query = http_build_query($params); + return $query !== '' ? $target . '?' . $query : $target; +} + +function qh_switch_lang_url(string $locale): string +{ + $params = $_GET; + $params['lang'] = in_array($locale, ['en', 'ar'], true) ? $locale : qh_other_locale(); + $target = basename((string) ($_SERVER['PHP_SELF'] ?? 'index.php')); + $query = http_build_query($params); + return $query !== '' ? $target . '?' . $query : $target; +} + +function qh_name(array $row, string $base = 'name', string $fallback = '—'): string +{ + $primaryKey = $base . '_' . (qh_is_ar() ? 'ar' : 'en'); + $secondaryKey = $base . '_' . (qh_is_ar() ? 'en' : 'ar'); + + $primary = trim((string) ($row[$primaryKey] ?? '')); + if ($primary !== '') { + return $primary; + } + + $secondary = trim((string) ($row[$secondaryKey] ?? '')); + return $secondary !== '' ? $secondary : $fallback; +} + +function qh_fetch_hospital_profile(): array +{ + static $profile = null; + if ($profile !== null) { + return $profile; + } + + $row = db()->query('SELECT * FROM hospital_profile_settings WHERE id = 1 LIMIT 1')->fetch(); + $profile = is_array($row) ? $row : []; + + return $profile; +} + +function qh_hospital_name(): string +{ + $profile = qh_fetch_hospital_profile(); + $name = qh_name($profile, 'name', ''); + if ($name !== '') { + return $name; + } + + $shortName = trim((string) ($profile['short_name'] ?? '')); + return $shortName !== '' ? $shortName : qh_project_name(); +} + +function qh_hospital_tagline(): string +{ + $profile = qh_fetch_hospital_profile(); + return qh_name($profile, 'tagline', ''); +} + +function qh_hospital_logo_url(): string +{ + $profile = qh_fetch_hospital_profile(); + return trim((string) ($profile['logo_url'] ?? '')); +} + +function qh_hospital_favicon_url(): string +{ + $profile = qh_fetch_hospital_profile(); + $favicon = trim((string) ($profile['favicon_url'] ?? '')); + if ($favicon !== '') { + return $favicon; + } + + return qh_hospital_logo_url(); +} + +function qh_hospital_contact_value(string $field): string +{ + $profile = qh_fetch_hospital_profile(); + return trim((string) ($profile[$field] ?? '')); +} + +function qh_hospital_brand_initials(): string +{ + $profile = qh_fetch_hospital_profile(); + $source = trim((string) ($profile['short_name'] ?? '')); + if ($source === '') { + $source = trim((string) ($profile['name_en'] ?? qh_project_name('Hospital Queue'))); + } + + $parts = preg_split('/[^\p{L}\p{N}]+/u', $source, -1, PREG_SPLIT_NO_EMPTY); + $initials = []; + foreach ($parts as $part) { + if (preg_match('/^[\p{L}\p{N}]/u', $part, $matches)) { + $initials[] = strtoupper($matches[0]); + } + if (count($initials) >= 2) { + break; + } + } + + return $initials !== [] ? implode('', array_slice($initials, 0, 2)) : 'HQ'; +} + +function qh_sanitize_hex_color(?string $value, string $fallback): string +{ + $candidate = strtoupper(trim((string) $value)); + return preg_match('/^#[0-9A-F]{6}$/', $candidate) ? $candidate : $fallback; +} + +function qh_hospital_primary_color(): string +{ + $profile = qh_fetch_hospital_profile(); + return qh_sanitize_hex_color($profile['primary_color'] ?? null, '#0F8B8D'); +} + +function qh_hospital_secondary_color(): string +{ + $profile = qh_fetch_hospital_profile(); + return qh_sanitize_hex_color($profile['secondary_color'] ?? null, '#16697A'); +} + +function qh_current_language_badge(): string +{ + return qh_t('English workspace', 'واجهة عربية'); +} + +function qh_admin_sections(): array +{ + return [ + 'admin.php' => [ + 'label' => qh_t('Overview', 'نظرة عامة'), + 'description' => qh_t('Admin home and setup summary.', 'الصفحة الرئيسية وملخص الإعدادات.'), + 'icon' => 'overview', + ], + 'admin_hospital.php' => [ + 'label' => qh_t('Hospital profile', 'ملف المستشفى'), + 'description' => qh_t('Manage logo, favicon, contact details, and brand colors.', 'إدارة الشعار والأيقونة وبيانات التواصل وألوان الهوية.'), + 'icon' => 'hospital', + ], + 'admin_clinics.php' => [ + 'label' => qh_t('Clinics', 'العيادات'), + 'description' => qh_t('Manage clinic codes, routing, and order.', 'إدارة رموز العيادات والمسار والترتيب.'), + 'icon' => 'clinic', + ], + 'admin_doctors.php' => [ + 'label' => qh_t('Doctors', 'الأطباء'), + 'description' => qh_t('Manage doctors, rooms, and assignments.', 'إدارة الأطباء والغرف والتعيينات.'), + 'icon' => 'doctor', + ], + ]; +} + +function qh_admin_allowed_pages(): array +{ + return array_keys(qh_admin_sections()); +} + +function qh_admin_return_to(?string $candidate = null): string +{ + $page = basename((string) ($candidate ?? 'admin.php')); + return in_array($page, qh_admin_allowed_pages(), true) ? $page : 'admin.php'; +} + +function qh_admin_sidebar_icon(string $icon): string +{ + return match ($icon) { + 'hospital' => '', + 'clinic' => '', + 'doctor' => '', + default => '', + }; +} + +function qh_admin_stats(): array +{ + $clinics = qh_fetch_clinics(); + $doctors = qh_fetch_doctors(); + $vitalsClinics = count(array_filter($clinics, static fn(array $clinic): bool => (int) ($clinic['requires_vitals'] ?? 0) === 1)); + + return [ + 'clinics' => count($clinics), + 'doctors' => count($doctors), + 'vitals_clinics' => $vitalsClinics, + 'direct_clinics' => max(count($clinics) - $vitalsClinics, 0), + ]; +} + +function qh_render_admin_sidebar(string $activePage, array $stats = []): void +{ + $sections = qh_admin_sections(); + $activePage = qh_admin_return_to($activePage); + if ($stats === []) { + $stats = qh_admin_stats(); + } + + echo '
'; + echo '
'; + echo ' ' . qh_h(qh_t('Admin panel', 'لوحة الإدارة')) . ''; + echo '

' . qh_h(qh_hospital_name()) . '

'; + echo '

' . qh_h(qh_hospital_tagline() !== '' ? qh_hospital_tagline() : qh_t('Move between dedicated pages instead of one long mixed admin screen.', 'تنقل بين صفحات مستقلة بدلاً من شاشة إدارة طويلة ومختلطة.')) . '

'; + echo '
'; + echo ' '; + echo '
'; + echo '
' . qh_h((string) ($stats['clinics'] ?? 0)) . '' . qh_h(qh_t('Clinics', 'العيادات')) . '
'; + echo '
' . qh_h((string) ($stats['doctors'] ?? 0)) . '' . qh_h(qh_t('Doctors', 'الأطباء')) . '
'; + echo '
' . qh_h((string) ($stats['vitals_clinics'] ?? 0)) . '' . qh_h(qh_t('Vitals-first clinics', 'العيادات التي تبدأ بالعلامات')) . '
'; + echo '
'; + echo '
'; +} + +function qh_ticket_next_stop(array $ticket): string +{ + return (int) ($ticket['clinic_requires_vitals'] ?? 0) === 1 + ? qh_t('Nursing vitals', 'العلامات الحيوية في التمريض') + : qh_t('Doctor waiting area', 'منطقة انتظار الطبيب'); +} + +function qh_ticket_last_note(array $ticket): string +{ + return match ((string) ($ticket['status'] ?? '')) { + 'waiting_vitals' => qh_t('Proceed to nursing vitals first.', 'يرجى التوجه أولاً إلى العلامات الحيوية في التمريض.'), + 'ready_for_doctor' => qh_t('Wait for the doctor call on the public display.', 'انتظر نداء الطبيب على الشاشة العامة.'), + 'called' => qh_t('The patient has been called to the doctor room.', 'تم نداء المريض إلى غرفة الطبيب.'), + 'in_progress' => qh_t('The consultation is currently in progress.', 'الاستشارة جارية حالياً.'), + 'done' => qh_t('The visit has been completed.', 'تم إكمال الزيارة.'), + 'no_show' => qh_t('The patient was marked as no-show.', 'تم تسجيل المريض كحالة عدم حضور.'), + default => trim((string) ($ticket['display_note'] ?? '')) !== '' ? (string) $ticket['display_note'] : '—', + }; +} + function qh_label(string $en, string $ar, string $wrapper = 'span'): string { $tag = preg_replace('/[^a-z0-9]/i', '', $wrapper) ?: 'span'; - return sprintf( - '<%1$s class="bi-label">%2$s / %3$s', - $tag, - qh_h($en), - qh_h($ar) - ); + return sprintf('<%1$s>%2$s', $tag, qh_h(qh_t($en, $ar))); } + + function qh_page_start(string $activePage, string $pageTitle, string $metaDescription = ''): void { + $locale = qh_locale(); $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? qh_project_description(); $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; - $fullTitle = $pageTitle . ' · ' . qh_project_name(); + $brandName = qh_hospital_name(); + $faviconUrl = qh_hospital_favicon_url(); + $fullTitle = $pageTitle . ' · ' . $brandName; $description = $metaDescription !== '' ? $metaDescription : qh_project_description(); - $bodyClass = 'page-' . preg_replace('/[^a-z0-9\-]/i', '-', $activePage); + $bodyClass = 'page-' . preg_replace('/[^a-z0-9\-]/i', '-', $activePage) . ' lang-' . $locale; $assetVersionCss = qh_asset_version('assets/css/custom.css'); - $assetVersionJs = qh_asset_version('assets/js/main.js'); + $primaryColor = qh_hospital_primary_color(); + $secondaryColor = qh_hospital_secondary_color(); echo ''; - echo ''; + echo ''; echo ''; echo ' '; echo ' '; @@ -187,11 +531,16 @@ function qh_page_start(string $activePage, string $pageTitle, string $metaDescri echo ' '; echo ' '; } - echo ' '; + if ($faviconUrl !== '') { + echo ' '; + echo ' '; + } + echo ' '; echo ' '; echo ' '; + echo ' '; echo ''; - echo ''; + echo ''; if ($activePage !== 'display') { qh_render_nav($activePage); } @@ -199,6 +548,8 @@ function qh_page_start(string $activePage, string $pageTitle, string $metaDescri qh_render_flash(); } + + function qh_page_end(): void { $assetVersionJs = qh_asset_version('assets/js/main.js'); @@ -211,37 +562,57 @@ function qh_page_end(): void 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', 'الشاشة العامة')], + 'home' => ['href' => qh_url('index.php'), 'label' => qh_t('Operations', 'العمليات')], + 'admin' => ['href' => qh_url('admin.php'), 'label' => qh_t('Admin', 'الإدارة')], + 'reception' => ['href' => qh_url('reception.php'), 'label' => qh_t('Reception', 'الاستقبال')], + 'nursing' => ['href' => qh_url('nursing.php'), 'label' => qh_t('Nursing', 'التمريض')], + 'doctor' => ['href' => qh_url('doctor.php'), 'label' => qh_t('Doctor', 'الطبيب')], + 'display' => ['href' => qh_url('display.php'), 'label' => qh_t('Display', 'الشاشة')], ]; - echo '
'; + $logoUrl = qh_hospital_logo_url(); + $tagline = qh_hospital_tagline(); + + echo '
'; echo ' '; echo '
'; } + + function qh_set_flash(string $type, string $message): void { $_SESSION['flash'] = ['type' => $type, 'message' => $message]; @@ -268,18 +639,27 @@ function qh_render_flash(): void echo '
'; echo '
'; echo '
' . qh_h((string) $flash['message']) . '
'; - echo ' '; + echo ' '; echo '
'; echo '
'; echo '
'; } + + function qh_redirect(string $location): void { + if (strpos($location, 'lang=') === false) { + $separator = str_contains($location, '?') ? '&' : '?'; + $location .= $separator . 'lang=' . qh_locale(); + } + 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"); @@ -398,12 +778,12 @@ function qh_create_ticket(string $patientName, int $clinicId, int $doctorId, str $pdo = db(); $clinic = qh_fetch_clinic($clinicId); if (!$clinic) { - throw new RuntimeException('Clinic not found.'); + throw new RuntimeException(qh_t('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.'); + throw new RuntimeException(qh_t('The doctor does not belong to the selected clinic.', 'الطبيب لا يتبع العيادة المحددة.')); } $ticketNumber = qh_generate_ticket_number($clinic['code']); @@ -477,9 +857,11 @@ function qh_status_meta(string $status): array function qh_status_badge(string $status): string { $meta = qh_status_meta($status); - return '' . qh_label($meta['en'], $meta['ar']) . ''; + return '' . qh_h(qh_t($meta['en'], $meta['ar'])) . ''; } + + function qh_call_message(array $ticket): array { $ticketNumber = $ticket['ticket_number'] ?? '---'; @@ -505,18 +887,25 @@ function qh_format_datetime(?string $value): string function qh_require_post(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { - qh_set_flash('danger', 'Invalid request method.'); + qh_set_flash('danger', qh_t('Invalid request method.', 'طريقة الطلب غير صالحة.')); qh_redirect('index.php'); } } + + function qh_admin_handle_request(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { return; } - $action = $_POST['action'] ?? ''; + $action = trim((string) ($_POST['action'] ?? '')); + if ($action === '') { + return; + } + + $returnTo = qh_admin_return_to($_POST['return_to'] ?? 'admin.php'); $pdo = db(); try { @@ -526,7 +915,7 @@ function qh_admin_handle_request(): void $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.'); + throw new InvalidArgumentException(qh_t('Please complete the clinic code and both clinic names.', 'يرجى إدخال رمز العيادة والاسمين الإنجليزي والعربي.')); } $stmt = $pdo->prepare( "INSERT INTO hospital_queue_records (item_type, code, name_en, name_ar, requires_vitals, sort_order, status) @@ -537,29 +926,59 @@ function qh_admin_handle_request(): void 'name_en' => $nameEn, 'name_ar' => $nameAr, 'requires_vitals' => $requiresVitals, - 'sort_order' => (int) ($_POST['sort_order'] ?? 50), + 'sort_order' => max((int) ($_POST['sort_order'] ?? 50), 1), ]); - qh_set_flash('success', 'Clinic saved successfully.'); + qh_set_flash('success', qh_t('Clinic saved successfully.', 'تم حفظ العيادة بنجاح.')); } elseif ($action === 'update_clinic') { $clinicId = (int) ($_POST['clinic_id'] ?? 0); + $code = strtoupper(trim((string) ($_POST['code'] ?? ''))); + $nameEn = trim((string) ($_POST['name_en'] ?? '')); + $nameAr = trim((string) ($_POST['name_ar'] ?? '')); + if ($clinicId <= 0 || $code === '' || $nameEn === '' || $nameAr === '') { + throw new InvalidArgumentException(qh_t('Please complete the clinic details before updating.', 'يرجى إكمال بيانات العيادة قبل التحديث.')); + } $stmt = $pdo->prepare( "UPDATE hospital_queue_records - SET requires_vitals = :requires_vitals, sort_order = :sort_order + SET code = :code, + name_en = :name_en, + name_ar = :name_ar, + requires_vitals = :requires_vitals, + sort_order = :sort_order WHERE item_type = 'clinic' AND id = :clinic_id" ); $stmt->execute([ + 'code' => substr($code, 0, 10), + 'name_en' => $nameEn, + 'name_ar' => $nameAr, 'requires_vitals' => isset($_POST['requires_vitals']) ? 1 : 0, - 'sort_order' => (int) ($_POST['sort_order'] ?? 50), + 'sort_order' => max((int) ($_POST['sort_order'] ?? 50), 1), 'clinic_id' => $clinicId, ]); - qh_set_flash('success', 'Clinic settings updated.'); + qh_set_flash('success', qh_t('Clinic updated successfully.', 'تم تحديث العيادة بنجاح.')); + } elseif ($action === 'delete_clinic') { + $clinicId = (int) ($_POST['clinic_id'] ?? 0); + if ($clinicId <= 0) { + throw new InvalidArgumentException(qh_t('Invalid clinic selected.', 'تم اختيار عيادة غير صالحة.')); + } + $doctorCountStmt = $pdo->prepare("SELECT COUNT(*) FROM hospital_queue_records WHERE item_type = 'doctor' AND clinic_id = :clinic_id"); + $doctorCountStmt->execute(['clinic_id' => $clinicId]); + $doctorCount = (int) $doctorCountStmt->fetchColumn(); + $ticketCountStmt = $pdo->prepare("SELECT COUNT(*) FROM hospital_queue_records WHERE item_type = 'ticket' AND clinic_id = :clinic_id"); + $ticketCountStmt->execute(['clinic_id' => $clinicId]); + $ticketCount = (int) $ticketCountStmt->fetchColumn(); + if ($doctorCount > 0 || $ticketCount > 0) { + throw new InvalidArgumentException(qh_t('This clinic cannot be deleted because it is linked to doctors or patient tickets.', 'لا يمكن حذف هذه العيادة لأنها مرتبطة بأطباء أو تذاكر مرضى.')); + } + $stmt = $pdo->prepare("DELETE FROM hospital_queue_records WHERE item_type = 'clinic' AND id = :clinic_id"); + $stmt->execute(['clinic_id' => $clinicId]); + qh_set_flash('success', qh_t('Clinic deleted successfully.', 'تم حذف العيادة بنجاح.')); } 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.'); + throw new InvalidArgumentException(qh_t('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) @@ -570,31 +989,129 @@ function qh_admin_handle_request(): void 'name_ar' => $nameAr, 'clinic_id' => $clinicId, 'room_number' => $roomNumber, - 'sort_order' => (int) ($_POST['sort_order'] ?? 50), + 'sort_order' => max((int) ($_POST['sort_order'] ?? 50), 1), ]); - qh_set_flash('success', 'Doctor profile saved.'); + qh_set_flash('success', qh_t('Doctor profile saved.', 'تم حفظ ملف الطبيب.')); } elseif ($action === 'update_doctor') { $doctorId = (int) ($_POST['doctor_id'] ?? 0); + $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 ($doctorId <= 0 || $nameEn === '' || $nameAr === '' || $clinicId <= 0 || $roomNumber === '') { + throw new InvalidArgumentException(qh_t('Please complete the doctor details before updating.', 'يرجى إكمال بيانات الطبيب قبل التحديث.')); + } $stmt = $pdo->prepare( "UPDATE hospital_queue_records - SET clinic_id = :clinic_id, room_number = :room_number, sort_order = :sort_order + SET name_en = :name_en, + name_ar = :name_ar, + clinic_id = :clinic_id, + room_number = :room_number, + sort_order = :sort_order WHERE item_type = 'doctor' AND id = :doctor_id" ); $stmt->execute([ + 'name_en' => $nameEn, + 'name_ar' => $nameAr, 'clinic_id' => $clinicId, 'room_number' => $roomNumber, - 'sort_order' => (int) ($_POST['sort_order'] ?? 50), + 'sort_order' => max((int) ($_POST['sort_order'] ?? 50), 1), 'doctor_id' => $doctorId, ]); - qh_set_flash('success', 'Doctor assignment updated.'); + qh_set_flash('success', qh_t('Doctor assignment updated.', 'تم تحديث تعيين الطبيب.')); + } elseif ($action === 'delete_doctor') { + $doctorId = (int) ($_POST['doctor_id'] ?? 0); + if ($doctorId <= 0) { + throw new InvalidArgumentException(qh_t('Invalid doctor selected.', 'تم اختيار طبيب غير صالح.')); + } + $ticketCountStmt = $pdo->prepare("SELECT COUNT(*) FROM hospital_queue_records WHERE item_type = 'ticket' AND doctor_id = :doctor_id"); + $ticketCountStmt->execute(['doctor_id' => $doctorId]); + $ticketCount = (int) $ticketCountStmt->fetchColumn(); + if ($ticketCount > 0) { + throw new InvalidArgumentException(qh_t('This doctor cannot be deleted because patient tickets are linked to the profile.', 'لا يمكن حذف هذا الطبيب لأن هناك تذاكر مرضى مرتبطة بالملف.')); + } + $stmt = $pdo->prepare("DELETE FROM hospital_queue_records WHERE item_type = 'doctor' AND id = :doctor_id"); + $stmt->execute(['doctor_id' => $doctorId]); + qh_set_flash('success', qh_t('Doctor deleted successfully.', 'تم حذف الطبيب بنجاح.')); + } elseif ($action === 'save_hospital_profile') { + $nameEn = trim((string) ($_POST['name_en'] ?? '')); + $nameAr = trim((string) ($_POST['name_ar'] ?? '')); + $shortName = trim((string) ($_POST['short_name'] ?? '')); + $taglineEn = trim((string) ($_POST['tagline_en'] ?? '')); + $taglineAr = trim((string) ($_POST['tagline_ar'] ?? '')); + $phone = trim((string) ($_POST['phone'] ?? '')); + $email = trim((string) ($_POST['email'] ?? '')); + $website = trim((string) ($_POST['website'] ?? '')); + $addressEn = trim((string) ($_POST['address_en'] ?? '')); + $addressAr = trim((string) ($_POST['address_ar'] ?? '')); + $workingHoursEn = trim((string) ($_POST['working_hours_en'] ?? '')); + $workingHoursAr = trim((string) ($_POST['working_hours_ar'] ?? '')); + $logoUrl = trim((string) ($_POST['logo_url'] ?? '')); + $faviconUrl = trim((string) ($_POST['favicon_url'] ?? '')); + $primaryColor = qh_sanitize_hex_color($_POST['primary_color'] ?? '', '#0F8B8D'); + $secondaryColor = qh_sanitize_hex_color($_POST['secondary_color'] ?? '', '#16697A'); + + if ($nameEn === '' || $nameAr === '') { + throw new InvalidArgumentException(qh_t('Please enter both the English and Arabic hospital names.', 'يرجى إدخال اسم المستشفى بالإنجليزية والعربية.')); + } + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + throw new InvalidArgumentException(qh_t('Please enter a valid hospital email address.', 'يرجى إدخال بريد إلكتروني صالح للمستشفى.')); + } + if ($website !== '' && filter_var($website, FILTER_VALIDATE_URL) === false) { + throw new InvalidArgumentException(qh_t('Please enter a valid website URL.', 'يرجى إدخال رابط موقع صالح.')); + } + if ($logoUrl !== '' && filter_var($logoUrl, FILTER_VALIDATE_URL) === false) { + throw new InvalidArgumentException(qh_t('Please enter a valid logo URL.', 'يرجى إدخال رابط صالح للشعار.')); + } + if ($faviconUrl !== '' && filter_var($faviconUrl, FILTER_VALIDATE_URL) === false) { + throw new InvalidArgumentException(qh_t('Please enter a valid favicon URL.', 'يرجى إدخال رابط صالح للأيقونة.')); + } + + $stmt = $pdo->prepare( + "UPDATE hospital_profile_settings + SET name_en = :name_en, + name_ar = :name_ar, + short_name = :short_name, + tagline_en = :tagline_en, + tagline_ar = :tagline_ar, + phone = :phone, + email = :email, + website = :website, + address_en = :address_en, + address_ar = :address_ar, + working_hours_en = :working_hours_en, + working_hours_ar = :working_hours_ar, + logo_url = :logo_url, + favicon_url = :favicon_url, + primary_color = :primary_color, + secondary_color = :secondary_color + WHERE id = 1" + ); + $stmt->execute([ + 'name_en' => $nameEn, + 'name_ar' => $nameAr, + 'short_name' => substr($shortName, 0, 40), + 'tagline_en' => $taglineEn, + 'tagline_ar' => $taglineAr, + 'phone' => $phone, + 'email' => $email, + 'website' => $website, + 'address_en' => $addressEn, + 'address_ar' => $addressAr, + 'working_hours_en' => $workingHoursEn, + 'working_hours_ar' => $workingHoursAr, + 'logo_url' => $logoUrl, + 'favicon_url' => $faviconUrl, + 'primary_color' => $primaryColor, + 'secondary_color' => $secondaryColor, + ]); + qh_set_flash('success', qh_t('Hospital profile updated successfully.', 'تم تحديث ملف المستشفى بنجاح.')); } } catch (Throwable $exception) { qh_set_flash('danger', $exception->getMessage()); } - qh_redirect('admin.php'); + qh_redirect($returnTo); } function qh_reception_handle_request(): void @@ -610,11 +1127,11 @@ function qh_reception_handle_request(): void $languagePref = trim((string) ($_POST['language_pref'] ?? 'en')); if ($patientName === '' || $clinicId <= 0 || $doctorId <= 0) { - throw new InvalidArgumentException('Please complete patient name, clinic, and doctor.'); + throw new InvalidArgumentException(qh_t('Please complete the patient name, clinic, and doctor.', 'يرجى إدخال اسم المريض والعيادة والطبيب.')); } $ticketId = qh_create_ticket($patientName, $clinicId, $doctorId, $languagePref); - qh_set_flash('success', 'Ticket issued successfully.'); + qh_set_flash('success', qh_t('Ticket issued successfully.', 'تم إصدار التذكرة بنجاح.')); qh_redirect('reception.php?ticket_id=' . $ticketId); } catch (Throwable $exception) { qh_set_flash('danger', $exception->getMessage()); @@ -632,7 +1149,7 @@ function qh_nursing_handle_request(): void $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.'); + throw new InvalidArgumentException(qh_t('Please add a short vitals note before sending the patient forward.', 'يرجى إضافة ملاحظة قصيرة للعلامات الحيوية قبل إرسال المريض.')); } $stmt = db()->prepare( @@ -646,7 +1163,7 @@ function qh_nursing_handle_request(): void 'vitals_notes' => $vitalsNotes, 'ticket_id' => $ticketId, ]); - qh_set_flash('success', 'Vitals captured and patient moved to doctor queue.'); + qh_set_flash('success', qh_t('Vitals captured and patient moved to the doctor queue.', 'تم حفظ العلامات الحيوية ونقل المريض إلى طابور الطبيب.')); } catch (Throwable $exception) { qh_set_flash('danger', $exception->getMessage()); } @@ -666,7 +1183,7 @@ function qh_doctor_handle_request(): void $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.'); + throw new InvalidArgumentException(qh_t('That ticket is not available for the selected doctor.', 'هذه التذكرة غير متاحة للطبيب المحدد.')); } if ($action === 'call_ticket') { @@ -680,7 +1197,7 @@ function qh_doctor_handle_request(): void 'display_note' => $message['en'], 'ticket_id' => $ticketId, ]); - qh_set_flash('success', 'Patient call pushed to the public display.'); + qh_set_flash('success', qh_t('Patient call was sent to the public display.', 'تم إرسال نداء المريض إلى الشاشة العامة.')); } elseif ($action === 'start_visit') { $stmt = db()->prepare( "UPDATE hospital_queue_records @@ -688,7 +1205,7 @@ function qh_doctor_handle_request(): void WHERE item_type = 'ticket' AND id = :ticket_id" ); $stmt->execute(['ticket_id' => $ticketId]); - qh_set_flash('success', 'Consultation started.'); + qh_set_flash('success', qh_t('Consultation started.', 'بدأت الاستشارة.')); } elseif ($action === 'complete_ticket') { $stmt = db()->prepare( "UPDATE hospital_queue_records @@ -696,7 +1213,7 @@ function qh_doctor_handle_request(): void WHERE item_type = 'ticket' AND id = :ticket_id" ); $stmt->execute(['ticket_id' => $ticketId]); - qh_set_flash('success', 'Visit marked as completed.'); + qh_set_flash('success', qh_t('Visit marked as completed.', 'تم إنهاء الزيارة.')); } elseif ($action === 'mark_no_show') { $stmt = db()->prepare( "UPDATE hospital_queue_records @@ -704,7 +1221,7 @@ function qh_doctor_handle_request(): void WHERE item_type = 'ticket' AND id = :ticket_id" ); $stmt->execute(['ticket_id' => $ticketId]); - qh_set_flash('warning', 'Patient marked as no-show.'); + qh_set_flash('warning', qh_t('Patient marked as no-show.', 'تم تسجيل المريض كحالة عدم حضور.')); } qh_redirect('doctor.php?doctor_id=' . $doctorId); diff --git a/reception.php b/reception.php index 66e699d..ceb4ea3 100644 --- a/reception.php +++ b/reception.php @@ -9,54 +9,57 @@ $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.'); +qh_page_start( + 'reception', + qh_t('Reception ticket issuance', 'إصدار التذاكر في الاستقبال'), + qh_t('Reception page with separated language views.', 'صفحة الاستقبال مع فصل واضح بين اللغتين.') +); ?>
- Reception / الاستقبال -

Issue one ticket for the full visit.

-

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

+ +
+

+

-

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

+

- +
- +
- +
- +
- +
@@ -66,40 +69,36 @@ qh_page_start('reception', 'Reception ticket issuance', 'Reception desk ticket i
-
Issued ticket / التذكرة الصادرة
+
-
· · Room
+
· ·
- +

-
Issued:
-
Language:
-
Next stop:
+
:
+
:
+
:
-
-
-

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

-

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

-
-
+

+

- - - - + + + + @@ -108,9 +107,9 @@ qh_page_start('reception', 'Reception ticket issuance', 'Reception desk ticket i - + - + diff --git a/ticket.php b/ticket.php index 55724b6..2695ecb 100644 --- a/ticket.php +++ b/ticket.php @@ -6,22 +6,27 @@ 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.'); +qh_page_start( + 'home', + qh_t('Ticket detail', 'تفاصيل التذكرة'), + qh_t('Detailed patient ticket timeline for the hospital queue workflow.', 'عرض تفصيلي لخط سير التذكرة في مسار طابور المستشفى.') +); ?>
- Ticket detail / تفاصيل التذكرة -

Track one patient through the visit.

-

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

+ +
+

+

- Ticket not found. - Return to reception and choose a valid ticket. + +
@@ -30,11 +35,11 @@ qh_page_start('home', 'Ticket detail', 'Detailed patient ticket timeline for the
-
· · Room
+
· ·
- Preferred language: + :
@@ -42,62 +47,32 @@ qh_page_start('home', 'Ticket detail', 'Detailed patient ticket timeline for the
-

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

+

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

Details / التفاصيل

+

-
Clinic
-
Doctor
-
Room
-
Vitals note
-
Last note
+
+
+
+
+
TicketPatientClinicStatus
View