Initial commit

This commit is contained in:
Porawit Dongwang
2026-09-16 23:20:08 +07:00
commit 0041668dbb
32577 changed files with 3687927 additions and 0 deletions
@@ -0,0 +1,34 @@
<?php
// api/analytics.php
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/config/database.php';
header('Content-Type: application/json');
// Need to be logged in
if (!isset($_SESSION['user_id'])) {
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
$action = $_REQUEST['action'] ?? '';
// In a real application, these queries would join the `jobs` and `job_timelines` tables
// to calculate precise response times and SLAs.
if ($action === 'get_dashboard_stats') {
// Mock response for dashboard
$data = [
'avg_response_time' => 2.5,
'sla_compliance' => 94.2,
'total_jobs_today' => 128,
'trend' => [150, 140, 160, 180, 190, 120, 110],
'sla_breakdown' => [
'labels' => ['ER', 'OPD1', 'OPD2', 'IPD1', 'IPD2', 'X-Ray', 'OR'],
'passed' => [50, 45, 30, 40, 35, 60, 20],
'failed' => [5, 2, 1, 4, 2, 3, 0]
]
];
echo json_encode(['status' => 'success', 'data' => $data]);
} else {
echo json_encode(['status' => 'error', 'message' => 'Unknown action']);
}
@@ -0,0 +1,54 @@
<?php
// api/avatar.php
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/config/database.php';
// Prevent caching to ensure we get the latest image if changed, though we might want to cache it eventually.
// Let's add standard caching headers for performance (e.g. cache for 1 day).
$cache_time = 86400;
header("Cache-Control: max-age=$cache_time, public");
$username = $_GET['username'] ?? '';
$name = $_GET['name'] ?? 'User';
$fallback_url = "https://ui-avatars.com/api/?name=" . urlencode($name) . "&background=random";
if (empty($username)) {
header("Location: $fallback_url");
exit;
}
$hos_pdo = Database::getHosInstance();
if (!$hos_pdo) {
header("Location: $fallback_url");
exit;
}
try {
$sql = "SELECT HR_IMAGE FROM hr_person WHERE HR_CID = ? AND HR_STATUS_ID='01'";
$stmt = $hos_pdo->prepare($sql);
$stmt->execute([$username]);
$person = $stmt->fetch();
if ($person && !empty($person['HR_IMAGE'])) {
// Output the BLOB image
// Try to determine mime type, or default to jpeg
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->buffer($person['HR_IMAGE']);
if ($mime === 'application/x-empty' || empty($mime)) {
$mime = 'image/jpeg';
}
header("Content-Type: $mime");
echo $person['HR_IMAGE'];
exit;
}
} catch (PDOException $e) {
// Silently fall back to UI Avatars on error
}
// If no image or error, redirect to fallback
header("Location: $fallback_url");
exit;
@@ -0,0 +1,64 @@
<?php
// api/chat.php
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/config/database.php';
header('Content-Type: application/json');
if (!isset($_SESSION['user_id'])) {
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
$action = $_REQUEST['action'] ?? '';
$conn = Database::getInstance();
if ($action === 'get_messages') {
$job_id = $_GET['job_id'] ?? '';
if (empty($job_id)) {
echo json_encode(['status' => 'error', 'message' => 'Job ID required']);
exit;
}
$query = "SELECT m.*, u.full_name, u.role
FROM job_messages m
JOIN users u ON m.user_id = u.id
WHERE m.job_id = :job_id
ORDER BY m.created_at ASC";
$stmt = $conn->prepare($query);
$stmt->bindParam(':job_id', $job_id);
$stmt->execute();
$messages = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['status' => 'success', 'data' => $messages]);
} elseif ($action === 'send_message') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid method']);
exit;
}
$job_id = $_POST['job_id'] ?? '';
$message = trim($_POST['message'] ?? '');
$user_id = $_SESSION['user_id'];
if (empty($job_id) || empty($message)) {
echo json_encode(['status' => 'error', 'message' => 'ข้อมูลไม่ครบถ้วน']);
exit;
}
try {
$query = "INSERT INTO job_messages (job_id, user_id, message) VALUES (:job_id, :user_id, :message)";
$stmt = $conn->prepare($query);
$stmt->bindParam(':job_id', $job_id);
$stmt->bindParam(':user_id', $user_id);
$stmt->bindParam(':message', $message);
$stmt->execute();
echo json_encode(['status' => 'success']);
} catch (Exception $e) {
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
} else {
echo json_encode(['status' => 'error', 'message' => 'Invalid action']);
}
@@ -0,0 +1,67 @@
<?php
// api/dashboard.php
require_once dirname(__DIR__) . '/config/database.php';
require_once dirname(__DIR__) . '/models/Job.php';
header('Content-Type: application/json');
// Need to be logged in
session_start();
if (!isset($_SESSION['user_id'])) {
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
$conn = Database::getInstance();
// 1. Fetch Stats
$stats_query = "
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status IN ('assigned', 'in_progress', 'waiting_for_return') THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed
FROM jobs
WHERE DATE(created_at) = CURDATE()
";
$stmt = $conn->query($stats_query);
$stats = $stmt->fetch();
// 2. Fetch Latest Jobs (Limit 10)
$jobs_query = "
SELECT j.*,
fd.name as from_dept,
COALESCE(td.name, j.to_department_custom) as to_dept
FROM jobs j
LEFT JOIN departments fd ON j.from_department_id = fd.id
LEFT JOIN departments td ON j.to_department_id = td.id
ORDER BY j.created_at DESC
LIMIT 10
";
$stmt2 = $conn->query($jobs_query);
$jobs = $stmt2->fetchAll();
// 3. Fetch Online Staff
$staff_query = "
SELECT id, username, full_name, status
FROM users
WHERE role IN ('staff', 'nurse')
AND status != 'offline'
ORDER BY status DESC, username ASC
";
$stmt3 = $conn->query($staff_query);
$staff = $stmt3->fetchAll();
echo json_encode([
'status' => 'success',
'data' => [
'stats' => [
'total' => $stats['total'] ?? 0,
'pending' => $stats['pending'] ?? 0,
'in_progress' => $stats['in_progress'] ?? 0,
'completed' => $stats['completed'] ?? 0
],
'jobs' => $jobs,
'staff' => $staff
]
]);
@@ -0,0 +1,129 @@
<?php
// api/executive.php
session_start();
require_once dirname(__DIR__) . '/config/database.php';
require_once dirname(__DIR__) . '/models/Job.php';
header('Content-Type: application/json; charset=utf-8');
if (!isset($_SESSION['user_id'])) {
echo json_encode(['success' => false, 'message' => 'Unauthorized']);
exit;
}
$conn = Database::getInstance();
$period = $_GET['period'] ?? 'today';
try {
$start_date = '';
$end_date = date('Y-m-d') . ' 23:59:59';
if ($period === 'week') {
$start_date = date('Y-m-d', strtotime('-7 days')) . ' 00:00:00';
} elseif ($period === 'month') {
$start_date = date('Y-m-01') . ' 00:00:00';
} else { // today
$start_date = date('Y-m-d') . ' 00:00:00';
}
// 1. SLA & Times
$sla_target = 15;
$query_times = "SELECT
TIMESTAMPDIFF(MINUTE, created_at, accepted_at) as wait_time,
TIMESTAMPDIFF(MINUTE, accepted_at, completed_at) as service_time
FROM jobs
WHERE status = 'completed' AND created_at BETWEEN :start AND :end";
$stmt_times = $conn->prepare($query_times);
$stmt_times->bindParam(':start', $start_date);
$stmt_times->bindParam(':end', $end_date);
$stmt_times->execute();
$completed_jobs = $stmt_times->fetchAll(PDO::FETCH_ASSOC);
$total_wait = 0;
$total_service = 0;
$sla_met = 0;
foreach ($completed_jobs as $job) {
$w = max(0, (int)$job['wait_time']);
$s = max(0, (int)$job['service_time']);
$total_wait += $w;
$total_service += $s;
if ($w <= $sla_target) {
$sla_met++;
}
}
$completed_count = count($completed_jobs);
$avg_wait = $completed_count > 0 ? round($total_wait / $completed_count) : 0;
$avg_service = $completed_count > 0 ? round($total_service / $completed_count) : 0;
$sla_percent = $completed_count > 0 ? round(($sla_met / $completed_count) * 100) : 100;
// 2. Trend Data (7 days)
$trend_data = [];
$trend_labels = [];
for ($i = 6; $i >= 0; $i--) {
$d = date('Y-m-d', strtotime("-$i days"));
$thai_days = ['อา.', 'จ.', 'อ.', 'พ.', 'พฤ.', 'ศ.', 'ส.'];
$day_index = date('w', strtotime($d));
$trend_labels[] = $thai_days[$day_index];
$q = "SELECT COUNT(*) as cnt FROM jobs WHERE DATE(created_at) = :d";
$s = $conn->prepare($q);
$s->bindParam(':d', $d);
$s->execute();
$trend_data[] = $s->fetch(PDO::FETCH_ASSOC)['cnt'];
}
// 3. Department Workload
$query_dept = "SELECT d.name, COUNT(j.id) as count
FROM jobs j
JOIN departments d ON j.from_department_id = d.id
WHERE j.created_at BETWEEN :start AND :end
GROUP BY d.id
ORDER BY count DESC
LIMIT 5";
$stmt_dept = $conn->prepare($query_dept);
$stmt_dept->bindParam(':start', $start_date);
$stmt_dept->bindParam(':end', $end_date);
$stmt_dept->execute();
$dept_labels = [];
$dept_data = [];
while ($row = $stmt_dept->fetch(PDO::FETCH_ASSOC)) {
$name = mb_strlen($row['name'], 'UTF-8') > 15 ? mb_substr($row['name'], 0, 15, 'UTF-8') . '...' : $row['name'];
$dept_labels[] = $name;
$dept_data[] = (int)$row['count'];
}
$jobModel = new Job();
$staffPerformance = $jobModel->getStaffPerformance($period);
echo json_encode([
'success' => true,
'kpi' => [
'sla_percent' => $sla_percent,
'avg_wait_time' => $avg_wait,
'avg_service_time' => $avg_service
],
'charts' => [
'trend' => [
'labels' => $trend_labels,
'data' => $trend_data
],
'departments' => [
'labels' => $dept_labels,
'data' => $dept_data
]
],
'staff_performance' => $staffPerformance
]);
exit;
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
@@ -0,0 +1,71 @@
<?php
// api/feedback.php
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/config/database.php';
header('Content-Type: application/json');
// Need to be logged in
if (!isset($_SESSION['user_id'])) {
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
$action = $_POST['action'] ?? '';
if ($action === 'rate_job') {
// CSRF check
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF Token']);
exit;
}
$job_id = $_POST['job_id'] ?? '';
$rating = (int)($_POST['rating'] ?? 0);
$feedback = trim($_POST['feedback'] ?? '');
if (empty($job_id) || $rating < 1 || $rating > 5) {
echo json_encode(['status' => 'error', 'message' => 'ข้อมูลไม่ครบถ้วน']);
exit;
}
$conn = Database::getInstance();
// Check if job exists and belongs to requester
$checkQuery = "SELECT id, requester_id, status FROM jobs WHERE id = :job_id LIMIT 1";
$checkStmt = $conn->prepare($checkQuery);
$checkStmt->bindParam(':job_id', $job_id);
$checkStmt->execute();
if ($checkStmt->rowCount() === 0) {
echo json_encode(['status' => 'error', 'message' => 'ไม่พบข้อมูลงาน']);
exit;
}
$job = $checkStmt->fetch();
// if ($job['requester_id'] != $_SESSION['user_id']) {
// echo json_encode(['status' => 'error', 'message' => 'ไม่มีสิทธิ์ประเมินงานนี้']);
// exit;
// }
if ($job['status'] !== 'completed') {
echo json_encode(['status' => 'error', 'message' => 'สามารถประเมินได้เฉพาะงานที่เสร็จสิ้นแล้ว']);
exit;
}
try {
$query = "UPDATE jobs SET rating = :rating, feedback = :feedback WHERE id = :job_id";
$stmt = $conn->prepare($query);
$stmt->bindParam(':rating', $rating);
$stmt->bindParam(':feedback', $feedback);
$stmt->bindParam(':job_id', $job_id);
$stmt->execute();
echo json_encode(['status' => 'success']);
} catch (Exception $e) {
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
} else {
echo json_encode(['status' => 'error', 'message' => 'Invalid action']);
}
@@ -0,0 +1,116 @@
<?php
// api/hr.php
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/config/database.php';
header('Content-Type: application/json');
// Check if user is logged in
if (!isset($_SESSION['user_id'])) {
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
$queryParam = $_POST['query'] ?? $_POST['cid'] ?? $_GET['query'] ?? $_GET['cid'] ?? '';
if (empty($queryParam)) {
echo json_encode(['status' => 'error', 'message' => 'กรุณาระบุเลขบัตรประชาชน หรือ ชื่อ-สกุล']);
exit;
}
$hos_pdo = Database::getHosInstance();
if (!$hos_pdo) {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถเชื่อมต่อฐานข้อมูล HR ได้']);
exit;
}
try {
$sql = "SELECT a.*,
b.HR_DEPARTMENT_SUB_SUB_NAME,
c.HR_LEVEL_NAME,
d.HR_PERSON_TYPE_NAME,
e.HR_PREFIX_NAME,
f.HR_POSITION_NAME
FROM hr_person a
LEFT OUTER JOIN hr_department_sub_sub b on a.HR_DEPARTMENT_SUB_SUB_ID = b.HR_DEPARTMENT_SUB_SUB_ID
LEFT OUTER JOIN hr_level c ON a.HR_LEVEL_ID = c.HR_LEVEL_ID
LEFT OUTER JOIN hr_person_type d ON a.HR_PERSON_TYPE_ID = d.HR_PERSON_TYPE_ID
LEFT OUTER JOIN hr_prefix e ON a.HR_PREFIX_ID=e.HR_PREFIX_ID
LEFT OUTER JOIN hr_position f ON a.HR_POSITION_ID=f.HR_POSITION_ID";
$is_cid = (is_numeric(trim($queryParam)) && strlen(trim($queryParam)) === 13);
if ($is_cid) {
$sql .= " WHERE a.HR_CID = ? AND a.HR_STATUS_ID='01'";
$stmt = $hos_pdo->prepare($sql);
$stmt->execute([trim($queryParam)]);
} else {
$terms = explode(' ', preg_replace('/\s+/', ' ', trim($queryParam)));
if (count($terms) > 1) {
$sql .= " WHERE a.HR_FNAME LIKE ? AND a.HR_LNAME LIKE ? AND a.HR_STATUS_ID='01' LIMIT 10";
$stmt = $hos_pdo->prepare($sql);
$stmt->execute(["%{$terms[0]}%", "%{$terms[1]}%"]);
} else {
$sql .= " WHERE (a.HR_FNAME LIKE ? OR a.HR_LNAME LIKE ?) AND a.HR_STATUS_ID='01' LIMIT 10";
$stmt = $hos_pdo->prepare($sql);
$stmt->execute(["%{$terms[0]}%", "%{$terms[0]}%"]);
}
}
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (count($results) > 0) {
$formatted_results = [];
foreach ($results as $person) {
$fullName = ($person['HR_PREFIX_NAME'] ?? '') . ($person['HR_FNAME'] ?? '') . ' ' . ($person['HR_LNAME'] ?? '');
$department = $person['HR_DEPARTMENT_SUB_SUB_NAME'] ?? 'ไม่ระบุ';
$position = ($person['HR_POSITION_NAME'] ?? '') . ' ' . ($person['HR_LEVEL_NAME'] ?? '');
$person_type = $person['HR_PERSON_TYPE_NAME'] ?? 'ไม่ระบุ';
$cid = $person['HR_CID'] ?? '';
// Calculate work duration
$work_duration = 'ไม่ระบุวันที่เริ่มงาน';
if (!empty($person['HR_STARTWORK_DATE'])) {
try {
$startDate = new DateTime($person['HR_STARTWORK_DATE']);
$currentDate = new DateTime();
if ($startDate <= $currentDate) {
$interval = $currentDate->diff($startDate);
$work_duration = $interval->format('%y ปี %m เดือน %d วัน');
}
} catch (Exception $e) {
// Keep default
}
}
$formatted_results[] = [
'cid' => $cid,
'full_name' => trim($fullName),
'department' => trim($department),
'position' => trim($position),
'person_type' => trim($person_type),
'work_duration' => $work_duration
];
}
if (count($formatted_results) === 1) {
echo json_encode([
'status' => 'success',
'message' => 'พบข้อมูลเจ้าหน้าที่',
'data' => $formatted_results[0]
]);
} else {
echo json_encode([
'status' => 'multiple',
'message' => 'พบรายชื่อมากกว่า 1 คน กรุณาเลือกรายการที่ถูกต้อง',
'data' => $formatted_results
]);
}
} else {
echo json_encode(['status' => 'error', 'message' => 'ไม่พบข้อมูลเจ้าหน้าที่ หรือไม่ได้อยู่ในสถานะปฏิบัติงาน']);
}
} catch (PDOException $e) {
echo json_encode(['status' => 'error', 'message' => 'เกิดข้อผิดพลาดในการสืบค้นข้อมูล: ' . $e->getMessage()]);
}
@@ -0,0 +1,427 @@
<?php
// api/jobs.php
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/Patient.php';
require_once dirname(__DIR__) . '/models/Job.php';
require_once dirname(__DIR__) . '/config/LineBot.php';
require_once dirname(__DIR__) . '/config/Dispatcher.php';
header('Content-Type: application/json');
$action = $_REQUEST['action'] ?? '';
// Check for post_max_size violation (which clears $_POST and $_FILES)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && empty($_POST) && empty($_FILES) && isset($_SERVER['CONTENT_LENGTH']) && $_SERVER['CONTENT_LENGTH'] > 0) {
echo json_encode(['status' => 'error', 'message' => 'ไฟล์รูปภาพมีขนาดใหญ่เกินกว่าที่เซิร์ฟเวอร์จะรับได้ (เกินขีดจำกัด post_max_size) กรุณาลดขนาดไฟล์ก่อนอัปโหลด']);
exit;
}
// Need to be logged in (Except for public job creation)
if (!isset($_SESSION['user_id']) && $action !== 'create_public_job') {
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
if ($action === 'search_patient') {
$hn = $_GET['hn'] ?? '';
if (empty($hn)) {
echo json_encode(['status' => 'error', 'message' => 'Missing HN']);
exit;
}
$patient = Patient::searchByHN($hn);
if ($patient) {
echo json_encode(['status' => 'success', 'data' => $patient]);
} else {
echo json_encode(['status' => 'error', 'message' => 'Patient not found']);
}
} elseif ($action === 'create_job') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid method']);
exit;
}
// CSRF check
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF Token']);
exit;
}
$data = [
'patient_hn' => $_POST['patient_hn'] ?? '',
'patient_name' => $_POST['patient_name'] ?? '',
'from_department_id' => $_POST['from_department_id'] ?? '',
'to_department_id' => $_POST['to_department_id'] ?? '',
'equipment_type' => $_POST['equipment_type'] ?? 'wheelchair',
'priority' => $_POST['priority'] ?? 'normal',
'notes' => $_POST['notes'] ?? '',
'requester_id' => $_SESSION['user_id'],
'infection_control' => isset($_POST['infection_control']) ? 1 : 0,
'is_scheduled' => isset($_POST['is_scheduled']) ? (int)$_POST['is_scheduled'] : 0,
'scheduled_time' => $_POST['scheduled_time'] ?? null,
'is_round_trip' => isset($_POST['is_round_trip']) ? 1 : 0
];
if (empty($data['patient_hn']) || empty($data['from_department_id']) || empty($data['to_department_id'])) {
echo json_encode(['status' => 'error', 'message' => 'ข้อมูลไม่ครบถ้วน']);
exit;
}
$jobModel = new Job();
$job_number = $jobModel->create($data);
if ($job_number) {
require_once dirname(__DIR__) . '/models/Setting.php';
require_once dirname(__DIR__) . '/config/TelegramBot.php';
$settingModel = new Setting();
$auto_dispatch = $settingModel->get('auto_dispatch_enabled', '0');
$msg = "🚨 มีงานใหม่!\nหมายเลข: {$job_number}\nผู้ป่วย: {$data['patient_name']}\nอุปกรณ์: {$data['equipment_type']}";
// Trigger LINE Notification
$lineBot = new LineBot();
$lineBot->sendGroupMessage($msg, $job_number); // will return false if no token
// Trigger Telegram Notification
$telegramBot = new TelegramBot();
$telegramBot->sendGroupMessage($msg, $job_number);
// Smart Auto Dispatch (If enabled)
if ($auto_dispatch === '1') {
$dispatcher = new Dispatcher();
// Actually pass the job_number / ID to dispatcher
// Wait, Dispatcher takes job_id. job_number is just a string (e.g. JOB2024...).
// Let's assume jobModel->create() returns the inserted ID, or we need to find the ID.
// Oh, previously $job_number = $jobModel->create($data) actually returned job_number.
// Let's fetch the actual job_id to pass to autoDispatch.
$jobInfo = $jobModel->getByJobNumber($job_number);
if ($jobInfo) {
$dispatcher->autoDispatch($jobInfo['id']);
}
}
echo json_encode(['status' => 'success', 'job_number' => $job_number]);
} else {
echo json_encode(['status' => 'error', 'message' => 'เกิดข้อผิดพลาดในการบันทึกข้อมูล']);
}
} elseif ($action === 'create_public_job') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid method']);
exit;
}
// CSRF check
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF Token']);
exit;
}
// Assign to a default admin user ID or get the first available admin
$jobModel = new Job();
$db = Database::getInstance();
$stmt = $db->query("SELECT id FROM users WHERE role = 'admin' LIMIT 1");
$admin = $stmt->fetch();
$requester_id = $admin ? $admin['id'] : 1;
$to_department_id = $_POST['to_department_id'] ?? '';
$to_department_custom = null;
if ($to_department_id === 'custom') {
$to_department_id = null;
$to_department_custom = trim($_POST['to_department_custom'] ?? '');
}
$patient_name = trim($_POST['patient_name'] ?? '');
// Handle Image Upload with Compression
$patient_image_path = null;
if (isset($_FILES['patient_image']) && $_FILES['patient_image']['error'] === UPLOAD_ERR_OK) {
$uploadDir = dirname(__DIR__) . '/uploads/patients/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}
$fileExt = strtolower(pathinfo($_FILES['patient_image']['name'], PATHINFO_EXTENSION));
$allowedExt = ['jpg', 'jpeg', 'png', 'webp'];
if (in_array($fileExt, $allowedExt)) {
// Force save as JPG for better compression of photos
$fileName = uniqid('img_') . '_' . time() . '.jpg';
$destPath = $uploadDir . $fileName;
$sourcePath = $_FILES['patient_image']['tmp_name'];
// Image compression and resize logic (Max 800px, 75% quality)
$info = getimagesize($sourcePath);
if ($info !== false && function_exists('imagecreatefromjpeg')) {
$image = null;
if ($info['mime'] == 'image/jpeg') $image = imagecreatefromjpeg($sourcePath);
elseif ($info['mime'] == 'image/png') $image = imagecreatefrompng($sourcePath);
elseif ($info['mime'] == 'image/webp' && function_exists('imagecreatefromwebp')) $image = imagecreatefromwebp($sourcePath);
elseif ($info['mime'] == 'image/gif') $image = imagecreatefromgif($sourcePath);
if ($image !== null) {
$maxWidth = 800;
$width = $info[0];
$height = $info[1];
if ($width > $maxWidth || $height > $maxWidth) {
$ratio = $width / $height;
if ($ratio > 1) {
$newWidth = $maxWidth;
$newHeight = $maxWidth / $ratio;
} else {
$newWidth = $maxWidth * $ratio;
$newHeight = $maxWidth;
}
$newImage = imagecreatetruecolor($newWidth, $newHeight);
// Convert white background for transparent images
$white = imagecolorallocate($newImage, 255, 255, 255);
imagefill($newImage, 0, 0, $white);
imagecopyresampled($newImage, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
$image = $newImage;
}
// Save as JPEG
if (imagejpeg($image, $destPath, 75)) {
$patient_image_path = 'uploads/patients/' . $fileName;
}
imagedestroy($image);
} else {
if (move_uploaded_file($sourcePath, $destPath)) {
$patient_image_path = 'uploads/patients/' . $fileName;
}
}
} else {
// Fallback if getimagesize fails, GD missing, or extension is allowed (rare)
if (move_uploaded_file($sourcePath, $destPath)) {
$patient_image_path = 'uploads/patients/' . $fileName;
}
}
}
}
if (empty($patient_name) && empty($patient_image_path)) {
echo json_encode(['status' => 'error', 'message' => 'กรุณาระบุชื่อผู้ป่วยหรือถ่ายรูปอย่างน้อยหนึ่งอย่าง']);
exit;
}
if (empty($_POST['from_department_id']) || (empty($to_department_id) && empty($to_department_custom))) {
echo json_encode(['status' => 'error', 'message' => 'ข้อมูลจุดรับส่งไม่ครบถ้วน']);
exit;
}
$data = [
'patient_hn' => $_POST['patient_hn'] ?? '',
'patient_name' => $patient_name,
'patient_image' => $patient_image_path,
'from_department_id' => $_POST['from_department_id'] ?? '',
'to_department_id' => $to_department_id,
'to_department_custom' => $to_department_custom,
'equipment_type' => $_POST['equipment_type'] ?? 'wheelchair',
'priority' => $_POST['priority'] ?? 'normal',
'notes' => ($_POST['notes'] ?? '') . "\n[เรียกผ่าน QR Code จุดรับผู้ป่วย]",
'requester_id' => $requester_id,
'infection_control' => isset($_POST['infection_control']) ? 1 : 0,
'is_scheduled' => 0,
'scheduled_time' => null,
'is_round_trip' => isset($_POST['is_round_trip']) ? 1 : 0
];
try {
$job_number = $jobModel->create($data);
if ($job_number) {
require_once dirname(__DIR__) . '/models/Setting.php';
require_once dirname(__DIR__) . '/config/TelegramBot.php';
$settingModel = new Setting();
$auto_dispatch = $settingModel->get('auto_dispatch_enabled', '0');
$display_name = empty($data['patient_name']) ? 'ไม่ระบุชื่อ (ดูรูปในระบบ)' : $data['patient_name'];
$display_to = empty($data['to_department_id']) ? $data['to_department_custom'] : "แผนกปลายทาง (ID: {$data['to_department_id']})";
$msg = "🚨 แสกนเรียกจากจุดรับ!\nหมายเลข: {$job_number}\nผู้ป่วย: {$display_name}\nปลายทาง: {$display_to}\nอุปกรณ์: {$data['equipment_type']}";
// Trigger LINE Notification
$lineBot = new LineBot();
$lineBot->sendGroupMessage($msg, $job_number);
// Trigger Telegram Notification
$telegramBot = new TelegramBot();
$telegramBot->sendGroupMessage($msg, $job_number);
// Smart Auto Dispatch (If enabled)
if ($auto_dispatch === '1') {
$dispatcher = new Dispatcher();
$jobInfo = $jobModel->getByJobNumber($job_number);
if ($jobInfo) {
$dispatcher->autoDispatch($jobInfo['id']);
}
}
echo json_encode(['status' => 'success', 'job_number' => $job_number]);
} else {
echo json_encode(['status' => 'error', 'message' => 'เกิดข้อผิดพลาดในการบันทึกข้อมูล']);
}
} catch (Throwable $e) {
echo json_encode(['status' => 'error', 'message' => 'เกิดข้อผิดพลาด: ' . $e->getMessage() . ' (Line: ' . $e->getLine() . ')']);
}
} elseif ($action === 'save_signature') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid method']);
exit;
}
$job_id = $_POST['job_id'] ?? '';
$signature_data = $_POST['signature'] ?? '';
if (empty($job_id) || empty($signature_data)) {
echo json_encode(['status' => 'error', 'message' => 'Missing data']);
exit;
}
// Decode Base64 image
list($type, $signature_data) = explode(';', $signature_data);
list(, $signature_data) = explode(',', $signature_data);
$signature_data = base64_decode($signature_data);
$filename = "sig_" . time() . "_" . uniqid() . ".png";
$filepath = dirname(__DIR__) . "/uploads/signatures/" . $filename;
if (file_put_contents($filepath, $signature_data)) {
// Update jobs table with signature_path and set status to completed
$jobModel = new Job();
// Since we have job_number from client, we need job_id
$jobInfo = $jobModel->getByJobNumber($job_id);
if ($jobInfo) {
$jobModel->updateStatus($jobInfo['id'], 'completed', $_SESSION['user_id']);
// Add a quick direct SQL to update signature path
$db = Database::getInstance();
$stmt = $db->prepare("UPDATE jobs SET notes = CONCAT(IFNULL(notes,''), '\n[Signature: ', :sig, ']') WHERE id = :id");
$stmt->execute([':sig' => $filename, ':id' => $jobInfo['id']]);
}
echo json_encode(['status' => 'success', 'file' => $filename]);
} else {
echo json_encode(['status' => 'error', 'message' => 'Failed to save signature']);
}
} elseif ($action === 'get_active_job') {
$jobModel = new Job();
$activeJob = $jobModel->getActiveJobByStaff($_SESSION['user_id']);
if ($activeJob) {
require_once dirname(__DIR__) . '/models/Setting.php';
$settingModel = new Setting();
$equipments = json_decode($settingModel->get('equipment_types') ?: '[]', true);
$priorities = json_decode($settingModel->get('job_priorities') ?: '[]', true);
// Defaults
$activeJob['equipment_name'] = $activeJob['equipment_type'];
$activeJob['equipment_icon'] = 'fa-wheelchair';
$activeJob['priority_name'] = $activeJob['priority'];
$activeJob['priority_color'] = 'blue';
// Match equipment
foreach ($equipments as $eq) {
if ($eq['id'] === $activeJob['equipment_type']) {
$activeJob['equipment_name'] = $eq['name'];
$activeJob['equipment_icon'] = $eq['icon'];
break;
}
}
// Match priority
foreach ($priorities as $pri) {
if ($pri['id'] === $activeJob['priority']) {
$activeJob['priority_name'] = $pri['name'];
$activeJob['priority_color'] = $pri['color'];
break;
}
}
echo json_encode(['status' => 'success', 'data' => $activeJob]);
} else {
echo json_encode(['status' => 'error', 'message' => 'No active job']);
}
} elseif ($action === 'update_status') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid method']);
exit;
}
$job_id = $_POST['job_id'] ?? '';
$new_status = $_POST['status'] ?? '';
if (empty($job_id) || empty($new_status)) {
echo json_encode(['status' => 'error', 'message' => 'Missing data']);
exit;
}
$jobModel = new Job();
$jobInfo = $jobModel->getByJobNumber($job_id);
if ($jobInfo) {
if ($jobModel->updateStatus($jobInfo['id'], $new_status, $_SESSION['user_id'])) {
echo json_encode(['status' => 'success', 'message' => 'Status updated']);
} else {
echo json_encode(['status' => 'error', 'message' => 'Failed to update status']);
}
} else {
echo json_encode(['status' => 'error', 'message' => 'Job not found']);
}
} elseif ($action === 'assign_job') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid method']);
exit;
}
$job_id = $_POST['job_id'] ?? '';
if (empty($job_id)) {
echo json_encode(['status' => 'error', 'message' => 'Missing data']);
exit;
}
$jobModel = new Job();
$jobInfo = $jobModel->getByJobNumber($job_id);
if ($jobInfo && $jobInfo['status'] === 'pending') {
$db = Database::getInstance();
$stmt = $db->prepare("UPDATE jobs SET status = 'assigned', assigned_to = :user_id WHERE id = :id");
$stmt->bindParam(':user_id', $_SESSION['user_id']);
$stmt->bindParam(':id', $jobInfo['id']);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'รับงานสำเร็จ']);
} else {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถรับงานได้']);
}
} else {
echo json_encode(['status' => 'error', 'message' => 'งานนี้ถูกรับไปแล้วหรือไม่พบงาน']);
}
} elseif ($action === 'cancel_job') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid method']);
exit;
}
$job_id = $_POST['job_id'] ?? '';
$reason = $_POST['reason'] ?? '';
if (empty($job_id) || empty($reason)) {
echo json_encode(['status' => 'error', 'message' => 'Missing data']);
exit;
}
$jobModel = new Job();
$jobInfo = $jobModel->getByJobNumber($job_id);
if ($jobInfo) {
if ($jobModel->cancelJob($jobInfo['id'], $reason, $_SESSION['user_id'])) {
echo json_encode(['status' => 'success', 'message' => 'ยกเลิกงานสำเร็จ']);
} else {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถยกเลิกงานได้']);
}
} else {
echo json_encode(['status' => 'error', 'message' => 'Job not found']);
}
} else {
echo json_encode(['status' => 'error', 'message' => 'Unknown action']);
}
@@ -0,0 +1,63 @@
<?php
// api/location.php
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/config/database.php';
header('Content-Type: application/json');
if (!isset($_SESSION['user_id'])) {
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
$action = $_REQUEST['action'] ?? '';
if ($action === 'update_location') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid method']);
exit;
}
$lat = $_POST['lat'] ?? null;
$lng = $_POST['lng'] ?? null;
$user_id = $_SESSION['user_id'];
if ($lat && $lng) {
$db = Database::getInstance();
// Upsert location (Insert or Update if exists)
$query = "INSERT INTO user_locations (user_id, latitude, longitude)
VALUES (:user_id, :lat, :lng)
ON DUPLICATE KEY UPDATE
latitude = :lat2, longitude = :lng2, last_updated = CURRENT_TIMESTAMP";
$stmt = $db->prepare($query);
$stmt->bindParam(':user_id', $user_id);
$stmt->bindParam(':lat', $lat);
$stmt->bindParam(':lng', $lng);
$stmt->bindParam(':lat2', $lat);
$stmt->bindParam(':lng2', $lng);
if ($stmt->execute()) {
echo json_encode(['status' => 'success']);
} else {
echo json_encode(['status' => 'error', 'message' => 'DB update failed']);
}
} else {
echo json_encode(['status' => 'error', 'message' => 'Missing coords']);
}
} elseif ($action === 'get_locations') {
// API for the map view to get all staff locations
$db = Database::getInstance();
$query = "SELECT l.latitude, l.longitude, l.last_updated, u.full_name, u.status
FROM user_locations l
JOIN users u ON l.user_id = u.id
WHERE l.last_updated >= NOW() - INTERVAL 5 MINUTE";
$stmt = $db->prepare($query);
$stmt->execute();
$locations = $stmt->fetchAll();
echo json_encode(['status' => 'success', 'data' => $locations]);
} else {
echo json_encode(['status' => 'error', 'message' => 'Unknown action']);
}
@@ -0,0 +1,104 @@
<?php
// api/maps.php
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/HospitalMap.php';
header('Content-Type: application/json');
if (!isset($_SESSION['user_id'])) {
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
$action = $_REQUEST['action'] ?? '';
$mapModel = new HospitalMap();
if ($action === 'list') {
$status = $_GET['status'] ?? null;
$maps = $mapModel->getAll($status);
echo json_encode(['status' => 'success', 'data' => $maps]);
} elseif ($action === 'upload') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid method']);
exit;
}
$name = $_POST['name'] ?? '';
$building = $_POST['building'] ?? '';
$floor = $_POST['floor'] ?? '';
$location = $_POST['transport_center_location'] ?? '';
$status = $_POST['status'] ?? 'active';
if (empty($name) || empty($building) || empty($floor)) {
echo json_encode(['status' => 'error', 'message' => 'กรุณากรอกข้อมูลให้ครบถ้วน']);
exit;
}
if (!isset($_FILES['map_image']) || $_FILES['map_image']['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['status' => 'error', 'message' => 'กรุณาอัปโหลดไฟล์รูปภาพแผนผัง']);
exit;
}
// Process file upload
$file = $_FILES['map_image'];
$allowed_types = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($file['type'], $allowed_types)) {
echo json_encode(['status' => 'error', 'message' => 'รองรับเฉพาะไฟล์รูปภาพ (JPG, PNG, WEBP) เท่านั้น']);
exit;
}
$upload_dir = dirname(__DIR__) . '/public/assets/img/maps/';
if (!is_dir($upload_dir)) {
mkdir($upload_dir, 0755, true);
}
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = 'map_' . time() . '_' . rand(1000, 9999) . '.' . $ext;
$target_path = $upload_dir . $filename;
if (move_uploaded_file($file['tmp_name'], $target_path)) {
$db_path = 'public/assets/img/maps/' . $filename;
$data = [
'name' => $name,
'building' => $building,
'floor' => $floor,
'image_path' => $db_path,
'transport_center_location' => $location,
'status' => $status
];
if ($mapModel->create($data)) {
echo json_encode(['status' => 'success', 'message' => 'อัปโหลดแผนผังเรียบร้อยแล้ว']);
} else {
// Delete file if DB insert fails
unlink($target_path);
echo json_encode(['status' => 'error', 'message' => 'เกิดข้อผิดพลาดในการบันทึกข้อมูล']);
}
} else {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถอัปโหลดไฟล์ได้']);
}
} elseif ($action === 'delete') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid method']);
exit;
}
$id = $_POST['id'] ?? '';
if (empty($id)) {
echo json_encode(['status' => 'error', 'message' => 'ID is required']);
exit;
}
if ($mapModel->delete($id)) {
echo json_encode(['status' => 'success', 'message' => 'ลบแผนผังเรียบร้อยแล้ว']);
} else {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถลบแผนผังได้']);
}
} else {
echo json_encode(['status' => 'error', 'message' => 'Invalid action']);
}
@@ -0,0 +1,42 @@
<?php
// api/settings.php
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/Setting.php';
header('Content-Type: application/json');
// Only allow Admin or Manager
if (!isset($_SESSION['user_id']) || !in_array($_SESSION['role'], ['admin', 'manager'])) {
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
$action = $_REQUEST['action'] ?? '';
if ($action === 'save') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid method']);
exit;
}
// CSRF Check
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF Token']);
exit;
}
$settingsData = $_POST['settings'] ?? [];
if (!empty($settingsData) && is_array($settingsData)) {
$settingModel = new Setting();
if ($settingModel->updateBulk($settingsData)) {
echo json_encode(['status' => 'success', 'message' => 'บันทึกการตั้งค่าเรียบร้อย']);
} else {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถบันทึกข้อมูลลงฐานข้อมูลได้']);
}
} else {
echo json_encode(['status' => 'error', 'message' => 'ไม่มีข้อมูลให้บันทึก']);
}
} else {
echo json_encode(['status' => 'error', 'message' => 'Unknown action']);
}
@@ -0,0 +1,171 @@
<?php
// api/users.php
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/User.php';
header('Content-Type: application/json');
$action = $_REQUEST['action'] ?? '';
if ($action === 'login') {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
exit;
}
$username = trim($_POST['username'] ?? '');
$password = trim($_POST['password'] ?? '');
if (empty($username) || empty($password)) {
echo json_encode(['status' => 'error', 'message' => 'กรุณากรอกชื่อผู้ใช้งานและรหัสผ่าน']);
exit;
}
$userModel = new User();
if ($userModel->login($username, $password)) {
// Set session variables
$_SESSION['user_id'] = $userModel->id;
$_SESSION['username'] = $userModel->username;
$_SESSION['full_name'] = $userModel->full_name;
$_SESSION['role'] = $userModel->role;
$_SESSION['department_id'] = $userModel->department_id;
echo json_encode(['status' => 'success', 'message' => 'เข้าสู่ระบบสำเร็จ']);
} else {
echo json_encode(['status' => 'error', 'message' => 'ชื่อผู้ใช้งานหรือรหัสผ่านไม่ถูกต้อง']);
}
} else {
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
}
} elseif ($action === 'logout') {
if (isset($_SESSION['user_id'])) {
$userModel = new User();
$userModel->updateStatus($_SESSION['user_id'], 'offline');
}
// Destroy session
session_unset();
session_destroy();
// Redirect to login page
header("Location: ../index.php?page=login");
exit;
} elseif ($action === 'add' && isset($_SESSION['role']) && $_SESSION['role'] === 'admin') {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
exit;
}
$username = trim($_POST['username'] ?? '');
$password = trim($_POST['password'] ?? '');
$full_name = trim($_POST['full_name'] ?? '');
$role = trim($_POST['role'] ?? '');
$department_id = !empty($_POST['department_id']) ? $_POST['department_id'] : null;
if (empty($username) || empty($password) || empty($full_name) || empty($role)) {
echo json_encode(['status' => 'error', 'message' => 'กรุณากรอกข้อมูลให้ครบถ้วน']);
exit;
}
$userModel = new User();
try {
if ($userModel->add($username, $password, $full_name, $role, $department_id)) {
echo json_encode(['status' => 'success', 'message' => 'เพิ่มเจ้าหน้าที่เรียบร้อยแล้ว']);
} else {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถเพิ่มเจ้าหน้าที่ได้']);
}
} catch (PDOException $e) {
echo json_encode(['status' => 'error', 'message' => 'เกิดข้อผิดพลาด: ' . $e->getMessage()]);
}
}
} elseif ($action === 'edit' && isset($_SESSION['role']) && $_SESSION['role'] === 'admin') {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
exit;
}
$id = $_POST['id'] ?? '';
$full_name = trim($_POST['full_name'] ?? '');
$role = trim($_POST['role'] ?? '');
$department_id = !empty($_POST['department_id']) ? $_POST['department_id'] : null;
if (empty($id) || empty($full_name) || empty($role)) {
echo json_encode(['status' => 'error', 'message' => 'กรุณากรอกข้อมูลให้ครบถ้วน']);
exit;
}
$userModel = new User();
try {
if ($userModel->update($id, $full_name, $role, $department_id)) {
echo json_encode(['status' => 'success', 'message' => 'แก้ไขข้อมูลเจ้าหน้าที่เรียบร้อยแล้ว']);
} else {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถแก้ไขข้อมูลได้']);
}
} catch (PDOException $e) {
echo json_encode(['status' => 'error', 'message' => 'เกิดข้อผิดพลาด: ' . $e->getMessage()]);
}
}
} elseif ($action === 'edit_password' && isset($_SESSION['role']) && $_SESSION['role'] === 'admin') {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
exit;
}
$id = $_POST['id'] ?? '';
$password = trim($_POST['password'] ?? '');
if (empty($id) || empty($password)) {
echo json_encode(['status' => 'error', 'message' => 'กรุณากรอกรหัสผ่านใหม่']);
exit;
}
$userModel = new User();
try {
if ($userModel->updatePassword($id, $password)) {
echo json_encode(['status' => 'success', 'message' => 'เปลี่ยนรหัสผ่านเรียบร้อยแล้ว']);
} else {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถเปลี่ยนรหัสผ่านได้']);
}
} catch (PDOException $e) {
echo json_encode(['status' => 'error', 'message' => 'เกิดข้อผิดพลาด: ' . $e->getMessage()]);
}
}
} elseif ($action === 'delete' && isset($_SESSION['role']) && $_SESSION['role'] === 'admin') {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
exit;
}
$id = $_POST['id'] ?? '';
if (empty($id)) {
echo json_encode(['status' => 'error', 'message' => 'ไม่พบรหัสผู้ใช้งาน']);
exit;
}
// Prevent self-deletion
if ($id == $_SESSION['user_id']) {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถลบบัญชีของตัวเองได้']);
exit;
}
$userModel = new User();
try {
if ($userModel->delete($id)) {
echo json_encode(['status' => 'success', 'message' => 'ลบเจ้าหน้าที่เรียบร้อยแล้ว']);
} else {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถลบเจ้าหน้าที่ได้']);
}
} catch (PDOException $e) {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถลบได้เนื่องจากมีการผูกกับงานอื่นอยู่']);
}
}
} else {
echo json_encode(['status' => 'error', 'message' => 'Invalid action']);
}
@@ -0,0 +1,114 @@
/* Floating Background Animation */
.bg-floating {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -1;
overflow: hidden;
background: #e2e8f0;
}
.dark .bg-floating {
background: #0f172a;
}
.bg-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.35;
animation: float 20s infinite ease-in-out alternate;
}
.bg-orb-1 {
width: 600px;
height: 600px;
top: -100px;
left: -100px;
background: radial-gradient(circle, rgba(16,185,129,0.3) 0%, rgba(16,185,129,0) 70%);
animation-delay: 0s;
}
.bg-orb-2 {
width: 500px;
height: 500px;
bottom: -50px;
right: -50px;
background: radial-gradient(circle, rgba(20,184,166,0.3) 0%, rgba(20,184,166,0) 70%);
animation-delay: -5s;
}
.bg-orb-3 {
width: 400px;
height: 400px;
top: 40%;
left: 30%;
background: radial-gradient(circle, rgba(56,189,248,0.2) 0%, rgba(56,189,248,0) 70%);
animation-delay: -10s;
}
@keyframes float {
0% { transform: translate(0, 0) scale(1); }
33% { transform: translate(30px, -50px) scale(1.1); }
66% { transform: translate(-20px, 20px) scale(0.9); }
100% { transform: translate(0, 0) scale(1); }
}
/* Loading Spinner for Buttons */
.btn-loading {
position: relative;
pointer-events: none;
opacity: 0.8;
}
.btn-loading .text {
visibility: hidden;
}
.btn-loading::after {
content: "";
position: absolute;
width: 1.25rem;
height: 1.25rem;
top: 0; left: 0; bottom: 0; right: 0;
margin: auto;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Page Transition */
.page-enter {
animation: fadeUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fadeUp {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* Hide SweetAlert background blur conflicts */
.swal2-backdrop-show {
backdrop-filter: blur(4px);
background: rgba(0,0,0,0.4) !important;
}
.dark .swal2-popup {
background: #1e293b;
color: #f1f5f9;
}
.dark .swal2-title {
color: #f8fafc;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

@@ -0,0 +1,50 @@
// assets/js/location.js
document.addEventListener('DOMContentLoaded', function() {
// Check if the current page is a staff page and if geolocation is supported
if (window.location.search.includes('page=staff')) {
if ("geolocation" in navigator) {
console.log("Geolocation supported. Starting GPS tracking...");
// Watch position updates continuously
navigator.geolocation.watchPosition(
function(position) {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
// Send to server
sendLocationToServer(lat, lng);
},
function(error) {
console.error("Error getting location: ", error);
},
{
enableHighAccuracy: true,
maximumAge: 10000, // 10 seconds
timeout: 5000
}
);
} else {
console.warn("Geolocation is not supported by this browser.");
}
}
});
function sendLocationToServer(lat, lng) {
const formData = new FormData();
formData.append('action', 'update_location');
formData.append('lat', lat);
formData.append('lng', lng);
fetch('api/location.php', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
// Silently succeed
// console.log("Location updated", data);
})
.catch(error => {
console.error('Error updating location:', error);
});
}
@@ -0,0 +1,53 @@
// assets/js/main.js
document.addEventListener('DOMContentLoaded', function() {
// --- Dark Mode System ---
const themeToggleBtn = document.getElementById('themeToggle');
const htmlElement = document.documentElement;
// Check local storage or system preference
if (localStorage.getItem('theme') === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
htmlElement.classList.add('dark');
} else {
htmlElement.classList.remove('dark');
}
if (themeToggleBtn) {
themeToggleBtn.addEventListener('click', function() {
htmlElement.classList.toggle('dark');
if (htmlElement.classList.contains('dark')) {
localStorage.setItem('theme', 'dark');
} else {
localStorage.setItem('theme', 'light');
}
});
}
// --- Loading Button Global Handling ---
// If a button has 'data-loading-text', it will show a spinner when clicked within a form
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', function(e) {
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) {
// Don't disable immediately if validation is required and fails, but assuming HTML5 validation passes here
submitBtn.classList.add('btn-loading');
// Optional: revert loading state if the form submission is prevented via AJAX in specific page scripts
}
});
});
// --- PWA Service Worker Registration ---
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js')
.then(registration => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(err => {
console.log('ServiceWorker registration failed: ', err);
});
});
}
});
@@ -0,0 +1,126 @@
<?php
// config/Dispatcher.php
require_once dirname(__DIR__) . '/config/database.php';
class Dispatcher {
private $conn;
public function __construct() {
$this->conn = Database::getInstance();
}
/**
* ค้นหาและจ่ายงานให้เจ้าหน้าที่โดยอัตโนมัติ (Smart Auto Dispatch)
* ปัจจัยการประเมิน (Scoring Algorithm):
* 1. สถานะ: ว่าง (Online) ได้คะแนนสูงสุด, กำลังรับส่ง (Busy) ถูกหักคะแนน
* 2. ภาระงาน: เจ้าหน้าที่ที่มีงานน้อยที่สุดในวันนี้ จะได้คะแนนบวกเพิ่ม
* 3. ระยะทาง: คำนวณจากพิกัด (user_locations) หาคนที่อยู่ใกล้ที่สุด (Haversine formula)
* @param int $job_id
* @param float $dest_lat พิกัดจุดรับ (ถ้ามี)
* @param float $dest_lng พิกัดจุดรับ (ถ้ามี)
*/
public function autoDispatch($job_id, $dest_lat = null, $dest_lng = null) {
// ดึงการตั้งค่า
require_once dirname(__DIR__) . '/models/Setting.php';
$settingModel = new Setting();
$max_distance_str = $settingModel->get('dispatch_max_distance', '2.0');
$max_distance = floatval($max_distance_str);
// ดึงรายการเจ้าหน้าที่ทั้งหมดที่ไม่ได้ออฟไลน์ พร้อมภาระงานในวันนี้ และพิกัดล่าสุด
$query = "
SELECT u.id, u.username, u.status,
(SELECT COUNT(*) FROM jobs j WHERE j.requester_id = u.id AND DATE(j.created_at) = CURDATE()) as today_jobs,
l.latitude, l.longitude
FROM users u
LEFT JOIN user_locations l ON u.id = l.user_id
AND l.last_updated >= NOW() - INTERVAL 15 MINUTE
WHERE u.role IN ('staff', 'nurse') AND u.status != 'offline'
";
$stmt = $this->conn->prepare($query);
$stmt->execute();
$staffs = $stmt->fetchAll();
if (empty($staffs)) {
return false; // ไม่มีเจ้าหน้าที่พร้อมรับงาน
}
$best_staff_id = null;
$highest_score = -9999;
foreach ($staffs as $staff) {
$score = 0;
// 1. ประเมินจากสถานะ (Status Score)
if ($staff['status'] === 'online') {
$score += 100; // ว่าง
} else {
$score += 20; // ไม่ว่าง (อาจจะรับงานซ้อนได้ถ้าระบบอนุญาต)
}
// 2. ประเมินจากภาระงาน (Workload Score)
// ยิ่งงานน้อย ยิ่งได้คะแนนเยอะ (ลดหลั่นลงไป งานละ -5 คะแนน)
$score -= ($staff['today_jobs'] * 5);
// 3. ประเมินจากระยะทาง (Distance Score)
if ($dest_lat !== null && $dest_lng !== null && $staff['latitude'] !== null && $staff['longitude'] !== null) {
$distance = $this->calculateDistance($staff['latitude'], $staff['longitude'], $dest_lat, $dest_lng);
// หากระยะทางเกินกว่าที่กำหนด จะถูกหักคะแนนหนัก หรือข้ามไปเลย
if ($distance > $max_distance) {
$score -= 100; // ตัดสิทธิ์กลายๆ
} else {
// ยิ่งใกล้ ยิ่งได้คะแนนเยอะ (สมมติว่ารัศมี 1km ได้ 50 คะแนน, หักลบตามระยะทาง)
$distance_score = max(0, 50 - ($distance * (50 / $max_distance)));
$score += $distance_score;
}
}
// ค้นหาผู้ที่ได้คะแนนสูงสุด
if ($score > $highest_score) {
$highest_score = $score;
$best_staff_id = $staff['id'];
}
}
if ($best_staff_id) {
$this->assignJob($job_id, $best_staff_id);
return $best_staff_id;
}
return false;
}
/**
* คำนวณระยะทางแบบ Haversine Formula (กิโลเมตร)
*/
private function calculateDistance($lat1, $lon1, $lat2, $lon2) {
$earth_radius = 6371; // km
$dLat = deg2rad($lat2 - $lat1);
$dLon = deg2rad($lon2 - $lon1);
$a = sin($dLat/2) * sin($dLat/2) + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon/2) * sin($dLon/2);
$c = 2 * asin(sqrt($a));
return $earth_radius * $c;
}
private function assignJob($job_id, $staff_id) {
// อัปเดตตาราง jobs
$query = "UPDATE jobs SET requester_id = :staff_id WHERE id = :job_id";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':staff_id', $staff_id);
$stmt->bindParam(':job_id', $job_id);
$stmt->execute();
// เปลี่ยนสถานะเจ้าหน้าที่เป็นกำลังทำงาน
$update_status = "UPDATE users SET status = 'busy' WHERE id = :staff_id";
$stmt3 = $this->conn->prepare($update_status);
$stmt3->bindParam(':staff_id', $staff_id);
$stmt3->execute();
// บันทึก Timeline
$query_timeline = "INSERT INTO job_timelines (job_id, status, description, created_by) VALUES (:job_id, 'assigned', 'Smart Auto Dispatch จ่ายงานอัตโนมัติ', 0)";
$stmt2 = $this->conn->prepare($query_timeline);
$stmt2->bindParam(':job_id', $job_id);
$stmt2->execute();
}
}
@@ -0,0 +1,41 @@
<?php
// config/LineBot.php
require_once __DIR__ . '/config.php';
require_once dirname(__DIR__) . '/models/Setting.php';
class LineBot {
public function sendGroupMessage($message, $jobId = null) {
$settingModel = new Setting();
$token = $settingModel->get('line_bot_token', '');
if (empty($token)) {
return false; // Skip if token is not set
}
$text = $message;
if ($jobId) {
$acceptUrl = BASE_URL . "?page=staff&action=accept&job_id=" . $jobId;
$text .= "\n\n🔗 กดรับงาน: " . $acceptUrl;
}
$queryData = http_build_query([
'message' => "\n" . $text
]);
// LINE Notify API
$ch = curl_init('https://notify-api.line.me/api/notify');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $queryData);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Content-Type: application/x-www-form-urlencoded',
'Authorization: Bearer ' . $token
));
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
}
@@ -0,0 +1,42 @@
<?php
// config/TelegramBot.php
require_once __DIR__ . '/config.php';
require_once dirname(__DIR__) . '/models/Setting.php';
class TelegramBot {
public function sendGroupMessage($message, $jobId = null) {
$settingModel = new Setting();
$token = $settingModel->get('telegram_bot_token', '');
$chat_id = $settingModel->get('telegram_chat_id', '');
if (empty($token) || empty($chat_id)) {
return false;
}
$text = $message;
if ($jobId) {
$acceptUrl = BASE_URL . "?page=staff&action=accept&job_id=" . $jobId;
$text .= "\n\n🔗 กดรับงาน: " . $acceptUrl;
}
// Actual cURL request to Telegram API
$url = "https://api.telegram.org/bot" . $token . "/sendMessage";
$data = [
'chat_id' => $chat_id,
'text' => $text,
'parse_mode' => 'HTML'
];
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
}
@@ -0,0 +1,44 @@
<?php
// config/config.php
session_start();
// Define base URL for the application
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
$host = $_SERVER['HTTP_HOST'];
// Assuming the project is in a folder named after the project in htdocs, we dynamically get the path
$script_path = dirname($_SERVER['SCRIPT_NAME']);
$base_url = $protocol . $host . $script_path . '/';
define('BASE_URL', $base_url);
// Notification API Tokens (Replace with real tokens when deploying)
define('LINE_BOT_TOKEN', 'YOUR_LINE_ACCESS_TOKEN');
define('LINE_GROUP_ID', 'YOUR_GROUP_ID');
define('TELEGRAM_BOT_TOKEN', 'YOUR_TELEGRAM_BOT_TOKEN');
define('TELEGRAM_CHAT_ID', 'YOUR_CHAT_ID');
// Security helpers
function escape($html) {
return htmlspecialchars($html, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
}
function generateCSRFToken() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function checkCSRFToken($token) {
if (empty($_SESSION['csrf_token']) || $token !== $_SESSION['csrf_token']) {
die("CSRF Token validation failed.");
}
return true;
}
// Redirect helper
function redirect($url) {
header("Location: " . BASE_URL . $url);
exit;
}
@@ -0,0 +1,57 @@
<?php
// config/database.php
define('DB_HOST', 'localhost');
define('DB_NAME', 'ksh_porter');
define('DB_USER', 'root');
define('DB_PASS', '@Samui@10742');
// HR Database Configuration
define('HOS_DB_HOST', '10.0.250.115');
define('HOS_DB_NAME', 'hosoffice_2566');
define('HOS_DB_USER', 'hosoffice');
define('HOS_DB_PASS', 'hosoffice10742');
class Database
{
private static $instance = null;
private $conn;
private function __construct()
{
try {
$dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$this->conn = new PDO($dsn, DB_USER, DB_PASS, $options);
} catch (PDOException $e) {
die("Database connection failed: " . $e->getMessage());
}
}
public static function getInstance()
{
if (!self::$instance) {
self::$instance = new Database();
}
return self::$instance->conn;
}
public static function getHosInstance()
{
try {
$dsn = "mysql:host=" . HOS_DB_HOST . ";dbname=" . HOS_DB_NAME . ";charset=utf8mb4";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
return new PDO($dsn, HOS_DB_USER, HOS_DB_PASS, $options);
} catch (PDOException $e) {
return null; // Return null if unable to connect to HR DB
}
}
}
@@ -0,0 +1,90 @@
-- Create database if not exists (Usually in phpMyAdmin you just import to an existing DB)
-- But we can add schema here for reference.
-- 1. ตารางข้อมูลแผนก (Departments)
CREATE TABLE IF NOT EXISTS `departments` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`code` VARCHAR(50) NOT NULL UNIQUE,
`name` VARCHAR(255) NOT NULL,
`building` VARCHAR(255) DEFAULT NULL,
`floor` VARCHAR(50) DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. ตารางผู้ใช้งาน / เจ้าหน้าที่ (Users)
CREATE TABLE IF NOT EXISTS `users` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(50) NOT NULL UNIQUE,
`password` VARCHAR(255) NOT NULL,
`full_name` VARCHAR(255) NOT NULL,
`role` ENUM('admin', 'manager', 'staff', 'nurse') NOT NULL DEFAULT 'staff',
`department_id` INT DEFAULT NULL,
`status` ENUM('online', 'offline', 'busy') DEFAULT 'offline',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`department_id`) REFERENCES `departments`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3. ตารางอุปกรณ์เปล (Equipments)
CREATE TABLE IF NOT EXISTS `equipments` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`asset_no` VARCHAR(100) NOT NULL UNIQUE,
`type` ENUM('wheelchair', 'stretcher', 'icu_bed', 'incubator') NOT NULL,
`status` ENUM('available', 'in_use', 'maintenance') DEFAULT 'available',
`current_location_id` INT DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`current_location_id`) REFERENCES `departments`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4. ตารางงานเปล (Jobs)
CREATE TABLE IF NOT EXISTS `jobs` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`job_number` VARCHAR(50) NOT NULL UNIQUE,
`patient_hn` VARCHAR(50) NOT NULL,
`patient_name` VARCHAR(255) NOT NULL,
`from_department_id` INT NOT NULL,
`to_department_id` INT NOT NULL,
`equipment_type` ENUM('wheelchair', 'stretcher', 'icu_bed', 'incubator', 'none') DEFAULT 'wheelchair',
`priority` ENUM('normal', 'urgent', 'emergency') DEFAULT 'normal',
`status` ENUM('pending', 'assigned', 'accepted', 'in_progress', 'completed', 'cancelled') DEFAULT 'pending',
`requester_id` INT NOT NULL,
`assigned_to` INT DEFAULT NULL,
`equipment_id` INT DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`accepted_at` DATETIME DEFAULT NULL,
`pickup_at` DATETIME DEFAULT NULL,
`completed_at` DATETIME DEFAULT NULL,
`notes` TEXT,
FOREIGN KEY (`from_department_id`) REFERENCES `departments`(`id`),
FOREIGN KEY (`to_department_id`) REFERENCES `departments`(`id`),
FOREIGN KEY (`requester_id`) REFERENCES `users`(`id`),
FOREIGN KEY (`assigned_to`) REFERENCES `users`(`id`),
FOREIGN KEY (`equipment_id`) REFERENCES `equipments`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5. ตาราง Job Timelines (Audit Trail)
CREATE TABLE IF NOT EXISTS `job_timelines` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`job_id` INT NOT NULL,
`status` VARCHAR(50) NOT NULL,
`description` TEXT,
`created_by` INT NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`job_id`) REFERENCES `jobs`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert Mock Data
INSERT INTO `departments` (`code`, `name`, `building`, `floor`) VALUES
('ER', 'ห้องฉุกเฉิน', 'อาคาร A', '1'),
('OPD1', 'แผนกผู้ป่วยนอก 1', 'อาคาร A', '2'),
('IPD1', 'หอผู้ป่วยใน 1', 'อาคาร B', '3'),
('XRAY', 'แผนกเอกซเรย์', 'อาคาร A', '1');
INSERT INTO `users` (`username`, `password`, `full_name`, `role`, `department_id`) VALUES
('admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Admin System', 'admin', NULL), -- password: password
('nurse1', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Nurse Somjai', 'nurse', 1),
('staff1', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Staff Somchai', 'staff', NULL);
INSERT INTO `equipments` (`asset_no`, `type`, `status`) VALUES
('WC-001', 'wheelchair', 'available'),
('ST-001', 'stretcher', 'available');
@@ -0,0 +1,35 @@
-- 1. เพิ่มคอลัมน์ใหม่ในตาราง jobs
ALTER TABLE `jobs`
ADD COLUMN `signature_path` VARCHAR(255) DEFAULT NULL AFTER `notes`,
ADD COLUMN `sla_minutes` INT DEFAULT 15 AFTER `priority`;
-- 2. สร้างตาราง user_locations (Live Tracking)
CREATE TABLE IF NOT EXISTS `user_locations` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL,
`latitude` DECIMAL(10, 8) NOT NULL,
`longitude` DECIMAL(11, 8) NOT NULL,
`last_updated` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3. สร้างตาราง notification_logs (LINE/Telegram Audit)
CREATE TABLE IF NOT EXISTS `notification_logs` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`job_id` INT NOT NULL,
`channel` ENUM('LINE', 'Telegram') NOT NULL,
`message` TEXT NOT NULL,
`status` ENUM('success', 'failed') NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`job_id`) REFERENCES `jobs`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4. สร้างตาราง equipment_logs (Asset Tracking History)
CREATE TABLE IF NOT EXISTS `equipment_logs` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`equipment_id` INT NOT NULL,
`job_id` INT DEFAULT NULL,
`action` VARCHAR(100) NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`equipment_id`) REFERENCES `equipments`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,22 @@
-- 1. สร้างตาราง settings
CREATE TABLE IF NOT EXISTS `settings` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`setting_key` VARCHAR(100) NOT NULL UNIQUE,
`setting_value` TEXT NOT NULL,
`category` ENUM('general', 'notification', 'dispatch') NOT NULL DEFAULT 'general',
`label` VARCHAR(255) NOT NULL,
`description` TEXT DEFAULT NULL,
`input_type` ENUM('text', 'password', 'number', 'boolean', 'textarea') NOT NULL DEFAULT 'text',
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. ข้อมูลตั้งต้น (Default Settings)
INSERT IGNORE INTO `settings` (`setting_key`, `setting_value`, `category`, `label`, `description`, `input_type`) VALUES
('hospital_name', 'โรงพยาบาลเกาะสมุย', 'general', 'ชื่อโรงพยาบาล/หน่วยงาน', 'แสดงที่หน้าจอหลักและแถบนำทาง', 'text'),
('line_bot_token', '', 'notification', 'LINE Bot Access Token', 'ออกผ่าน LINE Developers Console', 'password'),
('line_group_id', '', 'notification', 'LINE Group ID', 'Group ID ที่บอทจะส่งแจ้งเตือนไปหา', 'text'),
('telegram_bot_token', '', 'notification', 'Telegram Bot Token', 'ออกผ่าน BotFather', 'password'),
('telegram_chat_id', '', 'notification', 'Telegram Chat ID', 'ID ของกลุ่มหรือแชท', 'text'),
('auto_dispatch_enabled', '1', 'dispatch', 'เปิดใช้งาน Smart Auto Dispatch', 'ระบบจะจ่ายงานอัตโนมัติ (1 = เปิด, 0 = ปิด)', 'boolean'),
('sla_minutes_default', '15', 'dispatch', 'เวลา SLA เริ่มต้น (นาที)', 'เวลามาตรฐานสำหรับงานส่งผู้ป่วย', 'number'),
('dispatch_distance_weight', '50', 'dispatch', 'ความสำคัญของระยะทาง (คะแนน)', 'คะแนนสำหรับเจ้าหน้าที่ที่อยู่ใกล้ที่สุด', 'number');
@@ -0,0 +1,11 @@
-- 1. ปลดล็อกข้อจำกัดของชนิดอุปกรณ์ (Equipment Type)
ALTER TABLE `jobs` MODIFY `equipment_type` VARCHAR(100) DEFAULT 'wheelchair';
ALTER TABLE `equipments` MODIFY `type` VARCHAR(100) NOT NULL DEFAULT 'wheelchair';
-- 2. ปลดล็อกข้อจำกัดของระดับความฉุกเฉิน (Priority)
ALTER TABLE `jobs` MODIFY `priority` VARCHAR(100) DEFAULT 'normal';
-- 3. เพิ่มข้อมูลค่าเริ่มต้น (Default Settings) สำหรับการตั้งค่าผ่าน JSON
INSERT IGNORE INTO `settings` (`setting_key`, `setting_value`, `category`, `label`, `input_type`) VALUES
('job_priorities', '[{"id":"normal","name":"ทั่วไป (Normal)","color":"blue"},{"id":"urgent","name":"ด่วน (Urgency)","color":"amber"},{"id":"emergency","name":"ฉุกเฉิน (Emergency)","color":"red"}]', 'general', 'ระดับความฉุกเฉิน', 'textarea'),
('equipment_types', '[{"id":"wheelchair","name":"รถนั่ง (Wheelchair)","icon":"fa-wheelchair"},{"id":"stretcher","name":"รถนอน (Stretcher)","icon":"fa-bed"},{"id":"bed","name":"เตียง (Bed)","icon":"fa-bed-pulse"}]', 'general', 'ประเภทอุปกรณ์', 'textarea');
@@ -0,0 +1,18 @@
-- 1. เพิ่มคอมลัมน์ใหม่ในตาราง jobs
ALTER TABLE `jobs`
ADD COLUMN `is_scheduled` TINYINT(1) DEFAULT 0 AFTER `equipment_id`,
ADD COLUMN `scheduled_time` DATETIME DEFAULT NULL AFTER `is_scheduled`,
ADD COLUMN `infection_control` TINYINT(1) DEFAULT 0 AFTER `scheduled_time`,
ADD COLUMN `rating` INT DEFAULT NULL AFTER `completed_at`,
ADD COLUMN `feedback` TEXT DEFAULT NULL AFTER `rating`;
-- 2. สร้างตาราง job_messages (แชท)
CREATE TABLE IF NOT EXISTS `job_messages` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`job_id` INT NOT NULL,
`user_id` INT NOT NULL,
`message` TEXT NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`job_id`) REFERENCES `jobs`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS `hospital_maps` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(255) NOT NULL,
`building` VARCHAR(100) NOT NULL,
`floor` VARCHAR(50) NOT NULL,
`image_path` VARCHAR(255) NOT NULL,
`transport_center_location` TEXT,
`status` ENUM('active', 'inactive') DEFAULT 'active',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,10 @@
-- 1. เพิ่มคอลัมน์ is_round_trip ในตาราง jobs
ALTER TABLE `jobs`
ADD COLUMN `is_round_trip` TINYINT(1) DEFAULT 0 AFTER `equipment_type`;
-- 2. ปรับปรุงข้อจำกัด ENUM ของสถานะ (status) เพื่อให้รองรับ waiting_for_return
-- สำหรับระบบที่มีข้อมูลแล้ว อาจจะใช้ VARCHAR หรือปรับ ENUM ให้กว้างขึ้น
ALTER TABLE `jobs` MODIFY `status` VARCHAR(50) DEFAULT 'pending';
-- ปรับตาราง job_timelines เผื่อไว้ (แม้ว่าเดิมจะเป็น VARCHAR อยู่แล้ว)
ALTER TABLE `job_timelines` MODIFY `status` VARCHAR(50) NOT NULL;
@@ -0,0 +1,18 @@
-- database_v8.sql
-- เพิ่มฟีเจอร์ ถ่ายรูประบุตัวตน และ พิมพ์ปลายทางเองสำหรับงานที่ขอผ่าน QR Code
-- 1. เพิ่มคอลัมน์รูปภาพผู้ป่วย/ผู้ที่เรียกใช้งาน
ALTER TABLE `jobs`
ADD COLUMN `patient_image` VARCHAR(255) DEFAULT NULL AFTER `patient_name`;
-- 2. เพิ่มคอลัมน์ปลายทางที่พิมพ์เอง
ALTER TABLE `jobs`
ADD COLUMN `to_department_custom` VARCHAR(255) DEFAULT NULL AFTER `to_department_id`;
-- 3. แก้ไขให้ patient_name เป็น NULL ได้
ALTER TABLE `jobs`
MODIFY `patient_name` VARCHAR(255) DEFAULT NULL;
-- 4. แก้ไขให้ to_department_id เป็น NULL ได้ (กรณีพิมพ์เองจะไม่มี ID)
ALTER TABLE `jobs`
MODIFY `to_department_id` INT DEFAULT NULL;
@@ -0,0 +1,270 @@
<?php
ob_start();
// index.php (Main Router)
require_once 'config/config.php';
require_once 'config/database.php';
// Simple routing mechanism
$page = isset($_GET['page']) ? $_GET['page'] : 'dashboard';
// Check if user is logged in for protected pages
if (!isset($_SESSION['user_id']) && !in_array($page, ['login', 'public_request'])) {
redirect('?page=login');
}
?>
<!DOCTYPE html>
<html lang="th" class="antialiased">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#10b981">
<title>Smart Patient Transport System - รพ.เกาะสมุย</title>
<!-- PWA Manifest -->
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" href="assets/img/logo.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="assets/img/logo.png">
<!-- Tailwind CSS (CDN for zero-build setup) -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#ecfdf5',
100: '#d1fae5',
500: '#10b981',
600: '#059669',
700: '#047857',
900: '#064e3b',
}
},
fontFamily: {
sans: ['Inter', 'Sarabun', 'sans-serif'],
}
}
}
}
</script>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Sarabun:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- FontAwesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<!-- SweetAlert2 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="assets/css/style.css">
<style type="text/tailwindcss">
body { font-family: 'Inter', 'Sarabun', sans-serif; }
@layer utilities {
.glass-card {
@apply backdrop-blur-xl bg-white/70 dark:bg-slate-900/70 border border-white/60 dark:border-white/10 shadow-[var(--glass-shadow)] rounded-2xl;
}
.glass-nav {
@apply backdrop-blur-lg bg-emerald-800/95 dark:bg-slate-900/95 border-b border-emerald-900/20 dark:border-white/10 shadow-md sticky top-0 z-50;
}
.glass-input {
@apply backdrop-blur-md bg-white/80 dark:bg-slate-800/80 border border-slate-300 dark:border-slate-600 focus:bg-white dark:focus:bg-slate-900 focus:ring-2 focus:ring-emerald-500 rounded-xl transition-all duration-300;
}
.btn-primary-glass {
@apply bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white shadow-lg shadow-emerald-500/30 hover:shadow-emerald-500/50 rounded-xl transition-all duration-300 active:scale-95;
}
.btn-secondary-glass {
@apply bg-white/90 dark:bg-slate-800/90 hover:bg-white dark:hover:bg-slate-700 border border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200 rounded-xl transition-all duration-300 active:scale-95 shadow-sm;
}
}
</style>
</head>
<body class="text-slate-800 dark:text-slate-200 transition-colors duration-300 min-h-screen flex flex-col">
<!-- Floating Background Orbs -->
<div class="bg-floating">
<div class="bg-orb bg-orb-1"></div>
<div class="bg-orb bg-orb-2"></div>
<div class="bg-orb bg-orb-3"></div>
</div>
<?php if (isset($_SESSION['user_id'])): ?>
<nav class="glass-nav mb-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<!-- Logo & Brand -->
<div class="flex items-center">
<a href="?page=dashboard" class="flex items-center gap-3 shrink-0">
<div class="w-10 h-10 rounded-full bg-white flex items-center justify-center p-1 shadow-sm">
<img src="assets/img/logo.png" alt="Logo" class="w-full h-full object-contain">
</div>
<span class="font-bold text-white tracking-tight hidden sm:block">PT System<br><span class="text-xs font-normal text-emerald-100">รพ.เกาะสมุย</span></span>
</a>
</div>
<!-- Desktop Menu -->
<div class="hidden md:flex md:items-center md:space-x-4">
<a href="?page=dashboard" class="px-3 py-2 rounded-lg text-sm font-medium <?= $page == 'dashboard' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?> transition-colors">
<i class="fa-solid fa-chart-pie me-1"></i> Dashboard
</a>
<?php if (isset($_SESSION['role']) && ($_SESSION['role'] === 'admin' || $_SESSION['role'] === 'manager')): ?>
<a href="?page=executive" class="px-3 py-2 rounded-lg text-sm font-medium <?= $page == 'executive' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?> transition-colors">
<i class="fa-solid fa-chart-line me-1"></i> Executive
</a>
<?php endif; ?>
<a href="?page=request" class="px-3 py-2 rounded-lg text-sm font-medium <?= $page == 'request' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?> transition-colors">
<i class="fa-solid fa-bell-concierge me-1"></i> ขอเปล
</a>
<a href="?page=staff" class="px-3 py-2 rounded-lg text-sm font-medium <?= $page == 'staff' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?> transition-colors">
<i class="fa-solid fa-mobile-screen me-1"></i> หน้างาน
</a>
<?php if (isset($_SESSION['role']) && ($_SESSION['role'] === 'staff' || $_SESSION['role'] === 'admin')): ?>
<a href="?page=staff_summary" class="px-3 py-2 rounded-lg text-sm font-medium <?= $page == 'staff_summary' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?> transition-colors">
<i class="fa-solid fa-clipboard-list me-1"></i> ผลงานฉัน
</a>
<?php endif; ?>
<a href="?page=tracking" class="px-3 py-2 rounded-lg text-sm font-medium <?= $page == 'tracking' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?> transition-colors">
<i class="fa-solid fa-map-location-dot me-1"></i> พิกัด
</a>
<?php if ($_SESSION['role'] === 'admin'): ?>
<div class="h-6 w-px bg-white/20 mx-2 hidden lg:block"></div>
<a href="?page=users" class="px-3 py-2 rounded-lg text-sm font-medium <?= $page == 'users' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?> transition-colors" title="ทะเบียนบุคลากร">
<i class="fa-solid fa-users"></i>
</a>
<a href="?page=settings" class="px-3 py-2 rounded-lg text-sm font-medium <?= $page == 'settings' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?> transition-colors" title="ตั้งค่าระบบ">
<i class="fa-solid fa-gear"></i>
</a>
<?php endif; ?>
</div>
<!-- Right Side (Theme Toggle & Profile) -->
<div class="flex items-center gap-3">
<button id="themeToggle" class="p-2 rounded-full text-emerald-50 hover:bg-white/10 transition-colors" aria-label="Toggle Dark Mode">
<i class="fa-solid fa-moon dark:hidden"></i>
<i class="fa-solid fa-sun hidden dark:block"></i>
</button>
<div class="relative group">
<button class="flex items-center gap-2 p-2 rounded-lg text-emerald-50 hover:bg-white/10 transition-colors">
<img src="api/avatar.php?username=<?= urlencode($_SESSION['username'] ?? '') ?>&name=<?= urlencode($_SESSION['full_name'] ?? 'User') ?>" class="w-8 h-8 rounded-full border border-white/30 object-cover">
<span class="text-sm font-medium hidden sm:block"><?= escape($_SESSION['full_name'] ?? 'User') ?></span>
<i class="fa-solid fa-chevron-down text-xs"></i>
</button>
<!-- Dropdown -->
<div class="absolute right-0 mt-2 w-48 glass-card opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 transform origin-top-right">
<div class="p-1">
<a href="api/users.php?action=logout" class="block px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors">
<i class="fa-solid fa-arrow-right-from-bracket me-2"></i> ออกจากระบบ
</a>
</div>
</div>
</div>
<!-- Mobile Menu Button -->
<button id="mobileMenuBtn" class="md:hidden p-2 rounded-lg text-emerald-50 hover:bg-white/10 transition-colors">
<i class="fa-solid fa-bars text-xl"></i>
</button>
</div>
</div>
</div>
<!-- Mobile Menu -->
<div id="mobileMenu" class="md:hidden hidden border-t border-white/10 bg-emerald-800/95 dark:bg-slate-900/95 backdrop-blur-xl">
<div class="px-2 pt-2 pb-3 space-y-1">
<a href="?page=dashboard" class="block px-3 py-2 rounded-lg text-base font-medium <?= $page == 'dashboard' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?>">Dashboard</a>
<?php if (isset($_SESSION['role']) && ($_SESSION['role'] === 'admin' || $_SESSION['role'] === 'manager')): ?>
<a href="?page=executive" class="block px-3 py-2 rounded-lg text-base font-medium <?= $page == 'executive' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?>">Executive</a>
<?php endif; ?>
<a href="?page=request" class="block px-3 py-2 rounded-lg text-base font-medium <?= $page == 'request' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?>">ขอเปล</a>
<a href="?page=staff" class="block px-3 py-2 rounded-lg text-base font-medium <?= $page == 'staff' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?>">หน้างานเจ้าหน้าที่</a>
<a href="?page=tracking" class="block px-3 py-2 rounded-lg text-base font-medium <?= $page == 'tracking' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?>">พิกัด</a>
<?php if ($_SESSION['role'] === 'admin'): ?>
<a href="?page=users" class="block px-3 py-2 rounded-lg text-base font-medium <?= $page == 'users' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?>">ทะเบียนบุคลากร</a>
<a href="?page=settings" class="block px-3 py-2 rounded-lg text-base font-medium <?= $page == 'settings' ? 'bg-white/20 text-white' : 'text-emerald-50 hover:bg-white/10' ?>">ตั้งค่าระบบ</a>
<?php endif; ?>
</div>
</div>
</nav>
<?php endif; ?>
<main class="flex-1 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-10 page-enter">
<?php
// Load the requested page view
$view_path = "";
switch ($page) {
case 'login':
$view_path = 'views/auth/login.php';
break;
case 'dashboard':
$view_path = 'views/dashboard/index.php';
break;
case 'executive':
$view_path = 'views/dashboard/executive.php';
break;
case 'request':
$view_path = 'views/jobs/request.php';
break;
case 'staff':
$view_path = 'views/jobs/staff.php';
break;
case 'tracking':
$view_path = 'views/tracking/map.php';
break;
case 'users':
$view_path = 'views/users/index.php';
break;
case 'settings':
$view_path = 'views/settings/index.php';
break;
case 'public_request':
$view_path = 'views/public/request.php';
break;
case 'job_detail':
$view_path = 'views/jobs/detail.php';
break;
case 'staff_summary':
$view_path = 'views/jobs/staff_summary.php';
break;
default:
$view_path = 'views/dashboard/index.php';
break;
}
if (file_exists($view_path)) {
require_once $view_path;
} else {
echo '<div class="glass-card p-8 text-center text-red-500">
<i class="fa-solid fa-triangle-exclamation text-4xl mb-4"></i>
<h2 class="text-xl font-bold">404 Page Not Found</h2>
<p>ไม่พบหน้าที่ต้องการ</p>
</div>';
}
?>
</main>
<!-- Scripts -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Custom JS -->
<script src="assets/js/main.js"></script>
<script src="assets/js/location.js"></script>
<script>
// Mobile Menu Toggle
document.getElementById('mobileMenuBtn')?.addEventListener('click', function() {
document.getElementById('mobileMenu').classList.toggle('hidden');
});
</script>
</body>
</html>
@@ -0,0 +1,21 @@
{
"name": "PT System รพ.เกาะสมุย",
"short_name": "งานเปล",
"start_url": "./?page=dashboard",
"display": "standalone",
"background_color": "#10b981",
"theme_color": "#10b981",
"description": "Smart Patient Transport System - โรงพยาบาลเกาะสมุย",
"icons": [
{
"src": "assets/img/logo.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "assets/img/logo.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
@@ -0,0 +1,92 @@
<?php
// models/Department.php
require_once dirname(__DIR__) . '/config/database.php';
class Department {
private $conn;
private $table_name = "departments";
public function __construct() {
$this->conn = Database::getInstance();
$this->createTableIfNotExists();
}
private function createTableIfNotExists() {
$query = "CREATE TABLE IF NOT EXISTS `" . $this->table_name . "` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`code` varchar(50) NOT NULL UNIQUE,
`name` varchar(255) NOT NULL,
`status` enum('active','inactive') DEFAULT 'active',
`building` varchar(255) DEFAULT NULL,
`floor` varchar(50) DEFAULT NULL,
`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
$this->conn->exec($query);
// Ensure status column exists for older database versions
try {
$this->conn->exec("ALTER TABLE `" . $this->table_name . "` ADD COLUMN `status` ENUM('active','inactive') DEFAULT 'active'");
} catch (PDOException $e) {
// Column already exists, ignore
}
// Add default data if empty
$stmt = $this->conn->query("SELECT COUNT(*) FROM " . $this->table_name);
if ($stmt->fetchColumn() == 0) {
$this->conn->exec("INSERT INTO `" . $this->table_name . "` (`name`) VALUES ('ER (ฉุกเฉิน)'), ('OPD1 (อายุรกรรม)'), ('IPD1 (ผู้ป่วยในชาย)'), ('X-Ray (รังสีวิทยา)'), ('OR (ห้องผ่าตัด)')");
}
}
public function getAllActive() {
$query = "SELECT * FROM " . $this->table_name . " WHERE status = 'active' ORDER BY name ASC";
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt->fetchAll();
}
public function getById($id) {
if (!$id) return null;
$query = "SELECT * FROM " . $this->table_name . " WHERE id = :id LIMIT 1";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':id', $id);
$stmt->execute();
if ($stmt->rowCount() > 0) {
return $stmt->fetch(PDO::FETCH_ASSOC);
}
return null;
}
public function getAll() {
$query = "SELECT * FROM " . $this->table_name . " ORDER BY name ASC";
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt->fetchAll();
}
public function add($name) {
$code = 'D' . strtoupper(substr(uniqid(), -5)); // Generate random code
$query = "INSERT INTO " . $this->table_name . " (code, name, status) VALUES (:code, :name, 'active')";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':code', $code);
$stmt->bindParam(':name', $name);
return $stmt->execute();
}
public function update($id, $name, $status) {
$query = "UPDATE " . $this->table_name . " SET name = :name, status = :status WHERE id = :id";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':name', $name);
$stmt->bindParam(':status', $status);
$stmt->bindParam(':id', $id);
return $stmt->execute();
}
public function delete($id) {
$query = "DELETE FROM " . $this->table_name . " WHERE id = :id";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':id', $id);
return $stmt->execute();
}
}
@@ -0,0 +1,72 @@
<?php
// models/DispatchEngine.php
require_once dirname(__DIR__) . '/config/database.php';
require_once __DIR__ . '/User.php';
require_once __DIR__ . '/Job.php';
require_once __DIR__ . '/Setting.php';
require_once __DIR__ . '/Notification.php';
class DispatchEngine {
private $conn;
private $settingModel;
private $userModel;
private $jobModel;
public function __construct() {
$this->conn = Database::getInstance();
$this->settingModel = new Setting();
$this->userModel = new User();
$this->jobModel = new Job();
}
/**
* Run auto-dispatch for a newly created job
* @param int $job_id
* @return bool True if successfully assigned, false if no one available
*/
public function runAutoDispatch($job_id) {
// 1. Check if auto dispatch is enabled
$dispatchSettings = $this->settingModel->getAllFlat();
if (!isset($dispatchSettings['auto_dispatch_enabled']) || $dispatchSettings['auto_dispatch_enabled'] != '1') {
return false;
}
// 2. Find available staff
$staff = $this->userModel->getAvailableStaff();
if (!$staff) {
// No one is available, leave job as pending
return false;
}
$staff_id = $staff['id'];
$staff_name = $staff['full_name'];
// 3. Assign the job
$query = "UPDATE jobs SET status = 'assigned', assigned_to = :staff_id WHERE id = :job_id";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':staff_id', $staff_id);
$stmt->bindParam(':job_id', $job_id);
if ($stmt->execute()) {
// Log timeline
$this->jobModel->logTimeline($job_id, 'assigned', "ระบบจ่ายงานอัตโนมัติไปยัง: " . $staff_name, null);
// Note: Notification will be sent later in the job lifecycle if needed,
// but we can also trigger a specific "Auto Assigned" notification here.
try {
$job = $this->jobModel->getById($job_id);
if ($job) {
$notification = new Notification();
$msg = "🤖 [Auto Dispatch]\nระบบมอบหมายงาน " . $job['job_number'] . "\nให้กับ: " . $staff_name;
$notification->sendToAll($msg);
}
} catch (Exception $e) {
// Ignore
}
return true;
}
return false;
}
}
@@ -0,0 +1,102 @@
<?php
// models/HospitalMap.php
require_once dirname(__DIR__) . '/config/database.php';
class HospitalMap {
private $conn;
private $table_name = "hospital_maps";
public function __construct() {
$this->conn = Database::getInstance();
}
public function getAll($status = null) {
$query = "SELECT * FROM " . $this->table_name;
if ($status) {
$query .= " WHERE status = :status";
}
$query .= " ORDER BY building ASC, floor ASC";
$stmt = $this->conn->prepare($query);
if ($status) {
$stmt->bindParam(':status', $status);
}
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function getById($id) {
$query = "SELECT * FROM " . $this->table_name . " WHERE id = :id LIMIT 1";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':id', $id);
$stmt->execute();
if ($stmt->rowCount() > 0) {
return $stmt->fetch(PDO::FETCH_ASSOC);
}
return false;
}
public function create($data) {
$query = "INSERT INTO " . $this->table_name . "
(name, building, floor, image_path, transport_center_location, status)
VALUES (:name, :building, :floor, :image_path, :transport_center_location, :status)";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':name', $data['name']);
$stmt->bindParam(':building', $data['building']);
$stmt->bindParam(':floor', $data['floor']);
$stmt->bindParam(':image_path', $data['image_path']);
$stmt->bindParam(':transport_center_location', $data['transport_center_location']);
$stmt->bindParam(':status', $data['status']);
return $stmt->execute();
}
public function update($id, $data) {
$update_fields = [];
$params = [':id' => $id];
foreach (['name', 'building', 'floor', 'transport_center_location', 'status'] as $field) {
if (isset($data[$field])) {
$update_fields[] = "{$field} = :{$field}";
$params[":{$field}"] = $data[$field];
}
}
// Update image only if provided
if (isset($data['image_path'])) {
$update_fields[] = "image_path = :image_path";
$params[":image_path"] = $data['image_path'];
}
if (empty($update_fields)) return false;
$query = "UPDATE " . $this->table_name . " SET " . implode(", ", $update_fields) . " WHERE id = :id";
$stmt = $this->conn->prepare($query);
foreach ($params as $key => &$val) {
$stmt->bindParam($key, $val);
}
return $stmt->execute();
}
public function delete($id) {
// Find image path to delete file
$map = $this->getById($id);
if ($map && !empty($map['image_path'])) {
$file_path = dirname(__DIR__) . '/' . $map['image_path'];
if (file_exists($file_path)) {
unlink($file_path);
}
}
$query = "DELETE FROM " . $this->table_name . " WHERE id = :id";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':id', $id);
return $stmt->execute();
}
}
@@ -0,0 +1,312 @@
<?php
// models/Job.php
require_once dirname(__DIR__) . '/config/database.php';
require_once __DIR__ . '/Department.php';
require_once __DIR__ . '/Notification.php';
require_once __DIR__ . '/User.php';
require_once __DIR__ . '/DispatchEngine.php';
class Job {
private $conn;
private $table_name = "jobs";
public function __construct() {
$this->conn = Database::getInstance();
}
public function create($data) {
// Generate unique job number
$job_number = "JOB-" . date('Ymd') . "-" . str_pad(rand(1, 999), 3, '0', STR_PAD_LEFT);
$query = "INSERT INTO " . $this->table_name . "
(job_number, patient_hn, patient_name, patient_image, from_department_id, to_department_id, to_department_custom, equipment_type, priority, requester_id, notes, is_scheduled, scheduled_time, infection_control, is_round_trip)
VALUES (:job_number, :patient_hn, :patient_name, :patient_image, :from_department_id, :to_department_id, :to_department_custom, :equipment_type, :priority, :requester_id, :notes, :is_scheduled, :scheduled_time, :infection_control, :is_round_trip)";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':job_number', $job_number);
$stmt->bindParam(':patient_hn', $data['patient_hn']);
$stmt->bindValue(':patient_name', $data['patient_name'], PDO::PARAM_STR);
$stmt->bindValue(':patient_image', $data['patient_image'] ?? null, PDO::PARAM_STR);
$stmt->bindParam(':from_department_id', $data['from_department_id']);
$stmt->bindValue(':to_department_id', $data['to_department_id'], PDO::PARAM_INT);
$stmt->bindValue(':to_department_custom', $data['to_department_custom'] ?? null, PDO::PARAM_STR);
$stmt->bindParam(':equipment_type', $data['equipment_type']);
$stmt->bindParam(':priority', $data['priority']);
$stmt->bindParam(':requester_id', $data['requester_id']);
$stmt->bindParam(':notes', $data['notes']);
$is_scheduled = isset($data['is_scheduled']) ? (int)$data['is_scheduled'] : 0;
$scheduled_time = !empty($data['scheduled_time']) ? $data['scheduled_time'] : null;
$infection_control = isset($data['infection_control']) ? (int)$data['infection_control'] : 0;
$is_round_trip = isset($data['is_round_trip']) ? (int)$data['is_round_trip'] : 0;
$stmt->bindParam(':is_scheduled', $is_scheduled);
$stmt->bindValue(':scheduled_time', $scheduled_time, PDO::PARAM_STR);
$stmt->bindParam(':infection_control', $infection_control);
$stmt->bindParam(':is_round_trip', $is_round_trip);
if ($stmt->execute()) {
$job_id = $this->conn->lastInsertId();
$this->logTimeline($job_id, 'pending', 'สร้างคำของานเปล', $data['requester_id']);
// Send Notification
try {
$deptModel = new Department();
$from_dept = $deptModel->getById($data['from_department_id']);
$to_dept = $deptModel->getById($data['to_department_id']);
$from_name = $from_dept ? $from_dept['name'] : 'ไม่ระบุ';
$to_name = $to_dept ? $to_dept['name'] : 'ไม่ระบุ';
$notification = new Notification();
$msg = "🚨 มีคำของานเปลใหม่ 🚨\n";
$msg .= "เลขที่: " . $job_number . "\n";
$msg .= "ผู้ป่วย: " . $data['patient_name'] . " (HN: " . $data['patient_hn'] . ")\n";
$msg .= "จุดรับ: " . $from_name . "\n";
$msg .= "จุดส่ง: " . $to_name . "\n";
$msg .= "ความฉุกเฉิน: " . $data['priority'] . "\n";
$msg .= "อุปกรณ์: " . $data['equipment_type'];
if ($infection_control == 1) {
$msg .= "\n⚠️ ผู้ป่วยแยกโรค / เสี่ยงติดเชื้อ (Infection Control)";
}
if ($is_scheduled == 1) {
$msg .= "\n⏰ เวลานัดหมาย: " . $scheduled_time;
}
if (!empty($data['notes'])) {
$msg .= "\nหมายเหตุ: " . $data['notes'];
}
$notification->sendToAll($msg);
} catch (Exception $e) {
// Silently fail if notification fails, don't break job creation
}
// Run Auto Dispatch Engine only if it's not a scheduled job in the future
if ($is_scheduled == 0) {
try {
$dispatchEngine = new DispatchEngine();
$dispatchEngine->runAutoDispatch($job_id);
} catch (Exception $e) {
// Fail silently
}
}
return $job_number;
}
return false;
}
public function getById($id) {
$query = "SELECT * FROM " . $this->table_name . " WHERE id = :id LIMIT 1";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':id', $id);
$stmt->execute();
if ($stmt->rowCount() > 0) {
return $stmt->fetch(PDO::FETCH_ASSOC);
}
return false;
}
public function getByJobNumber($job_number) {
$query = "SELECT * FROM " . $this->table_name . " WHERE job_number = :job_number LIMIT 1";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':job_number', $job_number);
$stmt->execute();
if ($stmt->rowCount() > 0) {
return $stmt->fetch();
}
return false;
}
public function logTimeline($job_id, $status, $description, $user_id) {
$query = "INSERT INTO job_timelines (job_id, status, description, created_by) VALUES (:job_id, :status, :description, :created_by)";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':job_id', $job_id);
$stmt->bindParam(':status', $status);
$stmt->bindParam(':description', $description);
$stmt->bindParam(':created_by', $user_id);
$stmt->execute();
}
public function getActiveJobByStaff($staff_id) {
$query = "SELECT j.*,
fd.name as from_department_name,
td.name as to_department_name
FROM " . $this->table_name . " j
LEFT JOIN departments fd ON j.from_department_id = fd.id
LEFT JOIN departments td ON j.to_department_id = td.id
WHERE j.assigned_to = :staff_id
AND j.status IN ('assigned', 'accepted', 'in_progress', 'waiting_for_return')
ORDER BY j.id DESC LIMIT 1";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':staff_id', $staff_id);
$stmt->execute();
if ($stmt->rowCount() > 0) {
return $stmt->fetch(PDO::FETCH_ASSOC);
}
return false;
}
public function updateStatus($job_id, $status, $user_id) {
$update_field = "";
if ($status === 'accepted') {
$update_field = ", accepted_at = NOW()";
} elseif ($status === 'in_progress') {
$update_field = ", pickup_at = NOW()";
} elseif ($status === 'completed') {
$update_field = ", completed_at = NOW()";
}
$query = "UPDATE " . $this->table_name . "
SET status = :status {$update_field}
WHERE id = :job_id";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':status', $status);
$stmt->bindParam(':job_id', $job_id);
if ($stmt->execute()) {
$description = "อัปเดตสถานะเป็น: " . $status;
$this->logTimeline($job_id, $status, $description, $user_id);
// Send Notification for key events
try {
if (in_array($status, ['accepted', 'completed'])) {
$job = $this->getById($job_id);
$job_number = $job ? $job['job_number'] : $job_id;
$userModel = new User();
$user = $userModel->getById($user_id);
$user_name = $user ? $user['full_name'] : 'ระบบ';
$notification = new Notification();
if ($status === 'accepted') {
$msg = "✅ เจ้าหน้าที่รับงานแล้ว\nงาน: " . $job_number . "\nผู้รับงาน: " . $user_name;
} else if ($status === 'completed') {
$msg = "🏁 งานเสร็จสิ้น\nงาน: " . $job_number . "\nผู้ดำเนินการ: " . $user_name;
}
$notification->sendToAll($msg);
}
} catch (Exception $e) {
// Fail silently
}
return true;
}
return false;
}
public function cancelJob($job_id, $reason, $user_id) {
$status = 'cancelled';
// Append reason to notes
$query = "UPDATE " . $this->table_name . "
SET status = :status,
notes = CONCAT(IFNULL(notes, ''), '\n[ยกเลิก: ', :reason, ']')
WHERE id = :job_id";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':status', $status);
$stmt->bindParam(':reason', $reason);
$stmt->bindParam(':job_id', $job_id);
if ($stmt->execute()) {
$description = "ยกเลิกงาน เหตุผล: " . $reason;
$this->logTimeline($job_id, $status, $description, $user_id);
// Send Notification for cancellation
try {
$job = $this->getById($job_id);
$job_number = $job ? $job['job_number'] : $job_id;
$userModel = new User();
$user = $userModel->getById($user_id);
$user_name = $user ? $user['full_name'] : 'ระบบ';
$notification = new Notification();
$msg = "❌ ปฏิเสธ/ยกเลิกงาน\nงาน: " . $job_number . "\nเหตุผล: " . $reason . "\nผู้ยกเลิก: " . $user_name;
$notification->sendToAll($msg);
} catch (Exception $e) {
// Fail silently
}
return true;
}
return false;
}
public function getJobTimelines($job_id) {
$query = "SELECT t.*, u.full_name as created_by_name
FROM job_timelines t
LEFT JOIN users u ON t.created_by = u.id
WHERE t.job_id = :job_id
ORDER BY t.created_at ASC";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':job_id', $job_id);
$stmt->execute();
return $stmt->fetchAll();
}
public function getStaffHistory($staff_id, $limit = 50) {
$query = "SELECT j.*,
fd.name as from_department_name,
td.name as to_department_name
FROM " . $this->table_name . " j
LEFT JOIN departments fd ON j.from_department_id = fd.id
LEFT JOIN departments td ON j.to_department_id = td.id
WHERE j.assigned_to = :staff_id AND j.status IN ('completed', 'cancelled')
ORDER BY j.completed_at DESC, j.created_at DESC LIMIT :limit";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':staff_id', $staff_id);
$stmt->bindValue(':limit', (int)$limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
public function getStaffPerformance($date_range = 'today') {
$date_condition = "DATE(j.created_at) = CURDATE()";
if ($date_range === 'week') {
$date_condition = "YEARWEEK(j.created_at, 1) = YEARWEEK(CURDATE(), 1)";
} elseif ($date_range === 'month') {
$date_condition = "YEAR(j.created_at) = YEAR(CURDATE()) AND MONTH(j.created_at) = MONTH(CURDATE())";
}
$query = "SELECT u.id, u.full_name,
COUNT(j.id) as total_jobs,
SUM(CASE WHEN j.status = 'completed' THEN 1 ELSE 0 END) as completed_jobs,
SUM(CASE WHEN j.status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_jobs,
AVG(TIMESTAMPDIFF(MINUTE, j.accepted_at, j.completed_at)) as avg_service_time
FROM users u
LEFT JOIN " . $this->table_name . " j ON u.id = j.assigned_to AND " . $date_condition . "
WHERE u.role = 'staff'
GROUP BY u.id, u.full_name
ORDER BY completed_jobs DESC";
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt->fetchAll();
}
public function getStaffStats($staff_id, $date_range = 'today') {
$date_condition = "DATE(created_at) = CURDATE()";
if ($date_range === 'week') {
$date_condition = "YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)";
} elseif ($date_range === 'month') {
$date_condition = "YEAR(created_at) = YEAR(CURDATE()) AND MONTH(created_at) = MONTH(CURDATE())";
}
$query = "SELECT
COUNT(id) as total_jobs,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_jobs,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_jobs,
AVG(TIMESTAMPDIFF(MINUTE, accepted_at, completed_at)) as avg_service_time
FROM " . $this->table_name . "
WHERE assigned_to = :staff_id AND " . $date_condition;
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':staff_id', $staff_id);
$stmt->execute();
return $stmt->fetch();
}
}
@@ -0,0 +1,78 @@
<?php
// models/Notification.php
require_once __DIR__ . '/Setting.php';
class Notification {
private $settingModel;
private $lineToken;
private $telegramToken;
private $telegramChatId;
public function __construct() {
$this->settingModel = new Setting();
$settings = $this->settingModel->getAllFlat();
$this->lineToken = $settings['line_notify_token'] ?? null;
$this->telegramToken = $settings['telegram_bot_token'] ?? null;
$this->telegramChatId = $settings['telegram_chat_id'] ?? null;
}
/**
* Send a simple text message to all configured platforms
*/
public function sendToAll($message) {
if (!empty($this->lineToken)) {
$this->sendToLine($message);
}
if (!empty($this->telegramToken) && !empty($this->telegramChatId)) {
$this->sendToTelegram($message);
}
}
/**
* Send message via LINE Notify
*/
private function sendToLine($message) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://notify-api.line.me/api/notify");
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(['message' => "\n" . $message]));
$headers = [
'Content-type: application/x-www-form-urlencoded',
'Authorization: Bearer ' . $this->lineToken,
];
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
/**
* Send message via Telegram Bot
*/
private function sendToTelegram($message) {
$url = "https://api.telegram.org/bot" . $this->telegramToken . "/sendMessage";
$data = [
'chat_id' => $this->telegramChatId,
'text' => $message,
'parse_mode' => 'HTML'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
}
@@ -0,0 +1,23 @@
<?php
// models/Patient.php
// Mock class to simulate HIS Integration
class Patient {
// In a real application, this would connect to the HIS Database (e.g., HOSxP)
// using a separate PDO connection in Read-Only mode.
public static function searchByHN($hn) {
// Mock data
$mock_patients = [
'123456' => ['hn' => '123456', 'name' => 'นายใจดี รักสงบ', 'age' => 45, 'gender' => 'ชาย'],
'654321' => ['hn' => '654321', 'name' => 'ด.ญ.มานี มีตา', 'age' => 8, 'gender' => 'หญิง'],
'987654' => ['hn' => '987654', 'name' => 'นายสมหมาย สบายใจ', 'age' => 60, 'gender' => 'ชาย']
];
if (isset($mock_patients[$hn])) {
return $mock_patients[$hn];
}
return null;
}
}
@@ -0,0 +1,157 @@
<?php
// models/Setting.php
require_once dirname(__DIR__) . '/config/database.php';
class Setting {
private $conn;
private $table_name = "settings";
public function __construct() {
$this->conn = Database::getInstance();
}
/**
* ดึงค่า setting ตัวเดียว โดยใช้ key
*/
public function get($key, $default = null) {
$query = "SELECT setting_value FROM " . $this->table_name . " WHERE setting_key = :key LIMIT 1";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':key', $key);
$stmt->execute();
if ($stmt->rowCount() > 0) {
$row = $stmt->fetch();
return $row['setting_value'];
}
return $default;
}
/**
* ตั้งค่า setting ตัวเดียว โดยใช้ key
*/
public function set($key, $value) {
// เช็คว่ามี key นี้อยู่หรือไม่
$check_query = "SELECT id FROM " . $this->table_name . " WHERE setting_key = :key LIMIT 1";
$check_stmt = $this->conn->prepare($check_query);
$check_stmt->bindParam(':key', $key);
$check_stmt->execute();
if ($check_stmt->rowCount() > 0) {
$query = "UPDATE " . $this->table_name . " SET setting_value = :value WHERE setting_key = :key";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':key', $key);
$stmt->bindParam(':value', $value);
} else {
// สมมติ category เป็น 'general' และ label ให้เท่ากับ key สำหรับค่าใหม่ (ถ้ามี)
$query = "INSERT INTO " . $this->table_name . " (setting_key, setting_value, category, label) VALUES (:key, :value, 'general', :label)";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':key', $key);
$stmt->bindParam(':value', $value);
$stmt->bindParam(':label', $key);
}
return $stmt->execute();
}
/**
* ดึงค่า setting ทั้งหมด โดยแบ่งตาม category
*/
public function getAllByCategory() {
$query = "SELECT * FROM " . $this->table_name . " ORDER BY category, id";
$stmt = $this->conn->prepare($query);
$stmt->execute();
$results = [];
while ($row = $stmt->fetch()) {
$cat = $row['category'];
if (!isset($results[$cat])) {
$results[$cat] = [];
}
$results[$cat][] = $row;
}
return $results;
}
/**
* ดึงค่า setting ทั้งหมดแบบ Flat Array (key => value)
*/
public function getAllFlat() {
$query = "SELECT setting_key, setting_value FROM " . $this->table_name;
$stmt = $this->conn->prepare($query);
$stmt->execute();
$results = [];
while ($row = $stmt->fetch()) {
$results[$row['setting_key']] = $row['setting_value'];
}
return $results;
}
/**
* ดึงค่า setting ทั้งหมด โดยจัดกลุ่มเป็น associative array: category => [key => value]
*/
public function getAllGrouped() {
$query = "SELECT * FROM " . $this->table_name;
$stmt = $this->conn->prepare($query);
$stmt->execute();
$results = [];
while ($row = $stmt->fetch()) {
$cat = $row['category'];
$key = $row['setting_key'];
$val = $row['setting_value'];
if (!isset($results[$cat])) {
$results[$cat] = [];
}
$results[$cat][$key] = $val;
}
return $results;
}
/**
* ดึงค่า setting เฉพาะกลุ่ม (Category) แบบ Associative Array
*/
public function getGroup($category) {
$query = "SELECT setting_key, setting_value FROM " . $this->table_name . " WHERE category = :cat";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':cat', $category);
$stmt->execute();
$results = [];
while ($row = $stmt->fetch()) {
$results[$row['setting_key']] = $row['setting_value'];
}
// If empty, let's also try to fetch from flat array just in case it was inserted into 'general'
// This makes it backwards compatible with newly inserted settings.
if (empty($results)) {
$flat = $this->getAllFlat();
return $flat;
}
return $results;
}
/**
* อัปเดตค่า setting หลายตัวพร้อมกัน
*/
public function updateBulk($settings_array) {
$query = "UPDATE " . $this->table_name . " SET setting_value = :val WHERE setting_key = :key";
$stmt = $this->conn->prepare($query);
$this->conn->beginTransaction();
try {
foreach ($settings_array as $key => $val) {
$stmt->bindValue(':val', $val);
$stmt->bindValue(':key', $key);
$stmt->execute();
}
$this->conn->commit();
return true;
} catch (Exception $e) {
$this->conn->rollBack();
return false;
}
}
}
@@ -0,0 +1,169 @@
<?php
// models/User.php
require_once dirname(__DIR__) . '/config/database.php';
class User {
private $conn;
private $table_name = "users";
public $id;
public $username;
public $password;
public $full_name;
public $role;
public $department_id;
public $status;
public function __construct() {
$this->conn = Database::getInstance();
}
public function login($username, $password) {
$query = "SELECT id, username, password, full_name, role, department_id
FROM " . $this->table_name . "
WHERE username = :username LIMIT 0,1";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':username', $username);
$stmt->execute();
if ($stmt->rowCount() > 0) {
$row = $stmt->fetch();
if (password_verify($password, $row['password'])) {
// Set object properties
$this->id = $row['id'];
$this->username = $row['username'];
$this->full_name = $row['full_name'];
$this->role = $row['role'];
$this->department_id = $row['department_id'];
// Update status to online
$this->updateStatus($this->id, 'online');
return true;
}
}
return false;
}
public function updateStatus($user_id, $status) {
$query = "UPDATE " . $this->table_name . " SET status = :status WHERE id = :id";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':status', $status);
$stmt->bindParam(':id', $user_id);
$stmt->execute();
}
public function getById($id) {
$query = "SELECT * FROM " . $this->table_name . " WHERE id = :id LIMIT 1";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':id', $id);
$stmt->execute();
if ($stmt->rowCount() > 0) {
return $stmt->fetch();
}
return false;
}
public function getAll() {
$query = "SELECT u.*, d.name as department_name
FROM " . $this->table_name . " u
LEFT JOIN departments d ON u.department_id = d.id
ORDER BY u.created_at DESC";
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt->fetchAll();
}
public function add($username, $password, $full_name, $role, $department_id = null) {
$query = "INSERT INTO " . $this->table_name . " (username, password, full_name, role, department_id, status)
VALUES (:username, :password, :full_name, :role, :department_id, 'offline')";
$hashed_password = password_hash($password, PASSWORD_DEFAULT);
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':username', $username);
$stmt->bindParam(':password', $hashed_password);
$stmt->bindParam(':full_name', $full_name);
$stmt->bindParam(':role', $role);
if ($department_id) {
$stmt->bindParam(':department_id', $department_id);
} else {
$stmt->bindValue(':department_id', null, PDO::PARAM_NULL);
}
return $stmt->execute();
}
public function update($id, $full_name, $role, $department_id = null) {
$query = "UPDATE " . $this->table_name . "
SET full_name = :full_name, role = :role, department_id = :department_id
WHERE id = :id";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':full_name', $full_name);
$stmt->bindParam(':role', $role);
$stmt->bindParam(':id', $id);
if ($department_id) {
$stmt->bindParam(':department_id', $department_id);
} else {
$stmt->bindValue(':department_id', null, PDO::PARAM_NULL);
}
return $stmt->execute();
}
public function updatePassword($id, $password) {
$query = "UPDATE " . $this->table_name . " SET password = :password WHERE id = :id";
$hashed_password = password_hash($password, PASSWORD_DEFAULT);
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':password', $hashed_password);
$stmt->bindParam(':id', $id);
return $stmt->execute();
}
public function delete($id) {
// Since we have foreign keys, we might want to just set status or delete.
// Assuming delete for now based on schema constraints (SET NULL or CASCADE might be needed)
// Schema has jobs.requester_id and jobs.assigned_to referencing users(id).
// Deleting a user with jobs will fail unless we handle it, or we can soft delete.
// For simplicity, let's hard delete, but if it fails it throws PDOException.
$query = "DELETE FROM " . $this->table_name . " WHERE id = :id";
$stmt = $this->conn->prepare($query);
$stmt->bindParam(':id', $id);
return $stmt->execute();
}
public function getAvailableStaff() {
// Find staff who are online and NOT currently handling an active job
// Order by who has been idle the longest today
$query = "
SELECT u.id, u.full_name, MAX(j.completed_at) as last_job
FROM " . $this->table_name . " u
LEFT JOIN jobs j ON u.id = j.assigned_to
AND j.status = 'completed'
AND DATE(j.created_at) = CURDATE()
WHERE u.role = 'staff'
AND u.status = 'online'
AND u.id NOT IN (
SELECT assigned_to
FROM jobs
WHERE status IN ('assigned', 'accepted', 'in_progress')
AND assigned_to IS NOT NULL
)
GROUP BY u.id
ORDER BY last_job ASC
LIMIT 1
";
$stmt = $this->conn->query($query);
if ($stmt->rowCount() > 0) {
return $stmt->fetch(PDO::FETCH_ASSOC);
}
return false;
}
}
@@ -0,0 +1,89 @@
# MASTER PROMPT
## ระบบบริหารจัดการงานเปล (Patient Transport Management System)
> โรงพยาบาลเกาะสมุย
> PHP 8.1 + MySQL + HIS Integration
## เทคโนโลยี
- PHP 8.1 (PDO)
- MySQL
- Bootstrap 5.3
- JavaScript ES6
- jQuery
- AJAX
- SweetAlert2
- DataTables
- Chart.js
- ไม่ใช้ Framework
- ไม่ใช้ Composer
## คุณสมบัติหลัก
- เชื่อมต่อฐานข้อมูล HIS แบบ Read Only
- ค้นหาผู้ป่วยจาก HN/AN/CID
- ระบบเรียกงานเปลทุกแผนก
- Workflow การรับ-ส่งผู้ป่วย
- Dashboard Real-time
- Mobile/PWA
- ระบบแบ่งเวรและมอบหมายงาน
- รายงานและ KPI
- Audit Log
- REST API
- Security (PDO, CSRF, XSS, Session)
## เพิ่มเติม (Enterprise)
### 1. วิเคราะห์ภาระงานเจ้าหน้าที่
- เก็บ Response Time, Pickup Time, Delivery Time
- วิเคราะห์จำนวนงานต่อคน/เวร
- Ranking, Heat Map
- SLA Dashboard
- AI Recommendation
### 2. แจ้งเตือน LINE และ Telegram
- ส่งเข้า Group
- ปุ่ม "รับงาน"
- ลิงก์รับงานแบบ Token
- หากมีผู้รับก่อน ให้แจ้งว่างานถูกรับแล้ว
- แจ้งเตือนทุกสถานะของงาน
- Notification Log
- Transaction/Row Lock ป้องกันรับงานซ้ำ
### 3. Smart Auto Dispatch
- มอบหมายงานอัตโนมัติจากภาระงาน ระยะทาง เวร ความเชี่ยวชาญ
- Manual / Semi Auto / Auto
- SLA Escalation
- Smart Queue
### 4. Live Location Tracking
- ติดตามตำแหน่งผ่าน PWA/GPS
- แสดงแผนที่เจ้าหน้าที่
- Route Tracking
- Heat Map
- วิเคราะห์ระยะทางและเวลา
- รองรับ Indoor Location
### 5. Patient Transport Timeline
- Timeline ทุกเหตุการณ์
- Time Analysis
- Visual Timeline
- แนบรูป/PDF
- Digital Signature
- QR Code
- Append-only Audit Trail
### 6. ระบบบริหารอุปกรณ์
- เปล
- Wheelchair
- ICU Bed
- Incubator
- Oxygen
- ประวัติการใช้งาน
- Asset Tracking
### 7. Executive Dashboard
- ภาระงานรายวัน/เดือน/ปี
- Trend
- Forecast
- KPI
- SLA
- Export PDF/Excel/CSV
@@ -0,0 +1,42 @@
<?php
require_once __DIR__ . '/config/database.php';
try {
$db = Database::getInstance();
// 1. Alter jobs table equipment_type
$db->exec("ALTER TABLE jobs MODIFY equipment_type VARCHAR(100) DEFAULT 'wheelchair'");
echo "Modified jobs.equipment_type\n";
// 2. Alter jobs table priority
$db->exec("ALTER TABLE jobs MODIFY priority VARCHAR(100) DEFAULT 'normal'");
echo "Modified jobs.priority\n";
// 3. Alter equipments table type
$db->exec("ALTER TABLE equipments MODIFY type VARCHAR(100) NOT NULL DEFAULT 'wheelchair'");
echo "Modified equipments.type\n";
// 4. Insert default settings for job_priorities and equipment_types
$priorities = [
['id' => 'normal', 'name' => 'ทั่วไป (Normal)', 'color' => 'blue'],
['id' => 'urgent', 'name' => 'ด่วน (Urgency)', 'color' => 'amber'],
['id' => 'emergency', 'name' => 'ฉุกเฉิน (Emergency)', 'color' => 'red']
];
$equipments = [
['id' => 'wheelchair', 'name' => 'รถนั่ง (Wheelchair)', 'icon' => 'fa-wheelchair'],
['id' => 'stretcher', 'name' => 'รถนอน (Stretcher)', 'icon' => 'fa-bed'],
['id' => 'bed', 'name' => 'เตียง (Bed)', 'icon' => 'fa-bed-pulse']
];
$stmt = $db->prepare("INSERT IGNORE INTO settings (setting_key, setting_value, category, label, input_type) VALUES
('job_priorities', ?, 'general', 'ระดับความฉุกเฉิน', 'textarea'),
('equipment_types', ?, 'general', 'ประเภทอุปกรณ์', 'textarea')
");
$stmt->execute([json_encode($priorities, JSON_UNESCAPED_UNICODE), json_encode($equipments, JSON_UNESCAPED_UNICODE)]);
echo "Inserted default settings\n";
echo "Done.\n";
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
}
@@ -0,0 +1,44 @@
const CACHE_NAME = 'pt-system-cache-v1';
const urlsToCache = [
'./',
'./?page=login',
'./assets/css/style.css',
'./assets/js/main.js',
'./assets/js/location.js',
'./assets/img/logo.png'
];
// Install Event
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
return cache.addAll(urlsToCache);
})
);
});
// Fetch Event - Network first, fallback to cache
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(event.request);
})
);
});
// Activate Event - Clean up old caches
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});
@@ -0,0 +1,91 @@
<?php
// views/auth/login.php
?>
<div class="min-h-[80vh] flex items-center justify-center">
<div class="glass-card w-full max-w-md p-8 relative overflow-hidden">
<!-- Decorative blob -->
<div class="absolute -top-10 -right-10 w-32 h-32 bg-emerald-500/20 rounded-full blur-2xl"></div>
<div class="absolute -bottom-10 -left-10 w-32 h-32 bg-teal-500/20 rounded-full blur-2xl"></div>
<div class="text-center mb-8 relative z-10">
<div class="inline-flex items-center justify-center w-20 h-20 bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm rounded-full mb-4 shadow-sm border border-white/20">
<img src="assets/img/logo.png" alt="Logo" class="w-12 h-12 object-contain">
</div>
<h2 class="text-2xl font-bold bg-gradient-to-r from-emerald-600 to-teal-500 bg-clip-text text-transparent">ระบบบริหารจัดการงานเปล</h2>
<p class="text-slate-500 dark:text-slate-400 mt-2">โรงพยาบาลเกาะสมุย</p>
</div>
<form id="loginForm" class="relative z-10">
<input type="hidden" name="action" value="login">
<input type="hidden" name="csrf_token" value="<?= generateCSRFToken() ?>">
<div class="mb-5">
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">ชื่อผู้ใช้งาน</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fa-solid fa-user text-slate-400"></i>
</div>
<input type="text" class="glass-input block w-full pl-10 pr-3 py-3" id="username" name="username" required placeholder="รหัสเจ้าหน้าที่">
</div>
</div>
<div class="mb-6">
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">รหัสผ่าน</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fa-solid fa-lock text-slate-400"></i>
</div>
<input type="password" class="glass-input block w-full pl-10 pr-3 py-3" id="password" name="password" required placeholder="••••••••">
</div>
</div>
<button type="submit" class="btn-primary-glass w-full py-3 text-lg font-medium flex items-center justify-center">
<span class="text">เข้าสู่ระบบ <i class="fa-solid fa-arrow-right-to-bracket ml-2"></i></span>
</button>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
$('#loginForm').submit(function(e) {
e.preventDefault();
const btn = $(this).find('button[type="submit"]');
$.ajax({
url: 'api/users.php',
type: 'POST',
data: $(this).serialize(),
success: function(response) {
if (response.status === 'success') {
Swal.fire({
icon: 'success',
title: 'เข้าสู่ระบบสำเร็จ',
text: 'กำลังพาท่านเข้าสู่ระบบ...',
timer: 1500,
showConfirmButton: false,
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b'
}).then(() => {
window.location.href = '?page=dashboard';
});
} else {
btn.removeClass('btn-loading');
Swal.fire({
icon: 'error',
title: 'เข้าสู่ระบบล้มเหลว',
text: response.message,
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b'
});
}
},
error: function() {
btn.removeClass('btn-loading');
Swal.fire('ผิดพลาด', 'ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ได้', 'error');
}
});
});
});
</script>
@@ -0,0 +1,215 @@
<?php
// views/dashboard/executive.php
?>
<div class="mb-6 flex justify-between items-end flex-wrap gap-4">
<div>
<h2 class="text-2xl font-bold text-slate-800 dark:text-white">การวิเคราะห์ระบบ (Executive Dashboard)</h2>
<p class="text-slate-500 dark:text-slate-400">ดูสถิติประสิทธิภาพและตัวชี้วัด (KPI) ของหน่วยงาน</p>
</div>
<div class="flex gap-2">
<select class="glass-input text-sm py-2 px-3" id="periodSelect" onchange="loadExecutiveData()">
<option value="today">วันนี้</option>
<option value="week">สัปดาห์นี้</option>
<option value="month">เดือนนี้</option>
</select>
<button class="btn-primary-glass py-2 px-4 text-sm font-medium flex items-center">
<i class="fa-solid fa-file-export me-2"></i> Export CSV
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="glass-card p-6 border-t-4 border-t-emerald-500">
<h6 class="text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">SLA Compliance (< 15 นาที)</h6>
<h2 class="text-4xl font-bold text-slate-800 dark:text-white" id="slaPercent">0%</h2>
<div class="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-2 mt-4 overflow-hidden">
<div class="bg-emerald-500 h-2 rounded-full" id="slaBar" style="width: 0%"></div>
</div>
</div>
<div class="glass-card p-6 border-t-4 border-t-blue-500">
<h6 class="text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">เวลารอคอยเฉลี่ย (Wait Time)</h6>
<h2 class="text-4xl font-bold text-slate-800 dark:text-white"><span id="avgWaitTime">0</span> <span class="text-xl text-slate-500">นาที</span></h2>
<p class="text-xs text-red-500 mt-2"><i class="fa-solid fa-arrow-trend-up"></i> ช้าลงจากเมื่อวาน 2 นาที</p>
</div>
<div class="glass-card p-6 border-t-4 border-t-purple-500">
<h6 class="text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">เวลาให้บริการเฉลี่ย (Service Time)</h6>
<h2 class="text-4xl font-bold text-slate-800 dark:text-white"><span id="avgServiceTime">0</span> <span class="text-xl text-slate-500">นาที</span></h2>
<p class="text-xs text-green-500 mt-2"><i class="fa-solid fa-arrow-trend-down"></i> เร็วขึ้นจากเมื่อวาน 1 นาที</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="glass-card p-6">
<h5 class="font-bold text-slate-800 dark:text-white mb-4">แนวโน้มงาน 7 วันย้อนหลัง (Trend)</h5>
<div class="relative h-72">
<canvas id="trendChart"></canvas>
</div>
</div>
<div class="glass-card p-6">
<h5 class="font-bold text-slate-800 dark:text-white mb-4">ภาระงานแยกตามแผนก (Workload by Dept)</h5>
<div class="relative h-72">
<canvas id="deptChart"></canvas>
</div>
</div>
</div>
<div class="glass-card overflow-hidden mb-6">
<div class="p-6 border-b border-slate-200 dark:border-slate-700">
<h5 class="font-bold text-slate-800 dark:text-white">สถิติรายบุคคล (Staff Performance)</h5>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-100/50 dark:bg-slate-800/50 text-slate-500 dark:text-slate-400 text-xs uppercase tracking-wider">
<th class="p-4 font-medium border-b border-slate-200 dark:border-slate-700">พนักงาน</th>
<th class="p-4 font-medium border-b border-slate-200 dark:border-slate-700 text-center">รับงานทั้งหมด</th>
<th class="p-4 font-medium border-b border-slate-200 dark:border-slate-700 text-center">สำเร็จ</th>
<th class="p-4 font-medium border-b border-slate-200 dark:border-slate-700 text-center">ยกเลิก/ปฏิเสธ</th>
<th class="p-4 font-medium border-b border-slate-200 dark:border-slate-700 text-center">เวลาบริการเฉลี่ย</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800 text-sm" id="staffPerformanceTable">
<tr>
<td colspan="5" class="p-8 text-center text-slate-500"><i class="fa-solid fa-spinner fa-spin me-2"></i>กำลังโหลด...</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
let trendChartInstance = null;
let deptChartInstance = null;
document.addEventListener('DOMContentLoaded', function() {
loadExecutiveData();
});
function loadExecutiveData() {
const period = document.getElementById('periodSelect').value;
fetch(`api/executive.php?period=${period}`)
.then(response => response.json())
.then(data => {
if (data.success) {
// Update KPI Cards
const kpi = data.kpi;
document.getElementById('slaPercent').textContent = kpi.sla_percent + '%';
document.getElementById('slaBar').style.width = kpi.sla_percent + '%';
document.getElementById('avgWaitTime').textContent = kpi.avg_wait_time;
document.getElementById('avgServiceTime').textContent = kpi.avg_service_time;
// Update Charts
initCharts(data.charts);
// Update Staff Performance Table
renderStaffPerformance(data.staff_performance);
}
})
.catch(error => console.error('Error fetching dashboard data:', error));
}
function renderStaffPerformance(staffData) {
const tbody = document.getElementById('staffPerformanceTable');
tbody.innerHTML = '';
if (!staffData || staffData.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="p-8 text-center text-slate-500">ไม่มีข้อมูลในช่วงเวลานี้</td></tr>';
return;
}
staffData.forEach(staff => {
const total = staff.total_jobs || 0;
const completed = staff.completed_jobs || 0;
const cancelled = staff.cancelled_jobs || 0;
const avgTime = staff.avg_service_time ? Math.round(staff.avg_service_time) : 0;
tbody.innerHTML += `
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td class="p-4 font-medium text-slate-800 dark:text-slate-200">
<div class="flex items-center gap-3">
<img src="api/avatar.php?name=${encodeURIComponent(staff.full_name)}" class="w-8 h-8 rounded-full border border-slate-200 dark:border-slate-700">
${staff.full_name}
</div>
</td>
<td class="p-4 text-center font-bold text-blue-600 dark:text-blue-400">${total}</td>
<td class="p-4 text-center font-bold text-emerald-600 dark:text-emerald-400">${completed}</td>
<td class="p-4 text-center font-bold text-red-500 dark:text-red-400">${cancelled}</td>
<td class="p-4 text-center font-bold text-purple-600 dark:text-purple-400">${avgTime} นาที</td>
</tr>
`;
});
}
function initCharts(chartData) {
// Determine text colors based on theme
const isDark = document.documentElement.classList.contains('dark');
const textColor = isDark ? '#cbd5e1' : '#475569';
const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
// Trend Chart
const ctxTrend = document.getElementById('trendChart').getContext('2d');
if (trendChartInstance) {
trendChartInstance.destroy();
}
let gradient = ctxTrend.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, 'rgba(16, 185, 129, 0.5)'); // emerald-500
gradient.addColorStop(1, 'rgba(16, 185, 129, 0)');
trendChartInstance = new Chart(ctxTrend, {
type: 'line',
data: {
labels: chartData.trend.labels,
datasets: [{
label: 'จำนวนงาน',
data: chartData.trend.data,
borderColor: '#10b981',
backgroundColor: gradient,
borderWidth: 2,
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: gridColor }, ticks: { color: textColor } },
x: { grid: { display: false }, ticks: { color: textColor } }
}
}
});
// Department Chart
const ctxDept = document.getElementById('deptChart').getContext('2d');
if (deptChartInstance) {
deptChartInstance.destroy();
}
deptChartInstance = new Chart(ctxDept, {
type: 'bar',
data: {
labels: chartData.departments.labels,
datasets: [{
label: 'จำนวนงาน (Job)',
data: chartData.departments.data,
backgroundColor: '#3b82f6',
borderRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: gridColor }, ticks: { color: textColor } },
x: { grid: { display: false }, ticks: { color: textColor } }
}
}
});
}
</script>
@@ -0,0 +1,339 @@
<?php
// views/dashboard/index.php
?>
<div class="mb-6">
<h2 class="text-2xl font-bold text-slate-800 dark:text-white">ภาพรวมงานเปลวันนี้</h2>
<p class="text-slate-500 dark:text-slate-400">สถานะแบบ Real-time ข้อมูลอัปเดตตลอดเวลา</p>
</div>
<!-- Stats Row -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div class="glass-card p-6 border-l-4 border-l-blue-500 hover:scale-[1.02] transition-transform">
<div class="flex justify-between items-start">
<div>
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">งานทั้งหมด (วันนี้)</p>
<h3 class="text-3xl font-bold text-slate-800 dark:text-white mt-1" id="stat-total">0</h3>
</div>
<div class="p-3 bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 rounded-lg">
<i class="fa-solid fa-list-check fa-lg"></i>
</div>
</div>
</div>
<div class="glass-card p-6 border-l-4 border-l-yellow-500 hover:scale-[1.02] transition-transform">
<div class="flex justify-between items-start">
<div>
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">รอดำเนินการ</p>
<h3 class="text-3xl font-bold text-slate-800 dark:text-white mt-1" id="stat-pending">0</h3>
</div>
<div class="p-3 bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400 rounded-lg">
<i class="fa-solid fa-clock fa-lg"></i>
</div>
</div>
</div>
<div class="glass-card p-6 border-l-4 border-l-emerald-500 hover:scale-[1.02] transition-transform">
<div class="flex justify-between items-start">
<div>
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">กำลังดำเนินการ</p>
<h3 class="text-3xl font-bold text-slate-800 dark:text-white mt-1" id="stat-progress">0</h3>
</div>
<div class="p-3 bg-emerald-100 dark:bg-emerald-900/50 text-emerald-600 dark:text-emerald-400 rounded-lg">
<i class="fa-solid fa-person-walking fa-lg"></i>
</div>
</div>
</div>
<div class="glass-card p-6 border-l-4 border-l-green-500 hover:scale-[1.02] transition-transform">
<div class="flex justify-between items-start">
<div>
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">เสร็จสิ้น</p>
<h3 class="text-3xl font-bold text-slate-800 dark:text-white mt-1" id="stat-completed">0</h3>
</div>
<div class="p-3 bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400 rounded-lg">
<i class="fa-solid fa-check-double fa-lg"></i>
</div>
</div>
</div>
</div>
<!-- Quick Actions & Table -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<div class="glass-card overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200 dark:border-slate-700 bg-white/50 dark:bg-slate-800/50 flex justify-between items-center">
<h5 class="font-bold text-slate-800 dark:text-white m-0">งานล่าสุด</h5>
<button class="text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 text-sm font-medium" onclick="loadDashboardData()"><i class="fa-solid fa-rotate-right"></i> รีเฟรช</button>
</div>
<div class="p-6 overflow-x-auto">
<table class="w-full text-left border-collapse" id="jobsTable">
<thead>
<tr class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider border-b border-slate-200 dark:border-slate-700">
<th class="pb-3 px-4">เลขที่งาน</th>
<th class="pb-3 px-4">เวลาขอ</th>
<th class="pb-3 px-4">จุดรับ -> จุดส่ง</th>
<th class="pb-3 px-4">อุปกรณ์</th>
<th class="pb-3 px-4 text-center">สถานะ</th>
</tr>
</thead>
<tbody class="text-sm text-slate-700 dark:text-slate-300 divide-y divide-slate-200 dark:divide-slate-700" id="jobsTableBody">
<tr><td colspan="5" class="text-center py-4">กำลังโหลดข้อมูล...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="lg:col-span-1">
<div class="glass-card mb-6">
<div class="px-6 py-4 border-b border-slate-200 dark:border-slate-700 bg-white/50 dark:bg-slate-800/50">
<h5 class="font-bold text-slate-800 dark:text-white m-0">ทางลัด (Quick Actions)</h5>
</div>
<div class="p-6 flex flex-col gap-3">
<a href="?page=request" class="btn-primary-glass py-3 px-4 text-center font-medium w-full block">
<i class="fa-solid fa-plus-circle me-2"></i> ร้องของานเปลใหม่
</a>
<?php if ($_SESSION['role'] !== 'ward'): ?>
<a href="?page=staff" class="btn-secondary-glass py-3 px-4 text-center font-medium w-full block">
<i class="fa-solid fa-list-check me-2"></i> ดูงานที่ต้องรับผิดชอบ
</a>
<?php endif; ?>
</div>
</div>
<div class="glass-card">
<div class="px-6 py-4 border-b border-slate-200 dark:border-slate-700 bg-white/50 dark:bg-slate-800/50">
<h5 class="font-bold text-slate-800 dark:text-white m-0">เจ้าหน้าที่ศูนย์เปล (Online)</h5>
</div>
<div class="p-4 max-h-64 overflow-y-auto">
<ul class="space-y-3" id="staffOnlineList">
<li class="text-center text-sm text-slate-500 py-2">กำลังโหลดข้อมูล...</li>
</ul>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
loadDashboardData();
setInterval(loadDashboardData, 30000); // 30s auto refresh
});
function loadDashboardData() {
fetch('api/dashboard.php')
.then(response => response.json())
.then(res => {
if (res.status === 'success') {
const data = res.data;
// Update Stats
document.getElementById('stat-total').textContent = data.stats.total;
document.getElementById('stat-pending').textContent = data.stats.pending;
document.getElementById('stat-progress').textContent = data.stats.in_progress;
document.getElementById('stat-completed').textContent = data.stats.completed;
// Update Jobs Table
const tbody = document.getElementById('jobsTableBody');
if (data.jobs.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-4">ไม่มีข้อมูลงานในวันนี้</td></tr>';
} else {
let html = '';
data.jobs.forEach(job => {
let statusBadge = '';
if (job.status === 'pending') {
statusBadge = '<div class="flex flex-col sm:flex-row gap-2 justify-center items-center"><span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-500 px-3 py-1 rounded-full text-xs font-medium border border-yellow-200 dark:border-yellow-800 whitespace-nowrap">รอดำเนินการ</span>';
// Add Accept button for pending jobs
statusBadge += `<button onclick="acceptJob('${job.job_number}')" class="text-xs bg-emerald-500 hover:bg-emerald-600 text-white px-3 py-1 rounded-full transition-colors shadow-sm whitespace-nowrap flex items-center gap-1"><i class="fa-solid fa-hand-holding-hand"></i> รับงาน</button></div>`;
} else if (job.status === 'assigned' || job.status === 'in_progress' || job.status === 'waiting_for_return') {
statusBadge = '<span class="bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full text-xs font-medium border border-blue-200 dark:border-blue-800 whitespace-nowrap">กำลังดำเนินการ</span>';
} else if (job.status === 'completed') {
statusBadge = '<div class="flex flex-col sm:flex-row gap-2 justify-center items-center"><span class="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-500 px-3 py-1 rounded-full text-xs font-medium border border-green-200 dark:border-green-800 whitespace-nowrap">เสร็จสิ้น</span>';
if (!job.rating) {
statusBadge += `<button onclick="openRatingModal(${job.id}, '${job.job_number}')" class="text-xs bg-yellow-400 hover:bg-yellow-500 text-yellow-900 px-3 py-1 rounded-full transition-colors shadow-sm whitespace-nowrap flex items-center gap-1"><i class="fa-solid fa-star"></i> ให้คะแนน</button></div>`;
} else {
statusBadge += `<span class="text-xs text-yellow-500 bg-yellow-50 dark:bg-yellow-900/20 px-2 py-1 rounded-full whitespace-nowrap"><i class="fa-solid fa-star"></i> ${job.rating}</span></div>`;
}
} else {
statusBadge = '<span class="bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-300 px-3 py-1 rounded-full text-xs font-medium border border-slate-200 dark:border-slate-600 whitespace-nowrap">ยกเลิก</span>';
}
// Format equipment
let equipmentBadge = '';
if (job.equipment_type === 'wheelchair') equipmentBadge = 'รถนั่ง';
else if (job.equipment_type === 'stretcher') equipmentBadge = 'รถนอน';
else if (job.equipment_type === 'bed') equipmentBadge = 'เตียง';
else equipmentBadge = 'เดินไปเอง';
html += `
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors border-b border-slate-100 dark:border-slate-800 last:border-0">
<td class="py-3 px-4 font-medium text-emerald-600 dark:text-emerald-400 whitespace-nowrap">${job.job_number}</td>
<td class="py-3 px-4 whitespace-nowrap text-slate-500">${new Date(job.created_at).toLocaleTimeString('th-TH', {hour: '2-digit', minute:'2-digit'})} น.</td>
<td class="py-3 px-4 text-sm"><div class="flex items-center gap-2"><span class="truncate max-w-[120px] inline-block" title="${job.from_dept}">${job.from_dept}</span> <i class="fa-solid fa-arrow-right text-slate-300 text-[10px]"></i> <span class="truncate max-w-[120px] inline-block font-medium" title="${job.to_dept}">${job.to_dept}</span></div></td>
<td class="py-3 px-4 whitespace-nowrap"><span class="bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 px-2 py-1 rounded text-xs font-medium">${equipmentBadge}</span></td>
<td class="py-3 px-4 text-center">${statusBadge}</td>
</tr>
`;
});
tbody.innerHTML = html;
}
// Update Staff Online List
const staffList = document.getElementById('staffOnlineList');
if (data.staff.length === 0) {
staffList.innerHTML = '<li class="text-center text-sm text-slate-500 py-2">ไม่มีเจ้าหน้าที่ออนไลน์</li>';
} else {
let staffHtml = '';
data.staff.forEach(user => {
let statusColor = user.status === 'online' ? 'bg-green-500' : 'bg-yellow-500';
let statusText = user.status === 'online' ? 'ว่าง' : 'ติดงาน';
staffHtml += `
<li class="flex items-center justify-between p-2 hover:bg-slate-50 dark:hover:bg-slate-800/50 rounded-lg transition-colors">
<div class="flex items-center gap-3">
<div class="relative">
<img src="api/avatar.php?username=${encodeURIComponent(user.username)}&name=${encodeURIComponent(user.full_name || user.username)}" class="w-8 h-8 rounded-full object-cover shadow-sm">
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 ${statusColor} border-2 border-white dark:border-slate-900 rounded-full"></span>
</div>
<span class="text-sm font-medium">${user.username}</span>
</div>
<span class="text-xs text-slate-500 dark:text-slate-400">${statusText}</span>
</li>
`;
});
staffList.innerHTML = staffHtml;
}
} else {
console.error('Error fetching dashboard data:', res.message);
}
})
.catch(err => {
console.error('Fetch error:', err);
});
}
// Rating Modal Functions
function openRatingModal(jobId, jobNumber) {
document.getElementById('rating_job_id').value = jobId;
document.getElementById('ratingJobNumber').textContent = jobNumber;
// Reset stars
document.querySelectorAll('.rating-star').forEach(star => {
star.classList.remove('text-yellow-400', 'fas');
star.classList.add('text-slate-300', 'far');
});
document.getElementById('rating_value').value = '0';
document.getElementById('rating_feedback').value = '';
document.getElementById('ratingModal').classList.remove('hidden');
}
function closeRatingModal() {
document.getElementById('ratingModal').classList.add('hidden');
}
function setRating(rating) {
document.getElementById('rating_value').value = rating;
document.querySelectorAll('.rating-star').forEach((star, index) => {
if (index < rating) {
star.classList.remove('text-slate-300', 'far');
star.classList.add('text-yellow-400', 'fas');
} else {
star.classList.remove('text-yellow-400', 'fas');
star.classList.add('text-slate-300', 'far');
}
});
}
function submitRating() {
const jobId = document.getElementById('rating_job_id').value;
const rating = document.getElementById('rating_value').value;
const feedback = document.getElementById('rating_feedback').value;
if (rating == '0') {
Swal.fire('กรุณาให้คะแนน', 'โปรดเลือกระดับความพึงพอใจ', 'warning');
return;
}
fetch('api/feedback.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `action=rate_job&job_id=${jobId}&rating=${rating}&feedback=${encodeURIComponent(feedback)}&csrf_token=<?= $_SESSION['csrf_token'] ?? '' ?>`
})
.then(res => res.json())
.then(res => {
if (res.status === 'success') {
Swal.fire('ขอบคุณ!', 'บันทึกคะแนนความพึงพอใจแล้ว', 'success');
closeRatingModal();
loadDashboardData();
} else {
Swal.fire('ข้อผิดพลาด', res.message, 'error');
}
})
.catch(err => {
Swal.fire('ข้อผิดพลาด', 'เกิดการเชื่อมต่อล้มเหลว', 'error');
});
}
function acceptJob(jobNumber) {
Swal.fire({
title: 'รับงานนี้?',
text: "คุณต้องการรับงานหมายเลข " + jobNumber + " ใช่หรือไม่?",
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#10b981',
cancelButtonColor: '#ef4444',
confirmButtonText: 'ใช่, รับงาน',
cancelButtonText: 'ยกเลิก'
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url: 'api/jobs.php',
type: 'POST',
data: {
action: 'assign_job',
job_id: jobNumber
},
success: function(response) {
if(response.status === 'success') {
Swal.fire('สำเร็จ', response.message, 'success').then(() => {
window.location.href = '?page=staff';
});
} else {
Swal.fire('ผิดพลาด', response.message, 'error');
}
},
error: function() {
Swal.fire('ผิดพลาด', 'เกิดข้อผิดพลาดในการเชื่อมต่อ', 'error');
}
});
}
});
}
</script>
<!-- Rating Modal -->
<div id="ratingModal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
<div class="glass-card w-full max-w-md p-6 animate-fade-in-up">
<h3 class="text-xl font-bold text-slate-800 dark:text-white mb-4">ประเมินความพึงพอใจ</h3>
<p class="text-sm text-slate-500 mb-4">หมายเลขงาน: <span id="ratingJobNumber" class="font-bold"></span></p>
<input type="hidden" id="rating_job_id" value="">
<input type="hidden" id="rating_value" value="0">
<div class="flex justify-center gap-2 mb-6">
<i class="rating-star far fa-star text-4xl text-slate-300 cursor-pointer hover:scale-110 transition-transform" onclick="setRating(1)"></i>
<i class="rating-star far fa-star text-4xl text-slate-300 cursor-pointer hover:scale-110 transition-transform" onclick="setRating(2)"></i>
<i class="rating-star far fa-star text-4xl text-slate-300 cursor-pointer hover:scale-110 transition-transform" onclick="setRating(3)"></i>
<i class="rating-star far fa-star text-4xl text-slate-300 cursor-pointer hover:scale-110 transition-transform" onclick="setRating(4)"></i>
<i class="rating-star far fa-star text-4xl text-slate-300 cursor-pointer hover:scale-110 transition-transform" onclick="setRating(5)"></i>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">ข้อเสนอแนะเพิ่มเติม (ถ้ามี)</label>
<textarea id="rating_feedback" class="glass-input w-full px-4 py-2" rows="3" placeholder="ระบุข้อเสนอแนะ..."></textarea>
</div>
<div class="flex justify-end gap-3">
<button type="button" onclick="closeRatingModal()" class="btn-secondary-glass px-4 py-2">ยกเลิก</button>
<button type="button" onclick="submitRating()" class="btn-primary-glass px-6 py-2">ส่งผลประเมิน</button>
</div>
</div>
</div>
@@ -0,0 +1,175 @@
<?php
// views/jobs/detail.php
require_once 'models/Job.php';
require_once 'models/Department.php';
$job_number = $_GET['id'] ?? '';
if (empty($job_number)) {
echo "<div class='text-center p-8 text-red-500 font-bold'>ไม่พบรหัสงาน</div>";
return;
}
$jobModel = new Job();
$job = $jobModel->getByJobNumber($job_number);
if (!$job) {
echo "<div class='text-center p-8 text-red-500 font-bold'>ไม่พบข้อมูลงาน {$job_number}</div>";
return;
}
$timelines = $jobModel->getJobTimelines($job['id']);
$statusColors = [
'pending' => 'bg-yellow-100 text-yellow-800 border-yellow-200',
'assigned' => 'bg-blue-100 text-blue-800 border-blue-200',
'accepted' => 'bg-indigo-100 text-indigo-800 border-indigo-200',
'in_progress' => 'bg-purple-100 text-purple-800 border-purple-200',
'completed' => 'bg-green-100 text-green-800 border-green-200',
'cancelled' => 'bg-red-100 text-red-800 border-red-200',
];
$statusNames = [
'pending' => 'รอดำเนินการ',
'assigned' => 'มอบหมายแล้ว',
'accepted' => 'กำลังไปรับ',
'in_progress' => 'กำลังไปส่ง',
'completed' => 'เสร็จสิ้น',
'cancelled' => 'ยกเลิก',
];
$priorityColors = [
'normal' => 'emerald',
'urgent' => 'amber',
'emergency' => 'red'
];
$priorityNames = [
'normal' => 'ทั่วไป (Normal)',
'urgent' => 'ด่วน (Urgent)',
'emergency' => 'ฉุกเฉิน (Emergency)'
];
$badgeClass = $statusColors[$job['status']] ?? 'bg-slate-100 text-slate-800';
$badgeName = $statusNames[$job['status']] ?? $job['status'];
$pColor = $priorityColors[$job['priority']] ?? 'emerald';
$pName = $priorityNames[$job['priority']] ?? $job['priority'];
?>
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-slate-800 dark:text-white flex items-center flex-wrap gap-2">
รายละเอียดงาน <span class="px-3 py-1 text-sm rounded-full border <?= $badgeClass ?>"><?= $badgeName ?></span>
</h2>
<p class="text-slate-500 dark:text-slate-400 mt-1">หมายเลขงาน: <?= $job['job_number'] ?></p>
</div>
<button onclick="window.history.back()" class="bg-white/50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 px-4 py-2 rounded-xl transition-colors shrink-0">
<i class="fa-solid fa-arrow-left me-1"></i> กลับ
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- ข้อมูลหลัก -->
<div class="md:col-span-2 space-y-6">
<div class="glass-card p-6 border-t-4 border-t-<?= $pColor ?>-500">
<h5 class="text-lg font-bold text-slate-800 dark:text-white mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">ข้อมูลผู้ป่วย</h5>
<div class="flex flex-col sm:flex-row gap-6">
<?php if (!empty($job['patient_image'])): ?>
<div class="w-full sm:w-1/3">
<img src="<?= htmlspecialchars($job['patient_image']) ?>" class="w-full h-auto rounded-lg shadow-sm border border-slate-200 object-cover" alt="รูปผู้ป่วย">
</div>
<?php endif; ?>
<div class="flex-1">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-1">ชื่อผู้ป่วย</p>
<h4 class="text-xl font-bold text-slate-800 dark:text-white mb-3">
<?= htmlspecialchars($job['patient_name'] ?: 'ไม่ระบุชื่อ (ดูรูปภาพ)') ?>
<?php if ($job['patient_hn']): ?>
<span class="text-sm font-normal text-slate-500">(HN: <?= htmlspecialchars($job['patient_hn']) ?>)</span>
<?php endif; ?>
</h4>
<div class="grid grid-cols-2 gap-4 mt-4">
<div>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-1">ความเร่งด่วน</p>
<span class="px-2 py-1 bg-<?= $pColor ?>-100 text-<?= $pColor ?>-800 rounded text-xs font-medium border border-<?= $pColor ?>-200">
<?= $pName ?>
</span>
</div>
<div>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-1">อุปกรณ์</p>
<p class="font-medium text-slate-800 dark:text-white"><?= strtoupper($job['equipment_type']) ?></p>
</div>
</div>
<?php if ($job['infection_control'] == 1): ?>
<div class="mt-4 bg-red-100 border border-red-400 text-red-700 px-3 py-2 rounded-lg flex items-center text-sm shadow-sm font-medium">
<i class="fa-solid fa-biohazard me-2 text-lg"></i> ผู้ป่วยโรคติดต่อ / แยกกักโรค
</div>
<?php endif; ?>
</div>
</div>
</div>
<div class="glass-card p-6">
<h5 class="text-lg font-bold text-slate-800 dark:text-white mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">เส้นทางการเคลื่อนย้าย</h5>
<div class="flex items-center mb-4">
<div class="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400 flex items-center justify-center font-bold text-lg z-10 shadow-sm border border-red-200 shrink-0">A</div>
<div class="ml-4 flex-1">
<p class="text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wider">รับที่ (From)</p>
<p class="font-bold text-lg text-slate-800 dark:text-white"><?= htmlspecialchars($job['from_department_name'] ?? 'ไม่ระบุ') ?></p>
</div>
</div>
<div class="relative ml-5 border-l-2 border-dashed border-slate-300 dark:border-slate-600 h-8 -my-5 z-0"></div>
<div class="flex items-center mt-4">
<div class="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400 flex items-center justify-center font-bold text-lg z-10 shadow-sm border border-green-200 shrink-0">B</div>
<div class="ml-4 flex-1">
<p class="text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wider">ส่งที่ (To)</p>
<p class="font-bold text-lg text-slate-800 dark:text-white"><?= htmlspecialchars($job['to_department_name'] ?? $job['to_department_custom'] ?? 'ไม่ระบุ') ?></p>
</div>
</div>
</div>
<?php if (!empty($job['notes'])): ?>
<div class="glass-card p-6">
<h5 class="text-lg font-bold text-slate-800 dark:text-white mb-2 border-b border-slate-200 dark:border-slate-700 pb-2">หมายเหตุ</h5>
<p class="text-slate-700 dark:text-slate-300 whitespace-pre-line"><?= htmlspecialchars($job['notes']) ?></p>
</div>
<?php endif; ?>
</div>
<!-- Timeline -->
<div class="md:col-span-1">
<div class="glass-card p-6 h-full">
<h5 class="text-lg font-bold text-slate-800 dark:text-white mb-6 border-b border-slate-200 dark:border-slate-700 pb-2">ไทม์ไลน์ (Timeline)</h5>
<div class="relative border-l border-slate-200 dark:border-slate-700 ml-3 space-y-6 pb-4">
<?php if (empty($timelines)): ?>
<p class="text-slate-500 ml-4">ไม่มีข้อมูลไทม์ไลน์</p>
<?php else: ?>
<?php foreach($timelines as $index => $tl):
$isLast = ($index === count($timelines) - 1);
$tlColor = 'slate';
$icon = 'fa-circle-dot';
if (strpos($tl['status'], 'cancelled') !== false) { $tlColor = 'red'; $icon = 'fa-times'; }
elseif (strpos($tl['status'], 'completed') !== false) { $tlColor = 'green'; $icon = 'fa-check'; }
elseif (strpos($tl['status'], 'accepted') !== false) { $tlColor = 'blue'; $icon = 'fa-user-check'; }
?>
<div class="relative pl-6">
<div class="absolute -left-3 top-1 w-6 h-6 rounded-full bg-<?= $tlColor ?>-100 text-<?= $tlColor ?>-600 flex items-center justify-center border-2 border-white dark:border-slate-800 shadow-sm">
<i class="fa-solid <?= $icon ?> text-xs"></i>
</div>
<div class="<?= $isLast ? 'opacity-100' : 'opacity-75' ?>">
<p class="text-xs text-slate-500 mb-1"><?= date('d/m/Y H:i', strtotime($tl['created_at'])) ?></p>
<p class="font-medium text-sm text-slate-800 dark:text-white"><?= htmlspecialchars($tl['description']) ?></p>
<p class="text-xs text-slate-500 mt-1">โดย: <?= htmlspecialchars($tl['created_by_name'] ?? 'ระบบ') ?></p>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,184 @@
<?php
// views/jobs/request.php
?>
<div class="max-w-3xl mx-auto">
<div class="mb-6">
<h2 class="text-2xl font-bold text-slate-800 dark:text-white">ร้องของานเปล (Request Job)</h2>
<p class="text-slate-500 dark:text-slate-400">กรอกข้อมูลผู้ป่วยและสถานที่ให้ครบถ้วน</p>
</div>
<div class="glass-card p-6 md:p-8">
<form id="requestForm">
<input type="hidden" name="action" value="create_job">
<input type="hidden" name="csrf_token" value="<?= generateCSRFToken() ?>">
<h4 class="text-lg font-semibold text-emerald-600 dark:text-emerald-400 mb-4 border-b border-emerald-100 dark:border-emerald-900 pb-2">1. ข้อมูลผู้ป่วย</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mb-6">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">HN (ถ้ามี)</label>
<input type="text" class="glass-input w-full px-4 py-2" name="hn" placeholder="เช่น 6601234">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ชื่อ-สกุลผู้ป่วย <span class="text-red-500">*</span></label>
<input type="text" class="glass-input w-full px-4 py-2" name="patient_name" required placeholder="ชื่อ นามสกุล">
</div>
</div>
<h4 class="text-lg font-semibold text-emerald-600 dark:text-emerald-400 mb-4 border-b border-emerald-100 dark:border-emerald-900 pb-2">2. เส้นทางและอุปกรณ์</h4>
<?php
require_once dirname(__DIR__, 2) . '/models/Department.php';
require_once dirname(__DIR__, 2) . '/models/Setting.php';
$deptModel = new Department();
$departments = $deptModel->getAllActive();
$settingModel = new Setting();
$equipmentsJson = $settingModel->get('equipment_types');
$equipments = $equipmentsJson ? json_decode($equipmentsJson, true) : [
['id' => 'wheelchair', 'name' => 'รถนั่ง (Wheelchair)', 'icon' => 'fa-wheelchair'],
['id' => 'stretcher', 'name' => 'รถนอน (Stretcher)', 'icon' => 'fa-bed'],
['id' => 'bed', 'name' => 'เตียง (Bed)', 'icon' => 'fa-bed-pulse']
];
$prioritiesJson = $settingModel->get('job_priorities');
$priorities = $prioritiesJson ? json_decode($prioritiesJson, true) : [
['id' => 'normal', 'name' => 'ทั่วไป (Normal)', 'color' => 'blue'],
['id' => 'urgent', 'name' => 'ด่วน (Urgency)', 'color' => 'amber'],
['id' => 'emergency', 'name' => 'ฉุกเฉิน (Emergency)', 'color' => 'red']
];
?>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mb-5">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">จุดรับผู้ป่วย <span class="text-red-500">*</span></label>
<select class="glass-input w-full px-4 py-2" name="from_department_id" required>
<option value="">-- เลือกจุดรับ --</option>
<?php foreach($departments as $dept): ?>
<option value="<?= $dept['id'] ?>"><?= escape($dept['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">จุดส่งผู้ป่วย <span class="text-red-500">*</span></label>
<select class="glass-input w-full px-4 py-2" name="to_department_id" required>
<option value="">-- เลือกจุดส่ง --</option>
<?php foreach($departments as $dept): ?>
<option value="<?= $dept['id'] ?>"><?= escape($dept['name']) ?></option>
<?php endforeach; ?>
</select>
<div class="mt-2 flex items-center bg-blue-50 dark:bg-blue-900/20 p-2 rounded-lg border border-blue-100 dark:border-blue-800">
<input type="checkbox" id="is_round_trip" name="is_round_trip" value="1" class="w-4 h-4 text-blue-600 rounded border-blue-300 focus:ring-blue-500 cursor-pointer">
<label for="is_round_trip" class="ml-2 text-sm font-medium text-blue-800 dark:text-blue-300 cursor-pointer">
<i class="fa-solid fa-rotate-left me-1"></i> ไป-กลับ (รอรับกลับทันที)
</label>
</div>
</div>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">ประเภทอุปกรณ์ <span class="text-red-500">*</span></label>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<?php foreach($equipments as $index => $eq): ?>
<label class="relative flex items-center p-3 border border-slate-200 dark:border-slate-700 rounded-xl cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
<input type="radio" name="equipment_type" value="<?= escape($eq['id']) ?>" class="w-4 h-4 text-emerald-600 border-gray-300 focus:ring-emerald-500 dark:bg-slate-700 dark:border-slate-600" <?= $index === 0 ? 'required checked' : '' ?>>
<span class="ml-3 flex flex-col">
<span class="text-sm font-medium text-slate-900 dark:text-white flex items-center gap-2">
<i class="fa-solid <?= escape($eq['icon']) ?> text-emerald-500"></i> <?= escape($eq['name']) ?>
</span>
</span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="mb-5">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ระดับความฉุกเฉิน (Priority) <span class="text-red-500">*</span></label>
<select class="glass-input w-full px-4 py-2" name="priority" required>
<?php foreach($priorities as $index => $pri): ?>
<option value="<?= escape($pri['id']) ?>" class="text-<?= escape($pri['color']) ?>-600 font-medium" <?= $index === 0 ? 'selected' : '' ?>>
<?= escape($pri['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-5 flex items-center gap-2 bg-red-50 dark:bg-red-900/20 p-4 rounded-xl border border-red-200 dark:border-red-800">
<input type="checkbox" id="infection_control" name="infection_control" value="1" class="w-5 h-5 text-red-600 rounded border-red-300 focus:ring-red-500">
<label for="infection_control" class="text-sm font-medium text-red-800 dark:text-red-300">
<i class="fa-solid fa-biohazard me-1"></i> ผู้ป่วยโรคติดต่อ / ต้องการการแยกกักโรค (Infection Control)
</label>
</div>
<div class="mb-5">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">เวลาที่ต้องการให้มารับ (Booking)</label>
<div class="flex gap-4 mb-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="is_scheduled" value="0" checked class="text-emerald-500 focus:ring-emerald-500" onchange="document.getElementById('scheduleTimeDiv').classList.add('hidden')">
<span class="text-sm dark:text-slate-300">ด่วนทันที</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="is_scheduled" value="1" class="text-emerald-500 focus:ring-emerald-500" onchange="document.getElementById('scheduleTimeDiv').classList.remove('hidden')">
<span class="text-sm dark:text-slate-300">จองเวลาล่วงหน้า</span>
</label>
</div>
<div id="scheduleTimeDiv" class="hidden">
<input type="datetime-local" class="glass-input w-full px-4 py-2" name="scheduled_time">
</div>
</div>
<div class="mb-8">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">หมายเหตุ (ถ้ามี)</label>
<textarea class="glass-input w-full px-4 py-2" name="notes" rows="2" placeholder="เช่น ผู้ป่วยให้ออกซิเจน, ระวังสายน้ำเกลือ"></textarea>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-slate-200 dark:border-slate-700">
<button type="reset" class="btn-secondary-glass py-2 px-6">
ล้างข้อมูล
</button>
<button type="submit" class="btn-primary-glass py-2 px-8 flex items-center">
<span class="text"><i class="fa-solid fa-paper-plane me-2"></i> ส่งคำขอ</span>
</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
$('#requestForm').submit(function(e) {
e.preventDefault();
const btn = $(this).find('button[type="submit"]');
const originalText = btn.html();
btn.addClass('btn-loading');
$.ajax({
url: 'api/jobs.php',
type: 'POST',
data: $(this).serialize(),
success: function(response) {
if (response.status === 'success') {
Swal.fire({
icon: 'success',
title: 'บันทึกสำเร็จ',
text: 'หมายเลขงาน: ' + response.job_number,
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b'
}).then(() => {
window.location.href = '?page=dashboard';
});
} else {
btn.removeClass('btn-loading').html(originalText);
Swal.fire('ผิดพลาด', response.message, 'error');
}
},
error: function() {
btn.removeClass('btn-loading').html(originalText);
Swal.fire('ผิดพลาด', 'เกิดข้อผิดพลาดในการเชื่อมต่อ', 'error');
}
});
});
});
</script>
@@ -0,0 +1,596 @@
<?php
// views/jobs/staff.php
?>
<div class="mb-6 flex justify-between items-center">
<div>
<h2 class="text-2xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-emerald-600 to-teal-400 dark:from-emerald-400 dark:to-teal-200">งานของฉัน</h2>
<p class="text-slate-500 dark:text-slate-400 text-sm">รายการงานเปลที่คุณได้รับมอบหมาย</p>
</div>
<button onclick="openMapModal()" class="bg-white/80 dark:bg-slate-800/80 hover:bg-white dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 shadow-sm text-slate-700 dark:text-slate-300 px-4 py-2 rounded-xl text-sm font-medium transition-all backdrop-blur-sm">
<i class="fa-solid fa-map-location-dot text-emerald-500 mr-2"></i> แผนผังตึก
</button>
</div>
<div id="jobContainer">
<div class="glass-card p-8 text-center text-slate-500 flex flex-col items-center justify-center min-h-[300px]">
<i class="fa-solid fa-spinner fa-spin text-4xl mb-4 text-emerald-500"></i>
<p>กำลังตรวจสอบงาน...</p>
</div>
</div>
<!-- Signature & QR Modal (Tailwind version) -->
<div id="signatureModal" class="fixed inset-0 z-[100] hidden">
<!-- Backdrop -->
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"></div>
<!-- Modal Content -->
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div class="relative transform overflow-hidden rounded-2xl bg-white dark:bg-slate-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-slate-200 dark:border-slate-700">
<div class="bg-emerald-600 px-4 py-3 sm:px-6 flex justify-between items-center">
<h3 class="text-base font-semibold leading-6 text-white">ยืนยันการส่งผู้ป่วย</h3>
<button type="button" class="text-white hover:text-emerald-100 close-modal">
<i class="fa-solid fa-xmark text-xl"></i>
</button>
</div>
<div class="px-4 pb-4 pt-5 sm:p-6 sm:pb-4 text-center">
<h6 class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">แสกน QR Code เพื่อดูประวัติ</h6>
<div id="qrcode" class="mb-4 flex justify-center bg-white p-2 rounded-lg inline-block mx-auto"></div>
<div class="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
<h6 class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">ลายเซ็นพยาบาลผู้รับ (Digital Signature)</h6>
<div class="bg-slate-50 dark:bg-slate-900 rounded-xl p-1 border border-slate-200 dark:border-slate-700">
<canvas id="signature-pad" class="w-full h-48 rounded-lg cursor-crosshair touch-none"></canvas>
</div>
</div>
</div>
<div class="bg-slate-50 dark:bg-slate-700/50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<button type="button" class="inline-flex w-full justify-center rounded-xl bg-emerald-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500 sm:ml-3 sm:w-auto transition-colors" id="saveSignature">
บันทึกและปิดงาน
</button>
<button type="button" class="mt-3 inline-flex w-full justify-center rounded-xl bg-white dark:bg-slate-800 px-3 py-2 text-sm font-semibold text-slate-900 dark:text-slate-200 shadow-sm ring-1 ring-inset ring-slate-300 dark:ring-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 sm:mt-0 sm:w-auto transition-colors" id="clearSignature">
ล้างลายเซ็น
</button>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.5/dist/signature_pad.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
let currentJob = null;
let signaturePad = null;
let qrGeneratedFor = null;
const modal = document.getElementById('signatureModal');
// Init Signature Pad
const canvas = document.getElementById('signature-pad');
signaturePad = new SignaturePad(canvas, {
backgroundColor: 'rgba(255, 255, 255, 0)'
});
function resizeCanvas() {
const ratio = Math.max(window.devicePixelRatio || 1, 1);
canvas.width = canvas.offsetWidth * ratio;
canvas.height = canvas.offsetHeight * ratio;
canvas.getContext("2d").scale(ratio, ratio);
signaturePad.clear();
}
window.addEventListener("resize", resizeCanvas);
document.querySelectorAll('.close-modal').forEach(btn => {
btn.addEventListener('click', () => modal.classList.add('hidden'));
});
document.getElementById('clearSignature').addEventListener('click', () => {
signaturePad.clear();
});
function fetchActiveJob() {
$.ajax({
url: 'api/jobs.php',
type: 'GET',
data: { action: 'get_active_job' },
dataType: 'json',
success: function(res) {
if (res.status === 'success' && res.data) {
currentJob = res.data;
renderJob(currentJob);
} else {
renderNoJob();
}
},
error: function() {
renderNoJob(true);
}
});
}
function renderNoJob(isError = false) {
currentJob = null;
const msg = isError ? 'เกิดข้อผิดพลาดในการดึงข้อมูล' : 'ขณะนี้คุณยังไม่มีงานที่ได้รับมอบหมาย';
const icon = isError ? 'fa-triangle-exclamation text-red-500' : 'fa-check-circle text-emerald-500';
document.getElementById('jobContainer').innerHTML = `
<div class="glass-card p-8 text-center text-slate-500 flex flex-col items-center justify-center min-h-[300px]">
<div class="w-16 h-16 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center mb-4 text-3xl">
<i class="fa-solid ${icon}"></i>
</div>
<h3 class="text-xl font-medium text-slate-800 dark:text-white mb-2">${msg}</h3>
<p class="text-sm">ระบบจะแจ้งเตือนเมื่อมีงานใหม่เข้ามา</p>
<button onclick="location.reload()" class="mt-6 btn-secondary-glass px-6 py-2">
<i class="fa-solid fa-rotate-right me-2"></i> รีเฟรช
</button>
</div>
`;
}
function renderJob(job) {
let statusBadge = '';
let btnArrivedDisabled = '';
let btnPickedUpDisabled = 'opacity-50 cursor-not-allowed" disabled';
let btnCompletedDisabled = 'opacity-50 cursor-not-allowed" disabled';
let btnArrivedClass = 'btn-secondary-glass';
let btnPickedUpClass = 'btn-secondary-glass';
let btnArrivedText = '<i class="fa-solid fa-person-walking-arrow-right me-2"></i> ถึงจุดรับผู้ป่วย';
let btnPickedUpText = '<i class="fa-solid fa-bed-pulse me-2"></i> รับผู้ป่วยขึ้นเปล';
if (job.status === 'assigned') {
statusBadge = '<span class="bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300 text-xs font-semibold px-2.5 py-0.5 rounded border border-blue-200 dark:border-blue-800">กำลังเดินทางไปรับ</span>';
} else if (job.status === 'accepted') {
statusBadge = '<span class="bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-300 text-xs font-semibold px-2.5 py-0.5 rounded border border-indigo-200 dark:border-indigo-800">ถึงจุดรับผู้ป่วยแล้ว</span>';
btnArrivedDisabled = 'opacity-50 cursor-not-allowed" disabled';
btnArrivedClass = 'bg-emerald-100 text-emerald-700 border-emerald-200';
btnArrivedText = '<i class="fa-solid fa-check me-2"></i> ถึงจุดรับแล้ว';
btnPickedUpDisabled = '';
} else if (job.status === 'in_progress') {
statusBadge = '<span class="bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300 text-xs font-semibold px-2.5 py-0.5 rounded border border-amber-200 dark:border-amber-800">กำลังเดินทางไปส่ง</span>';
btnArrivedDisabled = 'opacity-50 cursor-not-allowed" disabled';
btnArrivedClass = 'bg-emerald-100 text-emerald-700 border-emerald-200';
btnArrivedText = '<i class="fa-solid fa-check me-2"></i> ถึงจุดรับแล้ว';
btnPickedUpDisabled = 'opacity-50 cursor-not-allowed" disabled';
btnPickedUpClass = 'bg-emerald-100 text-emerald-700 border-emerald-200';
btnPickedUpText = '<i class="fa-solid fa-check me-2"></i> รับผู้ป่วยแล้ว';
btnCompletedDisabled = '';
} else if (job.status === 'waiting_for_return') {
statusBadge = '<span class="bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300 text-xs font-semibold px-2.5 py-0.5 rounded border border-blue-200 dark:border-blue-800">กำลังรอรับกลับ</span>';
btnArrivedDisabled = 'opacity-50 cursor-not-allowed" disabled';
btnArrivedClass = 'bg-emerald-100 text-emerald-700 border-emerald-200';
btnArrivedText = '<i class="fa-solid fa-check me-2"></i> ถึงจุดรับแล้ว';
btnPickedUpDisabled = 'opacity-50 cursor-not-allowed" disabled';
btnPickedUpClass = 'bg-emerald-100 text-emerald-700 border-emerald-200';
btnPickedUpText = '<i class="fa-solid fa-check me-2"></i> รับผู้ป่วยแล้ว';
btnCompletedDisabled = '';
}
let equipmentIcon = job.equipment_icon || 'fa-wheelchair';
let equipmentName = job.equipment_name || job.equipment_type.toUpperCase();
const html = `
<div class="glass-card mb-8 border-l-4 border-${job.priority_color}-500" id="activeJobCard">
<div class="px-6 py-4 border-b border-emerald-500/20 bg-emerald-50/50 dark:bg-emerald-900/20 rounded-t-2xl flex justify-between items-center">
<h5 class="font-bold text-emerald-700 dark:text-emerald-400 m-0 flex items-center">
<span class="relative flex h-3 w-3 mr-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
</span>
งานที่กำลังดำเนินการ (Active)
</h5>
<div class="flex items-center gap-2">
${job.is_round_trip == 1 ? '<span class="bg-purple-100 text-purple-800 dark:bg-purple-900/50 dark:text-purple-300 text-xs font-bold px-2 py-1 rounded-full"><i class="fa-solid fa-rotate-left me-1"></i> ไป-กลับ</span>' : ''}
<button onclick="openChatModal(${job.id}, '${job.job_number}')" class="bg-blue-100 hover:bg-blue-200 text-blue-700 p-2 rounded-full transition-colors" title="แชทกับจุดรับ">
<i class="fa-solid fa-comment-dots"></i>
</button>
${statusBadge}
</div>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="flex justify-between items-start mb-4">
<div>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-1">หมายเลขงาน</p>
<h4 class="text-xl font-bold text-slate-800 dark:text-white">${job.job_number}</h4>
</div>
<span class="bg-${job.priority_color}-100 text-${job.priority_color}-800 text-xs font-medium px-2.5 py-0.5 rounded border border-${job.priority_color}-200">${job.priority_name}</span>
</div>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-1">ชื่อผู้ป่วย / รูปถ่าย</p>
${job.patient_image ? `<div class="mb-3"><img src="${job.patient_image}" class="w-full max-w-[200px] h-auto rounded-lg border border-slate-200 shadow-sm object-cover" alt="รูปผู้เรียก"></div>` : ''}
<p class="text-lg font-medium text-slate-800 dark:text-white mb-4">${job.patient_name || 'ไม่ระบุชื่อ (ดูรูปภาพ)'} ${job.patient_hn ? '(HN: ' + job.patient_hn + ')' : ''}</p>
${job.infection_control == 1 ? '<div class="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-2 rounded-lg flex items-center shadow-lg animate-pulse"><i class="fa-solid fa-biohazard text-xl mr-2"></i><span class="font-bold">ผู้ป่วยโรคติดต่อ / แยกกักโรค โปรดสวม PPE</span></div>' : ''}
<p class="text-sm text-slate-500 dark:text-slate-400 mb-1">อุปกรณ์</p>
<p class="font-medium text-slate-800 dark:text-white"><i class="fa-solid ${equipmentIcon} text-emerald-500 me-2"></i> ${equipmentName}</p>
</div>
<div class="bg-slate-50/50 dark:bg-slate-800/50 p-4 rounded-xl border border-slate-200 dark:border-slate-700">
<h6 class="font-semibold text-slate-700 dark:text-slate-300 mb-3 border-b border-slate-200 dark:border-slate-700 pb-2">เส้นทาง (Route)</h6>
<div class="flex items-center mb-4">
<div class="w-8 h-8 rounded-full bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400 flex items-center justify-center font-bold z-10">A</div>
<div class="ml-4">
<p class="text-xs text-slate-500 dark:text-slate-400">รับที่ (From)</p>
<p class="font-bold text-slate-800 dark:text-white">${job.from_department_name}</p>
</div>
</div>
<div class="relative ml-4 border-l-2 border-dashed border-slate-300 dark:border-slate-600 h-6 -my-4 z-0"></div>
<div class="flex items-center mt-4">
<div class="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400 flex items-center justify-center font-bold z-10">B</div>
<div class="ml-4">
<p class="text-xs text-slate-500 dark:text-slate-400">ส่งที่ (To)</p>
<p class="font-bold text-slate-800 dark:text-white">${job.to_department_name || job.to_department_custom}</p>
</div>
</div>
</div>
</div>
<div class="mt-8 pt-6 border-t border-slate-200 dark:border-slate-700">
<h6 class="font-medium text-slate-700 dark:text-slate-300 mb-4">อัปเดตสถานะงาน (Actions)</h6>
<div class="flex flex-col sm:flex-row gap-3">
<button class="flex-1 py-3 px-4 font-medium ${btnArrivedClass} ${btnArrivedDisabled}" id="btnArrived" onclick="updateJobStatus('accepted')">
${btnArrivedText}
</button>
<button class="flex-1 py-3 px-4 font-medium ${btnPickedUpClass} ${btnPickedUpDisabled}" id="btnPickedUp" onclick="updateJobStatus('in_progress')">
${btnPickedUpText}
</button>
${job.is_round_trip == 1 && job.status === 'in_progress' ?
`<button class="btn-primary-glass flex-1 py-3 px-4 font-medium ${btnCompletedDisabled}" id="btnWaitReturn" onclick="updateJobStatus('waiting_for_return')">
<i class="fa-solid fa-clock me-2"></i> รอรับกลับ
</button>` :
`<button class="btn-primary-glass flex-1 py-3 px-4 font-medium ${btnCompletedDisabled}" id="btnCompleted" onclick="openSignatureModal()">
<i class="fa-solid fa-check-double me-2"></i> ${job.status === 'waiting_for_return' ? 'ส่งกลับเรียบร้อย' : 'ส่งผู้ป่วยเรียบร้อย'}
</button>`}
</div>
<div class="mt-3">
<button class="w-full py-2 px-4 font-medium bg-white/50 dark:bg-slate-800 border border-red-200 dark:border-red-900/50 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-xl transition-colors shadow-sm" onclick="promptCancelJob('${job.job_number}')">
<i class="fa-solid fa-times-circle me-1"></i> ปฏิเสธ/ยกเลิกงาน (พบปัญหาระหว่างงาน)
</button>
</div>
</div>
</div>
</div>
`;
document.getElementById('jobContainer').innerHTML = html;
}
window.updateJobStatus = function(newStatus) {
if (!currentJob) return;
$.ajax({
url: 'api/jobs.php',
type: 'POST',
data: {
action: 'update_status',
job_id: currentJob.job_number,
status: newStatus
},
dataType: 'json',
success: function(res) {
if (res.status === 'success') {
// Refetch job to update UI
fetchActiveJob();
} else {
Swal.fire('ผิดพลาด', res.message, 'error');
}
}
});
}
window.promptCancelJob = function(jobNumber) {
Swal.fire({
title: 'ปฏิเสธ/ยกเลิกงาน',
input: 'text',
inputLabel: 'โปรดระบุเหตุผลที่ยกเลิกงาน (เช่น ผู้ป่วยปฏิเสธ, หาผู้ป่วยไม่พบ)',
inputPlaceholder: 'พิมพ์เหตุผลที่นี่...',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#ef4444',
cancelButtonColor: '#94a3b8',
confirmButtonText: 'ยืนยันยกเลิก',
cancelButtonText: 'ปิด',
inputValidator: (value) => {
if (!value) {
return 'กรุณาระบุเหตุผล!';
}
}
}).then((result) => {
if (result.isConfirmed) {
Swal.showLoading();
$.ajax({
url: 'api/jobs.php',
type: 'POST',
data: {
action: 'cancel_job',
job_id: jobNumber,
reason: result.value
},
dataType: 'json',
success: function(res) {
if (res.status === 'success') {
Swal.fire({
title: 'สำเร็จ!',
text: 'ยกเลิกงานเรียบร้อยแล้ว',
icon: 'success',
timer: 1500,
showConfirmButton: false
}).then(() => {
fetchActiveJob(); // Will show no jobs UI
});
} else {
Swal.fire('ข้อผิดพลาด', res.message, 'error');
}
},
error: function() {
Swal.fire('ข้อผิดพลาด', 'ไม่สามารถติดต่อเซิร์ฟเวอร์ได้', 'error');
}
});
}
});
}
window.openSignatureModal = function() {
if (!currentJob) return;
modal.classList.remove('hidden');
resizeCanvas();
const qrContainer = document.getElementById('qrcode');
if (qrGeneratedFor !== currentJob.job_number) {
qrContainer.innerHTML = "";
new QRCode(qrContainer, {
text: window.location.origin + window.location.pathname + "?page=job_detail&id=" + currentJob.job_number,
width: 128,
height: 128,
colorDark : "#0f172a",
colorLight : "#ffffff",
});
qrGeneratedFor = currentJob.job_number;
}
}
document.getElementById('saveSignature').addEventListener('click', function() {
if (signaturePad.isEmpty()) {
Swal.fire({
title: 'แจ้งเตือน',
text: 'กรุณาเซ็นชื่อรับผู้ป่วย',
icon: 'warning',
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b'
});
return;
}
const dataURL = signaturePad.toDataURL('image/png');
const formData = new FormData();
formData.append('action', 'save_signature');
formData.append('job_id', currentJob.job_number);
formData.append('signature', dataURL);
const btn = $(this);
const originalText = btn.html();
btn.html('<i class="fa-solid fa-spinner fa-spin"></i>').prop('disabled', true);
fetch('api/jobs.php', {
method: 'POST',
body: formData
}).then(res => res.json()).then(data => {
btn.html(originalText).prop('disabled', false);
if (data.status === 'success') {
modal.classList.add('hidden');
Swal.fire({
title: 'เสร็จสิ้น',
text: 'ปิดงานและบันทึกลายเซ็นเรียบร้อย',
icon: 'success',
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b'
}).then(() => {
fetchActiveJob(); // Should return No Job
});
} else {
Swal.fire('ผิดพลาด', data.message, 'error');
}
}).catch(err => {
btn.html(originalText).prop('disabled', false);
Swal.fire('ผิดพลาด', 'เกิดข้อผิดพลาดในการเชื่อมต่อ', 'error');
});
});
// Start fetching
fetchActiveJob();
});
// Chat Functions
let chatInterval;
function openChatModal(jobId, jobNumber) {
document.getElementById('chat_job_id').value = jobId;
document.getElementById('chatJobNumber').textContent = jobNumber;
document.getElementById('chatModal').classList.remove('hidden');
loadChatMessages();
chatInterval = setInterval(loadChatMessages, 3000);
}
function closeChatModal() {
document.getElementById('chatModal').classList.add('hidden');
clearInterval(chatInterval);
}
function loadChatMessages() {
const jobId = document.getElementById('chat_job_id').value;
fetch(`api/chat.php?action=get_messages&job_id=${jobId}`)
.then(res => res.json())
.then(res => {
if (res.status === 'success') {
const chatBox = document.getElementById('chatBox');
let html = '';
res.data.forEach(msg => {
const isMe = msg.user_id == <?= $_SESSION['user_id'] ?>;
if (isMe) {
html += `
<div class="flex justify-end mb-3">
<div class="bg-emerald-500 text-white rounded-l-xl rounded-br-xl px-4 py-2 max-w-[80%] shadow-sm">
<p class="text-sm">${msg.message}</p>
<p class="text-[10px] text-emerald-100 text-right mt-1">${new Date(msg.created_at).toLocaleTimeString('th-TH', {hour: '2-digit', minute:'2-digit'})}</p>
</div>
</div>`;
} else {
html += `
<div class="flex justify-start mb-3">
<div class="bg-white dark:bg-slate-700 text-slate-800 dark:text-white rounded-r-xl rounded-bl-xl px-4 py-2 max-w-[80%] shadow-sm border border-slate-100 dark:border-slate-600">
<p class="text-xs text-slate-500 dark:text-slate-400 font-bold mb-1">${msg.full_name || 'ผู้ใช้งาน'}</p>
<p class="text-sm">${msg.message}</p>
<p class="text-[10px] text-slate-400 text-right mt-1">${new Date(msg.created_at).toLocaleTimeString('th-TH', {hour: '2-digit', minute:'2-digit'})}</p>
</div>
</div>`;
}
});
chatBox.innerHTML = html;
chatBox.scrollTop = chatBox.scrollHeight;
}
});
}
function sendChatMessage() {
const jobId = document.getElementById('chat_job_id').value;
const input = document.getElementById('chatInput');
const message = input.value.trim();
if (!message) return;
input.value = '';
fetch('api/chat.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `action=send_message&job_id=${jobId}&message=${encodeURIComponent(message)}`
}).then(() => loadChatMessages());
}
// Map Functions
let hospitalMapsData = [];
function openMapModal() {
document.getElementById('mapModal').classList.remove('hidden');
loadHospitalMaps();
}
function closeMapModal() {
document.getElementById('mapModal').classList.add('hidden');
}
function loadHospitalMaps() {
fetch('api/maps.php?action=list&status=active')
.then(res => res.json())
.then(res => {
if (res.status === 'success') {
hospitalMapsData = res.data;
const selector = document.getElementById('mapSelector');
if (hospitalMapsData.length === 0) {
selector.innerHTML = '<option value="">ไม่มีข้อมูลแผนผังในระบบ</option>';
selector.disabled = true;
showMapDetails(null);
return;
}
selector.disabled = false;
let html = '<option value="">-- เลือกอาคารและชั้น --</option>';
hospitalMapsData.forEach(map => {
html += `<option value="${map.id}">${map.name} (${map.building} ชั้น ${map.floor})</option>`;
});
selector.innerHTML = html;
// Auto select first map if available
if(hospitalMapsData.length > 0) {
selector.value = hospitalMapsData[0].id;
showMapDetails(hospitalMapsData[0].id);
}
}
});
}
function showMapDetails(mapId) {
const container = document.getElementById('mapImageContainer');
const locText = document.getElementById('mapTransportLocationText');
const locBox = document.getElementById('mapTransportLocationBox');
if (!mapId) {
container.innerHTML = '<div class="text-slate-500 py-12 text-center w-full">กรุณาเลือกแผนผังที่ต้องการดู</div>';
locBox.classList.add('hidden');
return;
}
const map = hospitalMapsData.find(m => m.id == mapId);
if (map) {
container.innerHTML = `<img src="${map.image_path}" alt="${map.name}" class="max-w-full rounded shadow-sm border border-slate-200 dark:border-slate-700 animate-fade-in">`;
if (map.transport_center_location) {
locText.textContent = map.transport_center_location;
locBox.classList.remove('hidden');
} else {
locBox.classList.add('hidden');
}
}
}
</script>
<!-- Chat Modal -->
<div id="chatModal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
<div class="bg-slate-50 dark:bg-slate-800 w-full max-w-md rounded-2xl shadow-2xl flex flex-col h-[600px] max-h-[90vh] animate-fade-in-up border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="bg-emerald-600 px-4 py-3 flex justify-between items-center text-white">
<h3 class="font-bold flex items-center">
<i class="fa-solid fa-comments me-2"></i> แชทงาน <span id="chatJobNumber" class="ml-2 bg-emerald-500 px-2 py-0.5 rounded text-sm"></span>
</h3>
<button onclick="closeChatModal()" class="text-white hover:text-emerald-200"><i class="fa-solid fa-xmark text-xl"></i></button>
</div>
<input type="hidden" id="chat_job_id">
<div id="chatBox" class="flex-1 p-4 overflow-y-auto bg-slate-100 dark:bg-slate-900/50 flex flex-col gap-1">
<!-- Messages will be loaded here -->
</div>
<div class="p-3 bg-white dark:bg-slate-800 border-t border-slate-200 dark:border-slate-700 flex gap-2">
<input type="text" id="chatInput" class="flex-1 bg-slate-100 dark:bg-slate-900 border-none rounded-full px-4 py-2 text-sm focus:ring-2 focus:ring-emerald-500 dark:text-white" placeholder="พิมพ์ข้อความ..." onkeypress="if(event.key === 'Enter') sendChatMessage()">
<button onclick="sendChatMessage()" class="bg-emerald-500 hover:bg-emerald-600 text-white w-10 h-10 rounded-full flex items-center justify-center transition-colors shadow-sm">
<i class="fa-solid fa-paper-plane text-sm"></i>
</button>
</div>
</div>
</div>
<!-- Map Modal -->
<div id="mapModal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-sm">
<div class="bg-white dark:bg-slate-800 w-full max-w-4xl rounded-2xl shadow-2xl flex flex-col max-h-[90vh] animate-fade-in-up overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200 dark:border-slate-700 flex flex-col sm:flex-row justify-between items-start sm:items-center bg-slate-50 dark:bg-slate-800/50 gap-4">
<h3 class="font-bold text-slate-800 dark:text-white flex items-center text-lg whitespace-nowrap">
<i class="fa-solid fa-map-location-dot text-emerald-500 me-2"></i> แผนผังอาคารโรงพยาบาล
</h3>
<div class="flex items-center w-full sm:w-auto gap-3">
<select id="mapSelector" class="glass-input px-4 py-2 w-full sm:w-64 bg-white dark:bg-slate-900" onchange="showMapDetails(this.value)">
<option value="">กำลังโหลด...</option>
</select>
<button onclick="closeMapModal()" class="text-slate-500 hover:text-slate-700 dark:hover:text-white bg-slate-200 dark:bg-slate-700 w-10 h-10 rounded-xl flex items-center justify-center shrink-0"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
<div id="mapTransportLocationBox" class="bg-blue-50 dark:bg-blue-900/30 border-b border-blue-100 dark:border-blue-800 px-6 py-3 hidden">
<p class="text-blue-800 dark:text-blue-300 font-medium flex items-center text-sm">
<i class="fa-solid fa-location-crosshairs text-blue-500 mr-2"></i>
<strong>จุดประจำศูนย์เปล / จุดพักคอย:&nbsp;</strong> <span id="mapTransportLocationText"></span>
</p>
</div>
<div class="p-4 flex-1 overflow-auto bg-slate-100 dark:bg-slate-900 flex justify-center items-center" id="mapImageContainer">
<!-- Map Image -->
<div class="text-slate-500 py-12 text-center w-full">กำลังโหลดข้อมูล...</div>
</div>
</div>
</div>
@@ -0,0 +1,109 @@
<?php
// views/jobs/staff_summary.php
require_once 'models/Job.php';
$jobModel = new Job();
$user_id = $_SESSION['user_id'];
$period = $_GET['period'] ?? 'today';
$stats = $jobModel->getStaffStats($user_id, $period);
$history = $jobModel->getStaffHistory($user_id, 50); // Get last 50 jobs
$total_jobs = $stats['total_jobs'] ?? 0;
$completed_jobs = $stats['completed_jobs'] ?? 0;
$cancelled_jobs = $stats['cancelled_jobs'] ?? 0;
$avg_service_time = $stats['avg_service_time'] ? round($stats['avg_service_time']) : 0;
?>
<div class="mb-6 flex justify-between items-end flex-wrap gap-4">
<div>
<h2 class="text-2xl font-bold text-slate-800 dark:text-white">สรุปผลงานของฉัน</h2>
<p class="text-slate-500 dark:text-slate-400">ประวัติการทำงานและสถิติส่วนตัว</p>
</div>
<form method="GET" class="flex gap-2">
<input type="hidden" name="page" value="staff_summary">
<select name="period" class="glass-input text-sm py-2 px-3" onchange="this.form.submit()">
<option value="today" <?= $period == 'today' ? 'selected' : '' ?>>วันนี้</option>
<option value="week" <?= $period == 'week' ? 'selected' : '' ?>>สัปดาห์นี้</option>
<option value="month" <?= $period == 'month' ? 'selected' : '' ?>>เดือนนี้</option>
</select>
</form>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6 mb-8">
<div class="glass-card p-4 md:p-6 border-l-4 border-blue-500">
<h6 class="text-xs md:text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">รับงานทั้งหมด</h6>
<h2 class="text-2xl md:text-3xl font-bold text-slate-800 dark:text-white"><?= $total_jobs ?> <span class="text-base text-slate-500 font-normal">งาน</span></h2>
</div>
<div class="glass-card p-4 md:p-6 border-l-4 border-emerald-500">
<h6 class="text-xs md:text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">สำเร็จ</h6>
<h2 class="text-2xl md:text-3xl font-bold text-emerald-600 dark:text-emerald-400"><?= $completed_jobs ?> <span class="text-base text-slate-500 font-normal">งาน</span></h2>
</div>
<div class="glass-card p-4 md:p-6 border-l-4 border-red-500">
<h6 class="text-xs md:text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">ยกเลิก/ปฏิเสธ</h6>
<h2 class="text-2xl md:text-3xl font-bold text-red-600 dark:text-red-400"><?= $cancelled_jobs ?> <span class="text-base text-slate-500 font-normal">งาน</span></h2>
</div>
<div class="glass-card p-4 md:p-6 border-l-4 border-purple-500">
<h6 class="text-xs md:text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">เวลาบริการเฉลี่ย</h6>
<h2 class="text-2xl md:text-3xl font-bold text-purple-600 dark:text-purple-400"><?= $avg_service_time ?> <span class="text-base text-slate-500 font-normal">นาที</span></h2>
</div>
</div>
<div class="glass-card overflow-hidden">
<div class="p-4 md:p-6 border-b border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/50 flex justify-between items-center">
<h3 class="font-bold text-slate-800 dark:text-white">ประวัติงานล่าสุด (50 รายการ)</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-100/50 dark:bg-slate-800/50 text-slate-500 dark:text-slate-400 text-xs uppercase tracking-wider">
<th class="p-4 font-medium border-b border-slate-200 dark:border-slate-700">เลขที่งาน</th>
<th class="p-4 font-medium border-b border-slate-200 dark:border-slate-700">วันที่-เวลา</th>
<th class="p-4 font-medium border-b border-slate-200 dark:border-slate-700 hidden md:table-cell">ผู้ป่วย</th>
<th class="p-4 font-medium border-b border-slate-200 dark:border-slate-700 hidden sm:table-cell">เส้นทาง</th>
<th class="p-4 font-medium border-b border-slate-200 dark:border-slate-700 text-center">สถานะ</th>
<th class="p-4 font-medium border-b border-slate-200 dark:border-slate-700 text-center">จัดการ</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800 text-sm">
<?php if(empty($history)): ?>
<tr>
<td colspan="6" class="p-8 text-center text-slate-500">ยังไม่มีประวัติการรับงาน</td>
</tr>
<?php else: ?>
<?php foreach($history as $job): ?>
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors group">
<td class="p-4 font-medium text-slate-800 dark:text-slate-200 whitespace-nowrap"><?= $job['job_number'] ?></td>
<td class="p-4 text-slate-600 dark:text-slate-400 whitespace-nowrap">
<?= date('d/m/Y', strtotime($job['created_at'])) ?><br>
<span class="text-xs text-slate-400"><?= date('H:i', strtotime($job['created_at'])) ?> น.</span>
</td>
<td class="p-4 text-slate-800 dark:text-slate-200 hidden md:table-cell">
<?= htmlspecialchars($job['patient_name'] ?: 'ไม่ระบุชื่อ') ?>
</td>
<td class="p-4 hidden sm:table-cell text-xs">
<div class="flex items-center gap-1 text-slate-500 dark:text-slate-400 max-w-[200px]">
<span class="truncate"><?= htmlspecialchars($job['from_department_name']) ?></span>
<i class="fa-solid fa-arrow-right text-[10px]"></i>
<span class="truncate"><?= htmlspecialchars($job['to_department_name'] ?? $job['to_department_custom']) ?></span>
</div>
</td>
<td class="p-4 text-center">
<?php if($job['status'] === 'completed'): ?>
<span class="bg-green-100 text-green-800 border-green-200 border text-xs font-medium px-2.5 py-0.5 rounded-full whitespace-nowrap"><i class="fa-solid fa-check me-1"></i>สำเร็จ</span>
<?php elseif($job['status'] === 'cancelled'): ?>
<span class="bg-red-100 text-red-800 border-red-200 border text-xs font-medium px-2.5 py-0.5 rounded-full whitespace-nowrap"><i class="fa-solid fa-times me-1"></i>ยกเลิก</span>
<?php endif; ?>
</td>
<td class="p-4 text-center">
<a href="?page=job_detail&id=<?= $job['job_number'] ?>" class="inline-block px-3 py-1.5 bg-blue-50 text-blue-600 hover:bg-blue-100 rounded-lg text-xs font-medium transition-colors">
<i class="fa-solid fa-file-lines me-1"></i> รายละเอียด
</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,289 @@
<?php
// views/public/request.php
require_once dirname(__DIR__, 2) . '/models/Department.php';
require_once dirname(__DIR__, 2) . '/models/Setting.php';
$deptModel = new Department();
$departments = $deptModel->getAllActive();
$settingModel = new Setting();
$equipmentsJson = $settingModel->get('equipment_types');
$equipments = $equipmentsJson ? json_decode($equipmentsJson, true) : [
['id' => 'wheelchair', 'name' => 'รถนั่ง (Wheelchair)', 'icon' => 'fa-wheelchair'],
['id' => 'stretcher', 'name' => 'รถนอน (Stretcher)', 'icon' => 'fa-bed'],
['id' => 'bed', 'name' => 'เตียง (Bed)', 'icon' => 'fa-bed-pulse']
];
$prioritiesJson = $settingModel->get('job_priorities');
$priorities = $prioritiesJson ? json_decode($prioritiesJson, true) : [
['id' => 'normal', 'name' => 'ทั่วไป (Normal)', 'color' => 'blue'],
['id' => 'urgent', 'name' => 'ด่วน (Urgency)', 'color' => 'amber'],
['id' => 'emergency', 'name' => 'ฉุกเฉิน (Emergency)', 'color' => 'red']
];
// Get fixed origin from URL
$fixed_dept_id = isset($_GET['dept_id']) ? (int)$_GET['dept_id'] : 0;
$fixed_dept = null;
if ($fixed_dept_id > 0) {
foreach ($departments as $dept) {
if ($dept['id'] == $fixed_dept_id) {
$fixed_dept = $dept;
break;
}
}
}
?>
<div class="max-w-3xl mx-auto py-8">
<div class="mb-6 text-center">
<div class="w-16 h-16 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center text-3xl mx-auto mb-4">
<i class="fa-solid fa-qrcode"></i>
</div>
<h2 class="text-2xl font-bold text-slate-800 dark:text-white mb-2">เรียกรถเข็น / เวรเปลด่วน</h2>
<?php if ($fixed_dept): ?>
<p class="text-lg text-emerald-600 dark:text-emerald-400 font-medium">📍 จุดรับผู้ป่วย: <?= escape($fixed_dept['name']) ?></p>
<?php else: ?>
<p class="text-red-500 font-medium">⚠️ ไม่พบจุดรับผู้ป่วย กรุณาสแกน QR Code ใหม่อีกครั้ง</p>
<?php endif; ?>
</div>
<?php if ($fixed_dept): ?>
<div class="glass-card p-6 md:p-8">
<form id="publicRequestForm">
<input type="hidden" name="action" value="create_public_job">
<input type="hidden" name="csrf_token" value="<?= generateCSRFToken() ?>">
<input type="hidden" name="from_department_id" value="<?= $fixed_dept['id'] ?>">
<h4 class="text-lg font-semibold text-emerald-600 dark:text-emerald-400 mb-4 border-b border-emerald-100 dark:border-emerald-900 pb-2">1. ข้อมูลผู้ป่วย</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mb-6">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">HN (ถ้าทราบ)</label>
<input type="text" class="glass-input w-full px-4 py-3" name="patient_hn" placeholder="ระบุ HN">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ชื่อ-สกุลผู้ป่วย</label>
<input type="text" class="glass-input w-full px-4 py-3" name="patient_name" id="patient_name" placeholder="ชื่อ นามสกุล">
</div>
<div class="col-span-1 md:col-span-2">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">หรือถ่ายรูปเพื่อความรวดเร็วในการระบุตัวตน</label>
<input type="file" name="patient_image" id="patient_image" accept="image/*" capture="environment" class="glass-input w-full px-4 py-2 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-emerald-50 file:text-emerald-700 hover:file:bg-emerald-100">
<p class="text-xs text-slate-500 mt-1">* กรุณากรอกชื่อ หรือ ถ่ายรูปอย่างใดอย่างหนึ่ง</p>
</div>
</div>
<h4 class="text-lg font-semibold text-emerald-600 dark:text-emerald-400 mb-4 border-b border-emerald-100 dark:border-emerald-900 pb-2">2. ปลายทางและอุปกรณ์</h4>
<div class="mb-5">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">จุดส่งผู้ป่วย <span class="text-red-500">*</span></label>
<select class="glass-input w-full px-4 py-3 text-lg" name="to_department_id" id="to_department_id">
<option value="">-- เลือกจุดส่ง (ปลายทาง) --</option>
<?php foreach($departments as $dept): ?>
<?php if($dept['id'] != $fixed_dept['id']): ?>
<option value="<?= $dept['id'] ?>"><?= escape($dept['name']) ?></option>
<?php endif; ?>
<?php endforeach; ?>
<option value="custom">อื่นๆ (พิมพ์ระบุเอง)</option>
</select>
<div id="custom_to_department_div" class="hidden mt-3">
<input type="text" name="to_department_custom" id="to_department_custom" class="glass-input w-full px-4 py-3" placeholder="โปรดระบุจุดปลายทาง...">
</div>
<div class="mt-3 flex items-center bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-100 dark:border-blue-800">
<input type="checkbox" id="is_round_trip" name="is_round_trip" value="1" class="w-5 h-5 text-blue-600 rounded border-blue-300 focus:ring-blue-500 cursor-pointer">
<label for="is_round_trip" class="ml-3 text-base font-medium text-blue-800 dark:text-blue-300 cursor-pointer">
<i class="fa-solid fa-rotate-left me-1"></i> ไป-กลับ (รอรับกลับทันที)
</label>
</div>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">ประเภทอุปกรณ์ <span class="text-red-500">*</span></label>
<div class="grid grid-cols-1 gap-3">
<?php foreach($equipments as $index => $eq): ?>
<label class="relative flex items-center p-4 border border-slate-200 dark:border-slate-700 rounded-xl cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
<input type="radio" name="equipment_type" value="<?= escape($eq['id']) ?>" class="w-5 h-5 text-emerald-600 border-gray-300 focus:ring-emerald-500 dark:bg-slate-700 dark:border-slate-600" <?= $index === 0 ? 'required checked' : '' ?>>
<span class="ml-3 flex flex-col">
<span class="text-base font-medium text-slate-900 dark:text-white flex items-center gap-2">
<i class="fa-solid <?= escape($eq['icon']) ?> text-emerald-500 text-xl w-6"></i> <?= escape($eq['name']) ?>
</span>
</span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="mb-5">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ระดับความฉุกเฉิน</label>
<select class="glass-input w-full px-4 py-3" name="priority" required>
<?php foreach($priorities as $index => $pri): ?>
<option value="<?= escape($pri['id']) ?>" class="text-<?= escape($pri['color']) ?>-600 font-medium" <?= $index === 0 ? 'selected' : '' ?>>
<?= escape($pri['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-5 flex items-center gap-2 bg-red-50 dark:bg-red-900/20 p-4 rounded-xl border border-red-200 dark:border-red-800">
<input type="checkbox" id="infection_control" name="infection_control" value="1" class="w-5 h-5 text-red-600 rounded border-red-300 focus:ring-red-500">
<label for="infection_control" class="text-base font-medium text-red-800 dark:text-red-300">
<i class="fa-solid fa-biohazard me-1"></i> ผู้ป่วยโรคติดต่อ / แยกกักโรค
</label>
</div>
<div class="mb-8">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">หมายเหตุ (ถ้ามี)</label>
<textarea class="glass-input w-full px-4 py-3" name="notes" rows="2" placeholder="เช่น ผู้ป่วยให้ออกซิเจน, ระวังสายน้ำเกลือ"></textarea>
</div>
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
<button type="submit" class="btn-primary-glass w-full py-4 text-lg font-bold flex items-center justify-center">
<span class="text"><i class="fa-solid fa-paper-plane me-2"></i> ยืนยันการเรียกเวรเปล</span>
</button>
</div>
</form>
</div>
<?php endif; ?>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
$('#to_department_id').change(function() {
if ($(this).val() === 'custom') {
$('#custom_to_department_div').removeClass('hidden');
$('#to_department_custom').prop('required', true);
} else {
$('#custom_to_department_div').addClass('hidden');
$('#to_department_custom').prop('required', false).val('');
}
});
$('#publicRequestForm').submit(function(e) {
e.preventDefault();
// Validate at least one of name or image
const patientName = $('#patient_name').val().trim();
const fileInput = document.getElementById('patient_image');
const file = fileInput.files[0];
if (!patientName && !file) {
Swal.fire('ข้อมูลไม่ครบถ้วน', 'กรุณากรอกชื่อ-สกุลผู้ป่วย หรือถ่ายรูปอย่างใดอย่างหนึ่ง', 'warning');
return;
}
const toDept = $('#to_department_id').val();
if (!toDept) {
Swal.fire('ข้อมูลไม่ครบถ้วน', 'กรุณาเลือกหรือระบุจุดส่งปลายทาง', 'warning');
return;
}
const btn = $(this).find('button[type="submit"]');
const originalText = btn.html();
btn.addClass('btn-loading');
// Use FormData for file upload
const formData = new FormData(this);
// Client-side Image Compression function
const compressImage = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = event => {
const img = new Image();
img.src = event.target.result;
img.onload = () => {
const canvas = document.createElement('canvas');
const MAX_WIDTH = 800;
const MAX_HEIGHT = 800;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > MAX_WIDTH) {
height *= MAX_WIDTH / width;
width = MAX_WIDTH;
}
} else {
if (height > MAX_HEIGHT) {
width *= MAX_HEIGHT / height;
height = MAX_HEIGHT;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
// Handle rotation if needed (simplified) and draw image
// Draw white background for transparent images
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if(blob) {
resolve(new File([blob], 'compressed.jpg', { type: 'image/jpeg' }));
} else {
reject(new Error('Canvas to Blob failed'));
}
}, 'image/jpeg', 0.7); // 70% quality
};
img.onerror = error => reject(error);
};
reader.onerror = error => reject(error);
});
};
const submitForm = () => {
$.ajax({
url: 'api/jobs.php',
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function(response) {
if (response.status === 'success') {
Swal.fire({
icon: 'success',
title: 'เรียกเวรเปลสำเร็จ',
text: 'ระบบได้รับข้อมูลของคุณแล้ว หมายเลขงาน: ' + response.job_number,
confirmButtonText: 'ตกลง',
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b'
}).then(() => {
window.location.reload();
});
} else {
btn.removeClass('btn-loading').html(originalText);
Swal.fire('ผิดพลาด', response.message, 'error');
}
},
error: function(xhr) {
btn.removeClass('btn-loading').html(originalText);
let errorMsg = 'เกิดข้อผิดพลาดในการเชื่อมต่อ';
try {
let res = JSON.parse(xhr.responseText);
if (res.message) errorMsg = res.message;
} catch(e) {}
Swal.fire('ผิดพลาด', errorMsg, 'error');
}
});
};
if (file) {
compressImage(file).then(compressedFile => {
formData.set('patient_image', compressedFile);
submitForm();
}).catch(err => {
console.error('Image compression failed', err);
submitForm(); // proceed without compression if failed
});
} else {
submitForm();
}
});
});
</script>
@@ -0,0 +1,898 @@
<?php
// views/settings/index.php
require_once dirname(__DIR__, 2) . '/models/Setting.php';
require_once dirname(__DIR__, 2) . '/models/Department.php';
$settingModel = new Setting();
$deptModel = new Department();
$flatSettings = $settingModel->getAllFlat();
// Handle Department Actions
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'department') {
if (ob_get_length()) ob_clean();
header('Content-Type: application/json');
try {
if ($_POST['sub_action'] === 'add') {
$deptModel->add($_POST['name']);
echo json_encode(['status' => 'success', 'message' => 'เพิ่มจุดรับ-ส่งเรียบร้อยแล้ว']);
} elseif ($_POST['sub_action'] === 'edit') {
$deptModel->update($_POST['id'], $_POST['name'], $_POST['status']);
echo json_encode(['status' => 'success', 'message' => 'แก้ไขจุดรับ-ส่งเรียบร้อยแล้ว']);
} elseif ($_POST['sub_action'] === 'delete') {
$deptModel->delete($_POST['id']);
echo json_encode(['status' => 'success', 'message' => 'ลบจุดรับ-ส่งเรียบร้อยแล้ว']);
}
} catch (Exception $e) {
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'save_settings') {
if (ob_get_length()) ob_clean(); // Clear any HTML output buffered by index.php
header('Content-Type: application/json');
try {
if (isset($_POST['settings']) && is_array($_POST['settings'])) {
foreach ($_POST['settings'] as $key => $value) {
$settingModel->set($key, $value);
}
}
echo json_encode(['status' => 'success', 'message' => 'บันทึกการตั้งค่าเรียบร้อยแล้ว']);
} catch (Exception $e) {
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
exit;
}
?>
<div class="mb-8">
<h2 class="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-emerald-600 to-teal-400 dark:from-emerald-400 dark:to-teal-200 mb-2">ตั้งค่าระบบ (System Settings)</h2>
<p class="text-slate-500 dark:text-slate-400 text-lg">จัดการข้อมูลหน่วยงาน การแจ้งเตือน และการจ่ายงาน</p>
</div>
<?php
// Prepare SweetAlert message if saved
$swal_msg = '';
if (isset($_GET['saved'])) {
if ($_GET['saved'] === 'dept_add') $swal_msg = 'เพิ่มจุดรับ-ส่งเรียบร้อยแล้ว';
elseif ($_GET['saved'] === 'dept_edit') $swal_msg = 'แก้ไขจุดรับ-ส่งเรียบร้อยแล้ว';
elseif ($_GET['saved'] === 'dept_delete') $swal_msg = 'ลบจุดรับ-ส่งเรียบร้อยแล้ว';
else $swal_msg = 'บันทึกการตั้งค่าเรียบร้อยแล้ว';
}
?>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 pb-10">
<!-- Sidebar Navigation -->
<div class="lg:col-span-1">
<div class="glass-card sticky top-24 overflow-hidden border border-white/40 dark:border-slate-700/50 shadow-xl shadow-emerald-900/5 rounded-2xl">
<nav class="flex flex-col text-sm font-medium p-2 space-y-1" id="settingsTabs">
<a href="#tab-general" class="tab-link px-4 py-3 rounded-xl text-emerald-700 dark:text-emerald-300 bg-emerald-100/50 dark:bg-emerald-900/30 font-bold transition-all flex items-center group">
<div class="w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-800/50 flex items-center justify-center mr-3 group-hover:scale-110 transition-transform">
<i class="fa-solid fa-hospital text-emerald-600 dark:text-emerald-400"></i>
</div>
ข้อมูลหน่วยงาน
</a>
<a href="#tab-line" class="tab-link px-4 py-3 rounded-xl text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:text-slate-900 dark:hover:text-slate-200 transition-all flex items-center group">
<div class="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center mr-3 group-hover:scale-110 transition-transform">
<i class="fa-brands fa-line text-green-500"></i>
</div>
LINE Notify / Bot
</a>
<a href="#tab-telegram" class="tab-link px-4 py-3 rounded-xl text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:text-slate-900 dark:hover:text-slate-200 transition-all flex items-center group">
<div class="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center mr-3 group-hover:scale-110 transition-transform">
<i class="fa-brands fa-telegram text-blue-500"></i>
</div>
Telegram Bot
</a>
<a href="#tab-dispatch" class="tab-link px-4 py-3 rounded-xl text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:text-slate-900 dark:hover:text-slate-200 transition-all flex items-center group">
<div class="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center mr-3 group-hover:scale-110 transition-transform">
<i class="fa-solid fa-robot text-purple-500"></i>
</div>
ระบบจ่ายงานอัตโนมัติ
</a>
<a href="#tab-equipments" class="tab-link px-4 py-3 rounded-xl text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:text-slate-900 dark:hover:text-slate-200 transition-all flex items-center group">
<div class="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center mr-3 group-hover:scale-110 transition-transform">
<i class="fa-solid fa-wheelchair text-emerald-500"></i>
</div>
ประเภทอุปกรณ์
</a>
<a href="#tab-priorities" class="tab-link px-4 py-3 rounded-xl text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:text-slate-900 dark:hover:text-slate-200 transition-all flex items-center group">
<div class="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center mr-3 group-hover:scale-110 transition-transform">
<i class="fa-solid fa-triangle-exclamation text-red-500"></i>
</div>
ระดับความฉุกเฉิน
</a>
<a href="#tab-points" class="tab-link px-4 py-3 rounded-xl text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:text-slate-900 dark:hover:text-slate-200 transition-all flex items-center group">
<div class="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center mr-3 group-hover:scale-110 transition-transform">
<i class="fa-solid fa-map-location-dot text-rose-500"></i>
</div>
จุดรับ-ส่ง (จุดบริการ)
</a>
<a href="#tab-maps" class="tab-link px-4 py-3 rounded-xl text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:text-slate-900 dark:hover:text-slate-200 transition-all flex items-center group">
<div class="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center mr-3 group-hover:scale-110 transition-transform">
<i class="fa-solid fa-map text-amber-500"></i>
</div>
แผนผังอาคาร (Floor Plans)
</a>
</nav>
</div>
</div>
<!-- Settings Content -->
<div class="lg:col-span-3 relative min-h-[500px]">
<form id="mainSettingsForm">
<input type="hidden" name="action" value="save_settings">
<input type="hidden" name="csrf_token" value="<?= generateCSRFToken() ?>">
<!-- General Settings -->
<div id="tab-general" class="glass-card p-6 tab-content">
<h3 class="text-lg font-bold text-slate-800 dark:text-white mb-4 flex items-center">
<i class="fa-solid fa-hospital text-emerald-500 mr-2"></i> ข้อมูลหน่วยงาน
</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ชื่อโรงพยาบาล</label>
<input type="text" class="glass-input w-full px-4 py-2" name="settings[hospital_name]" value="<?= escape($settings['general']['hospital_name'] ?? 'โรงพยาบาลเกาะสมุย') ?>">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ชื่อหน่วยงาน</label>
<input type="text" class="glass-input w-full px-4 py-2" name="settings[department_name]" value="<?= escape($settings['general']['department_name'] ?? 'ศูนย์เปล (Patient Transport)') ?>">
</div>
</div>
</div>
<!-- LINE Settings -->
<div id="tab-line" class="glass-card p-6 tab-content hidden">
<h3 class="text-lg font-bold text-slate-800 dark:text-white mb-4 flex items-center">
<i class="fa-brands fa-line text-green-500 mr-2"></i> LINE Notify / Bot
</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">LINE Notify Token</label>
<div class="relative">
<input type="password" class="glass-input w-full px-4 py-2 pr-10" name="settings[line_notify_token]" value="<?= escape($flatSettings['line_notify_token'] ?? '') ?>">
<button type="button" class="absolute inset-y-0 right-0 px-3 flex items-center text-slate-400 hover:text-slate-600 toggle-password">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">LINE Group ID</label>
<input type="text" class="glass-input w-full px-4 py-2" name="settings[line_group_id]" value="<?= escape($flatSettings['line_group_id'] ?? '') ?>">
</div>
</div>
</div>
<!-- Telegram Settings -->
<div id="tab-telegram" class="glass-card p-6 tab-content hidden">
<h3 class="text-lg font-bold text-slate-800 dark:text-white mb-4 flex items-center">
<i class="fa-brands fa-telegram text-blue-500 mr-2"></i> Telegram Bot
</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Telegram Bot Token</label>
<div class="relative">
<input type="password" class="glass-input w-full px-4 py-2 pr-10" name="settings[telegram_bot_token]" value="<?= escape($flatSettings['telegram_bot_token'] ?? '') ?>">
<button type="button" class="absolute inset-y-0 right-0 px-3 flex items-center text-slate-400 hover:text-slate-600 toggle-password">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Telegram Chat ID</label>
<input type="text" class="glass-input w-full px-4 py-2" name="settings[telegram_chat_id]" value="<?= escape($flatSettings['telegram_chat_id'] ?? '') ?>">
</div>
</div>
</div>
<!-- Dispatch Settings -->
<div id="tab-dispatch" class="glass-card p-6 tab-content hidden">
<h3 class="text-lg font-bold text-slate-800 dark:text-white mb-4 flex items-center">
<i class="fa-solid fa-robot text-purple-500 mr-2"></i> ระบบจ่ายงานอัตโนมัติ
</h3>
<div class="space-y-4">
<div class="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl border border-slate-200 dark:border-slate-700">
<div>
<p class="font-medium text-slate-800 dark:text-white">เปิดใช้งาน Smart Auto Dispatch</p>
<p class="text-sm text-slate-500">ระบบจะค้นหาและจ่ายงานให้เจ้าหน้าที่ที่ว่างและอยู่ใกล้ที่สุดอัตโนมัติ</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="hidden" name="settings[auto_dispatch_enabled]" value="0">
<input type="checkbox" name="settings[auto_dispatch_enabled]" value="1" class="sr-only peer" <?= (isset($flatSettings['auto_dispatch_enabled']) && $flatSettings['auto_dispatch_enabled'] == '1') ? 'checked' : '' ?>>
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-emerald-500"></div>
</label>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">เวลามาตรฐาน SLA สำหรับงานส่งผู้ป่วย (นาที)</label>
<input type="number" class="glass-input w-full px-4 py-2" name="settings[sla_minutes_default]" value="<?= escape($flatSettings['sla_minutes_default'] ?? '15') ?>">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">น้ำหนักระยะทาง (กม.) ในการพิจารณา</label>
<p class="text-xs text-slate-500 mb-2">ระยะทางสูงสุดที่จะนำมาคำนวณการจ่ายงานอัตโนมัติ</p>
<input type="number" step="0.1" class="glass-input w-full px-4 py-2" name="settings[dispatch_max_distance]" value="<?= escape($flatSettings['dispatch_max_distance'] ?? '2.0') ?>">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ความสำคัญของระยะทาง (คะแนน)</label>
<p class="text-xs text-slate-500 mb-2">คะแนนเพิ่มสำหรับเจ้าหน้าที่ที่อยู่ใกล้ที่สุด (0-100)</p>
<input type="number" class="glass-input w-full px-4 py-2" name="settings[dispatch_distance_weight]" value="<?= escape($flatSettings['dispatch_distance_weight'] ?? '50') ?>">
</div>
</div>
</div>
<!-- Equipments Settings -->
<div id="tab-equipments" class="glass-card p-6 tab-content hidden">
<h3 class="text-lg font-bold text-slate-800 dark:text-white mb-4 flex items-center justify-between">
<span><i class="fa-solid fa-wheelchair text-emerald-500 mr-2"></i> ประเภทอุปกรณ์</span>
<button type="button" class="btn-secondary-glass py-1 px-3 text-sm" onclick="addEquipmentItem()">
<i class="fa-solid fa-plus me-1"></i> เพิ่มอุปกรณ์
</button>
</h3>
<div class="space-y-4">
<p class="text-sm text-slate-500 mb-2">กำหนดประเภทอุปกรณ์ที่สามารถเลือกได้ในหน้าแบบฟอร์มร้องของาน</p>
<input type="hidden" name="settings[equipment_types]" id="equipmentTypesJson">
<div id="equipmentList" class="space-y-3">
<!-- Dynamic content via JS -->
</div>
</div>
</div>
<!-- Priorities Settings -->
<div id="tab-priorities" class="glass-card p-6 tab-content hidden">
<h3 class="text-lg font-bold text-slate-800 dark:text-white mb-4 flex items-center justify-between">
<span><i class="fa-solid fa-triangle-exclamation text-red-500 mr-2"></i> ระดับความฉุกเฉิน</span>
<button type="button" class="btn-secondary-glass py-1 px-3 text-sm" onclick="addPriorityItem()">
<i class="fa-solid fa-plus me-1"></i> เพิ่มความฉุกเฉิน
</button>
</h3>
<div class="space-y-4">
<p class="text-sm text-slate-500 mb-2">กำหนดระดับความฉุกเฉินและสีที่จะใช้แสดงผล</p>
<input type="hidden" name="settings[job_priorities]" id="jobPrioritiesJson">
<div id="priorityList" class="space-y-3">
<!-- Dynamic content via JS -->
</div>
</div>
</div>
<!-- Save Button Area -->
<div class="sticky bottom-4 z-10 p-4 mt-6 flex justify-end bg-white/70 dark:bg-slate-900/70 backdrop-blur-md rounded-2xl border border-emerald-500/20 shadow-[0_8px_30px_rgb(0,0,0,0.12)]">
<button type="submit" class="btn-primary-glass py-2 px-8 font-medium text-lg rounded-xl flex items-center hover:scale-105 transition-transform duration-200">
<i class="fa-solid fa-save me-2"></i> บันทึกการตั้งค่า
</button>
</div>
</form>
<!-- Tab Content for Points (Outside the main form so it can have its own forms) -->
<div id="tab-points" class="tab-content hidden animate-[fadeUp_0.3s_ease-out]">
<div class="glass-card p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-slate-800 dark:text-white flex items-center">
<i class="fa-solid fa-map-location-dot text-rose-500 mr-2"></i> จุดรับ-ส่ง (จุดบริการ)
</h3>
<button type="button" onclick="openAddPointModal()" class="btn-primary-glass py-1 px-4 text-sm font-medium">
<i class="fa-solid fa-plus me-1"></i> เพิ่มจุดรับ-ส่ง
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider border-b border-slate-200 dark:border-slate-700">
<th class="pb-3 px-4">ชื่อจุดบริการ</th>
<th class="pb-3 px-4 text-center">สถานะ</th>
<th class="pb-3 px-4 text-right">จัดการ</th>
</tr>
</thead>
<tbody class="text-sm text-slate-700 dark:text-slate-300 divide-y divide-slate-200 dark:divide-slate-700">
<?php
$departments = $deptModel->getAll();
foreach ($departments as $dept):
?>
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td class="py-3 px-4 font-medium"><?= escape($dept['name']) ?></td>
<td class="py-3 px-4 text-center">
<?php if ($dept['status'] === 'active'): ?>
<span class="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-medium">เปิดใช้งาน</span>
<?php else: ?>
<span class="bg-slate-100 text-slate-800 px-2 py-1 rounded-full text-xs font-medium">ปิดใช้งาน</span>
<?php endif; ?>
</td>
<td class="py-3 px-4 text-right">
<a href="?page=public_request&dept_id=<?= $dept['id'] ?>" target="_blank" class="text-emerald-500 hover:text-emerald-700 mr-3" title="ดู/พิมพ์ QR Code (หน้าสแกนเรียกเวรเปล)">
<i class="fa-solid fa-qrcode"></i>
</a>
<button type="button" onclick="openEditPointModal(<?= $dept['id'] ?>, '<?= escape(addslashes($dept['name'])) ?>', '<?= $dept['status'] ?>')" class="text-blue-500 hover:text-blue-700 mr-3" title="แก้ไข">
<i class="fa-solid fa-edit"></i>
</button>
<form class="inline dept-delete-form">
<input type="hidden" name="csrf_token" value="<?= generateCSRFToken() ?>">
<input type="hidden" name="action" value="department">
<input type="hidden" name="sub_action" value="delete">
<input type="hidden" name="id" value="<?= $dept['id'] ?>">
<button type="submit" class="text-red-500 hover:text-red-700"><i class="fa-solid fa-trash"></i></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Add/Edit Point Modal -->
<div id="pointModal" class="fixed inset-0 z-50 hidden bg-slate-900/50 backdrop-blur-sm flex items-center justify-center">
<div class="glass-card w-full max-w-md p-6 transform transition-all">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold text-slate-800 dark:text-white" id="pointModalTitle">เพิ่มจุดรับ-ส่ง</h3>
<button type="button" onclick="closePointModal()" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
<i class="fa-solid fa-times fa-lg"></i>
</button>
</div>
<form id="deptModalForm">
<input type="hidden" name="csrf_token" value="<?= generateCSRFToken() ?>">
<input type="hidden" name="action" value="department">
<input type="hidden" name="sub_action" id="pointSubAction" value="add">
<input type="hidden" name="id" id="pointId" value="">
<div class="space-y-4 mb-6">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ชื่อจุดบริการ <span class="text-red-500">*</span></label>
<input type="text" id="pointName" name="name" class="glass-input w-full px-4 py-2" required>
</div>
<div id="statusContainer" class="hidden">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">สถานะ</label>
<select id="pointStatus" name="status" class="glass-input w-full px-4 py-2">
<option value="active">เปิดใช้งาน</option>
<option value="inactive">ปิดใช้งาน</option>
</select>
</div>
</div>
<div class="flex justify-end gap-3">
<button type="button" onclick="closePointModal()" class="btn-secondary-glass py-2 px-4">ยกเลิก</button>
<button type="submit" class="btn-primary-glass py-2 px-6">บันทึก</button>
</div>
</form>
</div>
</div>
<!-- Tab Content for Maps -->
<div id="tab-maps" class="tab-content hidden animate-[fadeUp_0.3s_ease-out]">
<div class="glass-card p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-slate-800 dark:text-white flex items-center">
<i class="fa-solid fa-map text-amber-500 mr-2"></i> แผนผังอาคาร
</h3>
<button type="button" onclick="openMapUploadModal()" class="btn-primary-glass py-1 px-4 text-sm font-medium">
<i class="fa-solid fa-plus me-1"></i> เพิ่มแผนผัง
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" id="mapsGrid">
<!-- Dynamic Map Cards -->
<div class="col-span-full text-center text-slate-500 py-8">กำลังโหลดข้อมูลแผนผัง...</div>
</div>
</div>
</div>
<!-- Map Upload Modal -->
<div id="mapUploadModal" class="fixed inset-0 z-50 hidden bg-slate-900/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="glass-card w-full max-w-md p-6 transform transition-all">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold text-slate-800 dark:text-white">เพิ่มแผนผังอาคาร</h3>
<button type="button" onclick="closeMapUploadModal()" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
<i class="fa-solid fa-times fa-lg"></i>
</button>
</div>
<form id="mapUploadForm">
<input type="hidden" name="action" value="upload">
<div class="space-y-4 mb-6">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ชื่อแผนผัง <span class="text-red-500">*</span></label>
<input type="text" name="name" class="glass-input w-full px-4 py-2" required placeholder="เช่น ชั้น 1 อาคารผู้ป่วยนอก">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ตึก/อาคาร <span class="text-red-500">*</span></label>
<input type="text" name="building" class="glass-input w-full px-4 py-2" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ชั้น <span class="text-red-500">*</span></label>
<input type="text" name="floor" class="glass-input w-full px-4 py-2" required>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ตำแหน่งศูนย์เปล/จุดพักคอย (ถ้ามี)</label>
<input type="text" name="transport_center_location" class="glass-input w-full px-4 py-2" placeholder="เช่น บริเวณหน้าลิฟต์ฝั่งตะวันออก">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ไฟล์รูปภาพแผนผัง <span class="text-red-500">*</span></label>
<input type="file" name="map_image" class="block w-full text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-emerald-50 file:text-emerald-700 hover:file:bg-emerald-100" accept="image/jpeg, image/png, image/webp" required>
</div>
</div>
<div class="flex justify-end gap-3">
<button type="button" onclick="closeMapUploadModal()" class="btn-secondary-glass py-2 px-4">ยกเลิก</button>
<button type="submit" class="btn-primary-glass py-2 px-6">อัปโหลด</button>
</div>
</form>
</div>
</div>
<script>
// Data from server
let equipmentTypesData = <?= isset($settings['general']['equipment_types']) && !empty($settings['general']['equipment_types']) ? $settings['general']['equipment_types'] : '[{"id":"wheelchair","name":"รถนั่ง (Wheelchair)","icon":"fa-wheelchair"},{"id":"stretcher","name":"รถนอน (Stretcher)","icon":"fa-bed"},{"id":"bed","name":"เตียง (Bed)","icon":"fa-bed-pulse"}]' ?>;
let jobPrioritiesData = <?= isset($settings['general']['job_priorities']) && !empty($settings['general']['job_priorities']) ? $settings['general']['job_priorities'] : '[{"id":"normal","name":"ทั่วไป (Normal)","color":"blue"},{"id":"urgent","name":"ด่วน (Urgency)","color":"amber"},{"id":"emergency","name":"ฉุกเฉิน (Emergency)","color":"red"}]' ?>;
// Ensure data is array if it's string
if (typeof equipmentTypesData === 'string') {
try { equipmentTypesData = JSON.parse(equipmentTypesData); } catch (e) { equipmentTypesData = []; }
}
if (typeof jobPrioritiesData === 'string') {
try { jobPrioritiesData = JSON.parse(jobPrioritiesData); } catch (e) { jobPrioritiesData = []; }
}
// Equipment Logic
function renderEquipments() {
const container = document.getElementById('equipmentList');
container.innerHTML = '';
equipmentTypesData.forEach((item, index) => {
const html = `
<div class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800/50 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="w-10 text-center text-slate-400"><i class="fa-solid ${item.icon} text-lg"></i></div>
<div class="flex-1 grid grid-cols-3 gap-3">
<input type="text" class="glass-input px-3 py-1 text-sm" placeholder="ID (เช่น wheelchair)" value="${item.id}" onchange="updateEquipment(${index}, 'id', this.value)">
<input type="text" class="glass-input px-3 py-1 text-sm" placeholder="ชื่อ (เช่น รถนั่ง)" value="${item.name}" onchange="updateEquipment(${index}, 'name', this.value)">
<input type="text" class="glass-input px-3 py-1 text-sm" placeholder="Icon (เช่น fa-wheelchair)" value="${item.icon}" onchange="updateEquipment(${index}, 'icon', this.value)">
</div>
<button type="button" class="text-red-500 hover:text-red-700 p-2" onclick="removeEquipment(${index})"><i class="fa-solid fa-trash"></i></button>
</div>`;
container.insertAdjacentHTML('beforeend', html);
});
document.getElementById('equipmentTypesJson').value = JSON.stringify(equipmentTypesData);
}
window.addEquipmentItem = function() {
equipmentTypesData.push({id: '', name: '', icon: 'fa-box'});
renderEquipments();
};
window.removeEquipment = function(index) {
equipmentTypesData.splice(index, 1);
renderEquipments();
};
window.updateEquipment = function(index, field, value) {
equipmentTypesData[index][field] = value;
document.getElementById('equipmentTypesJson').value = JSON.stringify(equipmentTypesData);
if(field === 'icon') renderEquipments(); // re-render to update icon preview
};
// Priority Logic
function renderPriorities() {
const container = document.getElementById('priorityList');
container.innerHTML = '';
jobPrioritiesData.forEach((item, index) => {
const html = `
<div class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800/50 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="w-10 text-center text-${item.color}-500"><i class="fa-solid fa-circle text-lg"></i></div>
<div class="flex-1 grid grid-cols-3 gap-3">
<input type="text" class="glass-input px-3 py-1 text-sm" placeholder="ID (เช่น normal)" value="${item.id}" onchange="updatePriority(${index}, 'id', this.value)">
<input type="text" class="glass-input px-3 py-1 text-sm" placeholder="ชื่อ (เช่น ทั่วไป)" value="${item.name}" onchange="updatePriority(${index}, 'name', this.value)">
<select class="glass-input px-3 py-1 text-sm" onchange="updatePriority(${index}, 'color', this.value)">
<option value="blue" ${item.color==='blue'?'selected':''}>Blue (ฟ้า)</option>
<option value="emerald" ${item.color==='emerald'?'selected':''}>Emerald (เขียว)</option>
<option value="amber" ${item.color==='amber'?'selected':''}>Amber (เหลือง)</option>
<option value="orange" ${item.color==='orange'?'selected':''}>Orange (ส้ม)</option>
<option value="red" ${item.color==='red'?'selected':''}>Red (แดง)</option>
<option value="purple" ${item.color==='purple'?'selected':''}>Purple (ม่วง)</option>
<option value="slate" ${item.color==='slate'?'selected':''}>Slate (เทา)</option>
</select>
</div>
<button type="button" class="text-red-500 hover:text-red-700 p-2" onclick="removePriority(${index})"><i class="fa-solid fa-trash"></i></button>
</div>`;
container.insertAdjacentHTML('beforeend', html);
});
document.getElementById('jobPrioritiesJson').value = JSON.stringify(jobPrioritiesData);
}
window.addPriorityItem = function() {
jobPrioritiesData.push({id: '', name: '', color: 'blue'});
renderPriorities();
};
window.removePriority = function(index) {
jobPrioritiesData.splice(index, 1);
renderPriorities();
};
window.updatePriority = function(index, field, value) {
jobPrioritiesData[index][field] = value;
document.getElementById('jobPrioritiesJson').value = JSON.stringify(jobPrioritiesData);
if(field === 'color') renderPriorities(); // re-render to update color preview
};
// Initialize
renderEquipments();
renderPriorities();
document.addEventListener('DOMContentLoaded', function() {
// Password Toggle
document.querySelectorAll('.toggle-password').forEach(btn => {
btn.addEventListener('click', function() {
const input = this.previousElementSibling;
const icon = this.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.remove('fa-eye');
icon.classList.add('fa-eye-slash');
} else {
input.type = 'password';
icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye');
}
});
});
// Tab Switching Logic
const tabLinks = document.querySelectorAll('.tab-link');
const tabContents = document.querySelectorAll('.tab-content');
tabLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
// Remove active classes from all links
tabLinks.forEach(l => {
l.classList.remove('text-emerald-700', 'dark:text-emerald-300', 'bg-emerald-100/50', 'dark:bg-emerald-900/30', 'font-bold');
l.classList.add('text-slate-600', 'dark:text-slate-400', 'hover:bg-slate-50', 'dark:hover:bg-slate-800/50', 'hover:text-slate-900', 'dark:hover:text-slate-200');
// Change icon bg back
const iconBg = l.querySelector('.w-8.h-8');
if (iconBg) {
iconBg.classList.remove('bg-emerald-100', 'dark:bg-emerald-800/50');
iconBg.classList.add('bg-slate-100', 'dark:bg-slate-800');
}
});
// Add active class to clicked link
this.classList.remove('text-slate-600', 'dark:text-slate-400', 'hover:bg-slate-50', 'dark:hover:bg-slate-800/50', 'hover:text-slate-900', 'dark:hover:text-slate-200');
this.classList.add('text-emerald-700', 'dark:text-emerald-300', 'bg-emerald-100/50', 'dark:bg-emerald-900/30', 'font-bold');
// Change icon bg to active
const iconBg = this.querySelector('.w-8.h-8');
if (iconBg) {
iconBg.classList.remove('bg-slate-100', 'dark:bg-slate-800');
iconBg.classList.add('bg-emerald-100', 'dark:bg-emerald-800/50');
}
// Hide all tab contents
tabContents.forEach(content => {
content.classList.add('hidden');
});
// Show target tab content
const targetId = this.getAttribute('href');
const targetContent = document.querySelector(targetId);
if (targetContent) {
targetContent.classList.remove('hidden');
targetContent.classList.add('animate-[fadeUp_0.3s_ease-out]');
// Save active tab to localStorage
localStorage.setItem('activeSettingsTab', targetId);
// If it's the points tab or maps tab, hide the main form's submit button section
const mainSaveBtn = document.querySelector('#mainSettingsForm .sticky.bottom-4');
if (targetId === '#tab-points' || targetId === '#tab-maps') {
if(mainSaveBtn) mainSaveBtn.classList.add('hidden');
} else {
if(mainSaveBtn) mainSaveBtn.classList.remove('hidden');
}
}
});
});
// Handle AJAX Submission for Main Settings
$('#mainSettingsForm').on('submit', function(e) {
e.preventDefault();
const btn = $(this).find('button[type="submit"]');
btn.addClass('btn-loading');
$.ajax({
url: '?page=settings',
type: 'POST',
data: $(this).serialize(),
dataType: 'json',
success: function(response) {
btn.removeClass('btn-loading');
if (response.status === 'success') {
Swal.fire({
icon: 'success',
title: 'สำเร็จ',
text: response.message,
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b',
timer: 2000,
showConfirmButton: false
});
} else {
Swal.fire('ผิดพลาด', response.message, 'error');
}
},
error: function() {
btn.removeClass('btn-loading');
Swal.fire('ผิดพลาด', 'ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ได้', 'error');
}
});
});
// Handle AJAX Submission for Department Delete
$('.dept-delete-form').on('submit', function(e) {
e.preventDefault();
Swal.fire({
title: 'ยืนยันการลบ?',
text: "คุณต้องการลบจุดบริการนี้ใช่หรือไม่",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#ef4444',
cancelButtonColor: '#94a3b8',
confirmButtonText: 'ลบ',
cancelButtonText: 'ยกเลิก',
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b'
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url: '?page=settings',
type: 'POST',
data: $(this).serialize(),
dataType: 'json',
success: function(response) {
if (response.status === 'success') {
Swal.fire({
icon: 'success',
title: 'สำเร็จ',
text: response.message,
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b',
timer: 1500,
showConfirmButton: false
}).then(() => {
window.location.reload();
});
} else {
Swal.fire('ผิดพลาด', response.message, 'error');
}
}
});
}
});
});
// Handle AJAX Submission for Department Modal (Add/Edit)
$('#deptModalForm').on('submit', function(e) {
e.preventDefault();
const btn = $(this).find('button[type="submit"]');
btn.addClass('btn-loading');
$.ajax({
url: '?page=settings',
type: 'POST',
data: $(this).serialize(),
dataType: 'json',
success: function(response) {
btn.removeClass('btn-loading');
if (response.status === 'success') {
closePointModal();
Swal.fire({
icon: 'success',
title: 'สำเร็จ',
text: response.message,
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b',
timer: 1500,
showConfirmButton: false
}).then(() => {
window.location.reload();
});
} else {
Swal.fire('ผิดพลาด', response.message, 'error');
}
},
error: function() {
btn.removeClass('btn-loading');
Swal.fire('ผิดพลาด', 'ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ได้', 'error');
}
});
});
// Restore active tab from localStorage
const savedTab = localStorage.getItem('activeSettingsTab');
if (savedTab) {
const linkToClick = document.querySelector(`.tab-link[href="${savedTab}"]`);
if (linkToClick) {
linkToClick.click();
}
}
// Trigger SweetAlert if saved
<?php if (!empty($swal_msg)): ?>
Swal.fire({
icon: 'success',
title: 'สำเร็จ',
text: '<?= $swal_msg ?>',
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b',
timer: 3000,
showConfirmButton: false
}).then(() => {
// Clean up URL to prevent alert on reload
window.history.replaceState(null, null, window.location.pathname + '?page=settings');
});
<?php endif; ?>
});
function openAddPointModal() {
document.getElementById('pointModalTitle').textContent = 'เพิ่มจุดรับ-ส่ง';
document.getElementById('pointSubAction').value = 'add';
document.getElementById('pointId').value = '';
document.getElementById('pointName').value = '';
document.getElementById('statusContainer').classList.add('hidden');
document.getElementById('pointModal').classList.remove('hidden');
}
function openEditPointModal(id, name, status) {
document.getElementById('pointModalTitle').textContent = 'แก้ไขจุดรับ-ส่ง';
document.getElementById('pointSubAction').value = 'edit';
document.getElementById('pointId').value = id;
document.getElementById('pointName').value = name;
document.getElementById('pointStatus').value = status;
document.getElementById('statusContainer').classList.remove('hidden');
document.getElementById('pointModal').classList.remove('hidden');
}
function closePointModal() {
document.getElementById('pointModal').classList.add('hidden');
}
// --- MAPS MANAGEMENT LOGIC ---
function loadMaps() {
fetch('api/maps.php?action=list')
.then(res => res.json())
.then(res => {
const grid = document.getElementById('mapsGrid');
if (res.status === 'success') {
if (res.data.length === 0) {
grid.innerHTML = '<div class="col-span-full text-center text-slate-500 py-8">ยังไม่มีข้อมูลแผนผังอาคาร</div>';
return;
}
let html = '';
res.data.forEach(map => {
html += `
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden group">
<div class="h-40 bg-slate-100 dark:bg-slate-900 relative overflow-hidden">
<img src="${map.image_path}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
<div class="absolute top-2 right-2 flex gap-1">
<span class="bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm text-xs px-2 py-1 rounded-md font-bold text-slate-700 dark:text-slate-200 shadow-sm">
${map.building} ชั้น ${map.floor}
</span>
</div>
</div>
<div class="p-4">
<h4 class="font-bold text-slate-800 dark:text-white mb-1 truncate" title="${map.name}">${map.name}</h4>
<div class="text-xs text-slate-500 dark:text-slate-400 mb-3 flex items-start">
<i class="fa-solid fa-location-dot mt-0.5 text-rose-500 mr-1.5 w-3"></i>
<span class="line-clamp-2">${map.transport_center_location || '- ไม่ระบุจุดศูนย์เปล -'}</span>
</div>
<div class="flex justify-between items-center mt-4">
<span class="text-xs ${map.status === 'active' ? 'text-green-500 bg-green-50 dark:bg-green-900/20' : 'text-slate-500 bg-slate-100 dark:bg-slate-800'} px-2 py-1 rounded-full">
${map.status === 'active' ? 'ใช้งาน' : 'ไม่ใช้งาน'}
</span>
<button onclick="deleteMap(${map.id}, '${map.name}')" class="text-red-500 hover:text-red-700 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 w-8 h-8 rounded-full flex items-center justify-center transition-colors">
<i class="fa-solid fa-trash text-xs"></i>
</button>
</div>
</div>
</div>`;
});
grid.innerHTML = html;
}
});
}
function openMapUploadModal() {
document.getElementById('mapUploadForm').reset();
document.getElementById('mapUploadModal').classList.remove('hidden');
}
function closeMapUploadModal() {
document.getElementById('mapUploadModal').classList.add('hidden');
}
document.getElementById('mapUploadForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const btn = this.querySelector('button[type="submit"]');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> กำลังอัปโหลด...';
btn.disabled = true;
fetch('api/maps.php', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(data => {
btn.innerHTML = originalText;
btn.disabled = false;
if (data.status === 'success') {
Swal.fire({
icon: 'success',
title: 'อัปโหลดสำเร็จ',
showConfirmButton: false,
timer: 1500
});
closeMapUploadModal();
loadMaps();
} else {
Swal.fire('ข้อผิดพลาด', data.message, 'error');
}
}).catch(err => {
btn.innerHTML = originalText;
btn.disabled = false;
Swal.fire('ข้อผิดพลาด', 'ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ได้', 'error');
});
});
function deleteMap(id, name) {
Swal.fire({
title: 'ยืนยันการลบแผนผัง?',
text: `ต้องการลบแผนผัง "${name}" ใช่หรือไม่? (รูปภาพจะถูกลบด้วย)`,
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#ef4444',
cancelButtonColor: '#94a3b8',
confirmButtonText: 'ลบข้อมูล',
cancelButtonText: 'ยกเลิก',
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b'
}).then((result) => {
if (result.isConfirmed) {
const formData = new FormData();
formData.append('action', 'delete');
formData.append('id', id);
fetch('api/maps.php', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(data => {
if (data.status === 'success') {
loadMaps();
Swal.fire({
title: 'ลบสำเร็จ!',
icon: 'success',
timer: 1500,
showConfirmButton: false,
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b'
});
} else {
Swal.fire('ข้อผิดพลาด', data.message, 'error');
}
});
}
});
}
// Initialize maps on load
document.addEventListener('DOMContentLoaded', function() {
if (window.location.hash === '#tab-maps') {
loadMaps();
}
// Also add click listener to map tab link to load data if not loaded
document.querySelector('a[href="#tab-maps"]').addEventListener('click', function() {
if(document.getElementById('mapsGrid').innerHTML.includes('กำลังโหลด')) {
loadMaps();
}
});
});
</script>
@@ -0,0 +1,109 @@
<?php
// views/tracking/map.php
?>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<div class="mb-4 flex flex-wrap gap-4 justify-between items-end">
<div>
<h2 class="text-2xl font-bold text-slate-800 dark:text-white">Live Tracking (พิกัดเจ้าหน้าที่)</h2>
<p class="text-slate-500 dark:text-slate-400">ติดตามตำแหน่งเจ้าหน้าที่แบบ Real-time</p>
</div>
<div class="flex items-center gap-2 bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400 px-3 py-1.5 rounded-lg border border-emerald-200 dark:border-emerald-800 text-sm">
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
</span>
กำลังอัปเดต (Live)
</div>
</div>
<div class="glass-card overflow-hidden h-[70vh] relative border-4 border-white/50 dark:border-slate-800/50">
<div id="map" class="w-full h-full"></div>
<!-- Floating Staff List Panel -->
<div class="absolute top-4 right-4 z-[400] w-64 glass-card bg-white/90 dark:bg-slate-900/90 shadow-xl max-h-[80%] flex flex-col">
<div class="px-4 py-3 border-b border-slate-200 dark:border-slate-700 font-medium text-sm flex justify-between items-center">
เจ้าหน้าที่ในพื้นที่
<span class="bg-emerald-100 text-emerald-800 dark:bg-emerald-900 text-emerald-300 text-xs px-2 py-0.5 rounded-full" id="staffCount">0</span>
</div>
<div class="overflow-y-auto p-2 space-y-1" id="mapStaffList">
<!-- Populated by JS -->
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Map centered at Koh Samui Hospital approx
const map = L.map('map').setView([9.537, 99.938], 16);
// Modern Map style depending on Dark Mode
const isDark = document.documentElement.classList.contains('dark');
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png';
L.tileLayer(tileUrl, {
maxZoom: 19,
attribution: '© OpenStreetMap contributors © CARTO'
}).addTo(map);
const markers = {};
function updateStaffLocations() {
// Mock data
const staffData = [
{ id: 1, name: "นายสมชาย ใจดี", lat: 9.5375, lng: 99.9385, status: "available" },
{ id: 2, name: "นางสาวสมศรี มีสุข", lat: 9.5368, lng: 99.9372, status: "busy" }
];
document.getElementById('staffCount').textContent = staffData.length;
const listContainer = document.getElementById('mapStaffList');
listContainer.innerHTML = '';
staffData.forEach(staff => {
// Update List
const statusColor = staff.status === 'available' ? 'bg-emerald-500' : 'bg-yellow-500';
const statusText = staff.status === 'available' ? 'ว่าง' : 'ติดงาน';
listContainer.innerHTML += `
<div class="flex items-center gap-2 p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg cursor-pointer transition-colors text-sm" onclick="focusStaff(${staff.lat}, ${staff.lng})">
<div class="relative flex-shrink-0">
<img src="api/avatar.php?name=${encodeURIComponent(staff.name)}" class="w-6 h-6 rounded-full object-cover">
<span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 border border-white dark:border-slate-800 rounded-full ${statusColor}"></span>
</div>
<span class="truncate text-slate-700 dark:text-slate-300 flex-1">${staff.name}</span>
</div>
`;
// Update Map Markers
const iconColor = staff.status === 'available' ? 'green' : 'orange';
const customIcon = L.divIcon({
className: 'custom-div-icon',
html: `<div class="w-8 h-8 rounded-full border-2 border-white shadow-lg bg-${iconColor}-500 overflow-hidden"><img src="api/avatar.php?name=${encodeURIComponent(staff.name)}" class="w-full h-full object-cover"></div>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
});
if (markers[staff.id]) {
markers[staff.id].setLatLng([staff.lat, staff.lng]);
} else {
markers[staff.id] = L.marker([staff.lat, staff.lng], {icon: customIcon})
.bindPopup(`<b>${staff.name}</b><br>สถานะ: ${statusText}`)
.addTo(map);
}
});
}
window.focusStaff = function(lat, lng) {
map.flyTo([lat, lng], 18, {
animate: true,
duration: 1.5
});
};
updateStaffLocations();
setInterval(updateStaffLocations, 10000); // 10s auto refresh
});
</script>
@@ -0,0 +1,448 @@
<?php
// views/users/index.php
require_once dirname(__DIR__, 2) . '/models/User.php';
require_once dirname(__DIR__, 2) . '/models/Department.php';
// Check if user is admin
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
echo '<div class="glass-card p-8 text-center text-red-500">
<i class="fa-solid fa-lock text-4xl mb-4"></i>
<h2 class="text-xl font-bold">Access Denied</h2>
<p>คุณไม่มีสิทธิ์เข้าถึงหน้านี้</p>
</div>';
return;
}
$userModel = new User();
$deptModel = new Department();
$users = $userModel->getAll();
$departments = $deptModel->getAll();
?>
<div class="mb-6 flex justify-between items-center">
<div>
<h2 class="text-2xl font-bold text-slate-800 dark:text-white">ทะเบียนบุคลากร (User Management)</h2>
<p class="text-slate-500 dark:text-slate-400">จัดการข้อมูลผู้ใช้งานระบบ และเชื่อมต่อฐานข้อมูล HR</p>
</div>
<button onclick="openUserModal('add')" class="btn-primary-glass py-2 px-4 font-medium flex items-center">
<i class="fa-solid fa-user-plus me-2"></i> เพิ่มผู้ใช้งาน
</button>
</div>
<!-- Users Table -->
<div class="glass-card overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider border-b border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/50">
<th class="py-4 px-6">ชื่อ - สกุล</th>
<th class="py-4 px-6">ชื่อผู้ใช้ (Username)</th>
<th class="py-4 px-6 text-center">สิทธิ์การใช้งาน</th>
<th class="py-4 px-6">แผนก (จุดบริการ)</th>
<th class="py-4 px-6 text-center">สถานะ</th>
<th class="py-4 px-6 text-right">จัดการ</th>
</tr>
</thead>
<tbody class="text-sm text-slate-700 dark:text-slate-300 divide-y divide-slate-200 dark:divide-slate-700">
<?php foreach ($users as $u): ?>
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td class="py-4 px-6 font-medium">
<div class="flex items-center gap-3">
<img src="api/avatar.php?username=<?= urlencode($u['username']) ?>&name=<?= urlencode($u['full_name']) ?>" class="rounded-full w-8 h-8 object-cover shadow-sm border border-slate-200 dark:border-slate-700">
<?= escape($u['full_name']) ?>
</div>
</td>
<td class="py-4 px-6"><?= escape($u['username']) ?></td>
<td class="py-4 px-6 text-center">
<?php
$roleClass = 'bg-slate-100 text-slate-800';
$roleName = 'ไม่ระบุ';
if ($u['role'] === 'admin') { $roleClass = 'bg-purple-100 text-purple-800'; $roleName = 'ผู้ดูแลระบบ'; }
if ($u['role'] === 'manager') { $roleClass = 'bg-blue-100 text-blue-800'; $roleName = 'หัวหน้างาน'; }
if ($u['role'] === 'nurse') { $roleClass = 'bg-pink-100 text-pink-800'; $roleName = 'พยาบาล'; }
if ($u['role'] === 'staff') { $roleClass = 'bg-emerald-100 text-emerald-800'; $roleName = 'เวรเปล'; }
?>
<span class="<?= $roleClass ?> px-2 py-1 rounded-full text-xs font-medium"><?= $roleName ?></span>
</td>
<td class="py-4 px-6 text-slate-500">
<?= escape($u['department_name'] ?? '-') ?>
</td>
<td class="py-4 px-6 text-center">
<?php if ($u['status'] === 'online'): ?>
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span> Online
</span>
<?php elseif ($u['status'] === 'busy'): ?>
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
<span class="w-1.5 h-1.5 rounded-full bg-orange-500"></span> Busy
</span>
<?php else: ?>
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-300">
<span class="w-1.5 h-1.5 rounded-full bg-slate-400"></span> Offline
</span>
<?php endif; ?>
</td>
<td class="py-4 px-6 text-right space-x-2">
<button onclick="openUserModal('edit', <?= htmlspecialchars(json_encode([
'id' => $u['id'],
'username' => $u['username'],
'full_name' => $u['full_name'],
'role' => $u['role'],
'department_id' => $u['department_id']
])) ?>)" class="text-blue-500 hover:text-blue-700 p-1" title="แก้ไขข้อมูล">
<i class="fa-solid fa-edit"></i>
</button>
<button onclick="openPasswordModal(<?= $u['id'] ?>, '<?= escape($u['username']) ?>')" class="text-amber-500 hover:text-amber-700 p-1" title="เปลี่ยนรหัสผ่าน">
<i class="fa-solid fa-key"></i>
</button>
<?php if ($u['id'] != $_SESSION['user_id']): ?>
<button onclick="deleteUser(<?= $u['id'] ?>)" class="text-red-500 hover:text-red-700 p-1" title="ลบผู้ใช้งาน">
<i class="fa-solid fa-trash"></i>
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- User Add/Edit Modal -->
<div id="userModal" class="fixed inset-0 z-50 hidden bg-slate-900/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="glass-card w-full max-w-2xl transform transition-all max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center sticky top-0 bg-white/80 dark:bg-slate-900/80 backdrop-blur z-10">
<h3 class="text-xl font-bold text-slate-800 dark:text-white" id="userModalTitle">เพิ่มผู้ใช้งานใหม่</h3>
<button type="button" onclick="closeUserModal()" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
<i class="fa-solid fa-times fa-lg"></i>
</button>
</div>
<div class="p-6 space-y-6">
<!-- HR Search Box (Only for Add) -->
<div id="hrSearchBox" class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4">
<label class="block text-sm font-medium text-blue-800 dark:text-blue-300 mb-2">
<i class="fa-solid fa-magnifying-glass me-1"></i> ค้นหาจากฐานข้อมูล HR (HOSxP)
</label>
<div class="flex gap-2">
<input type="text" id="hrCid" class="glass-input flex-1 px-4 py-2 bg-white dark:bg-slate-800" placeholder="ระบุเลขบัตรประชาชน หรือ ชื่อ-สกุล">
<button type="button" onclick="searchHR()" id="btnSearchHR" class="btn-primary-glass px-4 py-2 whitespace-nowrap">
ค้นหา
</button>
</div>
<div id="hrResult" class="mt-3 text-sm hidden">
<!-- Results populated by JS -->
</div>
</div>
<form id="userForm">
<input type="hidden" name="csrf_token" value="<?= generateCSRFToken() ?>">
<input type="hidden" name="action" id="userAction" value="add">
<input type="hidden" name="id" id="userId" value="">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Full Name -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ชื่อ - สกุล <span class="text-red-500">*</span></label>
<input type="text" name="full_name" id="userFullName" class="glass-input w-full px-4 py-2" required>
</div>
<!-- Username -->
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ชื่อผู้ใช้ (Username) <span class="text-red-500">*</span></label>
<input type="text" name="username" id="userUsername" class="glass-input w-full px-4 py-2" required>
<p class="text-xs text-slate-500 mt-1" id="usernameHint">แนะนำใช้เลขบัตรประชาชน หรือรหัสพนักงาน</p>
</div>
<!-- Password (Only for Add) -->
<div id="passwordField">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">รหัสผ่าน <span class="text-red-500">*</span></label>
<input type="password" name="password" id="userPassword" class="glass-input w-full px-4 py-2">
</div>
<!-- Role -->
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">สิทธิ์การใช้งาน <span class="text-red-500">*</span></label>
<select name="role" id="userRole" class="glass-input w-full px-4 py-2" required>
<option value="">-- เลือกสิทธิ์ --</option>
<option value="staff">เวรเปล (Staff)</option>
<option value="nurse">พยาบาล (Nurse)</option>
<option value="manager">หัวหน้างาน (Manager)</option>
<option value="admin">ผู้ดูแลระบบ (Admin)</option>
</select>
</div>
<!-- Department -->
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">แผนก / จุดบริการ (ระบบเปล)</label>
<select name="department_id" id="userDepartment" class="glass-input w-full px-4 py-2">
<option value="">-- ไม่ระบุ --</option>
<?php foreach ($departments as $d): ?>
<option value="<?= $d['id'] ?>"><?= escape($d['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="mt-6 flex justify-end gap-3 pt-4 border-t border-slate-200 dark:border-slate-700">
<button type="button" onclick="closeUserModal()" class="btn-secondary-glass py-2 px-4">ยกเลิก</button>
<button type="submit" class="btn-primary-glass py-2 px-8">บันทึกข้อมูล</button>
</div>
</form>
</div>
</div>
</div>
<!-- Password Reset Modal -->
<div id="passwordModal" class="fixed inset-0 z-50 hidden bg-slate-900/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="glass-card w-full max-w-sm transform transition-all">
<div class="p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center">
<h3 class="text-lg font-bold text-slate-800 dark:text-white">เปลี่ยนรหัสผ่าน</h3>
<button type="button" onclick="closePasswordModal()" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
<i class="fa-solid fa-times"></i>
</button>
</div>
<form id="passwordForm" class="p-6 space-y-4">
<input type="hidden" name="csrf_token" value="<?= generateCSRFToken() ?>">
<input type="hidden" name="action" value="edit_password">
<input type="hidden" name="id" id="pwdUserId" value="">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">ชื่อผู้ใช้งาน</label>
<input type="text" id="pwdUsername" class="glass-input w-full px-4 py-2 bg-slate-100 dark:bg-slate-800" readonly disabled>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">รหัสผ่านใหม่ <span class="text-red-500">*</span></label>
<input type="text" name="password" class="glass-input w-full px-4 py-2" required minlength="4">
</div>
<div class="flex justify-end gap-3 pt-4">
<button type="button" onclick="closePasswordModal()" class="btn-secondary-glass py-2 px-4">ยกเลิก</button>
<button type="submit" class="btn-primary-glass py-2 px-6">บันทึก</button>
</div>
</form>
</div>
</div>
<script>
function openUserModal(action, data = null) {
document.getElementById('userAction').value = action;
const form = document.getElementById('userForm');
if (action === 'add') {
document.getElementById('userModalTitle').textContent = 'เพิ่มผู้ใช้งานใหม่';
document.getElementById('hrSearchBox').classList.remove('hidden');
document.getElementById('passwordField').classList.remove('hidden');
document.getElementById('userUsername').readOnly = false;
document.getElementById('userUsername').classList.remove('bg-slate-100', 'dark:bg-slate-800');
document.getElementById('userPassword').required = true;
form.reset();
document.getElementById('userId').value = '';
document.getElementById('hrResult').classList.add('hidden');
} else if (action === 'edit' && data) {
document.getElementById('userModalTitle').textContent = 'แก้ไขข้อมูลผู้ใช้งาน';
document.getElementById('hrSearchBox').classList.add('hidden');
document.getElementById('passwordField').classList.add('hidden');
document.getElementById('userUsername').readOnly = true;
document.getElementById('userUsername').classList.add('bg-slate-100', 'dark:bg-slate-800');
document.getElementById('userPassword').required = false;
document.getElementById('userId').value = data.id;
document.getElementById('userUsername').value = data.username;
document.getElementById('userFullName').value = data.full_name;
document.getElementById('userRole').value = data.role;
document.getElementById('userDepartment').value = data.department_id || '';
}
document.getElementById('userModal').classList.remove('hidden');
}
function closeUserModal() {
document.getElementById('userModal').classList.add('hidden');
}
function selectHRResult(cid, fullName, position, department, workDuration) {
document.getElementById('userFullName').value = fullName;
document.getElementById('userUsername').value = cid;
document.getElementById('userPassword').value = '123456'; // Default pass is 123456
const resultBox = document.getElementById('hrResult');
resultBox.innerHTML = `
<div class="bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-300 p-3 rounded border border-green-200 dark:border-green-800">
<p><strong><i class="fa-solid fa-check-circle"></i> เลือก: ${fullName}</strong></p>
<ul class="mt-2 space-y-1 list-disc list-inside">
<li><strong>ชื่อ:</strong> ${fullName}</li>
<li><strong>ตำแหน่ง:</strong> ${position}</li>
<li><strong>หน่วยงาน (HR):</strong> ${department}</li>
<li><strong>อายุงาน:</strong> ${workDuration}</li>
</ul>
<p class="mt-2 text-xs opacity-80">* ระบบได้นำเลข CID ไปตั้งเป็น Username (รหัสผ่านเริ่มต้นคือ 123456)</p>
</div>
`;
}
function openPasswordModal(id, username) {
document.getElementById('passwordForm').reset();
document.getElementById('pwdUserId').value = id;
document.getElementById('pwdUsername').value = username;
document.getElementById('passwordModal').classList.remove('hidden');
}
function closePasswordModal() {
document.getElementById('passwordModal').classList.add('hidden');
}
function searchHR() {
const query = document.getElementById('hrCid').value.trim();
if (!query) {
Swal.fire('แจ้งเตือน', 'กรุณาระบุเลขบัตรประชาชน หรือ ชื่อ-สกุล', 'warning');
return;
}
const btn = document.getElementById('btnSearchHR');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> กำลังค้นหา...';
btn.disabled = true;
$.ajax({
url: 'api/hr.php',
type: 'POST',
data: { query: query },
dataType: 'json',
success: function(response) {
btn.innerHTML = originalText;
btn.disabled = false;
const resultBox = document.getElementById('hrResult');
if (response.status === 'success') {
const data = response.data;
selectHRResult(data.cid, data.full_name, data.position, data.department, data.work_duration);
resultBox.classList.remove('hidden');
} else if (response.status === 'multiple') {
let html = `
<div class="bg-amber-50 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 p-3 rounded border border-amber-200 dark:border-amber-800 mb-2">
<strong><i class="fa-solid fa-users"></i> ${response.message}</strong>
</div>
<div class="space-y-2 max-h-60 overflow-y-auto pr-2 custom-scrollbar">
`;
response.data.forEach(item => {
html += `
<button type="button" onclick="selectHRResult('${item.cid}', '${item.full_name}', '${item.position}', '${item.department}', '${item.work_duration}')"
class="w-full text-left bg-white dark:bg-slate-800 p-3 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-emerald-500 hover:shadow-md transition-all">
<div class="font-medium text-slate-800 dark:text-white">${item.full_name}</div>
<div class="text-xs text-slate-500 mt-1">
<span class="mr-3"><i class="fa-solid fa-briefcase mr-1"></i> ${item.position}</span>
<span><i class="fa-solid fa-hospital-user mr-1"></i> ${item.department}</span>
</div>
</button>
`;
});
html += `</div>`;
resultBox.innerHTML = html;
resultBox.classList.remove('hidden');
} else {
resultBox.innerHTML = `
<div class="bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-300 p-3 rounded border border-red-200 dark:border-red-800">
<i class="fa-solid fa-triangle-exclamation"></i> ${response.message}
</div>
`;
resultBox.classList.remove('hidden');
}
},
error: function() {
btn.innerHTML = originalText;
btn.disabled = false;
Swal.fire('ผิดพลาด', 'ไม่สามารถเชื่อมต่อระบบ HR ได้', 'error');
}
});
}
function deleteUser(id) {
Swal.fire({
title: 'ยืนยันการลบ?',
text: "คุณต้องการลบผู้ใช้งานนี้ใช่หรือไม่",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#ef4444',
cancelButtonColor: '#94a3b8',
confirmButtonText: 'ลบ',
cancelButtonText: 'ยกเลิก',
background: document.documentElement.classList.contains('dark') ? '#1e293b' : '#fff',
color: document.documentElement.classList.contains('dark') ? '#f1f5f9' : '#1e293b'
}).then((result) => {
if (result.isConfirmed) {
$.post('api/users.php', {
action: 'delete',
id: id,
csrf_token: '<?= generateCSRFToken() ?>'
}, function(response) {
if (response.status === 'success') {
Swal.fire({icon: 'success', title: 'สำเร็จ', text: response.message, timer: 1500, showConfirmButton: false})
.then(() => window.location.reload());
} else {
Swal.fire('ผิดพลาด', response.message, 'error');
}
}, 'json');
}
});
}
document.addEventListener('DOMContentLoaded', function() {
$('#userForm').on('submit', function(e) {
e.preventDefault();
const btn = $(this).find('button[type="submit"]');
btn.addClass('btn-loading');
$.ajax({
url: 'api/users.php',
type: 'POST',
data: $(this).serialize(),
dataType: 'json',
success: function(response) {
btn.removeClass('btn-loading');
if (response.status === 'success') {
closeUserModal();
Swal.fire({icon: 'success', title: 'สำเร็จ', text: response.message, timer: 1500, showConfirmButton: false})
.then(() => window.location.reload());
} else {
Swal.fire('ผิดพลาด', response.message, 'error');
}
},
error: function() {
btn.removeClass('btn-loading');
Swal.fire('ผิดพลาด', 'ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ได้', 'error');
}
});
});
$('#passwordForm').on('submit', function(e) {
e.preventDefault();
const btn = $(this).find('button[type="submit"]');
btn.addClass('btn-loading');
$.ajax({
url: 'api/users.php',
type: 'POST',
data: $(this).serialize(),
dataType: 'json',
success: function(response) {
btn.removeClass('btn-loading');
if (response.status === 'success') {
closePasswordModal();
Swal.fire({icon: 'success', title: 'สำเร็จ', text: response.message, timer: 1500, showConfirmButton: false});
} else {
Swal.fire('ผิดพลาด', response.message, 'error');
}
},
error: function() {
btn.removeClass('btn-loading');
Swal.fire('ผิดพลาด', 'ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ได้', 'error');
}
});
});
});
</script>