39414-vm/queue_bootstrap.php
2026-04-02 12:53:32 +00:00

1450 lines
65 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$publicPages = ["login.php", "logout.php", "install.php", "display.php", "ticket.php"];
$currentPage = basename((string) ($_SERVER["PHP_SELF"] ?? "index.php"));
$isInstalled = file_exists(__DIR__ . "/.installed") || file_exists(__DIR__ . "/install.lock");
if (!$isInstalled) {
try {
if (file_exists(__DIR__ . '/db/config.php')) {
require_once __DIR__ . '/db/config.php';
if (function_exists('db')) {
$pdo = db();
$stmt = @$pdo->query("SELECT COUNT(*) FROM users");
if ($stmt !== false && ((int)$stmt->fetchColumn()) > 0) {
$isInstalled = true;
@file_put_contents(__DIR__ . '/install.lock', date('Y-m-d H:i:s'));
}
}
}
} catch (\Throwable $e) {}
}
if (!$isInstalled && $currentPage !== "install.php") {
header("Location: install.php");
exit;
}
if (!in_array($currentPage, $publicPages, true)) {
if (empty($_SESSION["user_id"])) {
header("Location: login.php");
exit;
}
$role = $_SESSION["role"] ?? "admin";
$allowed = false;
if ($role === "admin") {
$allowed = true;
} elseif ($currentPage === "index.php") {
$allowed = true;
} elseif ($role === "reception" && $currentPage === "reception.php") {
$allowed = true;
} elseif ($role === "nursing" && $currentPage === "nursing.php") {
$allowed = true;
} elseif ($role === "doctor" && $currentPage === "doctor.php") {
$allowed = true;
}
if (!$allowed) {
header("Location: index.php");
exit;
}
}
require_once __DIR__ . '/db/config.php';
function qh_boot(): void
{
static $booted = false;
if ($booted) {
return;
}
qh_ensure_schema();
qh_seed_demo_data();
qh_seed_hospital_profile();
$profile = qh_fetch_hospital_profile();
$timezone = $profile['timezone'] ?? 'UTC';
if ($timezone) {
date_default_timezone_set($timezone);
try {
$offset = (new DateTime('now', new DateTimeZone($timezone)))->format('P');
db()->exec("SET time_zone = " . db()->quote($offset));
} catch (\Throwable $e) {}
}
$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);
$profileSql = <<<SQL
CREATE TABLE IF NOT EXISTS hospital_profile_settings (
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
name_en VARCHAR(160) DEFAULT NULL,
name_ar VARCHAR(160) DEFAULT NULL,
short_name VARCHAR(40) DEFAULT NULL,
tagline_en VARCHAR(255) DEFAULT NULL,
tagline_ar VARCHAR(255) DEFAULT NULL,
phone VARCHAR(60) DEFAULT NULL,
email VARCHAR(160) DEFAULT NULL,
website VARCHAR(255) DEFAULT NULL,
address_en VARCHAR(255) DEFAULT NULL,
address_ar VARCHAR(255) DEFAULT NULL,
working_hours_en VARCHAR(255) DEFAULT NULL,
working_hours_ar VARCHAR(255) DEFAULT NULL,
logo_url VARCHAR(255) DEFAULT NULL,
favicon_url VARCHAR(255) DEFAULT NULL,
primary_color VARCHAR(7) DEFAULT NULL,
secondary_color VARCHAR(7) DEFAULT NULL,
news_ticker_en VARCHAR(1000) DEFAULT NULL,
news_ticker_ar VARCHAR(1000) DEFAULT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL;
db()->exec($profileSql);
try { db()->exec("ALTER TABLE hospital_profile_settings ADD COLUMN news_ticker_en VARCHAR(1000) DEFAULT NULL"); } catch (\Throwable $e) {}
try { db()->exec("ALTER TABLE hospital_profile_settings ADD COLUMN news_ticker_ar VARCHAR(1000) DEFAULT NULL"); } catch (\Throwable $e) {}
try { db()->exec("ALTER TABLE hospital_profile_settings ADD COLUMN timezone VARCHAR(100) DEFAULT 'UTC'"); } catch (\Throwable $e) {}
try { db()->exec("ALTER TABLE hospital_profile_settings ADD COLUMN default_language VARCHAR(10) DEFAULT 'en'"); } catch (\Throwable $e) {}
}
try { db()->exec("CREATE TABLE IF NOT EXISTS users (id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, role VARCHAR(20) DEFAULT 'admin', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); } catch (\Throwable $e) {}
try { db()->exec("ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'admin'"); } catch (\Throwable $e) {}
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_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, news_ticker_en, news_ticker_ar)
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, :news_ticker_en, :news_ticker_ar)'
);
$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' => 'SunThu · 8:00 AM 8:00 PM',
'working_hours_ar' => 'الأحد الخميس · 8:00 ص 8:00 م',
'logo_url' => '',
'favicon_url' => '',
'primary_color' => '#0f8b8d',
'secondary_color' => '#16697a',
'news_ticker_en' => 'Welcome to our hospital.',
'news_ticker_ar' => 'مرحباً بكم في مستشفانا.',
]);
}
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_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'] ?? '')));
if (in_array($sessionLocale, ['en', 'ar'], true)) {
$locale = $sessionLocale;
} else {
$profile = qh_fetch_hospital_profile();
$defaultLang = strtolower(trim((string) ($profile['default_language'] ?? 'en')));
$locale = in_array($defaultLang, ['en', 'ar'], true) ? $defaultLang : '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_ads.php' => [
'label' => qh_t('Advertisements', 'الإعلانات'),
'description' => qh_t('Manage videos shown on the queue display.', 'إدارة الفيديوهات المعروضة على شاشة الطابور.'),
'icon' => 'display',
],
'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_users.php' => [
'label' => qh_t('Users', 'المستخدمون'),
'description' => qh_t('Manage system users and access.', 'إدارة مستخدمي النظام وصلاحيات الوصول.'),
'icon' => 'users',
],
'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' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 20V7l7-3 7 3v13"/><path d="M9 11h1"/><path d="M14 11h1"/><path d="M11 8h2"/><path d="M11 20v-4h2v4"/><path d="M3 20h18"/></svg>',
'clinic' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h16"/><path d="M6 20V8l6-4 6 4v12"/><path d="M9 12h1"/><path d="M14 12h1"/><path d="M11 20v-4h2v4"/></svg>',
'doctor' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/><circle cx="12" cy="12" r="8.5"/></svg>',
'users' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
default => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.5"/></svg>',
};
}
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 '<div class="panel-card admin-sidebar">';
echo ' <div class="admin-sidebar-top">';
echo ' <span class="section-kicker">' . qh_h(qh_t('Admin panel', 'لوحة الإدارة')) . '</span>';
echo ' <h1 class="section-title mt-3 mb-1">' . qh_h(qh_hospital_name()) . '</h1>';
echo ' <p class="section-copy mb-0">' . qh_h(qh_hospital_tagline() !== '' ? qh_hospital_tagline() : qh_t('Move between dedicated pages instead of one long mixed admin screen.', 'تنقل بين صفحات مستقلة بدلاً من شاشة إدارة طويلة ومختلطة.')) . '</p>';
echo ' </div>';
echo ' <nav class="admin-sidebar-nav" aria-label="Admin section navigation">';
foreach ($sections as $file => $section) {
$activeClass = $file === $activePage ? ' active' : '';
$icon = qh_admin_sidebar_icon((string) ($section['icon'] ?? 'overview'));
echo ' <a class="admin-sidebar-link' . $activeClass . '" href="' . qh_h(qh_url($file)) . '" title="' . qh_h($section['description']) . '">';
echo ' <span class="admin-sidebar-link-icon">' . $icon . '</span>';
echo ' <span class="admin-sidebar-link-text">' . qh_h($section['label']) . '</span>';
echo ' </a>';
}
echo ' </nav>';
echo ' <div class="admin-sidebar-meta">';
echo ' <div class="admin-mini-stat"><span class="admin-mini-stat-value">' . qh_h((string) ($stats['clinics'] ?? 0)) . '</span><span class="admin-mini-stat-label">' . qh_h(qh_t('Clinics', 'العيادات')) . '</span></div>';
echo ' <div class="admin-mini-stat"><span class="admin-mini-stat-value">' . qh_h((string) ($stats['doctors'] ?? 0)) . '</span><span class="admin-mini-stat-label">' . qh_h(qh_t('Doctors', 'الأطباء')) . '</span></div>';
echo ' <div class="admin-mini-stat"><span class="admin-mini-stat-value">' . qh_h((string) ($stats['vitals_clinics'] ?? 0)) . '</span><span class="admin-mini-stat-label">' . qh_h(qh_t('Vitals-first clinics', 'العيادات التي تبدأ بالعلامات')) . '</span></div>';
echo ' </div>';
echo '</div>';
}
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>%2$s</%1$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'] ?? '';
$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) . ' lang-' . $locale;
$assetVersionCss = qh_asset_version('assets/css/custom.css');
$primaryColor = qh_hospital_primary_color();
$secondaryColor = qh_hospital_secondary_color();
echo '<!doctype html>';
echo '<html lang="' . qh_h($locale) . '" dir="' . (qh_is_ar() ? 'rtl' : 'ltr') . '">';
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) . '">';
}
if ($faviconUrl !== '') {
echo ' <link rel="icon" href="' . qh_h($faviconUrl) . '">';
echo ' <link rel="apple-touch-icon" href="' . qh_h($faviconUrl) . '">';
}
echo ' <meta name="theme-color" content="' . qh_h($primaryColor) . '">';
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="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">';
echo ' <link rel="stylesheet" href="assets/css/custom.css?v=' . $assetVersionCss . '">';
echo ' <style>:root{--accent:' . qh_h($primaryColor) . ';--accent-strong:' . qh_h($secondaryColor) . ';}</style>';
echo '</head>';
$profile = qh_fetch_hospital_profile();
$timezone = qh_h($profile['timezone'] ?? 'UTC');
echo '<body class="' . qh_h($bodyClass) . '" data-page="' . qh_h($activePage) . '" data-locale="' . qh_h($locale) . '" data-timezone="' . $timezone . '">';
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
{
$profile = qh_fetch_hospital_profile();
$newsTicker = trim((string)($profile['news_ticker'] ?? ''));
if ($newsTicker !== '') {
echo '<div style="position: fixed; bottom: 0; left: 0; width: 100%; height: 40px; line-height: 40px; background: var(--accent-strong, #16697A); color: #fff; z-index: 9999; font-size: 1.25rem; font-weight: bold; overflow: hidden; box-shadow: 0 -2px 10px rgba(0,0,0,0.1);">';
echo '<marquee direction="right" scrollamount="6" scrolldelay="85">' . qh_h($newsTicker) . '</marquee>';
echo '</div>';
}
$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' => 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', 'الطبيب')],
'users' => ['href' => qh_url('admin_users.php'), 'label' => qh_t('Users', 'المستخدمون')],
'display' => ['href' => qh_url('display.php'), 'label' => qh_t('Display', 'الشاشة')],
];
$logoUrl = qh_hospital_logo_url();
$tagline = qh_hospital_tagline();
echo '<header class="app-header border-bottom 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 align-items-center" href="' . qh_h(qh_url('index.php')) . '">';
if ($logoUrl !== '') {
echo ' <span class="brand-mark brand-mark-image"><img src="' . qh_h($logoUrl) . '" alt="' . qh_h(qh_hospital_name()) . '"></span>';
} else {
echo ' <span class="brand-mark">' . qh_h(qh_hospital_brand_initials()) . '</span>';
}
echo ' <span class="brand-copy">';
echo ' <span class="brand-text">' . qh_h(qh_hospital_name()) . '</span>';
if ($tagline !== '') {
echo ' <span class="brand-subtext">' . qh_h($tagline) . '</span>';
}
echo ' </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="' . qh_h(qh_t('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']) . '">' . qh_h($link['label']) . '</a></li>';
}
if (!empty($_SESSION["user_id"])) {
echo ' <li class="nav-item"><a class="nav-link text-danger fw-semibold" href="' . qh_h(qh_url("logout.php")) . '"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-1"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>' . qh_h(qh_t("Logout", "تسجيل الخروج")) . '</a></li>';
}
echo ' </ul>';
echo ' <div class="lang-switcher ms-lg-3 mt-3 mt-lg-0">';
foreach (['en', 'ar'] as $lang) {
$activeClass = qh_locale() === $lang ? ' active' : '';
echo ' <a class="lang-switch-link' . $activeClass . '" href="' . qh_h(qh_switch_lang_url($lang)) . '">' . qh_h(qh_locale_label($lang)) . '</a>';
}
echo ' </div>';
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="' . qh_h(qh_t('Close', 'إغلاق')) . '"></button>';
echo ' </div>';
echo ' </div>';
echo '</div>';
}
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");
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' AND DATE(t.created_at) = CURDATE()";
$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 IN ('waiting_vitals', 'nursing_called') 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 DATE(created_at) = CURDATE() AND status IN ('waiting_vitals', 'nursing_called')")->fetchColumn(),
'ready_for_doctor' => (int) $pdo->query("SELECT COUNT(*) FROM hospital_queue_records WHERE item_type = 'ticket' AND DATE(created_at) = CURDATE() 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 DATE(created_at) = CURDATE() 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(qh_t('Clinic not found.', 'لم يتم العثور على العيادة.'));
}
$doctor = qh_fetch_doctor($doctorId);
if (!$doctor || (int) $doctor['clinic_id'] !== $clinicId) {
throw new RuntimeException(qh_t('The 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 DATE(created_at) = CURDATE()
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' => 'بانتظار العلامات الحيوية'],
'nursing_called' => ['class' => 'primary', 'en' => 'Nursing Call', '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_h(qh_t($meta['en'], $meta['ar'])) . '</span>';
}
function qh_call_message(array $ticket): array
{
$ticketNumber = $ticket['ticket_number'] ?? '---';
if (("ticket['status']" ?? '') === 'nursing_called') {
return [
'en' => sprintf('Ticket %s, please proceed to Nursing Station.', $ticketNumber),
'ar' => sprintf('رقم التذكرة %s، يرجى التوجه إلى محطة التمريض.', $ticketNumber),
];
}
$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', qh_t('Invalid request method.', 'طريقة الطلب غير صالحة.'));
qh_redirect('index.php');
}
}
function qh_handle_image_upload(string $inputName): string
{
if (!isset($_FILES[$inputName]) || $_FILES[$inputName]['error'] !== UPLOAD_ERR_OK) {
return '';
}
$tmpName = $_FILES[$inputName]['tmp_name'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo === false) return '';
$mime = finfo_file($finfo, $tmpName);
finfo_close($finfo);
if (!is_string($mime) || !str_starts_with($mime, 'image/')) {
throw new InvalidArgumentException(qh_t('Invalid image format.', 'تنسيق صورة غير صالح.'));
}
$ext = match ($mime) {
'image/jpeg' => '.jpg',
'image/png' => '.png',
'image/gif' => '.gif',
'image/webp' => '.webp',
'image/svg+xml' => '.svg',
'image/x-icon' => '.ico',
'image/vnd.microsoft.icon' => '.ico',
default => '.bin'
};
if ($ext === '.bin') {
throw new InvalidArgumentException(qh_t('Unsupported image type.', 'نوع الصورة غير مدعوم.'));
}
$uploadDir = __DIR__ . '/assets/images/uploads';
if (!is_dir($uploadDir)) {
if (!mkdir($uploadDir, 0775, true)) {
throw new RuntimeException(qh_t('Failed to create upload directory.', 'فشل إنشاء مجلد الرفع.'));
}
}
$filename = uniqid('img_', true) . $ext;
$dest = $uploadDir . '/' . $filename;
if (!move_uploaded_file($tmpName, $dest)) {
throw new RuntimeException(qh_t('Failed to save uploaded file.', 'فشل حفظ الملف المرفوع.'));
}
return 'assets/images/uploads/' . $filename;
}
function qh_admin_handle_request(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
return;
}
$action = trim((string) ($_POST['action'] ?? ''));
if ($action === '' || in_array($action, ['add_video', 'delete_video', 'toggle_status', 'add_news', 'delete_news', 'toggle_news_status', 'create_user', 'update_user', 'delete_user'])) {
return;
}
$returnTo = qh_admin_return_to($_POST['return_to'] ?? 'admin.php');
$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(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)
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' => max((int) ($_POST['sort_order'] ?? 50), 1),
]);
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 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' => max((int) ($_POST['sort_order'] ?? 50), 1),
'clinic_id' => $clinicId,
]);
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(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)
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' => max((int) ($_POST['sort_order'] ?? 50), 1),
]);
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 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' => max((int) ($_POST['sort_order'] ?? 50), 1),
'doctor_id' => $doctorId,
]);
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'] ?? ''));
$newsTickerEn = trim((string) ($_POST['news_ticker_en'] ?? ''));
$newsTickerAr = trim((string) ($_POST['news_ticker_ar'] ?? ''));
$timezone = trim((string) ($_POST['timezone'] ?? ''));
if ($timezone === '' || !in_array($timezone, timezone_identifiers_list(), true)) $timezone = 'UTC';
$defaultLanguage = trim((string) ($_POST['default_language'] ?? 'en'));
if (!in_array($defaultLanguage, ['en', 'ar'], true)) $defaultLanguage = 'en';
$profile = qh_fetch_hospital_profile();
$logoUrl = $profile['logo_url'] ?? '';
$faviconUrl = $profile['favicon_url'] ?? '';
if (!empty($_POST['remove_logo'])) {
$logoUrl = '';
} else {
$newLogo = qh_handle_image_upload('logo_upload');
if ($newLogo !== '') $logoUrl = $newLogo;
}
if (!empty($_POST['remove_favicon'])) {
$faviconUrl = '';
} else {
$newFavicon = qh_handle_image_upload('favicon_upload');
if ($newFavicon !== '') $faviconUrl = $newFavicon;
}
$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.', 'يرجى إدخال رابط موقع صالح.'));
}
$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,
news_ticker_en = :news_ticker_en,
news_ticker_ar = :news_ticker_ar,
timezone = :timezone,
default_language = :default_language
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,
'news_ticker_en' => $newsTickerEn,
'news_ticker_ar' => $newsTickerAr,
'timezone' => $timezone,
'default_language' => $defaultLanguage,
]);
qh_set_flash('success', qh_t('Hospital profile updated successfully.', 'تم تحديث ملف المستشفى بنجاح.'));
}
} catch (Throwable $exception) {
qh_set_flash('danger', $exception->getMessage());
}
qh_redirect($returnTo);
}
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(qh_t('Please complete the patient name, clinic, and doctor.', 'يرجى إدخال اسم المريض والعيادة والطبيب.'));
}
$ticketId = qh_create_ticket($patientName, $clinicId, $doctorId, $languagePref);
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());
qh_redirect('reception.php');
}
}
function qh_nursing_handle_request(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
return;
}
try {
$ticketId = (int) ($_POST['ticket_id'] ?? 0);
$action = trim((string) ($_POST['action'] ?? 'send'));
$ticket = qh_fetch_ticket($ticketId);
if (!$ticket) throw new InvalidArgumentException(qh_t('Invalid ticket.', 'تذكرة غير صالحة.'));
if ($action === 'call_ticket') {
$stmt = db()->prepare(
"UPDATE hospital_queue_records
SET status = 'nursing_called', called_at = NOW(), display_note = :display_note
WHERE item_type = 'ticket' AND id = :ticket_id"
);
$stmt->execute([
'display_note' => sprintf('Ticket %s, proceed to Nursing Station.', $ticket['ticket_number']),
'ticket_id' => $ticketId
]);
qh_set_flash('success', qh_t('Patient call was sent to the public display.', 'تم إرسال نداء المريض إلى الشاشة العامة.'));
} else {
$vitalsNotes = trim((string) ($_POST['vitals_notes'] ?? ''));
if ($vitalsNotes === '') throw new InvalidArgumentException(qh_t('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 IN ('waiting_vitals', 'nursing_called')"
);
$stmt->execute([
'vitals_notes' => $vitalsNotes,
'ticket_id' => $ticketId,
]);
qh_set_flash('success', qh_t('Vitals captured and patient moved to the 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(qh_t('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', qh_t('Patient call was sent 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', qh_t('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', qh_t('Visit marked as completed.', 'تم إنهاء الزيارة.'));
} elseif ($action === 'refer_ticket') {
$referToDoctorId = (int) ($_POST["refer_to_doctor_id"] ?? 0);
if ($referToDoctorId <= 0 || $referToDoctorId === $doctorId) {
throw new InvalidArgumentException(qh_t('Please select a valid doctor to refer the patient to.', 'يرجى اختيار طبيب صالح لتحويل المريض إليه.'));
}
$stmt = db()->prepare(
"UPDATE hospital_queue_records
SET status = 'ready_for_doctor', doctor_id = :refer_to_doctor_id, display_note = 'Referred'
WHERE item_type = 'ticket' AND id = :ticket_id"
);
$stmt->execute([
'refer_to_doctor_id' => $referToDoctorId,
'ticket_id' => $ticketId,
]);
qh_set_flash('success', qh_t('Patient referred successfully.', 'تم تحويل المريض بنجاح.'));
} 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', qh_t('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');
}
}