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,4 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^(.*)$ public/$1 [L]
</IfModule>
@@ -0,0 +1,17 @@
{
"name": "antigravity/trip-approval",
"description": "ระบบขออนุมัติเดินทางประชุม-อบรม",
"type": "project",
"require": {
"php": "^8.1",
"setasign/fpdi": "^2.6",
"tecnickcom/tcpdf": "^6.6",
"phpoffice/phpspreadsheet": "^1.29",
"phpmailer/phpmailer": "^6.9"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
@@ -0,0 +1,20 @@
<?php
require __DIR__ . '/../src/Core/Database.php';
$pdo = App\Core\Database::getSystemConnection();
try {
$pdo->exec("ALTER TABLE trip_requests ADD COLUMN cancel_reason TEXT DEFAULT NULL AFTER budget");
echo "Added cancel_reason column.\n";
} catch (Exception $e) { echo "cancel_reason exists.\n"; }
try {
$pdo->exec("ALTER TABLE trip_requests ADD COLUMN revise_reason TEXT DEFAULT NULL AFTER cancel_reason");
echo "Added revise_reason column.\n";
} catch (Exception $e) { echo "revise_reason exists.\n"; }
try {
$pdo->exec("ALTER TABLE trip_requests MODIFY COLUMN status ENUM('Draft', 'Pending', 'Approved', 'Rejected', 'Cancelled', 'Pending Cancel', 'Pending Revise') DEFAULT 'Draft'");
echo "Updated status ENUM.\n";
} catch (Exception $e) { echo "status ENUM error: " . $e->getMessage() . "\n"; }
echo "Migration complete.\n";
@@ -0,0 +1,159 @@
-- Database Schema: ระบบขออนุมัติเดินทางประชุม-อบรม
-- หมายเหตุ: การสร้างตารางนี้อ้างอิงกับ System Database ส่วน HR Database (`hr_person`) ถือว่าเป็นตารางที่มีอยู่แล้ว (External/Reference)
-- ==============================================
-- 1. System Roles & Permissions
-- ==============================================
CREATE TABLE `system_roles` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(50) NOT NULL UNIQUE COMMENT 'เช่น Admin, User, Approver_L1',
`description` VARCHAR(255) DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `system_user_roles` (
`user_id` VARCHAR(50) NOT NULL COMMENT 'เชื่อมกับ hr_person.HR_CID',
`role_id` INT NOT NULL,
PRIMARY KEY (`user_id`, `role_id`),
CONSTRAINT `fk_user_roles_role_id` FOREIGN KEY (`role_id`) REFERENCES `system_roles` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==============================================
-- 2. Trip Requests (การขอเดินทาง)
-- ==============================================
CREATE TABLE `trip_requests` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` VARCHAR(50) NOT NULL COMMENT 'ผู้ขอเดินทาง เชื่อมกับ hr_person.HR_CID',
`doc_number` VARCHAR(50) DEFAULT NULL COMMENT 'เลขอ้างอิงหนังสือ',
`doc_no` VARCHAR(50) DEFAULT NULL COMMENT 'เลขที่หนังสือ',
`doc_date` DATE DEFAULT NULL COMMENT 'ลงวันที่ของหนังสืออ้างถึง',
`doc_link` VARCHAR(500) DEFAULT NULL COMMENT 'ลิงค์ไฟล์เอกสาร',
`title` VARCHAR(255) NOT NULL COMMENT 'ชื่อเรื่อง',
`objective` TEXT NOT NULL COMMENT 'วัตถุประสงค์',
`personnel_type` VARCHAR(50) DEFAULT NULL COMMENT 'ประเภทบุคลากร',
`start_date` DATE NOT NULL,
`end_date` DATE NOT NULL,
`location` VARCHAR(255) NOT NULL COMMENT 'สถานที่ไปราชการ/อบรม',
`organizer` VARCHAR(255) DEFAULT NULL COMMENT 'ผู้จัด',
`depart_from` ENUM('สำนักงาน', 'บ้านพัก', 'อื่นๆ') DEFAULT 'สำนักงาน',
`depart_address` TEXT DEFAULT NULL,
`depart_datetime` DATETIME DEFAULT NULL,
`arrive_at` ENUM('สำนักงาน', 'บ้านพัก', 'อื่นๆ') DEFAULT 'สำนักงาน',
`arrive_address` TEXT DEFAULT NULL,
`arrive_datetime` DATETIME DEFAULT NULL,
`vehicle_type` VARCHAR(100) DEFAULT NULL COMMENT 'เครื่องบิน, รถยนต์ส่วนตัว, ฯลฯ',
`vehicle_plate` VARCHAR(50) DEFAULT NULL,
`budget_source` VARCHAR(100) DEFAULT NULL COMMENT 'แหล่งงบ',
`budget_remark` TEXT DEFAULT NULL,
`budget` DECIMAL(10,2) DEFAULT 0.00 COMMENT 'งบประมาณที่ขอ',
`cancel_reason` TEXT DEFAULT NULL COMMENT 'เหตุผลการยกเลิก',
`revise_reason` TEXT DEFAULT NULL COMMENT 'เหตุผลการแก้ไข',
`status` ENUM('Draft', 'Pending', 'Approved', 'Rejected', 'Cancelled', 'Pending Cancel', 'Pending Revise') DEFAULT 'Draft',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==============================================
-- 3. Trip Approvals (ประวัติการอนุมัติ / Workflow)
-- ==============================================
CREATE TABLE `trip_approvals` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`request_id` INT NOT NULL,
`approver_id` VARCHAR(50) NOT NULL COMMENT 'ผู้อนุมัติ เชื่อมกับ hr_person.HR_CID',
`step` INT NOT NULL DEFAULT 1 COMMENT 'ลำดับขั้นการอนุมัติ',
`status` ENUM('Pending', 'Approved', 'Rejected') DEFAULT 'Pending',
`comments` TEXT DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `fk_trip_approvals_request_id` FOREIGN KEY (`request_id`) REFERENCES `trip_requests` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==============================================
-- 4. PDF Templates & Fields (ดีไซน์เนอร์ลากวาง)
-- ==============================================
CREATE TABLE `pdf_templates` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(100) NOT NULL COMMENT 'ชื่อแบบฟอร์ม',
`file_path` VARCHAR(255) NOT NULL COMMENT 'พาร์ทไฟล์ PDF ต้นฉบับ',
`version` INT DEFAULT 1 COMMENT 'เวอร์ชันของแบบฟอร์ม',
`is_active` BOOLEAN DEFAULT TRUE,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `pdf_template_fields` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`template_id` INT NOT NULL,
`field_name` VARCHAR(50) NOT NULL COMMENT 'ชื่อตัวแปร เช่น {firstname}',
`field_type` ENUM('Text', 'Image', 'QR', 'Signature', 'Checkbox') DEFAULT 'Text',
`page_num` INT DEFAULT 1 COMMENT 'หน้าที่ตัวแปรนี้อยู่',
`x_pos` DECIMAL(8,4) NOT NULL COMMENT 'พิกัดแนวแกน X (mm)',
`y_pos` DECIMAL(8,4) NOT NULL COMMENT 'พิกัดแนวแกน Y (mm)',
`width` DECIMAL(8,4) DEFAULT 0 COMMENT 'ความกว้าง (ถ้าเป็นรูปหรือกล่อง)',
`height` DECIMAL(8,4) DEFAULT 0 COMMENT 'ความสูง (ถ้าเป็นรูปหรือกล่อง)',
`font_size` INT DEFAULT 14 COMMENT 'ขนาดตัวอักษร',
`font_family` VARCHAR(50) DEFAULT 'freeserif' COMMENT 'แบบอักษร',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `fk_pdf_fields_template_id` FOREIGN KEY (`template_id`) REFERENCES `pdf_templates` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==============================================
-- 5. Google Sheets Integration Settings
-- ==============================================
CREATE TABLE `google_sheet_settings` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`spreadsheet_id` VARCHAR(100) NOT NULL,
`api_key` VARCHAR(255) DEFAULT NULL COMMENT 'Google API Key',
`sheet_name` VARCHAR(100) NOT NULL,
`sync_range` VARCHAR(50) NOT NULL COMMENT 'เช่น A2:B',
`column_mapping` JSON NOT NULL COMMENT 'เก็บ JSON การแมปคอลัมน์ (เช่น {"0": "doc_number", "1": "title"})',
`last_synced_at` TIMESTAMP NULL DEFAULT NULL,
`status` ENUM('Active', 'Inactive', 'Error') DEFAULT 'Active',
`error_message` TEXT DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==============================================
-- 6. Audit Logs
-- ==============================================
CREATE TABLE `audit_logs` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`user_id` VARCHAR(50) DEFAULT NULL COMMENT 'ผู้กระทำ (NULL ได้หากทำโดยระบบ)',
`action` VARCHAR(50) NOT NULL COMMENT 'เช่น CREATE, UPDATE, DELETE, LOGIN, APPROVE',
`entity_type` VARCHAR(50) NOT NULL COMMENT 'ชื่อตาราง เช่น trip_requests',
`entity_id` INT DEFAULT NULL COMMENT 'ID ของตารางที่ถูกกระทำ',
`details` TEXT DEFAULT NULL COMMENT 'เก็บ JSON ข้อมูลที่เปลี่ยนแปลง',
`ip_address` VARCHAR(45) DEFAULT NULL,
`user_agent` VARCHAR(255) DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==============================================
-- 7. Initial Data (ข้อมูลเริ่มต้น)
-- ==============================================
INSERT INTO `system_roles` (`name`, `description`) VALUES
('Admin', 'ผู้ดูแลระบบ'),
('User', 'ผู้ใช้งานทั่วไป (ขอเดินทาง)'),
('Approver_L1', 'ผู้อนุมัติระดับที่ 1 (หัวหน้างาน)'),
('Approver_L2', 'ผู้อนุมัติระดับที่ 2 (ผู้อำนวยการ)');
-- ==============================================
-- 8. Approval Hierarchy (การตั้งค่าสายงานอนุมัติรายบุคคล)
-- ==============================================
CREATE TABLE `approval_hierarchy` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` VARCHAR(50) NOT NULL COMMENT 'รหัสพนักงานผู้ขอ',
`approver_id` VARCHAR(50) NOT NULL COMMENT 'รหัสพนักงานผู้อนุมัติ',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==============================================
-- 9. System Settings (ตั้งค่าระบบ เช่น LINE Notify Token)
-- ==============================================
CREATE TABLE `system_settings` (
`setting_key` VARCHAR(50) PRIMARY KEY,
`setting_value` TEXT,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,11 @@
-- 1. เพิ่มคอลัมน์เหตุผลการยกเลิก
ALTER TABLE `trip_requests`
ADD COLUMN `cancel_reason` TEXT DEFAULT NULL COMMENT 'เหตุผลการยกเลิก' AFTER `budget`;
-- 2. เพิ่มคอลัมน์เหตุผลการขอเปลี่ยนแปลง
ALTER TABLE `trip_requests`
ADD COLUMN `revise_reason` TEXT DEFAULT NULL COMMENT 'เหตุผลการแก้ไข' AFTER `cancel_reason`;
-- 3. อัปเดต ENUM ของฟิลด์ status เพื่อให้รองรับสถานะใหม่ 2 สถานะ
ALTER TABLE `trip_requests`
MODIFY COLUMN `status` ENUM('Draft', 'Pending', 'Approved', 'Rejected', 'Cancelled', 'Pending Cancel', 'Pending Revise') DEFAULT 'Draft';
@@ -0,0 +1,47 @@
# คู่มือการติดตั้ง (Installation Guide)
## ระบบขออนุมัติเดินทางประชุม-อบรม
### 1. ความต้องการของระบบ (System Requirements)
- PHP 8.3 หรือใหม่กว่า (เปิดใช้งาน pdo_mysql, mbstring, gd)
- MySQL 8.0 หรือ MariaDB 10.5 ขึ้นไป
- Apache (เปิดใช้งาน `mod_rewrite`) หรือ Nginx
- Composer
### 2. การติดตั้ง (Installation Steps)
**2.1 โคลนซอร์สโค้ด และ ติดตั้ง Dependencies**
```bash
git clone <repository_url> trip-approval
cd trip-approval
composer install
```
**2.2 ตั้งค่าฐานข้อมูล (Database Setup)**
1. สร้างฐานข้อมูลชื่อ `trip_approval_system`
2. นำเข้าโครงสร้างตารางจากไฟล์ `database/schema.sql`
```bash
mysql -u root -p trip_approval_system < database/schema.sql
```
3. สมมติฐานว่าระบบนี้เชื่อมต่อกับฐานข้อมูล `hosoffice_2566` (ตาราง `hr_person`) ที่มีอยู่เดิม
**2.3 ตั้งค่าการเชื่อมต่อในซอร์สโค้ด (Configuration)**
- เปิดไฟล์ `src/Core/Database.php`
- แก้ไข User, Password, DB Name สำหรับฝั่ง `$hrConnection` และ `$systemConnection` ให้ตรงกับเซิร์ฟเวอร์ของคุณ
**2.4 ตั้งค่า Web Server (Apache)**
- ให้ชี้ Document Root ของ Virtual Host ไปที่โฟลเดอร์ `public/`
- ตัวอย่าง `.htaccess` ในโฟลเดอร์ `public/`
```apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]
```
### 3. การทดสอบเข้าระบบ
- เข้าสู่ระบบด้วย Username/Password ที่มีอยู่ในตาราง `hr_person`
- หรือเพิ่มข้อมูลจำลองในตาราง `hr_person` เพื่อทดสอบ
### 4. การตั้งค่าเพิ่มเติม (Advanced Settings)
- **Google Sheets**: เข้าไปที่ "ตั้งค่าระบบ" > "Google Sheets Integration" เพื่อระบุ Spreadsheet ID
- **PDF Template**: เข้าไปที่ "ตั้งค่าระบบ" > "PDF Template Designer" เพื่ออัปโหลดฟอร์ม PDF ว่างเปล่า และลากวางตัวแปร
@@ -0,0 +1,132 @@
# เอกสารวิเคราะห์ระบบและ ER Diagram (System Analysis & ER Diagram)
## 1. ภาพรวมของระบบ (System Overview)
ระบบขออนุมัติเดินทางประชุม-อบรม เป็นระบบที่ช่วยให้บุคลากรสามารถสร้างคำขออนุมัติเดินทางไปราชการ ประชุม หรืออบรม ผ่านทางออนไลน์ โดยมีการอนุมัติผ่านสายงาน (Workflow) บันทึกประวัติการทำรายการ (Audit Log) สามารถสร้างแบบฟอร์ม PDF อัตโนมัติตามเทมเพลต และดึงข้อมูล/เชื่อมโยงกับ Google Sheets
## 2. โครงสร้างฐานข้อมูล (Database Design)
ระบบแบ่งออกเป็น 2 ฐานข้อมูล (หรืออาจเป็น 2 Schema ในเซิร์ฟเวอร์เดียวกัน)
1. **HR Database (`hosoffice_2566`)**: ใช้เป็นข้อมูลอ้างอิงและตรวจสอบสิทธิ์เข้าใช้งาน
2. **System Database (`system`)**: เก็บข้อมูลการขออนุมัติและการตั้งค่าระบบ
### ตารางใน System Database
1. **`system_roles`**: เก็บชื่อบทบาท (เช่น Admin, User, Approver_L1, Approver_L2)
2. **`system_user_roles`**: แมปผู้ใช้งาน (จาก `hr_person.id`) กับบทบาท
3. **`trip_requests`**: ข้อมูลคำขอเดินทาง (ชื่อเรื่อง, วันที่, สถานที่, งบประมาณ, สถานะ)
4. **`trip_approvals`**: ประวัติและสถานะการอนุมัติตามลำดับขั้น (Workflow)
5. **`pdf_templates`**: เก็บไฟล์แบบฟอร์มตั้งต้นที่ใช้วางตัวแปร
6. **`pdf_template_fields`**: พิกัด (X, Y) และข้อมูลของแต่ละช่องตัวแปรบน PDF
7. **`google_sheet_settings`**: ตั้งค่าการเชื่อมต่อและ Mapping Google Sheets
8. **`audit_logs`**: เก็บประวัติการใช้งานระบบ (ใคร ทำอะไร เมื่อไหร่)
## 3. ER Diagram
```mermaid
erDiagram
%% HR Database Reference
HR_PERSON {
int id PK
string username
string password_hash
string firstname
string lastname
string position
int department_id
}
%% System Database
SYSTEM_ROLES {
int id PK
string name "e.g. Admin, User, Manager"
string description
}
SYSTEM_USER_ROLES {
int user_id PK, FK "Refers to HR_PERSON.id"
int role_id PK, FK "Refers to SYSTEM_ROLES.id"
}
TRIP_REQUESTS {
int id PK
int user_id FK "Refers to HR_PERSON.id"
string doc_number "เลขที่หนังสือ"
string title "ชื่อเรื่อง"
text objective "วัตถุประสงค์"
date start_date
date end_date
string location
decimal budget
string status "Draft, Pending, Approved, Rejected"
datetime created_at
datetime updated_at
}
TRIP_APPROVALS {
int id PK
int request_id FK
int approver_id FK "Refers to HR_PERSON.id"
int step "ลำดับการอนุมัติ"
string status "Pending, Approved, Rejected"
text comments
datetime created_at
}
PDF_TEMPLATES {
int id PK
string name
string file_path
int version
datetime created_at
datetime updated_at
}
PDF_TEMPLATE_FIELDS {
int id PK
int template_id FK
string field_name "e.g. {firstname}"
string field_type "Text, Image, QR, Signature"
decimal x_pos
decimal y_pos
int page_num
decimal width
decimal height
int font_size
}
GOOGLE_SHEET_SETTINGS {
int id PK
string spreadsheet_id
string sheet_name
string sync_range
json column_mapping
datetime last_synced_at
string status
}
AUDIT_LOGS {
int id PK
int user_id FK "Refers to HR_PERSON.id"
string action "CREATE, UPDATE, DELETE, LOGIN"
string entity_type "TableName"
int entity_id
text details
string ip_address
datetime created_at
}
%% Relationships
HR_PERSON ||--o{ SYSTEM_USER_ROLES : "has"
SYSTEM_ROLES ||--o{ SYSTEM_USER_ROLES : "assigned_to"
HR_PERSON ||--o{ TRIP_REQUESTS : "creates"
TRIP_REQUESTS ||--o{ TRIP_APPROVALS : "has_workflow_steps"
HR_PERSON ||--o{ TRIP_APPROVALS : "approves"
PDF_TEMPLATES ||--o{ PDF_TEMPLATE_FIELDS : "contains"
HR_PERSON ||--o{ AUDIT_LOGS : "performs"
```
## 4. ข้อสังเกตเพิ่มเติม (Notes)
- การอนุมัติ (Trip Approvals) จะตรวจสอบแบบลำดับขั้น (Step-by-Step)
- PDF Template Fields เก็บพิกัดเพื่อใช้ในการ Render ด้วย TCPDF/FPDI
- การเก็บรหัสผ่าน (Password Hash) ฝั่ง HR จะถูกเรียกใช้โดย PDO ตัวแรก ส่วนเนื้อหาอื่นๆ จะเรียกใช้โดย PDO ตัวที่สอง
- โครงสร้างนี้สามารถรองรับได้ถึง OWASP Top 10 โดยการบังคับใช้ Prepared Statements (PDO) ตลอดทั้งแอปพลิเคชัน
@@ -0,0 +1,28 @@
# Test Cases (กรณีทดสอบ)
## 1. การยืนยันตัวตน (Authentication)
| TC ID | คำอธิบาย | ข้อมูลที่ใช้ทดสอบ | ผลลัพธ์ที่คาดหวัง |
|---|---|---|---|
| TC-AUTH-01 | เข้าสู่ระบบด้วยข้อมูลที่ถูกต้อง | Username=admin, Pass=1234 | เข้าสู่หน้า Dashboard สำเร็จ, มีชื่อแสดง |
| TC-AUTH-02 | เข้าสู่ระบบด้วยรหัสผ่านผิด | Username=admin, Pass=wrong | กลับไปหน้า Login, แสดงข้อความ "ชื่อผู้ใช้งานหรือรหัสผ่านไม่ถูกต้อง" |
| TC-AUTH-03 | ตรวจสอบ CSRF ในหน้า Login | (ยิง POST ผ่าน Postman ไม่มี CSRF) | ถูกปฏิเสธ (403 Forbidden) |
## 2. การขอเดินทาง (Trip Request)
| TC ID | คำอธิบาย | ข้อมูลที่ใช้ทดสอบ | ผลลัพธ์ที่คาดหวัง |
|---|---|---|---|
| TC-TRIP-01 | สร้างคำขอเดินทางครบถ้วน | กรอกครบทุกฟิลด์ | บันทึกลง DB สถานะ "Pending", Redirect ไปหน้าแรกพร้อมขึ้นแจ้งเตือนสำเร็จ |
| TC-TRIP-02 | สร้างคำขอโดยไม่กรอกฟิลด์จำเป็น | เว้นว่าง "สถานที่" | กลับไปหน้าฟอร์มเดิม แสดงข้อความเตือนให้กรอกครบถ้วน |
## 3. การอนุมัติ (Workflow)
| TC ID | คำอธิบาย | ข้อมูลที่ใช้ทดสอบ | ผลลัพธ์ที่คาดหวัง |
|---|---|---|---|
| TC-APP-01 | ผู้อนุมัติเข้าดูรายการ | Login ด้วยบัญชีผู้อนุมัติ | เห็นรายการที่ร้องขอจากพนักงานในตาราง "รอพิจารณา" |
| TC-APP-02 | อนุมัติรายการ (Approve) | กดปุ่ม "อนุมัติ" พร้อมคอมเมนต์ | สถานะใน `trip_requests` และ `trip_approvals` เปลี่ยนเป็น Approved, บันทึก Audit Log |
| TC-APP-03 | ไม่อนุมัติรายการ (Reject) | กดปุ่ม "ไม่อนุมัติ" | สถานะเปลี่ยนเป็น Rejected |
## 4. Google Sheets & PDF Designer
| TC ID | คำอธิบาย | ข้อมูลที่ใช้ทดสอบ | ผลลัพธ์ที่คาดหวัง |
|---|---|---|---|
| TC-INT-01 | ทดสอบหน้าตั้งค่า Sheet | กรอกข้อมูล Spreadsheet ID | บันทึกข้อมูลตั้งค่าสำเร็จ และดึงข้อมูล JSON มาแสดงผลเบื้องต้นได้ |
| TC-INT-02 | ลากวางฟิลด์ลง PDF | ลาก "{fullname}" ไปยัง Canvas | Canvas รับ Object และบันทึกพิกัด X, Y กลับมาได้ |
| TC-INT-03 | ตรวจสอบ XSS | พิมพ์ `<script>alert(1)</script>` ในช่อง Sheet Name | หน้าจอแสดงโค้ดเป็น Text ธรรมดา ไม่เกิด Alert |
@@ -0,0 +1,6 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]
</IfModule>
@@ -0,0 +1,333 @@
:root {
/* Apple + Stripe Color Palette (Light) */
--auth-bg-gradient-1: #fdfbfb;
--auth-bg-gradient-2: #ebedee;
--auth-bg-gradient-3: #e0c3fc;
--auth-bg-gradient-4: #8ec5fc;
--auth-glass-bg: rgba(255, 255, 255, 0.7);
--auth-glass-border: rgba(255, 255, 255, 0.5);
--auth-glass-shadow: rgba(0, 0, 0, 0.05);
--auth-text-main: #1d1d1f;
--auth-text-muted: #86868b;
--auth-input-bg: rgba(255, 255, 255, 0.8);
--auth-input-border: #d2d2d7;
--auth-input-focus: #0066cc;
--auth-input-focus-shadow: rgba(0, 102, 204, 0.2);
--auth-btn-bg: #0066cc;
--auth-btn-text: #ffffff;
--auth-btn-hover: #0077ed;
--auth-shape-1: rgba(142, 197, 252, 0.6);
--auth-shape-2: rgba(224, 195, 252, 0.6);
--auth-shape-3: rgba(255, 209, 255, 0.6);
}
[data-bs-theme="dark"] {
/* Apple + Stripe Color Palette (Dark) */
--auth-bg-gradient-1: #000000;
--auth-bg-gradient-2: #1c1c1e;
--auth-bg-gradient-3: #2c1b4d;
--auth-bg-gradient-4: #1a2a42;
--auth-glass-bg: rgba(28, 28, 30, 0.65);
--auth-glass-border: rgba(255, 255, 255, 0.1);
--auth-glass-shadow: rgba(0, 0, 0, 0.3);
--auth-text-main: #f5f5f7;
--auth-text-muted: #86868b;
--auth-input-bg: rgba(44, 44, 46, 0.8);
--auth-input-border: #3a3a3c;
--auth-input-focus: #2997ff;
--auth-input-focus-shadow: rgba(41, 151, 255, 0.25);
--auth-btn-bg: #2997ff;
--auth-btn-text: #ffffff;
--auth-btn-hover: #0071e3;
--auth-shape-1: rgba(26, 42, 66, 0.8);
--auth-shape-2: rgba(44, 27, 77, 0.8);
--auth-shape-3: rgba(66, 42, 66, 0.8);
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(-45deg, var(--auth-bg-gradient-1), var(--auth-bg-gradient-2), var(--auth-bg-gradient-3), var(--auth-bg-gradient-4));
background-size: 400% 400%;
animation: gradientBG 15s ease infinite;
color: var(--auth-text-main);
overflow: hidden;
position: relative;
transition: background-color 0.4s ease, color 0.4s ease;
}
/* Animated Gradient Background */
@keyframes gradientBG {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Canvas Particle Layer */
#particleCanvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
/* Floating Shapes */
.bg-shape {
position: absolute;
filter: blur(60px);
border-radius: 50%;
z-index: 1;
animation: float 20s infinite alternate;
}
.shape-1 {
width: 400px;
height: 400px;
background: var(--auth-shape-1);
top: -100px;
left: -100px;
animation-delay: 0s;
}
.shape-2 {
width: 500px;
height: 500px;
background: var(--auth-shape-2);
bottom: -150px;
right: -100px;
animation-delay: -5s;
}
.shape-3 {
width: 300px;
height: 300px;
background: var(--auth-shape-3);
top: 40%;
left: 50%;
animation-delay: -10s;
}
@keyframes float {
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
33% { transform: translate(30px, -50px) rotate(10deg) scale(1.1); }
66% { transform: translate(-20px, 20px) rotate(-5deg) scale(0.9); }
100% { transform: translate(0, 0) rotate(0deg) scale(1); }
}
/* Wrapper */
.auth-wrapper {
position: relative;
z-index: 10;
width: 100%;
max-width: 1000px;
padding-inline: 20px;
}
/* Glass Card */
.glass-card {
background: var(--auth-glass-bg);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--auth-glass-border);
border-radius: 24px;
box-shadow: 0 8px 32px var(--auth-glass-shadow), 0 1px 3px rgba(0,0,0,0.02);
overflow: hidden;
display: flex;
flex-direction: row;
min-height: 550px;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.glass-card:hover {
box-shadow: 0 12px 48px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.02);
transform: translateY(-2px);
}
/* Two-column layout */
.auth-illustration {
flex: 1;
background: rgba(255, 255, 255, 0.03);
border-inline-end: 1px solid var(--auth-glass-border);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 3rem;
text-align: center;
}
.auth-form-container {
flex: 1;
padding: 3rem;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
}
/* Typography */
.auth-title {
font-weight: 700;
font-size: 1.75rem;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
color: var(--auth-text-main);
}
.auth-subtitle {
color: var(--auth-text-muted);
font-size: 0.95rem;
margin-bottom: 2rem;
}
/* Microsoft Fluent / Stripe Inputs */
.form-floating > .form-control,
.form-floating > .form-control:focus {
background-color: var(--auth-input-bg);
border: 1.5px solid var(--auth-input-border);
color: var(--auth-text-main);
border-radius: 12px;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.form-floating > .form-control:focus {
border-color: var(--auth-input-focus);
box-shadow: 0 0 0 4px var(--auth-input-focus-shadow);
}
.form-floating > label {
color: var(--auth-text-muted);
}
.form-control:focus ~ label {
color: var(--auth-input-focus);
}
/* Micro-interaction Button */
.btn-auth {
background-color: var(--auth-btn-bg);
color: var(--auth-btn-text);
border: none;
border-radius: 12px;
padding: 14px 20px;
font-weight: 600;
font-size: 1rem;
letter-spacing: 0.01em;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
overflow: hidden;
}
.btn-auth:hover {
background-color: var(--auth-btn-hover);
transform: scale(1.02);
box-shadow: 0 4px 12px var(--auth-input-focus-shadow);
}
.btn-auth:active {
transform: scale(0.98);
}
/* Loading Overlay */
.auth-loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--auth-glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.auth-loading-overlay.active {
opacity: 1;
pointer-events: all;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--auth-input-border);
border-top-color: var(--auth-btn-bg);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Theme Switcher & Lang Toggle */
.auth-controls {
position: absolute;
top: 1.5rem;
inset-inline-end: 1.5rem;
display: flex;
gap: 0.5rem;
z-index: 20;
}
.control-btn {
background: var(--auth-glass-bg);
border: 1px solid var(--auth-glass-border);
color: var(--auth-text-muted);
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.control-btn:hover {
color: var(--auth-text-main);
background: var(--auth-input-bg);
transform: rotate(15deg) scale(1.1);
}
/* Responsive */
@media (max-width: 768px) {
.glass-card {
flex-direction: column;
}
.auth-illustration {
border-inline-end: none;
border-bottom: 1px solid var(--auth-glass-border);
padding: 2rem;
flex: none;
}
.auth-form-container {
padding: 2rem;
}
}
@@ -0,0 +1,161 @@
/* Premium 2026 Enterprise Theme Overrides */
/* Define variables specifically for the premium theme */
body[data-premium-theme="true"] {
/* Apple + Stripe Color Palette (Light) */
--premium-bg-gradient-1: #fdfbfb;
--premium-bg-gradient-2: #ebedee;
--premium-bg-gradient-3: #e0c3fc;
--premium-bg-gradient-4: #8ec5fc;
--premium-glass-bg: rgba(255, 255, 255, 0.7);
--premium-glass-border: rgba(255, 255, 255, 0.5);
--premium-glass-shadow: rgba(0, 0, 0, 0.05);
--premium-text-main: #1d1d1f;
--premium-text-muted: #86868b;
--premium-input-bg: rgba(255, 255, 255, 0.8);
--premium-input-border: #d2d2d7;
--premium-input-focus: #0066cc;
--premium-input-focus-shadow: rgba(0, 102, 204, 0.2);
--premium-btn-bg: #0066cc;
--premium-btn-text: #ffffff;
--premium-btn-hover: #0077ed;
/* Font family override */
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--premium-text-main);
/* Animated Gradient Background */
background: linear-gradient(-45deg, var(--premium-bg-gradient-1), var(--premium-bg-gradient-2), var(--premium-bg-gradient-3), var(--premium-bg-gradient-4));
background-size: 400% 400%;
animation: premiumGradientBG 15s ease infinite;
min-height: 100vh;
transition: background-color 0.4s ease, color 0.4s ease;
}
@keyframes premiumGradientBG {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Base resets when premium is active */
body[data-premium-theme="true"] .bg-primary,
body[data-premium-theme="true"] .bg-white,
body[data-premium-theme="true"] .bg-body-tertiary {
background-color: transparent !important;
}
/* Navbar Upgrade */
body[data-premium-theme="true"] .navbar {
background: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--premium-glass-border);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.03) !important;
}
body[data-premium-theme="true"] .navbar-dark .navbar-brand,
body[data-premium-theme="true"] .navbar-dark .nav-link,
body[data-premium-theme="true"] .navbar-dark .navbar-toggler-icon {
color: var(--premium-text-main) !important;
}
body[data-premium-theme="true"] .navbar-dark .nav-link:hover {
color: var(--premium-btn-hover) !important;
}
body[data-premium-theme="true"] .navbar-dark .navbar-toggler {
border-color: rgba(0,0,0,0.1);
}
body[data-premium-theme="true"] .navbar-dark .navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") !important;
}
/* Card Upgrades (Glassmorphism) */
body[data-premium-theme="true"] .card {
background: var(--premium-glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--premium-glass-border) !important;
border-radius: 20px !important;
box-shadow: 0 8px 32px var(--premium-glass-shadow) !important;
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
body[data-premium-theme="true"] .card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(0,0,0,0.08) !important;
}
body[data-premium-theme="true"] .card-header,
body[data-premium-theme="true"] .card-footer {
background: transparent !important;
border-color: var(--premium-glass-border) !important;
}
/* Forms & Inputs Upgrade */
body[data-premium-theme="true"] .form-control,
body[data-premium-theme="true"] .form-select {
background-color: var(--premium-input-bg);
border: 1px solid var(--premium-input-border);
border-radius: 10px;
color: var(--premium-text-main);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
body[data-premium-theme="true"] .form-control:focus,
body[data-premium-theme="true"] .form-select:focus {
border-color: var(--premium-input-focus);
box-shadow: 0 0 0 3px var(--premium-input-focus-shadow);
background-color: #ffffff;
}
/* Buttons Upgrade */
body[data-premium-theme="true"] .btn {
border-radius: 10px;
font-weight: 500;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
body[data-premium-theme="true"] .btn-primary {
background-color: var(--premium-btn-bg);
border-color: var(--premium-btn-bg);
color: var(--premium-btn-text);
}
body[data-premium-theme="true"] .btn-primary:hover,
body[data-premium-theme="true"] .btn-primary:focus {
background-color: var(--premium-btn-hover);
border-color: var(--premium-btn-hover);
box-shadow: 0 4px 12px var(--premium-input-focus-shadow);
transform: scale(1.02);
}
/* Footer Upgrade */
body[data-premium-theme="true"] .footer {
background-color: transparent;
border-top: none;
color: var(--premium-text-muted);
}
/* Canvas Particle Background container */
#premiumParticleCanvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -1;
pointer-events: none;
opacity: 0;
transition: opacity 1s ease;
}
body[data-premium-theme="true"] #premiumParticleCanvas {
opacity: 1;
}
@@ -0,0 +1,136 @@
document.addEventListener('DOMContentLoaded', () => {
initThemeManager();
initParticleSystem();
initFormInteractions();
});
// --- Theme Manager ---
function initThemeManager() {
const htmlElement = document.documentElement;
const themeBtn = document.getElementById('themeToggleBtn');
const themeIcon = document.getElementById('themeIcon');
// Check local storage or system preference
const savedTheme = localStorage.getItem('auth_theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
let currentTheme = savedTheme || (prefersDark ? 'dark' : 'light');
applyTheme(currentTheme);
if (themeBtn) {
themeBtn.addEventListener('click', () => {
currentTheme = currentTheme === 'light' ? 'dark' : 'light';
applyTheme(currentTheme);
});
}
function applyTheme(theme) {
htmlElement.setAttribute('data-bs-theme', theme);
localStorage.setItem('auth_theme', theme);
if (themeIcon) {
themeIcon.className = theme === 'light' ? 'fa-regular fa-moon' : 'fa-regular fa-sun';
}
}
}
// --- Particle System (Lightweight Canvas) ---
function initParticleSystem() {
const canvas = document.getElementById('particleCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let width, height, particles;
function resize() {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
}
class Particle {
constructor() {
this.reset();
}
reset() {
this.x = Math.random() * width;
this.y = Math.random() * height;
this.size = Math.random() * 2 + 0.5;
this.speedX = (Math.random() - 0.5) * 0.5;
this.speedY = (Math.random() - 0.5) * 0.5;
this.opacity = Math.random() * 0.5 + 0.1;
}
update() {
this.x += this.speedX;
this.y += this.speedY;
if (this.x < 0 || this.x > width || this.y < 0 || this.y > height) {
this.reset();
}
}
draw() {
// Read current theme to adjust particle color slightly
const theme = document.documentElement.getAttribute('data-bs-theme');
const color = theme === 'dark' ? `rgba(255, 255, 255, ${this.opacity})` : `rgba(0, 0, 0, ${this.opacity * 0.5})`;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
}
}
function init() {
resize();
particles = [];
const particleCount = Math.min(window.innerWidth / 15, 100); // Responsive count
for (let i = 0; i < particleCount; i++) {
particles.push(new Particle());
}
}
function animate() {
ctx.clearRect(0, 0, width, height);
particles.forEach(p => {
p.update();
p.draw();
});
requestAnimationFrame(animate);
}
window.addEventListener('resize', () => {
resize();
init();
});
init();
animate();
}
// --- Form Interactions & Loading Overlay ---
function initFormInteractions() {
const loginForm = document.getElementById('loginForm');
const loadingOverlay = document.getElementById('loadingOverlay');
const submitBtn = document.getElementById('submitBtn');
if (loginForm && loadingOverlay) {
loginForm.addEventListener('submit', (e) => {
// Prevent multiple submissions
if (loginForm.classList.contains('is-submitting')) {
e.preventDefault();
return;
}
loginForm.classList.add('is-submitting');
loadingOverlay.classList.add('active');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> กำลังเข้าสู่ระบบ...';
}
// Allow form to submit normally after showing UI
});
}
}
@@ -0,0 +1,108 @@
document.addEventListener('DOMContentLoaded', () => {
// Inject Canvas for background if it doesn't exist
if (!document.getElementById('premiumParticleCanvas')) {
const canvas = document.createElement('canvas');
canvas.id = 'premiumParticleCanvas';
document.body.prepend(canvas);
// Initialize particle system if premium theme is active
if (document.body.getAttribute('data-premium-theme') === 'true') {
initPremiumParticleSystem();
}
}
});
// Global function to toggle theme, callable from Settings page
window.togglePremiumTheme = function(enable) {
const isEnabled = enable === true;
localStorage.setItem('app_premium_theme', isEnabled ? 'true' : 'false');
document.body.setAttribute('data-premium-theme', isEnabled ? 'true' : 'false');
if (isEnabled) {
initPremiumParticleSystem();
}
};
// --- Particle System (Lightweight Canvas) ---
let premiumParticlesInitialized = false;
function initPremiumParticleSystem() {
if (premiumParticlesInitialized) return;
premiumParticlesInitialized = true;
const canvas = document.getElementById('premiumParticleCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let width, height, particles;
function resize() {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
}
class Particle {
constructor() {
this.reset();
}
reset() {
this.x = Math.random() * width;
this.y = Math.random() * height;
this.size = Math.random() * 2 + 0.5;
this.speedX = (Math.random() - 0.5) * 0.5;
this.speedY = (Math.random() - 0.5) * 0.5;
this.opacity = Math.random() * 0.4 + 0.1;
}
update() {
this.x += this.speedX;
this.y += this.speedY;
if (this.x < 0 || this.x > width || this.y < 0 || this.y > height) {
this.reset();
}
}
draw() {
// Check if theme is still active before drawing
if (document.body.getAttribute('data-premium-theme') !== 'true') return;
// Simple white/grey particles for the bright premium theme
ctx.fillStyle = `rgba(0, 0, 0, ${this.opacity * 0.4})`;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
}
}
function init() {
resize();
particles = [];
const particleCount = Math.min(window.innerWidth / 15, 80);
for (let i = 0; i < particleCount; i++) {
particles.push(new Particle());
}
}
function animate() {
if (document.body.getAttribute('data-premium-theme') === 'true') {
ctx.clearRect(0, 0, width, height);
particles.forEach(p => {
p.update();
p.draw();
});
}
requestAnimationFrame(animate);
}
window.addEventListener('resize', () => {
if (document.body.getAttribute('data-premium-theme') === 'true') {
resize();
init();
}
});
init();
animate();
}
@@ -0,0 +1,139 @@
<?php
session_start();
$scriptName = $_SERVER['SCRIPT_NAME'];
$baseDir = dirname(dirname($scriptName));
if ($baseDir === '/' || $baseDir === '\\') $baseDir = '';
define('BASE_URL', $baseDir);
// Simple PSR-4 Autoloader
spl_autoload_register(function ($class) {
// Project-specific namespace prefix
$prefix = 'App\\';
// Base directory for the namespace prefix
$base_dir = __DIR__ . '/../src/';
// Does the class use the namespace prefix?
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
// No, move to the next registered autoloader
return;
}
// Get the relative class name
$relative_class = substr($class, $len);
// Replace the namespace prefix with the base directory, replace namespace
// separators with directory separators in the relative class name, append
// with .php
$file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
// If the file exists, require it
if (file_exists($file)) {
require $file;
}
});
// Setup Global Error & Exception Handling
set_error_handler(function($severity, $message, $file, $line) {
if (!(error_reporting() & $severity)) {
return;
}
throw new \ErrorException($message, 0, $severity, $file, $line);
});
set_exception_handler(function($exception) {
error_log($exception->getMessage() . " in " . $exception->getFile() . " on line " . $exception->getLine());
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest';
if ($isAjax || (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false)) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'status' => 'error',
'message' => 'เกิดข้อผิดพลาดในระบบ โปรดติดต่อผู้ดูแลระบบ'
]);
exit;
}
http_response_code(500);
$error_message = 'เกิดข้อผิดพลาด: ' . $exception->getMessage() . ' ในไฟล์ ' . $exception->getFile() . ' บรรทัดที่ ' . $exception->getLine();
// Check if error view exists
$errorView = __DIR__ . '/../views/error.php';
if (file_exists($errorView)) {
require $errorView;
} else {
echo "<h1>500 Internal Server Error</h1><p>{$error_message}</p>";
}
exit;
});
// Load Router
use App\Core\Router;
$router = new Router();
// Routes definition
$router->add('GET', '/', 'DashboardController', 'index');
$router->add('GET', '/login', 'AuthController', 'login');
$router->add('POST', '/login', 'AuthController', 'authenticate');
$router->add('GET', '/logout', 'AuthController', 'logout');
$router->add('GET', '/trip/create', 'TripController', 'create');
$router->add('POST', '/trip/create', 'TripController', 'store');
$router->add('GET', '/trip/search-docs', 'TripController', 'searchDocs');
$router->add('GET', '/trip/edit', 'TripController', 'edit');
$router->add('POST', '/trip/edit', 'TripController', 'update');
$router->add('POST', '/trip/delete', 'TripController', 'delete');
$router->add('POST', '/trip/request-cancel', 'TripController', 'requestCancel');
$router->add('POST', '/trip/request-revise', 'TripController', 'requestRevise');
$router->add('GET', '/trip/approvals', 'ApprovalController', 'index');
$router->add('POST', '/trip/approvals/process', 'ApprovalController', 'process');
$router->add('GET', '/report', 'ReportController', 'index');
$router->add('GET', '/report/export', 'ReportController', 'export');
$router->add('GET', '/settings', 'SettingsController', 'index');
$router->add('GET', '/settings/google-sheets', 'SettingsController', 'googleSheets');
$router->add('POST', '/settings/google-sheets/save', 'SettingsController', 'googleSheetsSave');
$router->add('GET', '/settings/google-sheets/sync', 'SettingsController', 'googleSheetsSync');
$router->add('POST', '/settings/google-sheets/test', 'SettingsController', 'googleSheetsTest');
$router->add('GET', '/settings/hierarchy', 'SettingsController', 'approvalHierarchy');
$router->add('GET', '/settings/search-user', 'SettingsController', 'searchUser');
$router->add('POST', '/settings/hierarchy/add', 'SettingsController', 'addHierarchy');
$router->add('POST', '/settings/hierarchy/remove', 'SettingsController', 'removeHierarchy');
$router->add('GET', '/settings/line-notify', 'SettingsController', 'lineNotify');
$router->add('POST', '/settings/line-notify/save', 'SettingsController', 'saveLineNotify');
$router->add('POST', '/settings/line-notify/test', 'SettingsController', 'lineNotifyTest');
$router->add('GET', '/settings/telegram-notify', 'SettingsController', 'telegramNotify');
$router->add('POST', '/settings/telegram-notify/save', 'SettingsController', 'saveTelegramNotify');
$router->add('POST', '/settings/telegram-notify/test', 'SettingsController', 'telegramNotifyTest');
$router->add('GET', '/settings/pdf-designer', 'SettingsController', 'pdfDesigner');
$router->add('GET', '/settings/pdf-designer/preview', 'SettingsController', 'previewPdf');
$router->add('POST', '/settings/pdf-designer/upload', 'SettingsController', 'uploadPdfTemplate');
$router->add('POST', '/settings/pdf-designer/save', 'SettingsController', 'savePdfLayout');
$router->add('POST', '/settings/pdf-designer/delete', 'SettingsController', 'deletePdfTemplate');
$router->add('GET', '/trip/print', 'TripController', 'printPdf');
// Roles
$router->add('GET', '/roles', 'RoleController', 'index');
$router->add('GET', '/roles/search', 'RoleController', 'search');
$router->add('POST', '/roles/assign', 'RoleController', 'assign');
$router->add('POST', '/roles/remove', 'RoleController', 'remove');
$router->add('GET', '/reports', 'ReportController', 'index');
$router->add('GET', '/reports/export', 'ReportController', 'export');
$router->add('GET', '/audit', 'AuditController', 'index');
$router->add('GET', '/manual', 'ManualController', 'index');
$router->dispatch($_SERVER['REQUEST_URI'], $_SERVER['REQUEST_METHOD']);
@@ -0,0 +1,161 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Database;
use App\Core\Security;
class ApprovalController extends Controller
{
/**
* List all pending approvals for the current user
*/
public function index()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
$approvals = [];
try {
$pdo = Database::getSystemConnection();
$stmt = $pdo->prepare("
SELECT a.id as approval_id, a.step, a.status as approval_status, a.created_at,
r.id as request_id, r.doc_number, r.doc_link, r.title, r.objective, r.location, r.start_date, r.end_date, r.budget,
r.status as req_status, r.cancel_reason, r.revise_reason
FROM trip_approvals a
JOIN trip_requests r ON a.request_id = r.id
WHERE a.approver_id = :approver_id AND a.status = 'Pending'
ORDER BY a.created_at DESC
");
$stmt->execute(['approver_id' => $_SESSION['user_id']]);
$approvals = $stmt->fetchAll();
} catch (\PDOException $e) {
echo "Error: " . $e->getMessage();
exit;
}
$this->render('trip/approvals', [
'approvals' => $approvals,
'csrf_token' => Security::generateCsrfToken()
]);
}
/**
* Process an approval action (Approve / Reject)
*/
public function process()
{
Security::enforcePostWithCsrf();
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
$approval_id = $_POST['approval_id'] ?? null;
$action = $_POST['action'] ?? null; // 'approve' or 'reject'
$comments = trim($_POST['comments'] ?? '');
if (!$approval_id || !in_array($action, ['approve', 'reject'])) {
$_SESSION['flash_error'] = "ข้อมูลไม่ถูกต้อง";
$this->redirect('/trip/approvals');
}
$new_status = ($action === 'approve') ? 'Approved' : 'Rejected';
try {
$pdo = Database::getSystemConnection();
$pdo->beginTransaction();
// 1. Update trip_approvals
$stmt = $pdo->prepare("
UPDATE trip_approvals
SET status = :status, comments = :comments
WHERE id = :id AND approver_id = :approver_id AND status = 'Pending'
");
$stmt->execute([
'status' => $new_status,
'comments' => $comments,
'id' => $approval_id,
'approver_id' => $_SESSION['user_id']
]);
if ($stmt->rowCount() === 0) {
throw new \Exception("ไม่พบรายการที่รออนุมัติ หรือคุณไม่มีสิทธิ์");
}
// 2. Fetch the request ID and current status to update the main trip_requests status
$stmtReq = $pdo->prepare("SELECT r.id, r.status FROM trip_approvals a JOIN trip_requests r ON a.request_id = r.id WHERE a.id = :id");
$stmtReq->execute(['id' => $approval_id]);
$reqData = $stmtReq->fetch(\PDO::FETCH_ASSOC);
$request_id = $reqData['id'];
$current_req_status = $reqData['status'];
$final_req_status = $new_status;
if ($action === 'approve') {
if ($current_req_status === 'Pending Cancel') {
$final_req_status = 'Cancelled';
} elseif ($current_req_status === 'Pending Revise') {
$final_req_status = 'Approved';
}
} else { // reject
if ($current_req_status === 'Pending Cancel' || $current_req_status === 'Pending Revise') {
// If reject cancellation/revision, revert back to Approved
$final_req_status = 'Approved';
}
}
$stmtUpdateReq = $pdo->prepare("UPDATE trip_requests SET status = :status WHERE id = :req_id");
$stmtUpdateReq->execute([
'status' => $final_req_status,
'req_id' => $request_id
]);
// 3. Log Audit
$stmtAudit = $pdo->prepare("
INSERT INTO audit_logs (user_id, action, entity_type, entity_id)
VALUES (:user_id, :action_log, 'trip_requests', :req_id)
");
$stmtAudit->execute([
'user_id' => $_SESSION['user_id'],
'action_log' => strtoupper($new_status),
'req_id' => $request_id
]);
$pdo->commit();
// 4. Send Notifications
$stmtTitle = $pdo->prepare("SELECT title FROM trip_requests WHERE id = :id");
$stmtTitle->execute(['id' => $request_id]);
$title = $stmtTitle->fetchColumn();
$statusText = $new_status === 'Approved' ? '✅ อนุมัติแล้ว' : '❌ ไม่อนุมัติ';
$msg = "📝 <b>แจ้งผลการพิจารณา</b>\n";
$msg .= "เรื่อง: " . $title . "\n";
$msg .= "สถานะ: " . $statusText . "\n";
if (!empty($comments)) {
$msg .= "หมายเหตุ: " . $comments . "\n";
}
require_once __DIR__ . '/../Services/LineNotifyService.php';
$notifyService = new \App\Services\LineNotifyService();
$notifyService->send(strip_tags($msg));
require_once __DIR__ . '/../Services/TelegramNotifyService.php';
$telegramService = new \App\Services\TelegramNotifyService();
$telegramService->send($msg);
$_SESSION['flash_success'] = "ดำเนินการเรียบร้อยแล้ว";
} catch (\Exception $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
$_SESSION['flash_error'] = "เกิดข้อผิดพลาด: " . $e->getMessage();
}
$this->redirect('/trip/approvals');
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Database;
class AuditController extends Controller
{
/**
* Show audit logs
*/
public function index()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
$logs = [];
try {
$pdo = Database::getSystemConnection();
// In a real app, join with hr_person to get the username of the actor
$stmt = $pdo->query("
SELECT id, user_id, action, entity_type, entity_id, created_at
FROM audit_logs
ORDER BY created_at DESC
LIMIT 500
");
$logs = $stmt->fetchAll();
} catch (\PDOException $e) {
// Log error
}
$this->render('audit/index', ['logs' => $logs]);
}
}
@@ -0,0 +1,137 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Security;
use App\Core\Database;
class AuthController extends Controller
{
/**
* Show login form
*/
public function login()
{
// If already logged in, redirect to dashboard
if (isset($_SESSION['user_id'])) {
$this->redirect('/');
}
// We render login page without the main layout to keep it clean,
// or we can pass a specific auth layout
$this->render('auth/login', [
'csrf_token' => Security::generateCsrfToken()
], 'auth');
}
/**
* Handle login post request
*/
public function authenticate()
{
Security::enforcePostWithCsrf();
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if (empty($username) || empty($password)) {
$_SESSION['flash_error'] = "กรุณากรอกชื่อผู้ใช้งานและรหัสผ่าน";
$this->redirect('/login');
}
$pass_md5 = md5($password);
$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
WHERE a.HR_USERNAME = :user LIMIT 1";
try {
$pdoHr = Database::getHrConnection();
$stmt = $pdoHr->prepare($sql);
$stmt->execute(['user' => $username]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row) {
$_SESSION['flash_error'] = "ไม่พบบัญชีผู้ใช้นี้";
$this->redirect('/login');
}
if (strtolower($row['HR_PASSWORD']) !== strtolower($pass_md5)) {
$_SESSION['flash_error'] = "รหัสผ่านไม่ถูกต้อง";
$this->redirect('/login');
}
if ($row['HR_STATUS_ID'] !== '01') {
$_SESSION['flash_error'] = "บัญชีผู้ใช้นี้ถูกปิดการใช้งานไปแล้ว";
$this->redirect('/login');
}
// Prepare data
$fullname = ($row['HR_PREFIX_NAME'] ?? '') . ($row['HR_FNAME'] ?? '') . ' ' . ($row['HR_LNAME'] ?? '');
$department = $row['HR_DEPARTMENT_SUB_SUB_NAME'] ?? 'ไม่ระบุ';
$position = trim(($row['HR_POSITION_NAME'] ?? '') . ' ' . ($row['HR_LEVEL_NAME'] ?? ''));
$person_type = $row['HR_PERSON_TYPE_NAME'] ?? 'ไม่ระบุ';
// Calculate work duration
$work_duration = 'ไม่ระบุวันที่เริ่มงาน';
if (!empty($row['HR_STARTWORK_DATE'])) {
try {
$startDate = new \DateTime($row['HR_STARTWORK_DATE']);
$currentDate = new \DateTime();
// ตรวจสอบว่าวันที่เริ่มงานไม่ใช่อนาคต
if ($startDate <= $currentDate) {
$interval = $currentDate->diff($startDate);
$work_duration = $interval->format('%y ปี %m เดือน %d วัน');
}
} catch (\Exception $e) {
// do nothing, keep default message
}
}
// Login Success
$_SESSION['user_id'] = $row['HR_CID'];
$_SESSION['username'] = $username;
$_SESSION['fullname'] = trim($fullname);
$_SESSION['cid'] = $row['HR_CID'];
$_SESSION['department'] = $department;
$_SESSION['position'] = $position;
$_SESSION['person_type'] = $person_type;
$_SESSION['work_duration'] = $work_duration;
// Fetch roles from System Database
$pdoSys = Database::getSystemConnection();
$stmtRoles = $pdoSys->prepare("
SELECT r.name
FROM system_user_roles ur
JOIN system_roles r ON ur.role_id = r.id
WHERE ur.user_id = :user_id
");
$stmtRoles->execute(['user_id' => $row['HR_CID']]);
$_SESSION['roles'] = $stmtRoles->fetchAll(\PDO::FETCH_COLUMN);
$_SESSION['flash_success'] = "เข้าสู่ระบบสำเร็จ ยินดีต้อนรับ " . $_SESSION['fullname'];
$this->redirect('/');
} catch (\PDOException $e) {
// Log error
$_SESSION['flash_error'] = "เกิดข้อผิดพลาดของระบบ: " . $e->getMessage();
$this->redirect('/login');
}
}
/**
* Logout
*/
public function logout()
{
session_unset();
session_destroy();
$this->redirect('/login');
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Database;
class DashboardController extends Controller
{
public function index()
{
// Enforce login
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
// Mock statistics for the dashboard
$stats = [
'total_requests' => 0,
'pending_approval' => 0,
'approved' => 0,
'rejected' => 0
];
$recentRequests = [];
try {
$pdo = Database::getSystemConnection();
// Get stats for current user
$stmt = $pdo->prepare("
SELECT status, COUNT(*) as count
FROM trip_requests
WHERE user_id = :user_id
GROUP BY status
");
$stmt->execute(['user_id' => $_SESSION['user_id']]);
$results = $stmt->fetchAll();
foreach ($results as $row) {
$stats['total_requests'] += $row['count'];
if ($row['status'] === 'Pending') $stats['pending_approval'] += $row['count'];
if ($row['status'] === 'Approved') $stats['approved'] += $row['count'];
if ($row['status'] === 'Rejected') $stats['rejected'] += $row['count'];
}
// Determine selected year (default to current year)
$selectedYear = isset($_GET['year']) ? (int)$_GET['year'] : (int)date('Y');
// Get available years for the dropdown
$stmtYears = $pdo->prepare("
SELECT DISTINCT YEAR(created_at) as y
FROM trip_requests
WHERE user_id = :user_id
ORDER BY y DESC
");
$stmtYears->execute(['user_id' => $_SESSION['user_id']]);
$availableYears = $stmtYears->fetchAll(\PDO::FETCH_COLUMN);
if (empty($availableYears)) {
$availableYears = [date('Y')];
}
if (!in_array($selectedYear, $availableYears)) {
$availableYears[] = $selectedYear;
rsort($availableYears);
}
// Get all requests for the selected year
$stmtRecent = $pdo->prepare("
SELECT id, doc_number, title, location, start_date, end_date, status, created_at
FROM trip_requests
WHERE user_id = :user_id AND YEAR(created_at) = :year
ORDER BY created_at DESC
");
$stmtRecent->execute([
'user_id' => $_SESSION['user_id'],
'year' => $selectedYear
]);
$recentRequests = $stmtRecent->fetchAll();
} catch (\PDOException $e) {
// In a real app, log error. Here we just proceed with empty arrays.
}
$this->render('dashboard/index', [
'stats' => $stats,
'recentRequests' => $recentRequests,
'availableYears' => $availableYears,
'selectedYear' => $selectedYear,
'fullname' => $_SESSION['fullname'] ?? 'User',
'csrf_token' => \App\Core\Security::generateCsrfToken()
]);
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
class ManualController extends Controller
{
public function index()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
$roles = $_SESSION['roles'] ?? [];
$isAdmin = in_array('Admin', $roles);
$this->render('manual/index', [
'isAdmin' => $isAdmin
]);
}
}
@@ -0,0 +1,137 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Database;
class ReportController extends Controller
{
/**
* Show report dashboard
*/
public function index()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
$pdo = Database::getSystemConnection();
$currentYear = date('Y');
$filterYear = isset($_GET['year']) ? (int)$_GET['year'] : $currentYear;
// 1. Total trips this year
$stmtTrips = $pdo->prepare("SELECT COUNT(*) FROM trip_requests WHERE YEAR(start_date) = :year");
$stmtTrips->execute(['year' => $filterYear]);
$totalTrips = $stmtTrips->fetchColumn();
// 2. Total Budget this year (Approved only)
$stmtBudget = $pdo->prepare("SELECT SUM(budget) FROM trip_requests WHERE YEAR(start_date) = :year AND status = 'Approved'");
$stmtBudget->execute(['year' => $filterYear]);
$totalBudget = $stmtBudget->fetchColumn() ?: 0;
// 3. Status breakdown
$stmtStatus = $pdo->prepare("SELECT status, COUNT(*) as count FROM trip_requests WHERE YEAR(start_date) = :year GROUP BY status");
$stmtStatus->execute(['year' => $filterYear]);
$statusData = $stmtStatus->fetchAll(\PDO::FETCH_KEY_PAIR);
$pendingCount = $statusData['Pending'] ?? 0;
$approvedCount = $statusData['Approved'] ?? 0;
$rejectedCount = $statusData['Rejected'] ?? 0;
// 4. Monthly budget data for Chart
$stmtMonthly = $pdo->prepare("
SELECT MONTH(start_date) as month, SUM(budget) as total
FROM trip_requests
WHERE YEAR(start_date) = :year AND status = 'Approved'
GROUP BY MONTH(start_date)
ORDER BY MONTH(start_date)
");
$stmtMonthly->execute(['year' => $filterYear]);
$monthlyRaw = $stmtMonthly->fetchAll(\PDO::FETCH_ASSOC);
$monthlyBudget = array_fill(1, 12, 0);
foreach ($monthlyRaw as $row) {
$monthlyBudget[$row['month']] = (float)$row['total'];
}
// 5. Recent Trips List
$stmtRecent = $pdo->prepare("
SELECT id, title, location, start_date, budget, status, trip_running_number, doc_number, doc_no
FROM trip_requests
WHERE YEAR(start_date) = :year
ORDER BY created_at DESC
LIMIT 50
");
$stmtRecent->execute(['year' => $filterYear]);
$recentTrips = $stmtRecent->fetchAll();
$this->render('report/index', [
'totalTrips' => $totalTrips,
'totalBudget' => $totalBudget,
'pendingCount' => $pendingCount,
'approvedCount' => $approvedCount,
'rejectedCount' => $rejectedCount,
'monthlyBudget' => array_values($monthlyBudget),
'recentTrips' => $recentTrips,
'filterYear' => $filterYear
]);
}
/**
* Export data to Excel (Mock PhpSpreadsheet)
*/
public function export()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
// Mock exporting data
// In reality, require vendor/autoload.php and use PhpOffice\PhpSpreadsheet
$currentYear = date('Y');
$filterYear = isset($_GET['year']) ? (int)$_GET['year'] : $currentYear;
$filename = "trip_report_" . $filterYear . "_" . date('Ymd_His') . ".csv";
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename=' . $filename);
// Add BOM for UTF-8 Excel compatibility
echo "\xEF\xBB\xBF";
$output = fopen('php://output', 'w');
fputcsv($output, ['เลขที่หนังสือเดินทาง', 'เลขอ้างอิงหนังสือ', 'เลขที่หนังสือ', 'เรื่อง', 'สถานที่', 'วันที่เริ่ม', 'งบประมาณ', 'สถานะ']);
try {
$pdo = Database::getSystemConnection();
$stmt = $pdo->prepare("
SELECT trip_running_number, doc_number, doc_no, title, location, start_date, budget, status
FROM trip_requests
WHERE YEAR(start_date) = :year
ORDER BY created_at DESC
LIMIT 500
");
$stmt->execute(['year' => $filterYear]);
while ($row = $stmt->fetch()) {
fputcsv($output, [
$row['trip_running_number'],
$row['doc_number'],
$row['doc_no'],
$row['title'],
$row['location'],
$row['start_date'],
$row['budget'],
$row['status']
]);
}
} catch (\PDOException $e) {
fputcsv($output, ['Error generating report']);
}
fclose($output);
exit;
}
}
@@ -0,0 +1,168 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Security;
use App\Core\Database;
class RoleController extends Controller
{
/**
* Show Role Management Page
*/
public function index()
{
if (empty($_SESSION['user_id'])) $this->redirect('/login');
// Only Admin should access this in a real app, assuming all logged-in can see for now or check roles:
if (!in_array('Admin', $_SESSION['roles'] ?? [])) {
$_SESSION['flash_error'] = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (เฉพาะ Admin)";
$this->redirect('/');
}
$pdoSys = Database::getSystemConnection();
// Get all available roles
$stmtRoles = $pdoSys->query("SELECT * FROM system_roles ORDER BY id ASC");
$roles = $stmtRoles->fetchAll(\PDO::FETCH_ASSOC);
// Get currently assigned users
$stmtUsers = $pdoSys->query("
SELECT ur.user_id, ur.role_id, r.name as role_name, r.description as role_desc
FROM system_user_roles ur
JOIN system_roles r ON ur.role_id = r.id
ORDER BY r.id ASC
");
$assigned = $stmtUsers->fetchAll(\PDO::FETCH_ASSOC);
// Fetch their names from HR Database
$userIds = array_column($assigned, 'user_id');
$hrUsers = [];
if (!empty($userIds)) {
$pdoHr = Database::getHrConnection();
$inQuery = implode(',', array_fill(0, count($userIds), '?'));
$stmtHr = $pdoHr->prepare("
SELECT a.HR_CID, e.HR_PREFIX_NAME, a.HR_FNAME, a.HR_LNAME
FROM hr_person a
LEFT OUTER JOIN hr_prefix e ON a.HR_PREFIX_ID=e.HR_PREFIX_ID
WHERE a.HR_CID IN ($inQuery)
");
$stmtHr->execute($userIds);
$hrRows = $stmtHr->fetchAll(\PDO::FETCH_ASSOC);
foreach ($hrRows as $row) {
$hrUsers[$row['HR_CID']] = $row['HR_PREFIX_NAME'] . $row['HR_FNAME'] . ' ' . $row['HR_LNAME'];
}
}
// Merge names into assigned
foreach ($assigned as &$a) {
$a['fullname'] = $hrUsers[$a['user_id']] ?? 'ไม่พบข้อมูล (' . $a['user_id'] . ')';
}
$this->render('roles/index', [
'roles' => $roles,
'assigned' => $assigned,
'csrf_token' => Security::generateCsrfToken()
]);
}
/**
* AJAX Search users in HR Database
*/
public function search()
{
if (empty($_SESSION['user_id']) || !in_array('Admin', $_SESSION['roles'] ?? [])) {
echo json_encode([]); exit;
}
$query = trim($_GET['q'] ?? '');
if (empty($query) || mb_strlen($query) < 2) {
echo json_encode([]); exit;
}
try {
$pdoHr = Database::getHrConnection();
$stmt = $pdoHr->prepare("
SELECT a.HR_CID as id, e.HR_PREFIX_NAME, a.HR_FNAME, a.HR_LNAME
FROM hr_person a
LEFT OUTER JOIN hr_prefix e ON a.HR_PREFIX_ID=e.HR_PREFIX_ID
WHERE CONCAT(a.HR_FNAME, ' ', a.HR_LNAME) LIKE :q1
OR a.HR_FNAME LIKE :q2
OR a.HR_LNAME LIKE :q3
LIMIT 20
");
$likeQuery = "%{$query}%";
$stmt->execute([
'q1' => $likeQuery,
'q2' => $likeQuery,
'q3' => $likeQuery
]);
$results = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$formatted = array_map(function($r) {
return [
'id' => $r['id'],
'text' => $r['HR_PREFIX_NAME'] . $r['HR_FNAME'] . ' ' . $r['HR_LNAME']
];
}, $results);
echo json_encode($formatted);
} catch (\Exception $e) {
// แสดงข้อความ Error เป็นผลลัพธ์ใน Dropdown เพื่อให้รู้ว่าเชื่อมต่อผิดพลาดตรงไหน
echo json_encode([
['id' => '', 'text' => 'DB Error: ' . $e->getMessage()]
]);
}
exit;
}
/**
* Assign Role
*/
public function assign()
{
if (empty($_SESSION['user_id']) || !in_array('Admin', $_SESSION['roles'] ?? [])) $this->redirect('/login');
Security::enforcePostWithCsrf();
$user_id = trim($_POST['user_id'] ?? '');
$role_id = $_POST['role_id'] ?? '';
if (empty($user_id) || empty($role_id)) {
$_SESSION['flash_error'] = "กรุณาเลือกผู้ใช้งานและสิทธิ์";
$this->redirect('/roles');
}
try {
$pdoSys = Database::getSystemConnection();
$stmt = $pdoSys->prepare("INSERT IGNORE INTO system_user_roles (user_id, role_id) VALUES (?, ?)");
$stmt->execute([$user_id, $role_id]);
$_SESSION['flash_success'] = "เพิ่มสิทธิ์สำเร็จ";
} catch (\Exception $e) {
$_SESSION['flash_error'] = "เกิดข้อผิดพลาด: " . $e->getMessage();
}
$this->redirect('/roles');
}
/**
* Remove Role
*/
public function remove()
{
if (empty($_SESSION['user_id']) || !in_array('Admin', $_SESSION['roles'] ?? [])) $this->redirect('/login');
Security::enforcePostWithCsrf();
$user_id = trim($_POST['user_id'] ?? '');
$role_id = $_POST['role_id'] ?? '';
if (!empty($user_id) && !empty($role_id)) {
$pdoSys = Database::getSystemConnection();
$stmt = $pdoSys->prepare("DELETE FROM system_user_roles WHERE user_id = ? AND role_id = ?");
$stmt->execute([$user_id, $role_id]);
$_SESSION['flash_success'] = "ลบสิทธิ์สำเร็จ";
}
$this->redirect('/roles');
}
}
@@ -0,0 +1,691 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Security;
use App\Core\Database;
class SettingsController extends Controller
{
public function index()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
$this->autoMigrate();
$this->render('settings/index', [
'csrf_token' => Security::generateCsrfToken()
]);
}
private function autoMigrate()
{
$pdo = Database::getSystemConnection();
// 1. Approval Hierarchy
$pdo->exec("
CREATE TABLE IF NOT EXISTS `approval_hierarchy` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` VARCHAR(50) NOT NULL,
`approver_id` VARCHAR(50) NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
");
// 2. System Settings (for LINE Notify token, etc)
$pdo->exec("
CREATE TABLE IF NOT EXISTS `system_settings` (
`setting_key` VARCHAR(50) PRIMARY KEY,
`setting_value` TEXT,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
");
}
public function googleSheets()
{
if (empty($_SESSION['user_id'])) $this->redirect('/login');
$pdo = Database::getSystemConnection();
$stmt = $pdo->query("SELECT * FROM google_sheet_settings LIMIT 1");
$setting = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$setting) {
$setting = [
'spreadsheet_id' => '',
'connection_type' => 'API',
'api_key' => '',
'sheet_name' => 'Sheet1',
'sync_range' => 'A2:B',
'column_mapping' => '{"0": "doc_number", "1": "title"}',
'last_synced_at' => null
];
}
$this->render('settings/google_sheets', [
'setting' => $setting,
'csrf_token' => Security::generateCsrfToken()
]);
}
public function googleSheetsSave()
{
if (empty($_SESSION['user_id'])) $this->redirect('/login');
Security::enforcePostWithCsrf();
$connection_type = trim($_POST['connection_type'] ?? 'API');
$spreadsheet_id = trim($_POST['spreadsheet_id'] ?? '');
$api_key = trim($_POST['api_key'] ?? '');
$sheet_name = trim($_POST['sheet_name'] ?? '');
$sync_range = trim($_POST['sync_range'] ?? '');
$column_mapping = trim($_POST['column_mapping'] ?? '');
$pdo = Database::getSystemConnection();
$stmt = $pdo->query("SELECT id FROM google_sheet_settings LIMIT 1");
$exists = $stmt->fetchColumn();
if ($exists) {
$update = $pdo->prepare("UPDATE google_sheet_settings SET spreadsheet_id=?, connection_type=?, api_key=?, sheet_name=?, sync_range=?, column_mapping=? WHERE id=?");
$update->execute([$spreadsheet_id, $connection_type, $api_key, $sheet_name, $sync_range, $column_mapping, $exists]);
} else {
$insert = $pdo->prepare("INSERT INTO google_sheet_settings (spreadsheet_id, connection_type, api_key, sheet_name, sync_range, column_mapping) VALUES (?, ?, ?, ?, ?, ?)");
$insert->execute([$spreadsheet_id, $connection_type, $api_key, $sheet_name, $sync_range, $column_mapping]);
}
$_SESSION['flash_success'] = "บันทึกการตั้งค่า Google Sheets เรียบร้อยแล้ว";
$this->redirect('/settings/google-sheets');
}
public function googleSheetsTest()
{
if (empty($_SESSION['user_id'])) {
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
Security::enforcePostWithCsrf();
$pdo = Database::getSystemConnection();
$stmt = $pdo->query("SELECT * FROM google_sheet_settings LIMIT 1");
$setting = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$setting || empty($setting['spreadsheet_id'])) {
echo json_encode(['status' => 'error', 'message' => 'กรุณาตั้งค่า Spreadsheet ID ให้เรียบร้อยก่อน']);
exit;
}
$isPublic = ($setting['connection_type'] ?? 'API') === 'Public';
if (!$isPublic && empty($setting['api_key'])) {
echo json_encode(['status' => 'error', 'message' => 'กรุณากรอก API Key ก่อนทดสอบ']);
exit;
}
$values = [];
if ($isPublic) {
// ใช้เทคนิคดึง CSV ผ่าน gviz (ไม่ต้องใช้ API Key แต่ไฟล์ต้องแชร์สาธารณะ)
$url = "https://docs.google.com/spreadsheets/d/" . urlencode($setting['spreadsheet_id']) .
"/gviz/tq?tqx=out:csv&sheet=" . urlencode($setting['sheet_name']) .
"&range=" . urlencode($setting['sync_range']);
$context = stream_context_create(['http' => ['ignore_errors' => true]]);
$response = file_get_contents($url, false, $context);
if ($response === false) {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถเชื่อมต่อ Google Sheets ได้ (ตรวจสอบว่าเปิดแชร์ไฟล์เป็นสาธารณะหรือยัง)']);
exit;
}
// ตรวจสอบว่าเป็นหน้า HTML Error หรือไม่ (เช่น ลิงก์ผิดหรือไม่ได้แชร์)
if (strpos($response, '<html') !== false || strpos($response, '<!DOCTYPE') !== false) {
echo json_encode(['status' => 'error', 'message' => 'เข้าถึงไฟล์ไม่ได้ กรุณาตรวจสอบว่าเปิดแชร์ "ทุกคนที่มีลิงก์" (Anyone with the link) แล้ว']);
exit;
}
// แปลง CSV กลับเป็น Array
$lines = explode("\n", $response);
foreach ($lines as $line) {
if (trim($line) === '') continue;
$values[] = str_getcsv($line);
}
} else {
// ใช้ Google API v4 มาตรฐาน
$url = "https://sheets.googleapis.com/v4/spreadsheets/" . urlencode($setting['spreadsheet_id']) .
"/values/" . urlencode($setting['sheet_name'] . '!' . $setting['sync_range']) .
"?key=" . urlencode($setting['api_key']);
$context = stream_context_create(['http' => ['ignore_errors' => true]]);
$response = file_get_contents($url, false, $context);
if ($response === false) {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถเชื่อมต่อ API ได้']);
exit;
}
$data = json_decode($response, true);
if (isset($data['error'])) {
echo json_encode(['status' => 'error', 'message' => $data['error']['message'] ?? 'API Error']);
exit;
}
$values = $data['values'] ?? [];
}
if (empty($values)) {
echo json_encode(['status' => 'error', 'message' => 'เชื่อมต่อสำเร็จ แต่ไม่พบข้อมูลใน Range ที่ระบุ']);
exit;
}
$preview = [];
$mapping = json_decode($setting['column_mapping'], true);
if (is_array($mapping) && isset($values[0])) {
foreach ($mapping as $index => $field) {
$preview[$field] = $values[0][$index] ?? null;
}
}
if (!empty($setting['id'])) {
try {
$pdo->query("UPDATE google_sheet_settings SET last_synced_at = NOW() WHERE id = " . (int)$setting['id']);
} catch (\Exception $e) {
// Ignore DB update errors during test
}
}
$output = json_encode([
'status' => 'success',
'rows' => count($values),
'preview' => $preview
]);
if ($output === false) {
echo json_encode(['status' => 'error', 'message' => 'JSON Encode Error: ' . json_last_error_msg()]);
} else {
echo $output;
}
exit;
}
public function pdfDesigner()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
$pdo = Database::getSystemConnection();
// Auto-create table if not exists
$pdo->exec("
CREATE TABLE IF NOT EXISTS `pdf_templates` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(255) NOT NULL,
`file_path` VARCHAR(255) NOT NULL,
`field_mapping` JSON NULL,
`is_active` TINYINT(1) DEFAULT 1,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
");
// Ensure field_mapping column exists in case the table was created previously without it
try {
$pdo->exec("ALTER TABLE `pdf_templates` ADD COLUMN `field_mapping` JSON NULL AFTER `file_path`");
} catch (\PDOException $e) {
// Column already exists, do nothing
}
// Fetch current active template
$stmt = $pdo->query("SELECT * FROM pdf_templates WHERE is_active = 1 ORDER BY id DESC LIMIT 1");
$template = $stmt->fetch(\PDO::FETCH_ASSOC);
$this->render('settings/pdf_designer', [
'csrf_token' => Security::generateCsrfToken(),
'template' => $template
]);
}
public function approvalHierarchy()
{
if (empty($_SESSION['user_id'])) $this->redirect('/login');
$pdo = Database::getSystemConnection();
$hierarchyStmt = $pdo->query("SELECT * FROM approval_hierarchy");
$hierarchy = [];
$userIds = [];
$approverIds = [];
while ($row = $hierarchyStmt->fetch(\PDO::FETCH_ASSOC)) {
$hierarchy[] = $row;
if (!empty($row['user_id'])) $userIds[] = $row['user_id'];
if (!empty($row['approver_id'])) $approverIds[] = $row['approver_id'];
}
$allIds = array_unique(array_merge($userIds, $approverIds));
$userNames = [];
if (!empty($allIds)) {
$pdoHr = Database::getHrConnection();
$inQuery = implode(',', array_fill(0, count($allIds), '?'));
$stmtHr = $pdoHr->prepare("
SELECT a.HR_CID, e.HR_PREFIX_NAME, a.HR_FNAME, a.HR_LNAME
FROM hr_person a
LEFT OUTER JOIN hr_prefix e ON a.HR_PREFIX_ID=e.HR_PREFIX_ID
WHERE a.HR_CID IN ($inQuery)
");
$stmtHr->execute(array_values($allIds));
$hrRows = $stmtHr->fetchAll(\PDO::FETCH_ASSOC);
foreach ($hrRows as $row) {
$userNames[$row['HR_CID']] = $row['HR_PREFIX_NAME'] . $row['HR_FNAME'] . ' ' . $row['HR_LNAME'];
}
}
// Add names to hierarchy array
foreach ($hierarchy as &$h) {
$h['user_name'] = isset($userNames[$h['user_id']]) ? $userNames[$h['user_id']] : $h['user_id'];
$h['approver_name'] = isset($userNames[$h['approver_id']]) ? $userNames[$h['approver_id']] : $h['approver_id'];
}
$this->render('settings/hierarchy', [
'csrf_token' => Security::generateCsrfToken(),
'hierarchy' => $hierarchy
]);
}
/**
* AJAX Search users in HR Database
*/
public function searchUser()
{
if (empty($_SESSION['user_id'])) {
echo json_encode([]); exit;
}
$query = trim($_GET['q'] ?? '');
if (empty($query) || mb_strlen($query) < 2) {
echo json_encode([]); exit;
}
try {
$pdoHr = Database::getHrConnection();
$stmt = $pdoHr->prepare("
SELECT a.HR_CID as id, e.HR_PREFIX_NAME, a.HR_FNAME, a.HR_LNAME
FROM hr_person a
LEFT OUTER JOIN hr_prefix e ON a.HR_PREFIX_ID=e.HR_PREFIX_ID
WHERE CONCAT(a.HR_FNAME, ' ', a.HR_LNAME) LIKE :q1
OR a.HR_FNAME LIKE :q2
OR a.HR_LNAME LIKE :q3
LIMIT 20
");
$likeQuery = "%{$query}%";
$stmt->execute([
'q1' => $likeQuery,
'q2' => $likeQuery,
'q3' => $likeQuery
]);
$results = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$formatted = array_map(function($r) {
return [
'id' => $r['id'],
'text' => $r['HR_PREFIX_NAME'] . $r['HR_FNAME'] . ' ' . $r['HR_LNAME']
];
}, $results);
echo json_encode($formatted);
} catch (\Exception $e) {
echo json_encode([
['id' => '', 'text' => 'DB Error: ' . $e->getMessage()]
]);
}
exit;
}
public function addHierarchy()
{
if (empty($_SESSION['user_id'])) $this->redirect('/login');
Security::enforcePostWithCsrf();
$user_id = $_POST['user_id'] ?? '';
$approver_id = $_POST['approver_id'] ?? '';
if (empty($user_id) || empty($approver_id)) {
$_SESSION['flash_error'] = "กรุณาระบุพนักงานและผู้อนุมัติ";
$this->redirect('/settings/hierarchy');
}
try {
$pdo = Database::getSystemConnection();
$stmt = $pdo->prepare("
INSERT INTO approval_hierarchy (user_id, approver_id)
VALUES (:user_id, :approver_id)
ON DUPLICATE KEY UPDATE approver_id = :approver_id2
");
$stmt->execute([
'user_id' => $user_id,
'approver_id' => $approver_id,
'approver_id2' => $approver_id
]);
$_SESSION['flash_success'] = "เพิ่ม/อัปเดตสายงานอนุมัติเรียบร้อยแล้ว";
} catch (\Exception $e) {
$_SESSION['flash_error'] = "Error: " . $e->getMessage();
}
$this->redirect('/settings/hierarchy');
}
public function removeHierarchy()
{
if (empty($_SESSION['user_id'])) $this->redirect('/login');
Security::enforcePostWithCsrf();
$id = $_POST['id'] ?? '';
if (!empty($id)) {
try {
$pdo = Database::getSystemConnection();
$stmt = $pdo->prepare("DELETE FROM approval_hierarchy WHERE id = :id");
$stmt->execute(['id' => $id]);
$_SESSION['flash_success'] = "ลบสายงานอนุมัติเรียบร้อยแล้ว";
} catch (\Exception $e) {
$_SESSION['flash_error'] = "Error: " . $e->getMessage();
}
}
$this->redirect('/settings/hierarchy');
}
public function lineNotify()
{
if (empty($_SESSION['user_id'])) $this->redirect('/login');
$pdo = Database::getSystemConnection();
$stmt = $pdo->query("SELECT setting_value FROM system_settings WHERE setting_key = 'line_notify_token'");
$token = $stmt->fetchColumn();
$this->render('settings/line_notify', [
'csrf_token' => Security::generateCsrfToken(),
'line_token' => $token
]);
}
public function saveLineNotify()
{
if (empty($_SESSION['user_id'])) $this->redirect('/login');
Security::enforcePostWithCsrf();
$token = trim($_POST['line_token'] ?? '');
$pdo = Database::getSystemConnection();
$stmt = $pdo->prepare("
INSERT INTO system_settings (setting_key, setting_value)
VALUES ('line_notify_token', :token)
ON DUPLICATE KEY UPDATE setting_value = :token
");
$stmt->execute(['token' => $token]);
$_SESSION['flash_success'] = "บันทึก Line Notify Token เรียบร้อยแล้ว";
$this->redirect('/settings/line-notify');
}
public function telegramNotify()
{
if (empty($_SESSION['user_id'])) $this->redirect('/login');
$pdo = Database::getSystemConnection();
$stmt = $pdo->query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('telegram_bot_token', 'telegram_chat_id')");
$settings = $stmt->fetchAll(\PDO::FETCH_KEY_PAIR);
$this->render('settings/telegram_notify', [
'csrf_token' => Security::generateCsrfToken(),
'bot_token' => $settings['telegram_bot_token'] ?? '',
'chat_id' => $settings['telegram_chat_id'] ?? ''
]);
}
public function saveTelegramNotify()
{
if (empty($_SESSION['user_id'])) $this->redirect('/login');
Security::enforcePostWithCsrf();
$token = trim($_POST['bot_token'] ?? '');
$chatId = trim($_POST['chat_id'] ?? '');
$pdo = Database::getSystemConnection();
$stmt = $pdo->prepare("
INSERT INTO system_settings (setting_key, setting_value)
VALUES (:key, :val)
ON DUPLICATE KEY UPDATE setting_value = :val
");
$stmt->execute(['key' => 'telegram_bot_token', 'val' => $token]);
$stmt->execute(['key' => 'telegram_chat_id', 'val' => $chatId]);
$_SESSION['flash_success'] = "บันทึกการตั้งค่า Telegram เรียบร้อยแล้ว";
$this->redirect('/settings/telegram-notify');
}
public function lineNotifyTest()
{
if (empty($_SESSION['user_id'])) {
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
Security::enforcePostWithCsrf();
require_once __DIR__ . '/../Services/LineNotifyService.php';
$notifyService = new \App\Services\LineNotifyService();
$msg = "\n🔔 ทดสอบระบบแจ้งเตือน\n✅ การเชื่อมต่อกับ LINE Notify สำเร็จ!";
if ($notifyService->send($msg)) {
echo json_encode(['status' => 'success', 'message' => 'ส่งข้อความทดสอบสำเร็จ กรุณาตรวจสอบใน LINE']);
} else {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถส่งข้อความได้ กรุณาตรวจสอบ Token อีกครั้ง']);
}
exit;
}
public function telegramNotifyTest()
{
if (empty($_SESSION['user_id'])) {
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
Security::enforcePostWithCsrf();
require_once __DIR__ . '/../Services/TelegramNotifyService.php';
$telegramService = new \App\Services\TelegramNotifyService();
$msg = "🔔 <b>ทดสอบระบบแจ้งเตือน</b>\n✅ การเชื่อมต่อกับ Telegram Bot สำเร็จ!";
if ($telegramService->send($msg)) {
echo json_encode(['status' => 'success', 'message' => 'ส่งข้อความทดสอบสำเร็จ กรุณาตรวจสอบใน Telegram']);
} else {
echo json_encode(['status' => 'error', 'message' => 'ไม่สามารถส่งข้อความได้ กรุณาตรวจสอบ Bot Token และ Chat ID อีกครั้ง']);
}
exit;
}
public function uploadPdfTemplate()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
Security::enforcePostWithCsrf();
if (!isset($_FILES['pdf_file']) || $_FILES['pdf_file']['error'] !== UPLOAD_ERR_OK) {
$_SESSION['flash_error'] = "เกิดข้อผิดพลาดในการอัปโหลดไฟล์";
$this->redirect('/settings/pdf-designer');
}
$file = $_FILES['pdf_file'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'pdf') {
$_SESSION['flash_error'] = "รองรับเฉพาะไฟล์ PDF เท่านั้น";
$this->redirect('/settings/pdf-designer');
}
// Generate unique filename
$filename = 'template_' . time() . '.pdf';
$uploadDir = __DIR__ . '/../../public/uploads/templates/';
$destPath = $uploadDir . $filename;
$dbPath = '/uploads/templates/' . $filename;
if (move_uploaded_file($file['tmp_name'], $destPath)) {
$pdo = Database::getSystemConnection();
// Deactivate old templates
$pdo->query("UPDATE pdf_templates SET is_active = 0");
// Insert new template
$stmt = $pdo->prepare("INSERT INTO pdf_templates (name, file_path, is_active) VALUES (:name, :path, 1)");
$stmt->execute([
'name' => 'แบบฟอร์มขออนุมัติเดินทาง v' . date('YmdHis'),
'path' => $dbPath
]);
$_SESSION['flash_success'] = "อัปโหลดแม่แบบ PDF สำเร็จ";
} else {
$_SESSION['flash_error'] = "ไม่สามารถย้ายไฟล์ไปยังโฟลเดอร์ปลายทางได้";
}
$this->redirect('/settings/pdf-designer');
}
public function savePdfLayout()
{
if (empty($_SESSION['user_id'])) {
header('Content-Type: application/json');
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
$input = file_get_contents('php://input');
$data = json_decode($input, true);
if (!$data || empty($data['template_id'])) {
header('Content-Type: application/json');
echo json_encode(['status' => 'error', 'message' => 'Invalid data']);
exit;
}
try {
$pdo = Database::getSystemConnection();
$stmt = $pdo->prepare("UPDATE pdf_templates SET field_mapping = :mapping WHERE id = :id");
$stmt->execute([
'mapping' => json_encode($data['fields']),
'id' => $data['template_id']
]);
echo json_encode(['status' => 'success']);
} catch (\PDOException $e) {
header('Content-Type: application/json');
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
exit;
}
public function deletePdfTemplate()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
Security::enforcePostWithCsrf();
$id = $_POST['id'] ?? null;
if ($id) {
$pdo = Database::getSystemConnection();
$stmt = $pdo->prepare("SELECT file_path FROM pdf_templates WHERE id = :id");
$stmt->execute(['id' => $id]);
$template = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($template) {
// Delete file from disk
$filePath = __DIR__ . '/../../public' . $template['file_path'];
if (file_exists($filePath)) {
unlink($filePath);
}
// Delete from DB
$pdo->prepare("DELETE FROM pdf_templates WHERE id = :id")->execute(['id' => $id]);
$_SESSION['flash_success'] = "ลบแม่แบบเรียบร้อยแล้ว";
}
}
$this->redirect('/settings/pdf-designer');
}
public function previewPdf()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
$pdo = Database::getSystemConnection();
$stmt = $pdo->query("SELECT * FROM pdf_templates WHERE is_active = 1 ORDER BY id DESC LIMIT 1");
$template = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$template || empty($template['file_path'])) {
die("ยังไม่ได้ตั้งค่าแม่แบบ PDF (PDF Template)");
}
$templatePath = __DIR__ . '/../../public' . $template['file_path'];
if (!file_exists($templatePath)) {
die("ไม่พบไฟล์แม่แบบ PDF ในระบบ");
}
$fields = json_decode($template['field_mapping'], true) ?: [];
$formatThaiDate = function($timestamp) {
$months = ['', 'ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.', 'มิ.ย.', 'ก.ค.', 'ส.ค.', 'ก.ย.', 'ต.ค.', 'พ.ย.', 'ธ.ค.'];
$d = date('j', $timestamp);
$m = $months[date('n', $timestamp)];
$y = date('Y', $timestamp) + 543;
return "$d $m $y";
};
// Mock data for preview
$mockData = [
'fullname' => $_SESSION['fullname'] ?? 'นายสมชาย รักดี',
'position' => 'นักวิเคราะห์นโยบายและแผนชำนาญการ',
'department' => 'งานยุทธศาสตร์และแผนงาน',
'doc_number' => 'สธ 1234.5/678',
'trip_running_number' => (date('Y') + 543) . '/1',
'doc_date' => $formatThaiDate(time()),
'current_date' => $formatThaiDate(time()),
'title' => 'ทดสอบการพิมพ์แบบฟอร์มขออนุมัติเดินทาง',
'meeting_date' => $formatThaiDate(strtotime('+1 day')) . ' - ' . $formatThaiDate(strtotime('+3 days')),
'total_days' => '3',
'objective' => 'เพื่อใช้เป็นตัวอย่างในการจัดตำแหน่งข้อความ (Preview)',
'personnel_type' => 'ข้าราชการ',
'start_date' => date('Y-m-d', strtotime('+1 day')),
'end_date' => date('Y-m-d', strtotime('+3 days')),
'location' => 'โรงแรมริชมอนด์ จังหวัดนนทบุรี',
'organizer' => 'กระทรวงสาธารณสุข',
'depart_from' => 'สำนักงาน',
'depart_address' => 'โรงพยาบาลสมุย',
'depart_datetime' => $formatThaiDate(strtotime('+1 day')) . ' เวลา 08:00 น.',
'depart_date' => $formatThaiDate(strtotime('+1 day')),
'depart_time' => '08:00 น.',
'arrive_at' => 'สำนักงาน',
'arrive_address' => 'โรงพยาบาลสมุย',
'arrive_datetime' => $formatThaiDate(strtotime('+3 days')) . ' เวลา 18:00 น.',
'arrive_date' => $formatThaiDate(strtotime('+3 days')),
'arrive_time' => '18:00 น.',
'vehicle_type' => 'รถยนต์ของทางราชการ',
'vehicle_plate' => 'กข 1234 สมุย',
'budget_source' => 'เงินบำรุง รพ.',
'budget_remark' => 'เบิกเฉพาะค่าที่พัก',
'budget' => '15,000.00',
'organizer_support_amount' => '0.00',
'expense_accommodations_text' => '2 คืน คืนละ 1,500.00 บาท',
'expense_allowances_text' => '3 วัน วันละ 240.00 บาท',
'expense_transport' => '10,000.00',
'expense_registration' => '1,280.00'
];
require_once __DIR__ . '/../Services/PdfGeneratorService.php';
$pdfService = new \App\Services\PdfGeneratorService();
$pdfService->generateTripPdf($templatePath, $fields, $mockData);
exit;
}
}
@@ -0,0 +1,874 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Database;
use App\Core\Security;
class TripController extends Controller
{
public function printPdf()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
$id = $_GET['id'] ?? null;
if (!$id) {
$this->redirect('/');
}
$pdo = Database::getSystemConnection();
// Check if user is owner or approver
$stmt = $pdo->prepare("
SELECT * FROM trip_requests
WHERE id = :id
AND (
user_id = :user_id
OR id IN (SELECT request_id FROM trip_approvals WHERE approver_id = :user_id)
)
");
$stmt->execute(['id' => $id, 'user_id' => $_SESSION['user_id']]);
$trip = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$trip) {
die("ไม่พบคำขอ หรือคุณไม่มีสิทธิ์เข้าถึง");
}
if (strpos($trip['status'], 'Approved') === false && $trip['status'] !== 'Completed') {
die("คำขอนี้ยังไม่ได้รับการอนุมัติ จึงไม่สามารถพิมพ์เอกสารได้");
}
// Fetch active PDF template
$stmtTpl = $pdo->query("SELECT * FROM pdf_templates WHERE is_active = 1 ORDER BY id DESC LIMIT 1");
$template = $stmtTpl->fetch(\PDO::FETCH_ASSOC);
if (!$template || empty($template['file_path'])) {
die("ยังไม่ได้ตั้งค่าแม่แบบ PDF (PDF Template) กรุณาให้ผู้ดูแลระบบตั้งค่าก่อน");
}
$templatePath = __DIR__ . '/../../public' . $template['file_path'];
if (!file_exists($templatePath)) {
die("ไม่พบไฟล์แม่แบบ PDF ในระบบ: " . $template['file_path']);
}
$fields = json_decode($template['field_mapping'], true) ?: [];
// Prepare data for mapping
$trip['fullname'] = $_SESSION['fullname'];
$trip['position'] = $_SESSION['position'] ?? ''; // Assuming position is in session
$trip['department'] = $_SESSION['department'] ?? ''; // Added department from session
$formatThaiDate = function($dateString) {
if (empty($dateString)) return '';
$timestamp = strtotime($dateString);
if (!$timestamp) return '';
$months = ['', 'ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.', 'มิ.ย.', 'ก.ค.', 'ส.ค.', 'ก.ย.', 'ต.ค.', 'พ.ย.', 'ธ.ค.'];
$d = date('j', $timestamp);
$m = $months[date('n', $timestamp)];
$y = date('Y', $timestamp) + 543;
return "$d $m $y";
};
$trip['doc_date'] = $formatThaiDate($trip['doc_date'] ?? '');
$trip['current_date'] = $formatThaiDate(time());
// Meeting date
$start_date = strtotime($trip['start_date'] ?? '');
$end_date = strtotime($trip['end_date'] ?? '');
if ($start_date && $end_date) {
$trip['meeting_date'] = ($start_date == $end_date)
? $formatThaiDate($trip['start_date'])
: $formatThaiDate($trip['start_date']) . ' - ' . $formatThaiDate($trip['end_date']);
} else {
$trip['meeting_date'] = '';
}
// Total days (from depart to arrive)
$depart_time_stamp = strtotime($trip['depart_datetime'] ?? '');
$arrive_time_stamp = strtotime($trip['arrive_datetime'] ?? '');
if ($depart_time_stamp && $arrive_time_stamp && $arrive_time_stamp >= $depart_time_stamp) {
$diff = ceil(($arrive_time_stamp - $depart_time_stamp) / 86400);
$trip['total_days'] = $diff == 0 ? 1 : $diff;
} else {
$trip['total_days'] = '';
}
// Split depart date and time
if ($depart_time_stamp) {
$trip['depart_date'] = $formatThaiDate($trip['depart_datetime']);
$trip['depart_time'] = date('H:i', $depart_time_stamp) . ' น.';
$trip['depart_datetime'] = $trip['depart_date'] . ' เวลา ' . $trip['depart_time'];
} else {
$trip['depart_date'] = '';
$trip['depart_time'] = '';
}
// Split arrive date and time
if ($arrive_time_stamp) {
$trip['arrive_date'] = $formatThaiDate($trip['arrive_datetime']);
$trip['arrive_time'] = date('H:i', $arrive_time_stamp) . ' น.';
$trip['arrive_datetime'] = $trip['arrive_date'] . ' เวลา ' . $trip['arrive_time'];
} else {
$trip['arrive_date'] = '';
$trip['arrive_time'] = '';
}
// format budget
if (!empty($trip['budget'])) {
$trip['budget'] = number_format($trip['budget'], 2);
}
$trip['organizer_support_amount'] = !empty($trip['organizer_support_amount']) ? number_format($trip['organizer_support_amount'], 2) : '0.00';
$trip['expense_transport'] = !empty($trip['expense_transport']) ? number_format($trip['expense_transport'], 2) : '0.00';
$trip['expense_registration'] = !empty($trip['expense_registration']) ? number_format($trip['expense_registration'], 2) : '0.00';
// Parse JSON for PDF rendering
$accItems = json_decode($trip['expense_accommodations'] ?? '[]', true) ?: [];
$accTexts = [];
foreach ($accItems as $acc) {
$accTexts[] = $acc['days'] . ' คืน คืนละ ' . number_format($acc['price'], 2) . ' บาท';
}
$trip['expense_accommodations_text'] = implode(', ', $accTexts);
$alwItems = json_decode($trip['expense_allowances'] ?? '[]', true) ?: [];
$alwTexts = [];
foreach ($alwItems as $alw) {
$alwTexts[] = $alw['days'] . ' วัน วันละ ' . number_format($alw['price'], 2) . ' บาท';
}
$trip['expense_allowances_text'] = implode(', ', $alwTexts);
require_once __DIR__ . '/../Services/PdfGeneratorService.php';
$pdfService = new \App\Services\PdfGeneratorService();
// Output inline to browser
$pdfService->generateTripPdf($templatePath, $fields, $trip);
exit;
}
/**
* Show create trip request form
*/
public function create()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
$pdo = Database::getSystemConnection();
// Fetch distinct locations for autocomplete
$stmtLoc = $pdo->query("SELECT DISTINCT location FROM trip_requests WHERE location IS NOT NULL AND location != '' ORDER BY location");
$locations = $stmtLoc->fetchAll(\PDO::FETCH_COLUMN);
// Fetch distinct organizers for autocomplete
$stmtOrg = $pdo->query("SELECT DISTINCT organizer FROM trip_requests WHERE organizer IS NOT NULL AND organizer != '' ORDER BY organizer");
$organizers = $stmtOrg->fetchAll(\PDO::FETCH_COLUMN);
$this->render('trip/create', [
'csrf_token' => Security::generateCsrfToken(),
'locations' => $locations,
'organizers' => $organizers
]);
}
/**
* Handle trip request submission
*/
public function store()
{
Security::enforcePostWithCsrf();
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
$title = trim($_POST['title'] ?? '');
$doc_number = trim($_POST['doc_number'] ?? '');
$doc_no = trim($_POST['doc_no'] ?? '');
$doc_link = trim($_POST['doc_link'] ?? '');
$doc_date = !empty($_POST['doc_date']) ? $_POST['doc_date'] : null;
$objective = trim($_POST['objective'] ?? '');
$personnel_type = trim($_POST['personnel_type'] ?? '');
$start_date = $_POST['start_date'] ?? '';
$end_date = $_POST['end_date'] ?? '';
$location = trim($_POST['location'] ?? '');
$organizer = trim($_POST['organizer'] ?? '');
$depart_from = trim($_POST['depart_from'] ?? 'สำนักงาน');
$depart_address = trim($_POST['depart_address'] ?? '');
$depart_datetime = !empty($_POST['depart_datetime']) ? $_POST['depart_datetime'] : null;
$arrive_at = trim($_POST['arrive_at'] ?? 'สำนักงาน');
$arrive_address = trim($_POST['arrive_address'] ?? '');
$arrive_datetime = !empty($_POST['arrive_datetime']) ? $_POST['arrive_datetime'] : null;
$vehicle_type = trim($_POST['vehicle_type'] ?? '');
$vehicle_plate = trim($_POST['vehicle_plate'] ?? '');
$budget_source = trim($_POST['budget_source'] ?? '');
$budget_remark = trim($_POST['budget_remark'] ?? '');
$budget = (float)($_POST['budget'] ?? 0);
$organizer_support_amount = (float)($_POST['organizer_support_amount'] ?? 0);
$budget_type = trim($_POST['budget_type'] ?? 'lump_sum');
$expense_accommodations = [];
$acc_days = $_POST['acc_days'] ?? [];
$acc_prices = $_POST['acc_prices'] ?? [];
if (is_array($acc_days)) {
foreach ($acc_days as $idx => $days) {
$price = $acc_prices[$idx] ?? 0;
if ((float)$days > 0 || (float)$price > 0) {
$expense_accommodations[] = ['days' => (float)$days, 'price' => (float)$price, 'total' => (float)$days * (float)$price];
}
}
}
$expense_allowances = [];
$alw_days = $_POST['alw_days'] ?? [];
$alw_prices = $_POST['alw_prices'] ?? [];
if (is_array($alw_days)) {
foreach ($alw_days as $idx => $days) {
$price = $alw_prices[$idx] ?? 0;
if ((float)$days > 0 || (float)$price > 0) {
$expense_allowances[] = ['days' => (float)$days, 'price' => (float)$price, 'total' => (float)$days * (float)$price];
}
}
}
$expense_transport = (float)($_POST['expense_transport'] ?? 0);
$expense_registration = (float)($_POST['expense_registration'] ?? 0);
if (empty($title) || empty($objective) || empty($start_date) || empty($end_date) || empty($location)) {
$_SESSION['flash_error'] = "กรุณากรอกข้อมูลที่จำเป็นให้ครบถ้วน";
$this->redirect('/trip/create');
}
try {
$pdo = Database::getSystemConnection();
// Generate Running Number
$currentYearBE = date('Y') + 543;
$stmtLastRunning = $pdo->prepare("SELECT trip_running_number FROM trip_requests WHERE trip_running_number LIKE :year_prefix ORDER BY id DESC LIMIT 1");
$stmtLastRunning->execute(['year_prefix' => $currentYearBE . '/%']);
$lastRunning = $stmtLastRunning->fetchColumn();
$nextNumber = 1;
if ($lastRunning) {
$parts = explode('/', $lastRunning);
if (isset($parts[1])) {
$nextNumber = (int)$parts[1] + 1;
}
}
$trip_running_number = $currentYearBE . '/' . $nextNumber;
$stmt = $pdo->prepare("
INSERT INTO trip_requests (
user_id, doc_number, doc_no, doc_date, doc_link, title, objective, personnel_type,
start_date, end_date, location, organizer,
depart_from, depart_address, depart_datetime,
arrive_at, arrive_address, arrive_datetime,
vehicle_type, vehicle_plate, budget_source, budget_remark, budget,
organizer_support_amount, budget_type, expense_accommodations, expense_allowances, expense_transport, expense_registration,
status, trip_running_number
) VALUES (
:user_id, :doc_number, :doc_no, :doc_date, :doc_link, :title, :objective, :personnel_type,
:start_date, :end_date, :location, :organizer,
:depart_from, :depart_address, :depart_datetime,
:arrive_at, :arrive_address, :arrive_datetime,
:vehicle_type, :vehicle_plate, :budget_source, :budget_remark, :budget,
:organizer_support_amount, :budget_type, :expense_accommodations, :expense_allowances, :expense_transport, :expense_registration,
'Pending', :trip_running_number
)
");
$stmt->execute([
'user_id' => $_SESSION['user_id'],
'doc_number' => $doc_number,
'doc_no' => $doc_no,
'doc_date' => $doc_date,
'doc_link' => $doc_link,
'title' => $title,
'objective' => $objective,
'personnel_type' => $personnel_type,
'start_date' => $start_date,
'end_date' => $end_date,
'location' => $location,
'organizer' => $organizer,
'depart_from' => $depart_from,
'depart_address' => $depart_address,
'depart_datetime' => $depart_datetime,
'arrive_at' => $arrive_at,
'arrive_address' => $arrive_address,
'arrive_datetime' => $arrive_datetime,
'vehicle_type' => $vehicle_type,
'vehicle_plate' => $vehicle_plate,
'budget_source' => $budget_source,
'budget_remark' => $budget_remark,
'budget' => $budget,
'organizer_support_amount' => $organizer_support_amount,
'budget_type' => $budget_type,
'expense_accommodations' => json_encode($expense_accommodations, JSON_UNESCAPED_UNICODE),
'expense_allowances' => json_encode($expense_allowances, JSON_UNESCAPED_UNICODE),
'expense_transport' => $expense_transport,
'expense_registration' => $expense_registration,
'trip_running_number' => $trip_running_number
]);
$requestId = $pdo->lastInsertId();
// 2. Fetch approver from hierarchy, default to someone if not found
$stmtHierarchy = $pdo->prepare("SELECT approver_id FROM approval_hierarchy WHERE user_id = :uid");
$stmtHierarchy->execute(['uid' => $_SESSION['user_id']]);
$approver_id = $stmtHierarchy->fetchColumn();
if (!$approver_id) {
$approver_id = '2'; // Fallback / Admin
}
// 3. Create Approval Workflow Step
$stmtApprove = $pdo->prepare("
INSERT INTO trip_approvals (request_id, approver_id, step, status)
VALUES (:req_id, :approver_id, 1, 'Pending')
");
$stmtApprove->execute([
'req_id' => $requestId,
'approver_id' => $approver_id
]);
// 4. Log Audit
$stmtAudit = $pdo->prepare("
INSERT INTO audit_logs (user_id, action, entity_type, entity_id)
VALUES (:user_id, 'CREATE', 'trip_requests', :req_id)
");
$stmtAudit->execute([
'user_id' => $_SESSION['user_id'],
'req_id' => $requestId
]);
$pdo->commit();
// 5. Send Notifications (LINE & Telegram)
require_once __DIR__ . '/../Services/LineNotifyService.php';
$notifyService = new \App\Services\LineNotifyService();
$msg = "🔔 <b>มีคำขออนุมัติเดินทางใหม่!</b>\n";
$msg .= "เรื่อง: " . $_POST['title'] . "\n";
$msg .= "สถานที่: " . $_POST['location'] . "\n";
$msg .= "กรุณาเข้าสู่ระบบเพื่อพิจารณา: " . BASE_URL . "/trip/approvals";
// Send Line (stripped HTML tags for LINE compatibility)
$notifyService->send(strip_tags($msg));
require_once __DIR__ . '/../Services/TelegramNotifyService.php';
$telegramService = new \App\Services\TelegramNotifyService();
$telegramService->send($msg);
$_SESSION['flash_success'] = "บันทึกคำขออนุมัติเดินทางเรียบร้อยแล้ว";
$this->redirect('/');
} catch (\PDOException $e) {
$pdo->rollBack();
$_SESSION['flash_error'] = "เกิดข้อผิดพลาดในการบันทึกข้อมูล: " . $e->getMessage();
$this->redirect('/trip/create');
}
}
public function edit()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
$id = $_GET['id'] ?? null;
if (!$id) {
$this->redirect('/');
}
$pdo = Database::getSystemConnection();
$stmt = $pdo->prepare("SELECT * FROM trip_requests WHERE id = :id AND user_id = :user_id AND status = 'Pending'");
$stmt->execute(['id' => $id, 'user_id' => $_SESSION['user_id']]);
$trip = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$trip) {
$_SESSION['flash_error'] = "ไม่พบคำขอ หรือคำขอนี้ถูกดำเนินการไปแล้วจึงไม่สามารถแก้ไขได้";
$this->redirect('/');
}
// Fetch distinct locations for autocomplete
$stmtLoc = $pdo->query("SELECT DISTINCT location FROM trip_requests WHERE location IS NOT NULL AND location != '' ORDER BY location");
$locations = $stmtLoc->fetchAll(\PDO::FETCH_COLUMN);
// Fetch distinct organizers for autocomplete
$stmtOrg = $pdo->query("SELECT DISTINCT organizer FROM trip_requests WHERE organizer IS NOT NULL AND organizer != '' ORDER BY organizer");
$organizers = $stmtOrg->fetchAll(\PDO::FETCH_COLUMN);
$this->render('trip/edit', [
'trip' => $trip,
'csrf_token' => Security::generateCsrfToken(),
'locations' => $locations,
'organizers' => $organizers
]);
}
public function update()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
Security::enforcePostWithCsrf();
$id = trim($_POST['id'] ?? '');
$title = trim($_POST['title'] ?? '');
$doc_number = trim($_POST['doc_number'] ?? '');
$doc_no = trim($_POST['doc_no'] ?? '');
$doc_link = trim($_POST['doc_link'] ?? '');
$doc_date = !empty($_POST['doc_date']) ? $_POST['doc_date'] : null;
$objective = trim($_POST['objective'] ?? '');
$personnel_type = trim($_POST['personnel_type'] ?? '');
$start_date = trim($_POST['start_date'] ?? '');
$end_date = trim($_POST['end_date'] ?? '');
$location = trim($_POST['location'] ?? '');
$organizer = trim($_POST['organizer'] ?? '');
$depart_from = trim($_POST['depart_from'] ?? '');
$depart_address = trim($_POST['depart_address'] ?? '');
$depart_datetime = !empty($_POST['depart_datetime']) ? $_POST['depart_datetime'] : null;
$arrive_at = trim($_POST['arrive_at'] ?? '');
$arrive_address = trim($_POST['arrive_address'] ?? '');
$arrive_datetime = !empty($_POST['arrive_datetime']) ? $_POST['arrive_datetime'] : null;
$vehicle_type = trim($_POST['vehicle_type'] ?? '');
$vehicle_plate = trim($_POST['vehicle_plate'] ?? '');
$budget_source = trim($_POST['budget_source'] ?? '');
$budget_remark = trim($_POST['budget_remark'] ?? '');
$budget = floatval($_POST['budget'] ?? 0);
$organizer_support_amount = floatval($_POST['organizer_support_amount'] ?? 0);
$budget_type = trim($_POST['budget_type'] ?? 'lump_sum');
$expense_accommodations = [];
$acc_days = $_POST['acc_days'] ?? [];
$acc_prices = $_POST['acc_prices'] ?? [];
if (is_array($acc_days)) {
foreach ($acc_days as $idx => $days) {
$price = $acc_prices[$idx] ?? 0;
if ((float)$days > 0 || (float)$price > 0) {
$expense_accommodations[] = ['days' => (float)$days, 'price' => (float)$price, 'total' => (float)$days * (float)$price];
}
}
}
$expense_allowances = [];
$alw_days = $_POST['alw_days'] ?? [];
$alw_prices = $_POST['alw_prices'] ?? [];
if (is_array($alw_days)) {
foreach ($alw_days as $idx => $days) {
$price = $alw_prices[$idx] ?? 0;
if ((float)$days > 0 || (float)$price > 0) {
$expense_allowances[] = ['days' => (float)$days, 'price' => (float)$price, 'total' => (float)$days * (float)$price];
}
}
}
$expense_transport = (float)($_POST['expense_transport'] ?? 0);
$expense_registration = (float)($_POST['expense_registration'] ?? 0);
if (empty($id) || empty($title) || empty($start_date) || empty($end_date) || empty($location) || empty($objective)) {
$_SESSION['flash_error'] = "กรุณากรอกข้อมูลที่จำเป็นให้ครบถ้วน";
$this->redirect('/trip/edit?id=' . $id);
}
try {
$pdo = Database::getSystemConnection();
// Check if still Pending
$stmtCheck = $pdo->prepare("SELECT id FROM trip_requests WHERE id = :id AND user_id = :user_id AND status = 'Pending'");
$stmtCheck->execute(['id' => $id, 'user_id' => $_SESSION['user_id']]);
if (!$stmtCheck->fetch()) {
$_SESSION['flash_error'] = "ไม่สามารถแก้ไขคำขอนี้ได้";
$this->redirect('/');
}
$stmt = $pdo->prepare("
UPDATE trip_requests SET
doc_number = :doc_number,
doc_no = :doc_no,
doc_date = :doc_date,
doc_link = :doc_link,
title = :title,
objective = :objective,
personnel_type = :personnel_type,
start_date = :start_date,
end_date = :end_date,
location = :location,
organizer = :organizer,
depart_from = :depart_from,
depart_address = :depart_address,
depart_datetime = :depart_datetime,
arrive_at = :arrive_at,
arrive_address = :arrive_address,
arrive_datetime = :arrive_datetime,
vehicle_type = :vehicle_type,
vehicle_plate = :vehicle_plate,
budget_source = :budget_source,
budget_remark = :budget_remark,
budget = :budget,
organizer_support_amount = :organizer_support_amount,
budget_type = :budget_type,
expense_accommodations = :expense_accommodations,
expense_allowances = :expense_allowances,
expense_transport = :expense_transport,
expense_registration = :expense_registration
WHERE id = :id AND user_id = :user_id
");
$stmt->execute([
'doc_number' => $doc_number,
'doc_no' => $doc_no,
'doc_date' => $doc_date,
'doc_link' => $doc_link,
'title' => $title,
'objective' => $objective,
'personnel_type' => $personnel_type,
'start_date' => $start_date,
'end_date' => $end_date,
'location' => $location,
'organizer' => $organizer,
'depart_from' => $depart_from,
'depart_address' => $depart_address,
'depart_datetime' => $depart_datetime,
'arrive_at' => $arrive_at,
'arrive_address' => $arrive_address,
'arrive_datetime' => $arrive_datetime,
'vehicle_type' => $vehicle_type,
'vehicle_plate' => $vehicle_plate,
'budget_source' => $budget_source,
'budget_remark' => $budget_remark,
'budget' => $budget,
'organizer_support_amount' => $organizer_support_amount,
'budget_type' => $budget_type,
'expense_accommodations' => json_encode($expense_accommodations, JSON_UNESCAPED_UNICODE),
'expense_allowances' => json_encode($expense_allowances, JSON_UNESCAPED_UNICODE),
'expense_transport' => $expense_transport,
'expense_registration' => $expense_registration,
'id' => $id,
'user_id' => $_SESSION['user_id']
]);
// Add Audit Log
$stmtAudit = $pdo->prepare("
INSERT INTO audit_logs (user_id, action, entity_type, entity_id)
VALUES (:user_id, 'UPDATE', 'trip_requests', :entity_id)
");
$stmtAudit->execute([
'user_id' => $_SESSION['user_id'],
'entity_id' => $id
]);
$_SESSION['flash_success'] = "แก้ไขคำขอเดินทางเรียบร้อยแล้ว";
$this->redirect('/');
} catch (\PDOException $e) {
$_SESSION['flash_error'] = "เกิดข้อผิดพลาดในการแก้ไขข้อมูล: " . $e->getMessage();
$this->redirect('/trip/edit?id=' . $id);
}
}
public function delete()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
Security::enforcePostWithCsrf();
$id = trim($_POST['id'] ?? '');
if (empty($id)) {
$_SESSION['flash_error'] = "ไม่พบคำขอที่ต้องการลบ";
$this->redirect('/');
}
try {
$pdo = Database::getSystemConnection();
// Check if still Pending and owned by user
$stmtCheck = $pdo->prepare("SELECT id FROM trip_requests WHERE id = :id AND user_id = :user_id AND status = 'Pending'");
$stmtCheck->execute(['id' => $id, 'user_id' => $_SESSION['user_id']]);
if (!$stmtCheck->fetch()) {
$_SESSION['flash_error'] = "ไม่สามารถลบคำขอนี้ได้ (อาจถูกดำเนินการไปแล้ว หรือคุณไม่มีสิทธิ์)";
$this->redirect('/');
}
$pdo->beginTransaction();
// Delete approvals associated with this request
$stmtDelApp = $pdo->prepare("DELETE FROM trip_approvals WHERE request_id = :id");
$stmtDelApp->execute(['id' => $id]);
// Delete the trip request
$stmtDelTrip = $pdo->prepare("DELETE FROM trip_requests WHERE id = :id");
$stmtDelTrip->execute(['id' => $id]);
// Add Audit Log
$stmtAudit = $pdo->prepare("
INSERT INTO audit_logs (user_id, action, entity_type, entity_id)
VALUES (:user_id, 'DELETE', 'trip_requests', :entity_id)
");
$stmtAudit->execute([
'user_id' => $_SESSION['user_id'],
'entity_id' => $id
]);
$pdo->commit();
$_SESSION['flash_success'] = "ลบคำขอเดินทางเรียบร้อยแล้ว";
$this->redirect('/');
} catch (\PDOException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$_SESSION['flash_error'] = "เกิดข้อผิดพลาดในการลบข้อมูล: " . $e->getMessage();
$this->redirect('/trip/edit?id=' . $id);
}
}
public function requestCancel()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
Security::enforcePostWithCsrf();
$id = trim($_POST['id'] ?? '');
$cancel_reason = trim($_POST['cancel_reason'] ?? '');
if (empty($id) || empty($cancel_reason)) {
$_SESSION['flash_error'] = "กรุณาระบุเหตุผลการขอยกเลิก";
$this->redirect('/');
}
try {
$pdo = Database::getSystemConnection();
$pdo->beginTransaction();
$stmt = $pdo->prepare("UPDATE trip_requests SET status = 'Pending Cancel', cancel_reason = :reason WHERE id = :id AND user_id = :user_id AND status = 'Approved'");
$stmt->execute(['reason' => $cancel_reason, 'id' => $id, 'user_id' => $_SESSION['user_id']]);
if ($stmt->rowCount() > 0) {
// Find previous approver to send this request to
$stmtApp = $pdo->prepare("SELECT approver_id FROM trip_approvals WHERE request_id = :id ORDER BY id DESC LIMIT 1");
$stmtApp->execute(['id' => $id]);
$approver_id = $stmtApp->fetchColumn();
if ($approver_id) {
$stmtNewApp = $pdo->prepare("INSERT INTO trip_approvals (request_id, approver_id, step, status) VALUES (:id, :app_id, 2, 'Pending')");
$stmtNewApp->execute(['id' => $id, 'app_id' => $approver_id]);
}
$pdo->commit();
$_SESSION['flash_success'] = "ส่งคำขอยกเลิกสำเร็จ รอผู้อนุมัติพิจารณา";
} else {
$pdo->rollBack();
$_SESSION['flash_error'] = "ไม่สามารถขอยกเลิกได้ (ข้อมูลอาจไม่อยู่ในสถานะที่ทำได้)";
}
} catch (\PDOException $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
$_SESSION['flash_error'] = "เกิดข้อผิดพลาด: " . $e->getMessage();
}
$this->redirect('/');
}
public function requestRevise()
{
if (empty($_SESSION['user_id'])) {
$this->redirect('/login');
}
Security::enforcePostWithCsrf();
$id = trim($_POST['id'] ?? '');
$revise_reason = trim($_POST['revise_reason'] ?? '');
if (empty($id) || empty($revise_reason)) {
$_SESSION['flash_error'] = "กรุณาระบุรายละเอียดการเปลี่ยนแปลง";
$this->redirect('/');
}
try {
$pdo = Database::getSystemConnection();
$pdo->beginTransaction();
$stmt = $pdo->prepare("UPDATE trip_requests SET status = 'Pending Revise', revise_reason = :reason WHERE id = :id AND user_id = :user_id AND status = 'Approved'");
$stmt->execute(['reason' => $revise_reason, 'id' => $id, 'user_id' => $_SESSION['user_id']]);
if ($stmt->rowCount() > 0) {
// Find previous approver
$stmtApp = $pdo->prepare("SELECT approver_id FROM trip_approvals WHERE request_id = :id ORDER BY id DESC LIMIT 1");
$stmtApp->execute(['id' => $id]);
$approver_id = $stmtApp->fetchColumn();
if ($approver_id) {
$stmtNewApp = $pdo->prepare("INSERT INTO trip_approvals (request_id, approver_id, step, status) VALUES (:id, :app_id, 2, 'Pending')");
$stmtNewApp->execute(['id' => $id, 'app_id' => $approver_id]);
}
$pdo->commit();
$_SESSION['flash_success'] = "ส่งคำขอเปลี่ยนแปลงสำเร็จ รอผู้อนุมัติพิจารณา";
} else {
$pdo->rollBack();
$_SESSION['flash_error'] = "ไม่สามารถขอเปลี่ยนแปลงได้ (ข้อมูลอาจไม่อยู่ในสถานะที่ทำได้)";
}
} catch (\PDOException $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
$_SESSION['flash_error'] = "เกิดข้อผิดพลาด: " . $e->getMessage();
}
$this->redirect('/');
}
/**
* AJAX endpoint to search documents from Google Sheets (cached for 10 mins)
*/
public function searchDocs()
{
if (empty($_SESSION['user_id'])) {
echo json_encode(['results' => []]);
exit;
}
$query = trim($_GET['q'] ?? '');
$filterYearBE = trim($_GET['year'] ?? '');
$filterYearCE = $filterYearBE ? ((int)$filterYearBE - 543) : '';
$filterYearShort = $filterYearBE ? substr($filterYearBE, -2) : '';
$cacheKey = 'gsheet_docs_cache_v3';
$cacheTime = 'gsheet_docs_time_v3';
// Check cache (10 minutes)
if (isset($_SESSION[$cacheKey]) && isset($_SESSION[$cacheTime]) && (time() - $_SESSION[$cacheTime] < 600)) {
$data = $_SESSION[$cacheKey];
} else {
// Fetch fresh data
$pdo = Database::getSystemConnection();
$stmt = $pdo->query("SELECT * FROM google_sheet_settings LIMIT 1");
$setting = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$setting || empty($setting['spreadsheet_id'])) {
echo json_encode(['results' => []]);
exit;
}
$isPublic = ($setting['connection_type'] ?? 'API') === 'Public';
$values = [];
if ($isPublic) {
$url = "https://docs.google.com/spreadsheets/d/" . urlencode($setting['spreadsheet_id']) .
"/gviz/tq?tqx=out:csv&sheet=" . urlencode($setting['sheet_name']) .
"&range=" . urlencode($setting['sync_range']);
$context = stream_context_create(['http' => ['ignore_errors' => true]]);
$response = file_get_contents($url, false, $context);
if ($response !== false && strpos($response, '<html') === false) {
$lines = explode("\n", $response);
foreach ($lines as $line) {
if (trim($line) === '') continue;
$values[] = str_getcsv($line);
}
}
} else {
if (empty($setting['api_key'])) {
echo json_encode(['results' => []]);
exit;
}
$url = "https://sheets.googleapis.com/v4/spreadsheets/" . urlencode($setting['spreadsheet_id']) .
"/values/" . urlencode($setting['sheet_name'] . '!' . $setting['sync_range']) .
"?key=" . urlencode($setting['api_key']);
$context = stream_context_create(['http' => ['ignore_errors' => true]]);
$response = file_get_contents($url, false, $context);
if ($response !== false) {
$decoded = json_decode($response, true);
$values = $decoded['values'] ?? [];
}
}
$mapping = json_decode($setting['column_mapping'] ?? '{}', true);
$data = [];
if (is_array($mapping) && !empty($values)) {
$docIndex = -1;
$docNoIndex = -1;
$titleIndex = -1;
$dateIndex = -1;
$linkIndex = -1;
foreach ($mapping as $idx => $field) {
if ($field === 'doc_number') $docIndex = $idx;
if ($field === 'doc_no') $docNoIndex = $idx;
if ($field === 'title') $titleIndex = $idx;
if ($field === 'doc_date') $dateIndex = $idx;
if ($field === 'doc_link') $linkIndex = $idx;
}
if ($docIndex >= 0) {
foreach ($values as $row) {
$docNumber = $row[$docIndex] ?? '';
$docNo = ($docNoIndex >= 0) ? ($row[$docNoIndex] ?? '') : '';
$title = ($titleIndex >= 0) ? ($row[$titleIndex] ?? '') : '';
$docDate = ($dateIndex >= 0) ? ($row[$dateIndex] ?? '') : '';
$docLink = ($linkIndex >= 0) ? ($row[$linkIndex] ?? '') : '';
if (!empty($docNumber)) {
$data[] = [
'id' => $docNumber,
'text' => $docNumber,
'doc_no' => $docNo,
'title' => $title,
'doc_date' => $docDate,
'doc_link' => $docLink
];
}
}
}
}
// Save to session cache
$_SESSION[$cacheKey] = $data;
$_SESSION[$cacheTime] = time();
}
// Filter by query and year
$results = [];
$queryLower = mb_strtolower($query, 'UTF-8');
foreach ($data as $item) {
// Check Document Number match
$isQueryMatch = ($query === '' || mb_strpos(mb_strtolower($item['text'], 'UTF-8'), $queryLower) !== false);
// Check Year match (if year is selected)
$isYearMatch = true;
if ($filterYearBE !== '') {
$isYearMatch = false;
$itemDate = $item['doc_date'] ?? '';
if ($itemDate) {
// Extract all numbers from the date string
preg_match_all('/\d+/', $itemDate, $matches);
$numbers = $matches[0] ?? [];
// Check if any number is the BE year, CE year, or 2-digit BE year
foreach ($numbers as $num) {
if ($num == $filterYearBE || $num == $filterYearCE || $num == $filterYearShort) {
$isYearMatch = true;
break;
}
}
}
}
if ($isQueryMatch && $isYearMatch) {
$results[] = $item;
if (count($results) >= 50) break; // limit to 50
}
}
if (empty($results) && $query === '') {
$results = array_slice(array_reverse($data), 0, 50);
}
echo json_encode(['results' => $results]);
exit;
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Core;
class Controller
{
/**
* Render a view file with extracted parameters.
*/
protected function render(string $viewPath, array $params = [], string $layout = 'main')
{
extract($params);
$viewFile = __DIR__ . '/../../views/' . $viewPath . '.php';
if (!file_exists($viewFile)) {
die("View $viewFile not found.");
}
// Start output buffering for the view content
ob_start();
require $viewFile;
$content = ob_get_clean();
// Render within a layout if provided
if ($layout) {
$layoutFile = __DIR__ . '/../../views/layouts/' . $layout . '.php';
if (file_exists($layoutFile)) {
require $layoutFile;
} else {
echo $content;
}
} else {
echo $content;
}
}
/**
* Redirect to a specific URL
*/
protected function redirect(string $url)
{
if (defined('BASE_URL') && BASE_URL !== '' && strpos($url, '/') === 0) {
$url = BASE_URL . $url;
}
header("Location: $url");
exit;
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Core;
use PDO;
use PDOException;
class Database
{
private static ?PDO $hrConnection = null;
private static ?PDO $systemConnection = null;
/**
* Get the connection to the HR Database
*/
public static function getHrConnection(): PDO
{
if (self::$hrConnection === null) {
$host = '127.0.0.1'; // Update with real settings
$db = 'hosoffice_2566';
$user = 'root';
$pass = '';
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
self::$hrConnection = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
// In production, log this error instead of throwing to user
throw new PDOException($e->getMessage(), (int)$e->getCode());
}
}
return self::$hrConnection;
}
/**
* Get the connection to the System Database
*/
public static function getSystemConnection(): PDO
{
if (self::$systemConnection === null) {
$host = '127.0.0.1'; // Update with real settings
$db = 'trip_approval_system';
$user = 'root';
$pass = '';
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
self::$systemConnection = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
// In production, log this error instead of throwing to user
throw new PDOException($e->getMessage(), (int)$e->getCode());
}
}
return self::$systemConnection;
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Core;
class Router
{
private array $routes = [];
public function add(string $method, string $path, string $controller, string $action)
{
$this->routes[] = [
'method' => strtoupper($method),
'path' => $path,
'controller' => $controller,
'action' => $action
];
}
public function dispatch(string $uri, string $method)
{
$uri = parse_url($uri, PHP_URL_PATH);
if (defined('BASE_URL') && BASE_URL !== '' && strpos($uri, BASE_URL) === 0) {
$uri = substr($uri, strlen(BASE_URL));
}
if ($uri === '') {
$uri = '/';
}
// Remove trailing slash except for root
if ($uri !== '/' && substr($uri, -1) === '/') {
$uri = substr($uri, 0, -1);
}
foreach ($this->routes as $route) {
// Simple exact match for now
if ($route['path'] === $uri && $route['method'] === strtoupper($method)) {
$controllerClass = "App\\Controllers\\" . $route['controller'];
if (class_exists($controllerClass)) {
$controller = new $controllerClass();
$action = $route['action'];
if (method_exists($controller, $action)) {
return $controller->$action();
}
}
}
}
// Fallback 404
http_response_code(404);
echo "404 Not Found: URI=" . htmlspecialchars($uri) . " Method=" . htmlspecialchars(strtoupper($method));
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Core;
class Security
{
/**
* Generate a CSRF token and store it in the session.
*/
public static function generateCsrfToken(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/**
* Verify the CSRF token submitted in a form.
*/
public static function verifyCsrfToken(?string $token): bool
{
if (empty($_SESSION['csrf_token']) || empty($token)) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Ensure the request is POST and CSRF token is valid.
* Dies with a 403 status if invalid.
*/
public static function enforcePostWithCsrf(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); // Method Not Allowed
die('Invalid Request Method');
}
$token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
if (!self::verifyCsrfToken($token)) {
http_response_code(403);
die('CSRF token validation failed');
}
}
/**
* Sanitize output to prevent XSS.
*/
public static function escape(?string $string): string
{
if ($string === null) {
return '';
}
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Services;
class GoogleSheetsService
{
private string $spreadsheetId;
private string $apiKey; // or Service Account JSON path
public function __construct(string $spreadsheetId)
{
$this->spreadsheetId = $spreadsheetId;
// In a real scenario, API Key or OAuth JSON should be loaded from env or DB
$this->apiKey = 'DUMMY_API_KEY';
}
/**
* Fetch data from a specific range using Google Sheets API v4
*/
public function fetchData(string $range): array
{
// Mock implementation
// The real implementation would use file_get_contents or cURL:
// $url = "https://sheets.googleapis.com/v4/spreadsheets/{$this->spreadsheetId}/values/{$range}?key={$this->apiKey}";
return [
['DOC001', 'ประชุมวิชาการประจำปี', 'เพื่อพัฒนาศักยภาพ', 'กรุงเทพมหานคร', '2023-11-01', '2023-11-05', '5000'],
['DOC002', 'อบรมหลักสูตร AI', 'เรียนรู้เทคโนโลยีใหม่', 'เชียงใหม่', '2023-12-10', '2023-12-12', '8000'],
];
}
/**
* Parse raw data and map it to DB structure based on column mapping
*/
public function parseData(array $rawData, array $mapping): array
{
$parsed = [];
foreach ($rawData as $row) {
$entry = [];
foreach ($mapping as $index => $field) {
$entry[$field] = $row[$index] ?? null;
}
$parsed[] = $entry;
}
return $parsed;
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Services;
use App\Core\Database;
class LineNotifyService
{
private $token;
private $apiUrl = "https://notify-api.line.me/api/notify";
public function __construct()
{
// Fetch token from system_settings
$pdo = Database::getSystemConnection();
$stmt = $pdo->query("SELECT setting_value FROM system_settings WHERE setting_key = 'line_notify_token'");
$this->token = $stmt->fetchColumn();
}
/**
* Send a notification message via LINE Notify
*
* @param string $message The message to send
* @return bool True if successful, false otherwise
*/
public function send(string $message): bool
{
if (empty($this->token)) {
return false; // Token not set, silently fail or handle elsewhere
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->apiUrl);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(['message' => $message]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/x-www-form-urlencoded',
'Authorization: Bearer ' . $this->token
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $httpCode === 200;
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services;
use setasign\Fpdi\Tcpdf\Fpdi;
class PdfGeneratorService
{
/**
* Generate a PDF by importing a template and overlaying text
* based on coordinates saved in the database.
*/
public function generateTripPdf(string $templatePath, array $fields, array $data, string $outputPath = ''): void
{
// Require composer autoload
require_once __DIR__ . '/../../vendor/autoload.php';
$pdf = new Fpdi();
$pdf->SetMargins(0, 0, 0);
$pdf->SetAutoPageBreak(false);
$pdf->setFontSubsetting(true);
try {
$pageCount = $pdf->setSourceFile($templatePath);
} catch (\Exception $e) {
die("Error loading PDF Template: " . $e->getMessage());
}
// We assume template is 1 page A4 for this system
$pdf->AddPage();
$templateId = $pdf->importPage(1);
$pdf->useTemplate($templateId, 0, 0, 210); // A4 width is 210mm
$fontFamily = 'freeserif'; // Default fallback
$fontPath = __DIR__ . '/../../public/assets/fonts/Sarabun-Regular.ttf';
// Load Sarabun font if available
if (file_exists($fontPath)) {
$fontName = \TCPDF_FONTS::addTTFfont($fontPath, 'TrueTypeUnicode', '', 96);
if ($fontName) {
$fontFamily = $fontName;
}
}
$pdf->setCellPaddings(0, 0, 0, 0);
$pdf->setCellMargins(0, 0, 0, 0);
// Uniform conversion from Canvas (800px width) to A4 mm (210mm width)
$scale = 210 / 800;
foreach ($fields as $field) {
$fieldName = $field['field'] ?? '';
$xCanvas = $field['x'] ?? 0;
$yCanvas = $field['y'] ?? 0;
$fontSizeCanvas = $field['font_size'] ?? 20;
$hexColor = $field['color'] ?? '#000000';
// Map the field name to actual data
$key = trim($fieldName, '{}');
$value = $data[$key] ?? '';
if ($value !== '') {
$x_mm = $xCanvas * $scale;
$y_mm = $yCanvas * $scale;
// Font size in pt. Canvas font size is in px (1px = 0.75pt).
// We use scale to keep it relative to the document size.
// 1 mm = 2.83465 pt.
// Size in mm on PDF = $fontSizeCanvas * $scale. Size in pt = Size in mm * 2.83465.
$fontSize_pt = ($fontSizeCanvas * $scale) * 2.83465;
// Parse hex color (e.g. #ff0000)
$hexColor = ltrim($hexColor, '#');
if (strlen($hexColor) == 6) {
$r = hexdec(substr($hexColor, 0, 2));
$g = hexdec(substr($hexColor, 2, 2));
$b = hexdec(substr($hexColor, 4, 2));
$pdf->SetTextColor($r, $g, $b);
} else {
$pdf->SetTextColor(0, 0, 0);
}
$pdf->SetFont($fontFamily, '', $fontSize_pt > 0 ? $fontSize_pt : 14);
$pdf->SetXY($x_mm, $y_mm);
// TCPDF Write text
$pdf->Cell(0, 0, $value, 0, false, 'L');
}
}
if ($outputPath) {
$pdf->Output($outputPath, 'F');
} else {
// Output to browser
$pdf->Output('trip_document.pdf', 'I');
}
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Services;
use App\Core\Database;
class TelegramNotifyService
{
private $botToken;
private $chatId;
public function __construct()
{
$pdo = Database::getSystemConnection();
$stmt = $pdo->query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('telegram_bot_token', 'telegram_chat_id')");
$settings = $stmt->fetchAll(\PDO::FETCH_KEY_PAIR);
$this->botToken = $settings['telegram_bot_token'] ?? null;
$this->chatId = $settings['telegram_chat_id'] ?? null;
}
/**
* Send a notification message via Telegram
*
* @param string $message The message to send
* @return bool True if successful, false otherwise
*/
public function send(string $message): bool
{
if (empty($this->botToken) || empty($this->chatId)) {
return false;
}
$url = "https://api.telegram.org/bot{$this->botToken}/sendMessage";
$data = [
'chat_id' => $this->chatId,
'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);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $httpCode === 200;
}
}
@@ -0,0 +1,67 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">ระบบติดตาม (Audit Log)</h1>
<a href="<?= BASE_URL ?>/settings" class="btn btn-outline-secondary shadow-sm"><i class="fa-solid fa-arrow-left me-1"></i> กลับไปตั้งค่า</a>
</div>
<div class="card shadow border-0 rounded-4 mb-4">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary"><i class="fa-solid fa-list-check me-2"></i>ประวัติการใช้งานล่าสุด</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-striped table-hover align-middle" id="auditTable">
<thead class="table-dark">
<tr>
<th>วัน-เวลา</th>
<th>User ID</th>
<th>Action</th>
<th>Entity Type</th>
<th>Entity ID</th>
</tr>
</thead>
<tbody>
<?php if (empty($logs)): ?>
<tr>
<td colspan="5" class="text-center py-3">ไม่มีข้อมูลการกระทำ</td>
</tr>
<?php else: ?>
<?php foreach ($logs as $log): ?>
<tr>
<td><?= date('d/m/Y H:i:s', strtotime($log['created_at'])) ?></td>
<td><?= $log['user_id'] ?></td>
<td>
<?php
$color = 'secondary';
if($log['action'] == 'CREATE') $color = 'success';
if($log['action'] == 'APPROVED') $color = 'primary';
if($log['action'] == 'REJECTED') $color = 'danger';
?>
<span class="badge bg-<?= $color ?>"><?= $log['action'] ?></span>
</td>
<td><code><?= $log['entity_type'] ?></code></td>
<td><?= $log['entity_id'] ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php
$extraScripts = '
<script>
$(document).ready(function() {
if($("#auditTable tbody tr td").length > 1) {
$("#auditTable").DataTable({
"language": {
"url": "//cdn.datatables.net/plug-ins/1.13.6/i18n/th.json"
},
"order": [[ 0, "desc" ]]
});
}
});
</script>
';
?>
@@ -0,0 +1,77 @@
<!-- Theme & Language Controls -->
<div class="auth-controls">
<button class="control-btn" id="langToggleBtn" title="เปลี่ยนภาษา (Change Language)">
<i class="fa-solid fa-language"></i>
</button>
<button class="control-btn" id="themeToggleBtn" title="สลับโหมดสี (Toggle Theme)">
<i id="themeIcon" class="fa-regular fa-moon"></i>
</button>
</div>
<div class="glass-card">
<!-- Left Column: Premium Illustration -->
<div class="auth-illustration">
<!-- Logo Illustration -->
<img src="<?= BASE_URL ?>/assets/img/logo.png" alt="โลโก้กระทรวงสาธารณสุข" width="160" height="160" style="filter: drop-shadow(0 0 20px rgba(0, 102, 204, 0.2)); margin-bottom: 2rem; animation: float 10s infinite alternate ease-in-out;">
<h2 class="auth-title" style="font-size: 1.5rem;">ระบบขออนุมัติเดินทาง</h2>
<p class="auth-subtitle" style="margin-bottom: 0;">จัดการเอกสารและการอนุมัติอย่างเป็นระบบเพื่อองค์กรยุคใหม่</p>
</div>
<!-- Right Column: Login Form -->
<div class="auth-form-container">
<!-- Loading Overlay -->
<div class="auth-loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
<div class="fw-semibold" style="color: var(--auth-text-main);">กำลังเข้าสู่ระบบ...</div>
</div>
<div class="text-center mb-4">
<h1 class="auth-title">ยินดีต้อนรับเข้าสู่ระบบ</h1>
<p class="auth-subtitle">ระบบขออนุมัติเดินทางประชุม-อบรม</p>
</div>
<?php if(isset($_SESSION['flash_error'])): ?>
<div class="alert alert-danger py-2 small rounded-3 border-0 shadow-sm" style="background-color: rgba(220, 53, 69, 0.1); color: #dc3545;">
<i class="fa-solid fa-circle-exclamation me-1"></i>
<?= \App\Core\Security::escape($_SESSION['flash_error']); unset($_SESSION['flash_error']); ?>
</div>
<?php endif; ?>
<form action="<?= BASE_URL ?>/login" method="POST" id="loginForm">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<div class="form-floating mb-3">
<input type="text" class="form-control" id="floatingInput" name="username" placeholder="ชื่อผู้ใช้งาน (HR)" required autofocus autocomplete="username">
<label for="floatingInput"><i class="fa-regular fa-user me-2"></i>ชื่อผู้ใช้งาน</label>
</div>
<div class="form-floating mb-4">
<input type="password" class="form-control" id="floatingPassword" name="password" placeholder="รหัสผ่าน" required autocomplete="current-password">
<label for="floatingPassword"><i class="fa-solid fa-lock me-2"></i>รหัสผ่าน</label>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="rememberMe" style="border-color: var(--auth-input-border);">
<label class="form-check-label small" for="rememberMe" style="color: var(--auth-text-muted);">
จดจำฉันไว้ในระบบ
</label>
</div>
<a href="#" class="text-decoration-none small" style="color: var(--auth-btn-bg); font-weight: 500;">ลืมรหัสผ่าน?</a>
</div>
<button class="btn-auth w-100 d-flex justify-content-center align-items-center" type="submit" id="submitBtn">
<span>เข้าสู่ระบบ</span>
<i class="fa-solid fa-arrow-right ms-2 opacity-75"></i>
</button>
</form>
<div class="text-center mt-5">
<p class="small mb-0" style="color: var(--auth-text-muted);">
© <?= date('Y') ?> ระบบจัดการภายในองค์กร สงวนลิขสิทธิ์
</p>
</div>
</div>
</div>
@@ -0,0 +1,274 @@
<div class="d-flex justify-content-end mb-4">
<a href="<?= BASE_URL ?>/trip/create" class="btn btn-primary btn-lg shadow-sm rounded-pill fw-bold">
<i class="fa-solid fa-plus me-2"></i>สร้างคำขอใหม่
</a>
</div>
<div class="row">
<!-- Total Requests Card -->
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-0 border-start border-4 border-primary shadow-sm rounded-4 h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">คำขอทั้งหมด (ของคุณ)</div>
<div class="h5 mb-0 font-weight-bold text-gray-800"><?= number_format($stats['total_requests']) ?></div>
</div>
<div class="col-auto">
<i class="fa-solid fa-file-lines fa-2x text-black-50 opacity-25"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Pending Approval Card -->
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-0 border-start border-4 border-warning shadow-sm rounded-4 h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">รอการอนุมัติ</div>
<div class="h5 mb-0 font-weight-bold text-gray-800"><?= number_format($stats['pending_approval']) ?></div>
</div>
<div class="col-auto">
<i class="fa-solid fa-clock fa-2x text-black-50 opacity-25"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Approved Card -->
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-0 border-start border-4 border-success shadow-sm rounded-4 h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">อนุมัติแล้ว</div>
<div class="h5 mb-0 font-weight-bold text-gray-800"><?= number_format($stats['approved']) ?></div>
</div>
<div class="col-auto">
<i class="fa-solid fa-check-circle fa-2x text-black-50 opacity-25"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Rejected Card -->
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-0 border-start border-4 border-danger shadow-sm rounded-4 h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-danger text-uppercase mb-1">ไม่อนุมัติ/ยกเลิก</div>
<div class="h5 mb-0 font-weight-bold text-gray-800"><?= number_format($stats['rejected']) ?></div>
</div>
<div class="col-auto">
<i class="fa-solid fa-xmark-circle fa-2x text-black-50 opacity-25"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-lg-8">
<div class="card shadow-sm mb-4 border-0 rounded-4">
<div class="card-header py-3 bg-white d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">รายการคำขอ (ปี <?= $selectedYear + 543 ?>)</h6>
<form method="GET" class="d-inline-block">
<div class="input-group input-group-sm">
<label class="input-group-text border-0 bg-light">เลือกปี</label>
<select name="year" class="form-select form-select-sm border-light" onchange="this.form.submit()">
<?php foreach ($availableYears as $y): ?>
<option value="<?= $y ?>" <?= $y == $selectedYear ? 'selected' : '' ?>>ปี <?= $y + 543 ?></option>
<?php endforeach; ?>
</select>
</div>
</form>
</div>
<div class="card-body">
<?php if (empty($recentRequests)): ?>
<p class="text-center text-muted my-5">ยังไม่มีรายการคำขอในปีนี้</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-hover align-middle" id="dashboardTable">
<thead class="table-light">
<tr>
<th>วันที่ขอ</th>
<th>เลขที่หนังสือ</th>
<th>เรื่อง / สถานที่</th>
<th>สถานะ</th>
<th>จัดการ</th>
</tr>
</thead>
<tbody>
<?php foreach ($recentRequests as $req): ?>
<tr>
<td data-order="<?= strtotime($req['created_at']) ?>"><?= date('d/m/Y', strtotime($req['created_at'])) ?></td>
<td><?= \App\Core\Security::escape($req['doc_number'] ?: '-') ?></td>
<td>
<strong><?= \App\Core\Security::escape($req['title']) ?></strong><br>
<small class="text-muted"><i class="fa-solid fa-location-dot"></i> <?= \App\Core\Security::escape($req['location']) ?></small>
</td>
<td>
<?php if($req['status'] === 'Pending'): ?>
<span class="badge bg-warning text-dark">รออนุมัติ</span>
<?php elseif($req['status'] === 'Approved'): ?>
<span class="badge bg-success">อนุมัติแล้ว</span>
<?php elseif($req['status'] === 'Rejected'): ?>
<span class="badge bg-danger">ไม่อนุมัติ</span>
<?php else: ?>
<span class="badge bg-secondary"><?= $req['status'] ?></span>
<?php endif; ?>
</td>
<td>
<div class="btn-group btn-group-sm">
<?php if ($req['status'] === 'Approved'): ?>
<a href="<?= BASE_URL ?>/trip/print?id=<?= $req['id'] ?>" target="_blank" class="btn btn-outline-secondary" title="พิมพ์เอกสาร">
<i class="fa-solid fa-print"></i>
</a>
<button type="button" class="btn btn-outline-warning" title="ขอเปลี่ยนแปลงกำหนดการ" data-bs-toggle="modal" data-bs-target="#reviseModal<?= $req['id'] ?>">
<i class="fa-solid fa-pen"></i>
</button>
<button type="button" class="btn btn-outline-danger" title="ขอยกเลิกการเดินทาง" data-bs-toggle="modal" data-bs-target="#cancelModal<?= $req['id'] ?>">
<i class="fa-solid fa-ban"></i>
</button>
<!-- Modal for Revision Request -->
<div class="modal fade" id="reviseModal<?= $req['id'] ?>" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content text-start">
<form action="<?= BASE_URL ?>/trip/request-revise" method="POST">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<input type="hidden" name="id" value="<?= $req['id'] ?>">
<div class="modal-header bg-warning text-dark">
<h5 class="modal-title">ขอเปลี่ยนแปลงกำหนดการ</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>คุณกำลังขอเปลี่ยนแปลงกำหนดการสำหรับ: <strong><?= \App\Core\Security::escape($req['title']) ?></strong></p>
<div class="mb-3">
<label class="form-label text-danger">ระบุรายละเอียดที่เปลี่ยนแปลง *</label>
<textarea class="form-control" name="revise_reason" rows="4" required placeholder="เช่น ขอเลื่อนวันเดินทางเป็นวันที่ 10-12 ต.ค. เนื่องจากผู้จัดเลื่อนกำหนดการ..."></textarea>
</div>
<div class="alert alert-info small">
<i class="fa-solid fa-circle-info"></i> คำขอนี้จะถูกส่งกลับไปให้ผู้อนุมัติพิจารณาการเปลี่ยนแปลงอีกครั้ง
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">ปิด</button>
<button type="submit" class="btn btn-warning"><i class="fa-solid fa-paper-plane me-1"></i> ส่งคำขอเปลี่ยนแปลง</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal for Cancellation Request -->
<div class="modal fade" id="cancelModal<?= $req['id'] ?>" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content text-start">
<form action="<?= BASE_URL ?>/trip/request-cancel" method="POST">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<input type="hidden" name="id" value="<?= $req['id'] ?>">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title">ขอยกเลิกการเดินทาง</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>คุณกำลังขอยกเลิกรายการ: <strong><?= \App\Core\Security::escape($req['title']) ?></strong></p>
<div class="mb-3">
<label class="form-label text-danger">เหตุผลที่ขอยกเลิก *</label>
<textarea class="form-control" name="cancel_reason" rows="3" required placeholder="เช่น ผู้จัดประกาศยกเลิกงาน หรือ ติดภารกิจด่วน"></textarea>
</div>
<div class="alert alert-warning small">
<i class="fa-solid fa-circle-info"></i> คำขอนี้จะถูกส่งกลับไปให้ผู้อนุมัติพิจารณาการยกเลิกอีกครั้ง
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">ปิด</button>
<button type="submit" class="btn btn-danger"><i class="fa-solid fa-paper-plane me-1"></i> ส่งคำขอยกเลิก</button>
</div>
</form>
</div>
</div>
</div>
<?php else: ?>
<button class="btn btn-outline-secondary" title="พิมพ์ได้เมื่ออนุมัติแล้วเท่านั้น" disabled>
<i class="fa-solid fa-print"></i>
</button>
<?php endif; ?>
<?php if ($req['status'] === 'Pending'): ?>
<a href="<?= BASE_URL ?>/trip/edit?id=<?= $req['id'] ?>" class="btn btn-outline-primary" title="แก้ไข">
<i class="fa-solid fa-pen-to-square"></i>
</a>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm mb-4 border-0 rounded-4">
<div class="card-header py-3 bg-white d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">สถิติ (Chart)</h6>
</div>
<div class="card-body">
<canvas id="myPieChart"></canvas>
</div>
</div>
</div>
</div>
<?php
// Adding extra script for Chart.js and DataTables
$extraScripts = '
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
// Initialize DataTables
if ($("#dashboardTable").length > 0) {
$("#dashboardTable").DataTable({
"language": {
"url": "//cdn.datatables.net/plug-ins/1.13.6/i18n/th.json"
},
"order": [[0, "desc"]], // เรียงจากวันที่ขอล่าสุด
"pageLength": 10 // แสดง 10 รายการต่อหน้า
});
}
var ctx = document.getElementById("myPieChart");
var myPieChart = new Chart(ctx, {
type: "doughnut",
data: {
labels: ["รออนุมัติ", "อนุมัติ", "ไม่อนุมัติ"],
datasets: [{
data: [' . $stats['pending_approval'] . ', ' . $stats['approved'] . ', ' . $stats['rejected'] . '],
backgroundColor: ["#ffc107", "#198754", "#dc3545"],
hoverBackgroundColor: ["#e0a800", "#157347", "#c82333"],
hoverBorderColor: "rgba(234, 236, 244, 1)",
}],
},
options: {
maintainAspectRatio: false,
cutout: "70%",
plugins: {
legend: {
position: "bottom"
}
}
},
});
});
</script>
';
?>
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>เกิดข้อผิดพลาด - ระบบขออนุมัติเดินทาง</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Sarabun:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Sarabun', sans-serif;
background-color: #f4f6f9;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
max-width: 500px;
width: 100%;
}
.error-icon {
font-size: 5rem;
color: #dc3545;
}
</style>
</head>
<body>
<div class="container text-center error-container">
<div class="card shadow border-0 rounded-4">
<div class="card-body p-5">
<i class="fa-solid fa-triangle-exclamation error-icon mb-4"></i>
<h2 class="fw-bold mb-3">โอ๊ะโอ! เกิดข้อผิดพลาด</h2>
<p class="text-muted mb-4">
<?= isset($error_message) ? htmlspecialchars($error_message) : 'เกิดข้อผิดพลาดในระบบ โปรดติดต่อผู้ดูแลระบบ' ?>
</p>
<div class="d-grid gap-2">
<a href="<?= isset($_SERVER['HTTP_REFERER']) ? htmlspecialchars($_SERVER['HTTP_REFERER']) : '/' ?>" class="btn btn-primary py-2">
<i class="fa-solid fa-arrow-rotate-left me-2"></i>กลับไปหน้าก่อนหน้า
</a>
<a href="/" class="btn btn-outline-secondary py-2">
<i class="fa-solid fa-house me-2"></i>กลับไปหน้าหลัก
</a>
</div>
</div>
</div>
<p class="text-muted mt-4 small">© <?= date('Y') ?> ระบบขออนุมัติเดินทางไปราชการ/ประชุม/อบรม</p>
</div>
</body>
</html>
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="th" dir="ltr" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>เข้าสู่ระบบ | ระบบขออนุมัติเดินทางประชุม-อบรม</title>
<!-- Favicon -->
<link rel="shortcut icon" href="<?= BASE_URL ?>/assets/img/logo.png" type="image/png">
<!-- Google Fonts (Inter) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- Custom Auth CSS -->
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/auth.css?v=<?= time() ?>">
</head>
<body>
<!-- Particle Background Canvas -->
<canvas id="particleCanvas"></canvas>
<!-- Animated Gradient Background Elements -->
<div class="bg-shape shape-1"></div>
<div class="bg-shape shape-2"></div>
<div class="bg-shape shape-3"></div>
<!-- Main Content -->
<div class="auth-wrapper">
<?= $content ?>
</div>
<!-- Bootstrap 5 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Custom Auth JS -->
<script src="<?= BASE_URL ?>/assets/js/auth.js?v=<?= time() ?>"></script>
</body>
</html>
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>เข้าสู่ระบบ | ระบบขออนุมัติเดินทางประชุม-อบรม</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- Google Fonts (Sarabun) -->
<link href="https://fonts.googleapis.com/css2?family=Sarabun:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Sarabun', sans-serif;
background-color: #f4f6f9;
}
</style>
</head>
<body class="d-flex align-items-center py-4 bg-body-tertiary" style="min-height: 100vh;">
<?= $content ?>
<!-- Bootstrap 5 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ระบบขออนุมัติเดินทางประชุม-อบรม</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- Google Fonts (Sarabun & Inter) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Sarabun:wght@300;400;500;600&display=swap" rel="stylesheet">
<!-- Premium Theme Styles -->
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/premium.css?v=<?= time() ?>">
<style>
body {
font-family: 'Sarabun', sans-serif;
background-color: #f4f6f9;
}
.navbar-brand {
font-weight: 600;
}
.main-content {
padding: 2rem 0;
min-height: calc(100vh - 140px);
}
.footer {
background-color: #fff;
border-top: 1px solid #e9ecef;
padding: 1rem 0;
text-align: center;
}
</style>
</head>
<body>
<!-- Anti-FOUC Script for Premium Theme -->
<script>
if (localStorage.getItem('app_premium_theme') === 'true') {
document.body.setAttribute('data-premium-theme', 'true');
}
</script>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary shadow-sm">
<div class="container">
<a class="navbar-brand" href="<?= BASE_URL ?>/">
<i class="fa-solid fa-plane-departure me-2"></i>ระบบขอเดินทาง-อบรม
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link active" href="<?= BASE_URL ?>/"><i class="fa-solid fa-house me-1"></i>หน้าแรก</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?= BASE_URL ?>/trip/create"><i class="fa-solid fa-plus me-1"></i>ขออนุมัติเดินทาง</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?= BASE_URL ?>/trip/approvals"><i class="fa-solid fa-check-to-slot me-1"></i>พิจารณาอนุมัติ</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?= BASE_URL ?>/report"><i class="fa-solid fa-chart-pie me-1"></i>รายงานสรุป</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?= BASE_URL ?>/reports"><i class="fa-solid fa-file-excel me-1"></i>รายงาน</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?= BASE_URL ?>/audit"><i class="fa-solid fa-list-check me-1"></i>ประวัติการใช้งาน</a>
</li>
<li class="nav-item">
<a class="nav-link text-info" href="<?= BASE_URL ?>/manual"><i class="fa-solid fa-book-open me-1"></i>คู่มือ</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<?php
$roleIcon = 'fa-user-circle';
$roles = $_SESSION['roles'] ?? [];
if (in_array('Admin', $roles)) {
$roleIcon = 'fa-user-shield text-danger';
} elseif (in_array('Approver_L2', $roles) || in_array('Approver_L1', $roles)) {
$roleIcon = 'fa-user-tie text-primary';
}
$display_name = $_SESSION['fullname'] ?? 'ผู้ใช้งาน';
?>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
<i class="fa-solid <?= $roleIcon ?> me-1"></i> <?= htmlspecialchars($display_name) ?>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="<?= BASE_URL ?>/settings"><i class="fa-solid fa-gear me-2"></i>ตั้งค่าระบบ</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="<?= BASE_URL ?>/logout"><i class="fa-solid fa-sign-out-alt me-2"></i>ออกจากระบบ</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="container main-content">
<!-- Error/Success Messages Placeholder -->
<?php if(isset($_SESSION['flash_success'])): ?>
<div class="alert alert-success alert-dismissible fade show">
<?= \App\Core\Security::escape($_SESSION['flash_success']); unset($_SESSION['flash_success']); ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<?php if(isset($_SESSION['flash_error'])): ?>
<div class="alert alert-danger alert-dismissible fade show">
<?= \App\Core\Security::escape($_SESSION['flash_error']); unset($_SESSION['flash_error']); ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<?= $content ?>
</div>
<!-- Footer -->
<footer class="footer">
<div class="container">
<span class="text-muted">© <?= date('Y') ?> ระบบขออนุมัติเดินทางไปราชการ/ประชุม/อบรม</span>
</div>
</footer>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Bootstrap 5 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- DataTables (Optional default) -->
<link href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css" rel="stylesheet">
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
<!-- Optional custom scripts pushed from views -->
<?php if(isset($extraScripts)) echo $extraScripts; ?>
<!-- Premium Theme Logic -->
<script src="<?= BASE_URL ?>/assets/js/premium.js?v=<?= time() ?>"></script>
</body>
</html>
@@ -0,0 +1,293 @@
<!-- Hero Banner -->
<div class="card shadow-sm border-0 mb-4 bg-primary text-white overflow-hidden" style="border-radius: 15px;">
<div class="card-body p-4 p-md-5 position-relative">
<div class="position-absolute top-0 end-0 opacity-10 pt-3 pe-3">
<i class="fa-solid fa-book-open-reader fa-10x"></i>
</div>
<div class="position-relative" style="z-index: 1;">
<h2 class="fw-bold mb-2">คู่มือการใช้งานระบบ</h2>
<p class="lead mb-0 opacity-75">ศูนย์รวมคำแนะนำและขั้นตอนการใช้งานสำหรับทุกระดับสิทธิ์</p>
</div>
</div>
</div>
<div class="row">
<!-- Sidebar Navigation -->
<div class="col-lg-3 mb-4">
<div class="card shadow-sm border-0" style="border-radius: 15px;">
<div class="card-header bg-white py-3 border-bottom-0 pt-4">
<h6 class="m-0 font-weight-bold text-muted text-uppercase" style="letter-spacing: 1px; font-size: 0.85rem;">หมวดหมู่คู่มือ</h6>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush" id="manual-tab" role="tablist" style="border-bottom-left-radius: 15px; border-bottom-right-radius: 15px; overflow: hidden;">
<a class="list-group-item list-group-item-action active p-3 border-0" id="list-user-list" data-bs-toggle="list" href="#list-user" role="tab">
<div class="d-flex align-items-center">
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
<i class="fa-solid fa-user"></i>
</div>
<div>
<h6 class="mb-0 fw-bold">ผู้ใช้ทั่วไป</h6>
<small class="opacity-75">การขอเดินทางและติดตาม</small>
</div>
</div>
</a>
<a class="list-group-item list-group-item-action p-3 border-0" id="list-approver-list" data-bs-toggle="list" href="#list-approver" role="tab">
<div class="d-flex align-items-center">
<div class="bg-warning bg-opacity-10 text-warning rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
<i class="fa-solid fa-user-tie"></i>
</div>
<div>
<h6 class="mb-0 fw-bold">ผู้อนุมัติ</h6>
<small class="opacity-75">การพิจารณาคำขอ</small>
</div>
</div>
</a>
<?php if ($isAdmin): ?>
<a class="list-group-item list-group-item-action p-3 border-0" id="list-admin-list" data-bs-toggle="list" href="#list-admin" role="tab">
<div class="d-flex align-items-center">
<div class="bg-danger bg-opacity-10 text-danger rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
<i class="fa-solid fa-user-shield"></i>
</div>
<div>
<h6 class="mb-0 fw-bold">ผู้ดูแลระบบ</h6>
<small class="opacity-75">การตั้งค่าและจัดการระบบ</small>
</div>
</div>
</a>
<?php endif; ?>
</div>
</div>
</div>
</div>
<!-- Main Content Area -->
<div class="col-lg-9">
<div class="card shadow-sm border-0 mb-4" style="border-radius: 15px;">
<div class="card-body p-4 p-lg-5">
<div class="tab-content" id="nav-tabContent">
<!-- ส่วนของผู้ใช้ทั่วไป -->
<div class="tab-pane fade show active" id="list-user" role="tabpanel">
<div class="d-flex align-items-center mb-4 pb-3 border-bottom">
<i class="fa-solid fa-user fa-2x text-primary me-3"></i>
<h4 class="fw-bold mb-0">คู่มือสำหรับผู้ใช้งานทั่วไป</h4>
</div>
<div class="row g-4">
<div class="col-md-6">
<div class="card h-100 border-0 bg-light rounded-4">
<div class="card-body p-4">
<div class="mb-3">
<span class="badge bg-primary px-3 py-2 rounded-pill"><i class="fa-solid fa-pen me-1"></i> ขั้นตอนที่ 1</span>
</div>
<h5 class="fw-bold text-dark">การสร้างคำขอเดินทาง</h5>
<p class="text-muted small mb-3">คุณสามารถสร้างคำขอใหม่ได้จากเมนู <strong>"ขออนุมัติเดินทาง"</strong> หรือหน้าแดชบอร์ด</p>
<ul class="text-muted small ps-3 mb-0">
<li class="mb-2">กรอกรายละเอียดสถานที่ วันที่ และ <strong class="text-primary">แหล่งงบประมาณ</strong> ให้ครบถ้วน</li>
<li class="mb-2">สามารถแจกแจง <strong class="text-primary">รายละเอียดค่าใช้จ่าย</strong> (ค่าที่พัก, ค่าเบี้ยเลี้ยง, ค่าพาหนะ, ค่าลงทะเบียน) ได้ในระบบ</li>
<li class="mb-2">เมื่อสร้างคำขอสำเร็จ ระบบจะสร้าง <strong class="text-success">"เลขที่หนังสือเดินทาง"</strong> (เช่น พ.ศ./Running) ให้อัตโนมัติ</li>
<li>ระบบจะส่งคำขอให้ผู้อนุมัติของคุณโดยอัตโนมัติ ตามที่ Admin ตั้งค่าไว้</li>
</ul>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 border-0 bg-light rounded-4">
<div class="card-body p-4">
<div class="mb-3">
<span class="badge bg-info px-3 py-2 rounded-pill"><i class="fa-solid fa-magnifying-glass me-1"></i> ขั้นตอนที่ 2</span>
</div>
<h5 class="fw-bold text-dark">การติดตามสถานะและรายงาน</h5>
<p class="text-muted small mb-3">ตรวจสอบสถานะได้ที่ <strong>"แดชบอร์ด"</strong> และดูสถิติที่ <strong>"รายงานสรุป"</strong></p>
<div class="d-flex align-items-center mb-2">
<span class="badge bg-warning text-dark me-2" style="width: 70px;">รออนุมัติ</span>
<small class="text-muted">กำลังรอพิจารณา (สามารถแก้ไขได้)</small>
</div>
<div class="d-flex align-items-center mb-2">
<span class="badge bg-success me-2" style="width: 70px;">อนุมัติแล้ว</span>
<small class="text-muted">คลิก <i class="fa-solid fa-print mx-1"></i> เพื่อพิมพ์แบบฟอร์ม</small>
</div>
<div class="d-flex align-items-center mb-2">
<span class="badge bg-danger me-2" style="width: 70px;">ไม่อนุมัติ</span>
<small class="text-muted">คำขอถูกปฏิเสธ ดูหมายเหตุได้</small>
</div>
<hr class="my-2">
<small class="text-muted d-block"><i class="fa-solid fa-chart-pie me-1 text-primary"></i> <strong>รายงานสรุป:</strong> สามารถเลือกกรองสถิติตามปี พ.ศ. และ Export เป็น CSV ได้</small>
</div>
</div>
</div>
</div>
<div class="row g-4 mt-1">
<div class="col-12">
<div class="card border-0 bg-light rounded-4 shadow-sm">
<div class="card-body p-4">
<div class="mb-3">
<span class="badge bg-danger px-3 py-2 rounded-pill"><i class="fa-solid fa-ban me-1"></i> ขั้นตอนพิเศษ (กรณีมีการเปลี่ยนแปลงหน้างาน)</span>
</div>
<h5 class="fw-bold text-dark">การขอยกเลิก / ขอเปลี่ยนแปลงกำหนดการ</h5>
<p class="text-muted small mb-3">เมื่อคำขอได้รับการ <strong>"อนุมัติแล้ว"</strong> แต่มีการเปลี่ยนแปลงจากผู้จัดงาน คุณสามารถดำเนินการเพิ่มเติมได้จากหน้าแดชบอร์ด:</p>
<ul class="text-muted small ps-3 mb-0">
<li class="mb-2"><strong>ขอยกเลิก:</strong> กดปุ่มไอคอน <i class="fa-solid fa-ban text-danger mx-1"></i> พร้อมระบุเหตุผล (เช่น ผู้จัดประกาศยกเลิกงาน)</li>
<li class="mb-2"><strong>ขอเปลี่ยนแปลง:</strong> กดปุ่มไอคอน <i class="fa-solid fa-pen text-warning mx-1"></i> พร้อมระบุรายละเอียดที่เปลี่ยนแปลง (เช่น ขอเลื่อนวันเดินทาง)</li>
<li><span class="text-danger fw-bold">สำคัญ:</span> คำขอดังกล่าวจะถูกเปลี่ยนสถานะเป็น <span class="badge bg-danger">ขอยกเลิก</span> หรือ <span class="badge bg-warning text-dark">ขอเปลี่ยนแปลง</span> และจะถูกส่งกลับไปให้ <strong>ผู้อนุมัติ</strong> พิจารณาอีกครั้ง</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- ส่วนของผู้อนุมัติ -->
<div class="tab-pane fade" id="list-approver" role="tabpanel">
<div class="d-flex align-items-center mb-4 pb-3 border-bottom">
<i class="fa-solid fa-user-tie fa-2x text-warning me-3"></i>
<h4 class="fw-bold mb-0">คู่มือสำหรับผู้อนุมัติ</h4>
</div>
<div class="card border-0 bg-warning bg-opacity-10 rounded-4 mb-4">
<div class="card-body p-4">
<h5 class="fw-bold text-warning-emphasis"><i class="fa-regular fa-bell me-2"></i> การรับการแจ้งเตือน</h5>
<p class="text-muted small mb-0">
เมื่อมีคำขอที่ต้องให้คุณพิจารณา ระบบจะส่งการแจ้งเตือนไปหาคุณ (หากองค์กรของคุณเปิดใช้งาน LINE หรือ Telegram) หรือคุณสามารถหมั่นเข้ามาตรวจสอบได้ที่เมนู <strong>"พิจารณาอนุมัติ"</strong>
</p>
</div>
</div>
<h5 class="fw-bold mt-4"><i class="fa-solid fa-list-check text-warning me-2"></i> ขั้นตอนการอนุมัติเอกสาร</h5>
<div class="timeline mt-3 px-3 border-start border-3 border-warning ms-2">
<div class="position-relative mb-4 ps-4">
<div class="position-absolute bg-warning rounded-circle" style="width: 12px; height: 12px; left: -8px; top: 5px;"></div>
<h6 class="fw-bold">1. เปิดรายการคำขอ</h6>
<p class="text-muted small">ไปที่เมนูพิจารณาอนุมัติ และคลิกปุ่ม <span class="badge bg-primary">พิจารณา</span> ที่รายการที่ต้องการ</p>
</div>
<div class="position-relative mb-4 ps-4">
<div class="position-absolute bg-warning rounded-circle" style="width: 12px; height: 12px; left: -8px; top: 5px;"></div>
<h6 class="fw-bold">2. ตรวจสอบข้อมูล</h6>
<p class="text-muted small">ระบบจะแสดงหน้าต่าง (Modal) ให้คุณตรวจสอบสถานที่ วันที่ และงบประมาณโดยละเอียด</p>
</div>
<div class="position-relative ps-4">
<div class="position-absolute bg-warning rounded-circle" style="width: 12px; height: 12px; left: -8px; top: 5px;"></div>
<h6 class="fw-bold">3. บันทึกผล</h6>
<p class="text-muted small mb-0">คุณสามารถระบุ <strong>"หมายเหตุ"</strong> เพื่อสื่อสารกับผู้ขอได้ จากนั้นคลิกปุ่ม <span class="text-success fw-bold">อนุมัติ</span> หรือ <span class="text-danger fw-bold">ไม่อนุมัติ</span></p>
</div>
</div>
</div>
<!-- ส่วนของผู้ดูแลระบบ -->
<?php if ($isAdmin): ?>
<div class="tab-pane fade" id="list-admin" role="tabpanel">
<div class="d-flex align-items-center mb-4 pb-3 border-bottom">
<i class="fa-solid fa-user-shield fa-2x text-danger me-3"></i>
<h4 class="fw-bold mb-0">คู่มือสำหรับผู้ดูแลระบบ (Admin)</h4>
</div>
<div class="alert alert-danger bg-danger bg-opacity-10 border-0 text-danger-emphasis rounded-4 mb-4" role="alert">
<i class="fa-solid fa-lock me-2"></i> เนื้อหาส่วนนี้จะแสดงผลให้เฉพาะผู้ที่มีสิทธิ์ Admin เท่านั้น
</div>
<!-- Accordion for Admin Manual -->
<div class="accordion accordion-flush" id="adminManualAccordion">
<!-- Item 1 -->
<div class="accordion-item border-0 mb-3 bg-light rounded-4 overflow-hidden">
<h2 class="accordion-header">
<button class="accordion-button collapsed bg-transparent fw-bold text-dark shadow-none" type="button" data-bs-toggle="collapse" data-bs-target="#adminCollapseOne">
<i class="fa-solid fa-users-gear text-info me-3"></i> 1. การจัดการสิทธิ์ (Roles) และสายงานอนุมัติ
</button>
</h2>
<div id="adminCollapseOne" class="accordion-collapse collapse" data-bs-parent="#adminManualAccordion">
<div class="accordion-body text-muted small pt-0 pb-4">
<ul>
<li class="mb-2"><strong>จัดการสิทธิ์:</strong> ไปที่หน้าตั้งค่า > จัดการสิทธิ์ เพื่อพิมพ์ค้นหาชื่อพนักงาน และมอบหมาย Role เช่น Admin หรือ Approver</li>
<li><strong>สายงานอนุมัติ:</strong> ไปที่หน้าตั้งค่า > สายงานอนุมัติ
<ul class="mt-1">
<li>สามารถ <strong>ค้นหาชื่อ-สกุล</strong> ของพนักงานและผู้อนุมัติจากฐานข้อมูล HR ได้โดยตรง</li>
<li>สามารถกดปุ่ม <i class="fa-solid fa-trash text-danger"></i> ลบรายการรายบุคคลออกจากตารางได้ทันที</li>
</ul>
</li>
</ul>
</div>
</div>
</div>
<!-- Item 2 -->
<div class="accordion-item border-0 mb-3 bg-light rounded-4 overflow-hidden">
<h2 class="accordion-header">
<button class="accordion-button collapsed bg-transparent fw-bold text-dark shadow-none" type="button" data-bs-toggle="collapse" data-bs-target="#adminCollapseTwo">
<i class="fa-solid fa-file-pdf text-danger me-3"></i> 2. การออกแบบฟอร์ม PDF ต้นฉบับ
</button>
</h2>
<div id="adminCollapseTwo" class="accordion-collapse collapse" data-bs-parent="#adminManualAccordion">
<div class="accordion-body text-muted small pt-0 pb-4">
<p class="mb-2">เมนู <strong>"ฟอร์ม PDF ต้นฉบับ"</strong> ในหน้าตั้งค่าระบบ ใช้สำหรับสร้างเอกสารฟอร์มใบปะหน้า</p>
<ul>
<li class="mb-1">อัปโหลดไฟล์ PDF เปล่าที่เป็นฟอร์มขององค์กร</li>
<li class="mb-1">ลาก (Drag) แท็กตัวแปรต่างๆ จากแถบด้านซ้าย ไปวางทับพิกัดบนฟอร์ม PDF ให้ตรงช่อง (Drop)</li>
<li class="mb-1">กดบันทึกพิกัด เมื่อผู้ใช้งานสั่งพิมพ์ ระบบจะนำพิกัดเหล่านี้ไปวางทับใน PDF อัตโนมัติ</li>
<li><span class="text-primary fw-bold">ใหม่!</span> เพิ่มตัวแปร <code class="text-info">{trip_running_number}</code> สำหรับแสดงเลขที่หนังสือเดินทาง และ <code class="text-info">{current_date}</code> สำหรับวันที่พิมพ์เอกสารปัจจุบัน</li>
</ul>
</div>
</div>
</div>
<!-- Item 3 -->
<div class="accordion-item border-0 mb-3 bg-light rounded-4 overflow-hidden">
<h2 class="accordion-header">
<button class="accordion-button collapsed bg-transparent fw-bold text-dark shadow-none" type="button" data-bs-toggle="collapse" data-bs-target="#adminCollapseThree">
<i class="fa-solid fa-bell text-success me-3"></i> 3. การเชื่อมต่อและระบบแจ้งเตือน
</button>
</h2>
<div id="adminCollapseThree" class="accordion-collapse collapse" data-bs-parent="#adminManualAccordion">
<div class="accordion-body text-muted small pt-0 pb-4">
<ul>
<li class="mb-2"><strong>Google Sheets:</strong> ตั้งค่าดึงข้อมูลจากไฟล์ Spreadsheet ภายนอกเข้าสู่ระบบผ่าน API หรือ Public Link</li>
<li class="mb-2"><strong>LINE Notify:</strong> นำ Token ของ LINE Notify มากรอกเพื่อให้ระบบแจ้งเตือนเข้ากลุ่ม LINE</li>
<li><strong>Telegram:</strong> ระบุ Bot Token และ Chat ID สำหรับรับการแจ้งเตือนทาง Telegram</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
<style>
/* Custom Styling for the Manual Page */
.list-group-item.active {
background-color: transparent !important;
color: #0d6efd !important;
font-weight: 600;
border-left: 4px solid #0d6efd !important;
}
.list-group-item.active .bg-opacity-10 {
background-color: #0d6efd !important;
color: white !important;
}
.accordion-button:not(.collapsed) {
color: inherit;
background-color: transparent;
box-shadow: none;
}
.timeline {
position: relative;
}
.timeline::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 14px;
width: 3px;
background: #ffc107;
opacity: 0.3;
}
</style>
@@ -0,0 +1,237 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">รายงานสรุป (Summary Reports)</h1>
<div class="d-flex gap-2">
<select class="form-select form-select-sm shadow-sm" id="yearFilter" onchange="window.location.href='<?= BASE_URL ?>/report?year='+this.value">
<?php
$currentCE = date('Y');
for($i = 0; $i <= 3; $i++) {
$yearCE = $currentCE - $i;
$yearBE = $yearCE + 543;
$selected = (isset($filterYear) && $filterYear == $yearCE) ? 'selected' : '';
echo "<option value='{$yearCE}' {$selected}>ปี พ.ศ. {$yearBE}</option>";
}
?>
</select>
<a href="<?= BASE_URL ?>/report/export?year=<?= isset($filterYear) ? $filterYear : date('Y') ?>" class="btn btn-sm btn-success shadow-sm">
<i class="fas fa-download fa-sm text-white-50"></i> Export to CSV
</a>
</div>
</div>
<!-- Content Row -->
<div class="row">
<!-- Total Trips -->
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-0 border-start border-4 border-primary shadow-sm rounded-4 h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">จำนวนการขอเดินทาง (ปีนี้)</div>
<div class="h5 mb-0 font-weight-bold text-gray-800"><?= number_format($totalTrips) ?> ครั้ง</div>
</div>
<div class="col-auto">
<i class="fas fa-calendar fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Total Budget -->
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-0 border-start border-4 border-success shadow-sm rounded-4 h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">งบประมาณที่อนุมัติแล้ว (ปีนี้)</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">฿<?= number_format($totalBudget, 2) ?></div>
</div>
<div class="col-auto">
<i class="fas fa-money-bill-wave fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Pending Requests -->
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-0 border-start border-4 border-warning shadow-sm rounded-4 h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">รอการอนุมัติ (Pending)</div>
<div class="h5 mb-0 font-weight-bold text-gray-800"><?= number_format($pendingCount) ?> รายการ</div>
</div>
<div class="col-auto">
<i class="fas fa-clock fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Rejected Requests -->
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-0 border-start border-4 border-danger shadow-sm rounded-4 h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-danger text-uppercase mb-1">ไม่อนุมัติ (Rejected)</div>
<div class="h5 mb-0 font-weight-bold text-gray-800"><?= number_format($rejectedCount) ?> รายการ</div>
</div>
<div class="col-auto">
<i class="fas fa-times-circle fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Area Chart -->
<div class="col-xl-8 col-lg-7">
<div class="card shadow-sm border-0 rounded-4 mb-4">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">สถิติการใช้งบประมาณรายเดือน</h6>
</div>
<div class="card-body">
<div class="chart-area">
<canvas id="myAreaChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Pie Chart -->
<div class="col-xl-4 col-lg-5">
<div class="card shadow-sm border-0 rounded-4 mb-4">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">สัดส่วนสถานะการอนุมัติ</h6>
</div>
<div class="card-body">
<div class="chart-pie pt-4 pb-2">
<canvas id="myPieChart"></canvas>
</div>
<div class="mt-4 text-center small">
<span class="mr-2"><i class="fas fa-circle text-success"></i> อนุมัติแล้ว</span>
<span class="mr-2"><i class="fas fa-circle text-warning"></i> รอพิจารณา</span>
<span class="mr-2"><i class="fas fa-circle text-danger"></i> ไม่อนุมัติ</span>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Trips Table -->
<div class="card shadow-sm border-0 rounded-4 mb-4 overflow-hidden">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">ประวัติรายการเดินทางล่าสุด</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" id="dataTable" width="100%" cellspacing="0">
<thead>
<tr>
<th>เลขที่หนังสือเดินทาง</th>
<th>ชื่อเรื่อง</th>
<th>สถานที่</th>
<th>วันที่เดินทาง</th>
<th>งบประมาณ</th>
<th>สถานะ</th>
</tr>
</thead>
<tbody>
<?php foreach ($recentTrips as $trip): ?>
<tr>
<td><?= htmlspecialchars($trip['trip_running_number'] ?? '-') ?></td>
<td><?= htmlspecialchars($trip['title']) ?></td>
<td><?= htmlspecialchars($trip['location']) ?></td>
<td><?= date('d/m/Y', strtotime($trip['start_date'])) ?></td>
<td><?= number_format($trip['budget'], 2) ?></td>
<td>
<?php if ($trip['status'] === 'Approved'): ?>
<span class="badge bg-success">อนุมัติแล้ว</span>
<?php elseif ($trip['status'] === 'Rejected'): ?>
<span class="badge bg-danger">ไม่อนุมัติ</span>
<?php else: ?>
<span class="badge bg-warning text-dark">รอพิจารณา</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php
$extraScripts = '
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
// Area Chart (Budget per month)
var ctxArea = document.getElementById("myAreaChart");
var monthlyData = ' . json_encode($monthlyBudget) . ';
var myLineChart = new Chart(ctxArea, {
type: "line",
data: {
labels: ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.", "ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."],
datasets: [{
label: "งบประมาณ (บาท)",
lineTension: 0.3,
backgroundColor: "rgba(78, 115, 223, 0.05)",
borderColor: "rgba(78, 115, 223, 1)",
pointRadius: 3,
pointBackgroundColor: "rgba(78, 115, 223, 1)",
pointBorderColor: "rgba(78, 115, 223, 1)",
pointHoverRadius: 3,
pointHoverBackgroundColor: "rgba(78, 115, 223, 1)",
pointHoverBorderColor: "rgba(78, 115, 223, 1)",
pointHitRadius: 10,
pointBorderWidth: 2,
data: monthlyData,
}],
},
options: {
maintainAspectRatio: false,
layout: { padding: { left: 10, right: 25, top: 25, bottom: 0 } },
scales: { x: { grid: { display: false, drawBorder: false } }, y: { ticks: { maxTicksLimit: 5, padding: 10 }, grid: { color: "rgb(234, 236, 244)", zeroLineColor: "rgb(234, 236, 244)", drawBorder: false, borderDash: [2], zeroLineBorderDash: [2] } } },
plugins: { legend: { display: false } }
}
});
// Pie Chart (Status)
var ctxPie = document.getElementById("myPieChart");
var myPieChart = new Chart(ctxPie, {
type: "doughnut",
data: {
labels: ["อนุมัติแล้ว", "รอพิจารณา", "ไม่อนุมัติ"],
datasets: [{
data: [' . $approvedCount . ', ' . $pendingCount . ', ' . $rejectedCount . '],
backgroundColor: ["#1cc88a", "#f6c23e", "#e74a3b"],
hoverBackgroundColor: ["#17a673", "#dda20a", "#e02d1b"],
hoverBorderColor: "rgba(234, 236, 244, 1)",
}],
},
options: {
maintainAspectRatio: false,
plugins: { legend: { display: false } },
cutout: "80%",
},
});
// DataTable
$("#dataTable").DataTable({
"language": {
"url": "//cdn.datatables.net/plug-ins/1.13.6/i18n/th.json"
},
"order": [[2, "desc"]]
});
});
</script>
';
?>
@@ -0,0 +1,36 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">ระบบรายงาน</h1>
</div>
<div class="row">
<div class="col-md-4">
<div class="card shadow border-0 mb-4 h-100 rounded-4">
<div class="card-body text-center p-5">
<i class="fa-solid fa-file-excel fa-4x text-success mb-3"></i>
<h4 class="card-title fw-bold">สรุปการเดินทางทั้งหมด</h4>
<p class="card-text text-muted mb-4">
ส่งออกข้อมูลคำขอเดินทางทั้งหมดในรูปแบบไฟล์ Excel (CSV)
</p>
<a href="<?= BASE_URL ?>/reports/export" class="btn btn-outline-success px-4 rounded-pill" target="_blank">
<i class="fa-solid fa-download me-1"></i> ดาวน์โหลด Excel
</a>
</div>
</div>
</div>
<!-- Placeholder for more reports -->
<div class="col-md-4">
<div class="card shadow border-0 mb-4 h-100 rounded-4 opacity-50">
<div class="card-body text-center p-5">
<i class="fa-solid fa-chart-bar fa-4x text-primary mb-3"></i>
<h4 class="card-title fw-bold">รายงานแยกตามหน่วยงาน</h4>
<p class="card-text text-muted mb-4">
(เร็วๆ นี้) สรุปงบประมาณและจำนวนครั้งที่เดินทาง แยกตามแผนก
</p>
<button class="btn btn-outline-primary px-4" disabled>
<i class="fa-solid fa-eye me-1"></i> ดูรายงาน
</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,134 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">จัดการสิทธิ์ผู้ใช้งาน (Roles)</h1>
<a href="<?= BASE_URL ?>/settings" class="btn btn-outline-secondary shadow-sm"><i class="fa-solid fa-arrow-left me-1"></i> กลับไปตั้งค่า</a>
</div>
<div class="row">
<!-- ฟอร์มเพิ่มสิทธิ์ -->
<div class="col-lg-4">
<div class="card shadow-sm border-0 rounded-4 mb-4 overflow-hidden">
<div class="card-header bg-primary text-white py-3">
<h6 class="m-0 font-weight-bold"><i class="fa-solid fa-user-plus me-2"></i>มอบสิทธิ์ให้ผู้ใช้</h6>
</div>
<div class="card-body">
<form action="<?= BASE_URL ?>/roles/assign" method="POST">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<div class="mb-3">
<label class="form-label fw-bold">ค้นหาผู้ใช้ (ชื่อ - นามสกุล)</label>
<select class="form-control" id="userSelect" name="user_id" required>
<option value="">-- พิมพ์ชื่อเพื่อค้นหา --</option>
</select>
<div class="form-text">ระบบจะค้นหาจากฐานข้อมูลบุคลากร (HR)</div>
</div>
<div class="mb-4">
<label class="form-label fw-bold">เลือกสิทธิ์ (Role)</label>
<select class="form-select" name="role_id" required>
<option value="">-- เลือกสิทธิ์ --</option>
<?php foreach($roles as $role): ?>
<option value="<?= $role['id'] ?>"><?= $role['name'] ?> (<?= $role['description'] ?>)</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-check me-1"></i> มอบสิทธิ์</button>
</form>
</div>
</div>
</div>
<!-- ตารางผู้ที่มีสิทธิ์ -->
<div class="col-lg-8">
<div class="card shadow-sm border-0 rounded-4 mb-4 overflow-hidden">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary"><i class="fa-solid fa-users-gear me-2"></i>รายชื่อผู้ที่ได้รับสิทธิ์ในระบบ</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-hover align-middle" id="rolesTable">
<thead class="table-light">
<tr>
<th>รหัส (HR_CID)</th>
<th>ชื่อ - นามสกุล</th>
<th>สิทธิ์ที่ได้รับ</th>
<th class="text-center" width="100">จัดการ</th>
</tr>
</thead>
<tbody>
<?php if(empty($assigned)): ?>
<tr>
<td colspan="4" class="text-center py-3">ยังไม่มีผู้ได้รับสิทธิ์</td>
</tr>
<?php else: ?>
<?php foreach($assigned as $a): ?>
<tr>
<td><?= $a['user_id'] ?></td>
<td><?= htmlspecialchars($a['fullname']) ?></td>
<td>
<span class="badge bg-info text-dark">
<?= htmlspecialchars($a['role_name']) ?>
</span>
<small class="text-muted d-block"><?= htmlspecialchars($a['role_desc']) ?></small>
</td>
<td class="text-center">
<form action="<?= BASE_URL ?>/roles/remove" method="POST" onsubmit="return confirm('ยืนยันการลบสิทธิ์นี้?');">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<input type="hidden" name="user_id" value="<?= $a['user_id'] ?>">
<input type="hidden" name="role_id" value="<?= $a['role_id'] ?>">
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i></button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<?php
// เพิ่ม Select2 CSS & JS
$extraScripts = '
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
$(document).ready(function() {
if($("#rolesTable tbody tr td").length > 1) {
$("#rolesTable").DataTable({
"language": {
"url": "//cdn.datatables.net/plug-ins/1.13.6/i18n/th.json"
}
});
}
$("#userSelect").select2({
theme: "bootstrap-5",
placeholder: "-- พิมพ์ชื่อเพื่อค้นหา --",
allowClear: true,
ajax: {
url: "'.BASE_URL.'/roles/search",
dataType: "json",
delay: 250,
data: function (params) {
return {
q: params.term
};
},
processResults: function (data) {
return {
results: data
};
},
cache: true
},
minimumInputLength: 2
});
});
</script>
';
?>
@@ -0,0 +1,154 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">ตั้งค่า Google Sheets Sync</h1>
<a href="<?= BASE_URL ?>/settings" class="btn btn-outline-secondary shadow-sm"><i class="fa-solid fa-arrow-left me-1"></i> กลับ</a>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm border-0 rounded-4 mb-4 overflow-hidden">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-success"><i class="fa-solid fa-plug me-2"></i>รายละเอียดการเชื่อมต่อ</h6>
</div>
<div class="card-body">
<form action="<?= BASE_URL ?>/settings/google-sheets/save" method="POST">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<div class="mb-3">
<label class="form-label fw-bold">รูปแบบการเชื่อมต่อ (Connection Type)</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="connection_type" id="connAPI" value="API" <?= ($setting['connection_type'] ?? 'API') === 'API' ? 'checked' : '' ?>>
<label class="form-check-label" for="connAPI">ใช้ Google API Key (มาตรฐาน)</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="connection_type" id="connPublic" value="Public" <?= ($setting['connection_type'] ?? '') === 'Public' ? 'checked' : '' ?>>
<label class="form-check-label" for="connPublic">ดึงจากไฟล์ Public (ไม่ต้องใช้ API Key)</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Spreadsheet ID</label>
<input type="text" class="form-control" name="spreadsheet_id" value="<?= htmlspecialchars($setting['spreadsheet_id'] ?? '') ?>" placeholder="เช่น 1BxiMVs0XRYFgwnTE...">
<div class="form-text">รหัส 44 ตัวอักษรที่อยู่ใน URL ของ Google Sheets</div>
</div>
<div class="mb-3" id="apiKeyContainer">
<label class="form-label fw-bold">Google API Key</label>
<input type="text" class="form-control" name="api_key" value="<?= htmlspecialchars($setting['api_key'] ?? '') ?>" placeholder="เช่น AIzaSyC...">
<div class="form-text">รหัส API Key จาก Google Cloud Console (จำเป็นสำหรับการดึงข้อมูลจริง)</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label fw-bold">Sheet Name</label>
<input type="text" class="form-control" name="sheet_name" value="<?= htmlspecialchars($setting['sheet_name'] ?? 'Sheet1') ?>" placeholder="เช่น Sheet1">
</div>
<div class="col-md-6">
<label class="form-label fw-bold">Sync Range</label>
<input type="text" class="form-control" name="sync_range" value="<?= htmlspecialchars($setting['sync_range'] ?? 'A2:B') ?>" placeholder="เช่น A2:B">
</div>
</div>
<div class="mb-4">
<label class="form-label fw-bold">Column Mapping (JSON)</label>
<textarea class="form-control text-monospace" name="column_mapping" rows="4"><?= htmlspecialchars($setting['column_mapping'] ?? '{"1": "doc_number", "4": "doc_no", "5": "title", "10": "doc_date", "11": "doc_link"}') ?></textarea>
<div class="form-text">รูปแบบ JSON เพื่อแมปคอลัมน์จาก Sheet (Index 0) เข้าสู่ฐานข้อมูล</div>
</div>
<button type="submit" class="btn btn-success"><i class="fa-solid fa-save me-1"></i> บันทึกการตั้งค่า</button>
<button type="button" class="btn btn-outline-primary ms-2" id="testSyncBtn"><i class="fa-solid fa-rotate me-1"></i> ทดสอบดึงข้อมูล</button>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm border-0 rounded-4 bg-success text-white overflow-hidden">
<div class="card-body p-4 text-center">
<i class="fa-solid fa-cloud-arrow-down fa-3x mb-3 opacity-75"></i>
<h5>สถานะการเชื่อมต่อ</h5>
<p class="mb-0">
<?php if(!empty($setting['last_synced_at'])): ?>
<span class="badge bg-light text-success mb-2">ทำงานปกติ</span><br>
<small>ดึงข้อมูลล่าสุด: <?= date('d/m/Y H:i', strtotime($setting['last_synced_at'])) ?></small>
<?php else: ?>
<span class="badge bg-warning text-dark mb-2">ยังไม่เคยดึงข้อมูล</span>
<?php endif; ?>
</p>
</div>
</div>
<div class="card shadow-sm border-0 rounded-4 mt-4 overflow-hidden">
<div class="card-body">
<h6 class="fw-bold"><i class="fa-solid fa-circle-info text-info me-2"></i>ข้อควรระวัง</h6>
<ul class="small text-muted ps-3 mb-0">
<li class="mb-1">ต้องแชร์ Spreadsheet ให้อ่านได้ (Anyone with the link) หรือผ่าน Service Account</li>
<li class="mb-1">วันที่ใน Google Sheets ควรใช้ Format `YYYY-MM-DD` เพื่อง่ายต่อการนำเข้า MySQL</li>
<li>หากต้องการดึงอัตโนมัติ ต้องตั้ง Cron Job เรียกใช้ API Endpoint ของระบบ</li>
</ul>
</div>
</div>
</div>
</div>
<?php
$extraScripts = '
<script>
document.addEventListener("DOMContentLoaded", function() {
const connAPI = document.getElementById("connAPI");
const connPublic = document.getElementById("connPublic");
const apiKeyContainer = document.getElementById("apiKeyContainer");
function toggleApiKey() {
if (connAPI.checked) {
apiKeyContainer.style.display = "block";
} else {
apiKeyContainer.style.display = "none";
}
}
connAPI.addEventListener("change", toggleApiKey);
connPublic.addEventListener("change", toggleApiKey);
toggleApiKey(); // Initial check
});
document.getElementById("testSyncBtn").addEventListener("click", function() {
const btn = this;
btn.disabled = true;
btn.innerHTML = \'<i class="fa-solid fa-spinner fa-spin me-1"></i> กำลังทดสอบ...\';
fetch("' . BASE_URL . '/settings/google-sheets/test", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: "csrf_token=' . $csrf_token . '"
})
.then(async res => {
const text = await res.text();
try {
return JSON.parse(text);
} catch (e) {
// คืนค่า Text ดิบๆ ไปเลยเพื่อให้ User เห็นว่า Server ฟ้องว่าอะไร
throw new Error("Server ไม่ได้ตอบกลับเป็น JSON: " + text.substring(0, 100));
}
})
.then(data => {
btn.disabled = false;
btn.innerHTML = \'<i class="fa-solid fa-rotate me-1"></i> ทดสอบดึงข้อมูล\';
if (data.status === "success") {
alert("เชื่อมต่อสำเร็จ! พบข้อมูล " + data.rows + " แถว\\nตัวอย่างรายการแรก: " + JSON.stringify(data.preview));
} else {
alert("เกิดข้อผิดพลาด: " + data.message);
}
})
.catch(err => {
btn.disabled = false;
btn.innerHTML = \'<i class="fa-solid fa-rotate me-1"></i> ทดสอบดึงข้อมูล\';
alert("Error: " + err.message);
});
});
</script>
';
?>
@@ -0,0 +1,129 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">ตั้งค่าสายงานอนุมัติ (Approval Hierarchy)</h1>
<a href="<?= BASE_URL ?>/settings" class="btn btn-outline-secondary shadow-sm"><i class="fa-solid fa-arrow-left me-1"></i> กลับไปตั้งค่า</a>
</div>
<div class="row">
<!-- ฟอร์มเพิ่มสายงานอนุมัติ -->
<div class="col-lg-4">
<div class="card shadow-sm border-0 rounded-4 mb-4 overflow-hidden">
<div class="card-header bg-primary text-white py-3">
<h6 class="m-0 font-weight-bold"><i class="fa-solid fa-sitemap me-2"></i>กำหนดผู้อนุมัติ</h6>
</div>
<div class="card-body">
<form action="<?= BASE_URL ?>/settings/hierarchy/add" method="POST">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<div class="mb-3">
<label class="form-label fw-bold">พนักงาน (ผู้ขออนุมัติ)</label>
<select class="form-control userSelect" name="user_id" required>
<option value="">-- พิมพ์ชื่อเพื่อค้นหาพนักงาน --</option>
</select>
</div>
<div class="mb-4">
<label class="form-label fw-bold">ผู้อนุมัติ (Approver)</label>
<select class="form-control userSelect" name="approver_id" required>
<option value="">-- พิมพ์ชื่อเพื่อค้นหาผู้อนุมัติ --</option>
</select>
</div>
<button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-save me-1"></i> บันทึกข้อมูล</button>
</form>
</div>
</div>
</div>
<!-- ตารางสายงานอนุมัติ -->
<div class="col-lg-8">
<div class="card shadow-sm border-0 rounded-4 mb-4 overflow-hidden">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary"><i class="fa-solid fa-list me-2"></i>รายชื่อสายงานอนุมัติในระบบ</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-hover align-middle" id="hierarchyTable">
<thead class="table-light">
<tr>
<th>พนักงาน</th>
<th>ผู้อนุมัติ</th>
<th class="text-center" width="100">จัดการ</th>
</tr>
</thead>
<tbody>
<?php if (empty($hierarchy)): ?>
<tr>
<td colspan="3" class="text-center py-3">ยังไม่มีการกำหนดสายงานอนุมัติ</td>
</tr>
<?php else: ?>
<?php foreach ($hierarchy as $h): ?>
<tr>
<td>
<i class="fa-solid fa-user me-2 text-muted"></i>
<?= htmlspecialchars($h['user_name']) ?>
<small class="text-muted d-block">(<?= htmlspecialchars($h['user_id']) ?>)</small>
</td>
<td>
<i class="fa-solid fa-user-check me-2 text-success"></i>
<?= htmlspecialchars($h['approver_name']) ?>
<small class="text-muted d-block">(<?= htmlspecialchars($h['approver_id']) ?>)</small>
</td>
<td class="text-center">
<form action="<?= BASE_URL ?>/settings/hierarchy/remove" method="POST" onsubmit="return confirm('ยืนยันการลบข้อมูลนี้?');">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<input type="hidden" name="id" value="<?= $h['id'] ?>">
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i></button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<?php
// เพิ่ม Select2 CSS & JS
$extraScripts = '
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
$(document).ready(function() {
if($("#hierarchyTable tbody tr td").length > 1) {
$("#hierarchyTable").DataTable({
"language": {
"url": "//cdn.datatables.net/plug-ins/1.13.6/i18n/th.json"
}
});
}
$(".userSelect").select2({
theme: "bootstrap-5",
allowClear: true,
ajax: {
url: "'.BASE_URL.'/settings/search-user",
dataType: "json",
delay: 250,
data: function (params) {
return {
q: params.term
};
},
processResults: function (data) {
return {
results: data
};
},
cache: true
},
minimumInputLength: 2
});
});
</script>
';
?>
@@ -0,0 +1,136 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">ตั้งค่าระบบ</h1>
</div>
<div class="row">
<!-- Roles Management Card -->
<div class="col-md-4 mb-4">
<div class="card shadow-sm border-0 h-100 rounded-4">
<div class="card-body text-center p-5 d-flex flex-column">
<i class="fa-solid fa-users-gear fa-4x text-info mb-3"></i>
<h4 class="card-title fw-bold">จัดการสิทธิ์ (Roles)</h4>
<p class="card-text text-muted mb-4">
กำหนดสิทธิ์ผู้ใช้งาน เช่น ผู้ดูแลระบบ, ผู้อนุมัติระดับ 1, 2
</p>
<a href="<?= BASE_URL ?>/roles" class="btn btn-outline-info px-4 mt-auto">
<i class="fa-solid fa-user-plus me-1"></i> จัดการสิทธิ์
</a>
</div>
</div>
</div>
<!-- PDF Template Setup Card -->
<div class="col-md-4 mb-4">
<div class="card shadow-sm border-0 h-100 rounded-4">
<div class="card-body text-center p-5 d-flex flex-column">
<i class="fa-solid fa-file-pdf fa-4x text-danger mb-3"></i>
<h4 class="card-title fw-bold">ฟอร์ม PDF ต้นฉบับ</h4>
<p class="card-text text-muted mb-4">
ออกแบบและจัดวางตำแหน่งข้อมูลบนฟอร์ม PDF ด้วยระบบลากวาง (Drag & Drop)
</p>
<a href="<?= BASE_URL ?>/settings/pdf-designer" class="btn btn-outline-danger px-4 mt-auto">
<i class="fa-solid fa-pen-ruler me-1"></i> เข้าสู่หน้าออกแบบ
</a>
</div>
</div>
</div>
<!-- Google Sheets Setup Card -->
<div class="col-md-4 mb-4">
<div class="card shadow-sm border-0 h-100 rounded-4">
<div class="card-body text-center p-5 d-flex flex-column">
<i class="fa-solid fa-table fa-4x text-success mb-3"></i>
<h4 class="card-title fw-bold">Google Sheets Integration</h4>
<p class="card-text text-muted mb-4">
ตั้งค่าการเชื่อมต่อ API ของ Google Sheets เพี่อซิงค์ข้อมูลลงฐานข้อมูล กำหนด Column Mapping และ Range ของข้อมูล
</p>
<a href="<?= BASE_URL ?>/settings/google-sheets" class="btn btn-outline-success px-4 mt-auto">
<i class="fa-solid fa-plug me-1"></i> ตั้งค่าการเชื่อมต่อ
</a>
</div>
</div>
</div>
<!-- Approval Hierarchy Card -->
<div class="col-md-4 mb-4">
<div class="card shadow-sm border-0 h-100 rounded-4">
<div class="card-body text-center p-5 d-flex flex-column">
<i class="fa-solid fa-sitemap fa-4x text-warning mb-3"></i>
<h4 class="card-title fw-bold">สายงานอนุมัติ</h4>
<p class="card-text text-muted mb-4">
ตั้งค่าผู้อนุมัติสำหรับพนักงานแต่ละคน เพื่อให้ระบบส่งเอกสารไปยังผู้อนุมัติได้โดยอัตโนมัติ
</p>
<a href="<?= BASE_URL ?>/settings/hierarchy" class="btn btn-outline-warning px-4 mt-auto">
<i class="fa-solid fa-users me-1"></i> ตั้งค่าผู้อนุมัติ
</a>
</div>
</div>
</div>
<!-- LINE Notify Card -->
<div class="col-md-4 mb-4">
<div class="card shadow-sm border-0 h-100 rounded-4">
<div class="card-body text-center p-5 d-flex flex-column">
<i class="fa-brands fa-line fa-4x text-success mb-3"></i>
<h4 class="card-title fw-bold">แจ้งเตือน (LINE)</h4>
<p class="card-text text-muted mb-4">
ตั้งค่า Token ของ LINE Notify เพื่อให้ระบบสามารถส่งข้อความแจ้งเตือนเมื่อมีคำขอหรือมีการอนุมัติ
</p>
<a href="<?= BASE_URL ?>/settings/line-notify" class="btn btn-outline-success px-4 mt-auto">
<i class="fa-solid fa-bell me-1"></i> ตั้งค่าแจ้งเตือน
</a>
</div>
</div>
</div>
<!-- Telegram Notify Card -->
<div class="col-md-4 mb-4">
<div class="card shadow-sm border-0 h-100 rounded-4">
<div class="card-body text-center p-5 d-flex flex-column">
<i class="fa-brands fa-telegram fa-4x text-info mb-3"></i>
<h4 class="card-title fw-bold">แจ้งเตือน (Telegram)</h4>
<p class="card-text text-muted mb-4">
เชื่อมต่อกับบอท Telegram ของคุณ เพื่อส่งข้อความแจ้งเตือนแบบเรียลไทม์
</p>
<a href="<?= BASE_URL ?>/settings/telegram-notify" class="btn btn-outline-info px-4 mt-auto">
<i class="fa-solid fa-paper-plane me-1"></i> ตั้งค่า Telegram
</a>
</div>
</div>
</div>
<!-- Appearance & Theme Setup Card -->
<div class="col-md-4 mb-4">
<div class="card shadow-sm border-0 h-100 rounded-4">
<div class="card-body text-center p-5 d-flex flex-column">
<i class="fa-solid fa-palette fa-4x text-primary mb-3"></i>
<h4 class="card-title fw-bold">ตั้งค่าความสวยงาม</h4>
<p class="card-text text-muted mb-4">
เปิดใช้งาน <strong>Premium Theme</strong> สำหรับระบบ (ดีไซน์ Glassmorphism + Animated Background)
</p>
<div class="mt-auto d-flex justify-content-center align-items-center">
<div class="form-check form-switch fs-4">
<input class="form-check-input cursor-pointer" type="checkbox" role="switch" id="premiumThemeToggle">
<label class="form-check-label fs-5 ms-2" for="premiumThemeToggle" style="cursor: pointer;">เปิดใช้งาน</label>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('premiumThemeToggle');
// Set initial state from localStorage
if (localStorage.getItem('app_premium_theme') === 'true') {
themeToggle.checked = true;
}
// Listen for changes
themeToggle.addEventListener('change', function(e) {
if (typeof window.togglePremiumTheme === 'function') {
window.togglePremiumTheme(e.target.checked);
}
});
});
</script>
@@ -0,0 +1,65 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">ตั้งค่า LINE Notify</h1>
<a href="<?= BASE_URL ?>/settings" class="btn btn-secondary"><i class="fa-solid fa-arrow-left me-1"></i> กลับ</a>
</div>
<div class="row">
<div class="col-lg-6">
<div class="card shadow border-0 mb-4">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-success"><i class="fa-brands fa-line me-2"></i>การเชื่อมต่อ LINE Notify</h6>
</div>
<div class="card-body">
<p class="text-muted">ระบบจะส่งการแจ้งเตือนไปยังผู้มีอำนาจพิจารณาเมื่อมีคำขอใหม่ และแจ้งเตือนผู้ขอเมื่อมีการอนุมัติ/ปฏิเสธ</p>
<form action="<?= BASE_URL ?>/settings/line-notify/save" method="POST">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<div class="mb-3">
<label class="form-label">LINE Notify Token <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="line_token" value="<?= htmlspecialchars($line_token ?? '') ?>" placeholder="กรอก Token ที่ได้จาก LINE Notify">
<div class="form-text">คุณสามารถรับ Token ได้จาก <a href="https://notify-bot.line.me/" target="_blank">LINE Notify</a></div>
</div>
<button type="submit" class="btn btn-success"><i class="fa-solid fa-save me-1"></i> บันทึกตั้งค่า</button>
<button type="button" class="btn btn-outline-primary ms-2" id="testLineBtn"><i class="fa-solid fa-paper-plane me-1"></i> ทดสอบส่งข้อความ</button>
</form>
</div>
</div>
</div>
</div>
<?php
$extraScripts = '
<script>
document.getElementById("testLineBtn").addEventListener("click", function() {
const btn = this;
btn.disabled = true;
btn.innerHTML = \'<i class="fa-solid fa-spinner fa-spin me-1"></i> กำลังทดสอบ...\';
fetch("' . BASE_URL . '/settings/line-notify/test", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: "csrf_token=' . $csrf_token . '"
})
.then(res => res.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = \'<i class="fa-solid fa-paper-plane me-1"></i> ทดสอบส่งข้อความ\';
if (data.status === "success") {
alert(data.message);
} else {
alert("เกิดข้อผิดพลาด: " + data.message);
}
})
.catch(err => {
btn.disabled = false;
btn.innerHTML = \'<i class="fa-solid fa-paper-plane me-1"></i> ทดสอบส่งข้อความ\';
alert("Error: " + err.message);
});
});
</script>
';
?>
@@ -0,0 +1,419 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">PDF Template Designer</h1>
<a href="<?= BASE_URL ?>/settings" class="btn btn-outline-secondary shadow-sm"><i class="fa-solid fa-arrow-left me-1"></i> กลับ</a>
</div>
<div class="row">
<!-- Tools & Fields Panel -->
<div class="col-lg-3">
<div class="card shadow-sm rounded-4 border-0 mb-4 overflow-hidden">
<div class="card-header bg-danger text-white py-3">
<h6 class="m-0 font-weight-bold"><i class="fa-solid fa-toolbox me-2"></i>เครื่องมือสร้างฟิลด์</h6>
</div>
<div class="card-body">
<p class="small text-muted mb-3">ลากฟิลด์ที่ต้องการไปวางบนแบบฟอร์ม PDF</p>
<div class="d-grid gap-2 mb-4" style="max-height: 400px; overflow-y: auto; padding-right: 5px;">
<!-- ข้อมูลบุคคล -->
<button class="btn btn-outline-primary btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{fullname}">
<i class="fa-solid fa-user me-2"></i> ชื่อ-นามสกุล <code>{fullname}</code>
</button>
<button class="btn btn-outline-primary btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{position}">
<i class="fa-solid fa-briefcase me-2"></i> ตำแหน่ง <code>{position}</code>
</button>
<button class="btn btn-outline-primary btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{department}">
<i class="fa-solid fa-sitemap me-2"></i> หน่วยงาน <code>{department}</code>
</button>
<button class="btn btn-outline-primary btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{personnel_type}">
<i class="fa-solid fa-id-badge me-2"></i> ประเภทบุคลากร <code>{personnel_type}</code>
</button>
<!-- ข้อมูลหนังสือและวันที่ -->
<button class="btn btn-outline-info btn-sm draggable-field text-start mt-2" draggable="true" data-type="Text" data-field="{doc_number}">
<i class="fa-solid fa-hashtag me-2"></i> เลขอ้างอิงหนังสือ <code>{doc_number}</code>
</button>
<button class="btn btn-outline-info btn-sm draggable-field text-start mt-1" draggable="true" data-type="Text" data-field="{doc_no}">
<i class="fa-solid fa-hashtag me-2"></i> เลขที่หนังสือ <code>{doc_no}</code>
</button>
<button class="btn btn-outline-info btn-sm draggable-field text-start mt-1" draggable="true" data-type="Text" data-field="{trip_running_number}">
<i class="fa-solid fa-list-ol me-2"></i> เลขที่หนังสือเดินทาง <code>{trip_running_number}</code>
</button>
<button class="btn btn-outline-info btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{doc_date}">
<i class="fa-solid fa-calendar-day me-2"></i> วันที่หนังสือ <code>{doc_date}</code>
</button>
<button class="btn btn-outline-info btn-sm draggable-field text-start mt-1" draggable="true" data-type="Text" data-field="{title}">
<i class="fa-solid fa-heading me-2"></i> เรื่อง <code>{title}</code>
</button>
<button class="btn btn-outline-info btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{meeting_date}">
<i class="fa-solid fa-calendar-week me-2"></i> วันที่ประชุม/เดินทาง <code>{meeting_date}</code>
</button>
<button class="btn btn-outline-info btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{current_date}">
<i class="fa-regular fa-clock me-2"></i> วันที่ปัจจุบัน (พิมพ์) <code>{current_date}</code>
</button>
<!-- ข้อมูลสถานที่/ผู้จัด -->
<button class="btn btn-outline-secondary btn-sm draggable-field text-start mt-2" draggable="true" data-type="Text" data-field="{location}">
<i class="fa-solid fa-location-dot me-2"></i> สถานที่ <code>{location}</code>
</button>
<button class="btn btn-outline-secondary btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{organizer}">
<i class="fa-solid fa-building me-2"></i> ผู้จัด <code>{organizer}</code>
</button>
<!-- ข้อมูลวันเดินทาง (แยกย่อย) -->
<button class="btn btn-outline-warning btn-sm draggable-field text-start mt-2" draggable="true" data-type="Text" data-field="{depart_datetime}">
<i class="fa-solid fa-plane-departure me-2"></i> (รวม) วันเวลาออกเดินทาง <code>{depart_datetime}</code>
</button>
<button class="btn btn-outline-warning btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{arrive_datetime}">
<i class="fa-solid fa-plane-arrival me-2"></i> (รวม) วันเวลากลับถึง <code>{arrive_datetime}</code>
</button>
<button class="btn btn-outline-warning btn-sm draggable-field text-start mt-1" draggable="true" data-type="Text" data-field="{depart_date}">
<i class="fa-regular fa-calendar-check me-2"></i> วันที่ออกเดินทาง <code>{depart_date}</code>
</button>
<button class="btn btn-outline-warning btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{depart_time}">
<i class="fa-regular fa-clock me-2"></i> เวลาออกเดินทาง <code>{depart_time}</code>
</button>
<button class="btn btn-outline-warning btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{arrive_date}">
<i class="fa-regular fa-calendar-check me-2"></i> วันที่กลับถึง <code>{arrive_date}</code>
</button>
<button class="btn btn-outline-warning btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{arrive_time}">
<i class="fa-regular fa-clock me-2"></i> เวลากลับถึง <code>{arrive_time}</code>
</button>
<button class="btn btn-outline-warning btn-sm draggable-field text-start mt-1" draggable="true" data-type="Text" data-field="{total_days}">
<i class="fa-solid fa-stopwatch me-2"></i> รวมจำนวนวัน <code>{total_days}</code>
</button>
<button class="btn btn-outline-success btn-sm draggable-field text-start mt-2" draggable="true" data-type="Text" data-field="{vehicle_type}">
<i class="fa-solid fa-car me-2"></i> ประเภทยานพาหนะ <code>{vehicle_type}</code>
</button>
<button class="btn btn-outline-success btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{vehicle_plate}">
<i class="fa-solid fa-id-card me-2"></i> ทะเบียนรถ (หากมี) <code>{vehicle_plate}</code>
</button>
<!-- งบประมาณ -->
<button class="btn btn-outline-success btn-sm draggable-field text-start mt-2" draggable="true" data-type="Text" data-field="{budget_source}">
<i class="fa-solid fa-sack-dollar me-2"></i> เบิกค่าใช้จ่ายจาก <code>{budget_source}</code>
</button>
<button class="btn btn-outline-success btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{organizer_support_amount}">
<i class="fa-solid fa-hand-holding-dollar me-2"></i> จำนวนเงินสนับสนุน <code>{organizer_support_amount}</code>
</button>
<button class="btn btn-outline-success btn-sm draggable-field text-start mt-1" draggable="true" data-type="Text" data-field="{budget}">
<i class="fa-solid fa-money-bill me-2"></i> จำนวนเงินรวม <code>{budget}</code>
</button>
<button class="btn btn-outline-success btn-sm draggable-field text-start mt-1" draggable="true" data-type="Text" data-field="{expense_accommodations_text}">
<i class="fa-solid fa-bed me-2"></i> รายละเอียดที่พัก <code>{expense_accommodations_text}</code>
</button>
<button class="btn btn-outline-success btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{expense_allowances_text}">
<i class="fa-solid fa-utensils me-2"></i> รายละเอียดเบี้ยเลี้ยง <code>{expense_allowances_text}</code>
</button>
<button class="btn btn-outline-success btn-sm draggable-field text-start mt-1" draggable="true" data-type="Text" data-field="{expense_transport}">
<i class="fa-solid fa-ticket me-2"></i> ค่าโดยสารทั้งหมด <code>{expense_transport}</code>
</button>
<button class="btn btn-outline-success btn-sm draggable-field text-start" draggable="true" data-type="Text" data-field="{expense_registration}">
<i class="fa-solid fa-address-card me-2"></i> ค่าลงทะเบียน <code>{expense_registration}</code>
</button>
<!-- ลายเซ็น -->
<button class="btn btn-outline-dark btn-sm draggable-field text-start mt-2" draggable="true" data-type="Signature" data-field="{signature_1}">
<i class="fa-solid fa-signature me-2"></i> ลายเซ็นผู้อนุมัติ <code>{signature_1}</code>
</button>
<button class="btn btn-outline-dark btn-sm draggable-field text-start" draggable="true" data-type="QR" data-field="{qr_code}">
<i class="fa-solid fa-qrcode me-2"></i> QR Code อ้างอิง
</button>
</div>
<hr>
<!-- Property Editor Card (Hidden by default) -->
<div id="propertyEditor" class="card shadow-sm border-info mb-4 d-none">
<div class="card-header bg-info text-white py-2">
<h6 class="m-0 font-weight-bold small"><i class="fa-solid fa-palette me-2"></i>ปรับแต่งข้อความที่เลือก</h6>
</div>
<div class="card-body py-2 px-3">
<div class="mb-2">
<label class="form-label small fw-bold text-muted mb-1">ขนาดตัวอักษร</label>
<div class="input-group input-group-sm">
<input type="number" id="fieldSize" class="form-control" value="13" min="8" max="72">
<span class="input-group-text">pt</span>
</div>
</div>
<div class="mb-2">
<label class="form-label small fw-bold text-muted mb-1">สีตัวอักษร</label>
<input type="color" id="fieldColor" class="form-control form-control-color w-100" value="#000000" style="height: 38px;">
</div>
<small class="text-danger d-block mt-2"><i class="fa-solid fa-circle-info me-1"></i>กดปุ่ม Delete บนคีย์บอร์ดเพื่อลบ</small>
</div>
</div>
<form action="<?= BASE_URL ?>/settings/pdf-designer/upload" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<div class="mb-3">
<label class="form-label fw-bold">อัปโหลดแบบฟอร์ม PDF ใหม่</label>
<input class="form-control form-control-sm" type="file" name="pdf_file" accept=".pdf" required>
</div>
<button type="submit" class="btn btn-sm btn-danger w-100"><i class="fa-solid fa-upload me-1"></i> อัปโหลด</button>
</form>
</div>
</div>
<button id="saveLayoutBtn" class="btn btn-success w-100 shadow-sm rounded-4 py-2"><i class="fa-solid fa-floppy-disk me-1"></i> บันทึกตำแหน่งฟิลด์</button>
</div>
<!-- Canvas Editor Area -->
<div class="col-lg-9">
<div class="card shadow-sm rounded-4 border-0 mb-4 overflow-hidden">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<h6 class="m-0 font-weight-bold text-danger"><i class="fa-solid fa-object-group me-2"></i>พื้นที่ออกแบบ</h6>
</div>
<div>
<?php if($template): ?>
<a href="<?= BASE_URL . $template['file_path'] ?>" target="_blank" class="btn btn-sm btn-outline-info me-2">
<i class="fa-solid fa-file-pdf"></i> ต้นฉบับ
</a>
<a href="<?= BASE_URL ?>/settings/pdf-designer/preview" target="_blank" class="btn btn-sm btn-success me-2">
<i class="fa-solid fa-eye"></i> ดูตัวอย่าง (Preview)
</a>
<?php endif; ?>
<span class="badge bg-secondary me-2">แบบฟอร์ม: <?= $template ? htmlspecialchars($template['name']) : 'ยังไม่ได้อัปโหลด' ?></span>
<?php if($template): ?>
<form action="<?= BASE_URL ?>/settings/pdf-designer/delete" method="POST" class="d-inline" onsubmit="return confirm('คุณแน่ใจหรือไม่ว่าต้องการลบแม่แบบนี้?');">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<input type="hidden" name="id" value="<?= $template['id'] ?>">
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i> ลบ</button>
</form>
<?php endif; ?>
</div>
</div>
<div class="card-body bg-light text-center p-0" style="height: 800px; overflow: auto; position: relative;" id="canvasContainer">
<div style="position: relative; margin: 20px auto; width: 800px; display: inline-block;" id="canvasWrapper">
<canvas id="pdfRenderer" width="800" height="1131" style="position: absolute; top: 0; left: 0; box-shadow: 0 4px 8px rgba(0,0,0,0.1); background-color: white; z-index: 1;"></canvas>
<canvas id="pdfCanvas" width="800" height="1131" style="position: absolute; top: 0; left: 0; z-index: 2;"></canvas>
</div>
</div>
</div>
</div>
</div>
<?php
$pdfUrl = $template ? BASE_URL . $template['file_path'] : '';
$templateId = $template ? $template['id'] : 0;
$fieldMapping = !empty($template['field_mapping']) ? $template['field_mapping'] : '[]';
$extraScripts = '
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
var pdfUrl = "' . $pdfUrl . '";
var templateId = ' . $templateId . ';
var existingMapping = ' . $fieldMapping . ';
var pdfRenderer = document.getElementById("pdfRenderer");
var canvasWrapper = document.getElementById("canvasWrapper");
// Initialize Fabric
var canvas = new fabric.Canvas("pdfCanvas");
canvas.preserveObjectStacking = true; // Keep objects in order
if (pdfUrl) {
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js";
var loadingTask = pdfjsLib.getDocument(pdfUrl);
loadingTask.promise.then(function(pdf) {
return pdf.getPage(1);
}).then(function(page) {
var unscaledViewport = page.getViewport({scale: 1});
var scale = 800 / unscaledViewport.width;
var viewport = page.getViewport({scale: scale});
// Set underlying PDF canvas
pdfRenderer.height = viewport.height;
pdfRenderer.width = viewport.width;
// Adjust wrapper and Fabric canvas to match
canvasWrapper.style.width = viewport.width + "px";
canvasWrapper.style.height = viewport.height + "px";
canvas.setWidth(viewport.width);
canvas.setHeight(viewport.height);
var renderContext = {
canvasContext: pdfRenderer.getContext("2d"),
viewport: viewport
};
return page.render(renderContext).promise.then(function() {
// PDF is now rendered natively to the bottom canvas!
// We just load existing fields onto the transparent top canvas.
loadExistingMapping();
});
}).catch(function(err) {
console.error("Error rendering PDF", err);
Swal.fire("ข้อผิดพลาด PDF.js", "ไม่สามารถแสดง PDF ได้: " + err.message, "error");
loadDummyBackground();
});
} else {
loadDummyBackground();
}
function loadDummyBackground() {
fabric.Image.fromURL("https://via.placeholder.com/800x1131.png?text=Please+Upload+PDF+Template", function(img) {
img.set({ selectable: false, evented: false, opacity: 0.3 });
canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas));
loadExistingMapping();
});
}
function loadExistingMapping() {
if (!existingMapping || existingMapping.length === 0) return;
existingMapping.forEach(function(item) {
var textObj;
if (item.field.includes("signature") || item.field.includes("qr")) {
textObj = new fabric.Rect({
left: 0, top: 0, width: item.width, height: item.height,
fill: "rgba(255,0,0,0.2)", stroke: "red", strokeWidth: 2
});
var label = new fabric.Text(item.field, { left: 10, top: 15, fontSize: 16, fill: "red" });
textObj = new fabric.Group([textObj, label], { left: item.x, top: item.y });
textObj.customField = item.field;
} else {
textObj = new fabric.Text(item.field, {
left: item.x, top: item.y, fontSize: item.font_size || 13,
fontFamily: "Sarabun", fill: item.color || "#000000", borderColor: "blue"
});
textObj.customField = item.field;
}
canvas.add(textObj);
});
}
var fields = document.querySelectorAll(".draggable-field");
fields.forEach(function(field) {
field.addEventListener("dragstart", function(e) {
e.dataTransfer.setData("text/plain", JSON.stringify({
type: field.dataset.type,
field: field.dataset.field
}));
});
});
var container = document.getElementById("canvasContainer");
container.addEventListener("dragover", function(e) { e.preventDefault(); });
container.addEventListener("drop", function(e) {
e.preventDefault();
var data = JSON.parse(e.dataTransfer.getData("text/plain"));
var rect = document.querySelector(".canvas-container").getBoundingClientRect();
var x = e.clientX - rect.left;
var y = e.clientY - rect.top;
var textObj;
if(data.type === "Text") {
textObj = new fabric.Text(data.field, {
left: x, top: y, fontSize: 13, fontFamily: "Sarabun", fill: "#000000"
});
} else {
textObj = new fabric.Rect({
left: 0, top: 0, width: 150, height: 50,
fill: "rgba(255,0,0,0.2)", stroke: "red", strokeWidth: 2
});
var label = new fabric.Text(data.field, { left: 10, top: 15, fontSize: 16, fill: "red" });
textObj = new fabric.Group([textObj, label], { left: x, top: y });
}
textObj.customField = data.field;
canvas.add(textObj);
canvas.setActiveObject(textObj);
});
window.addEventListener("keydown", function(e) {
if(e.key === "Delete" || e.key === "Backspace") {
var activeObject = canvas.getActiveObject();
if (activeObject && !activeObject.isEditing) {
canvas.remove(activeObject);
document.getElementById("propertyEditor").classList.add("d-none");
}
}
});
// Handle selection for property editor
canvas.on("selection:created", showProperties);
canvas.on("selection:updated", showProperties);
canvas.on("selection:cleared", function() {
document.getElementById("propertyEditor").classList.add("d-none");
});
function showProperties(e) {
var obj = e.selected[0];
if (obj && obj.type === "text") {
document.getElementById("propertyEditor").classList.remove("d-none");
document.getElementById("fieldSize").value = Math.round(obj.fontSize * obj.scaleY);
document.getElementById("fieldColor").value = obj.fill;
} else {
document.getElementById("propertyEditor").classList.add("d-none");
}
}
document.getElementById("fieldSize").addEventListener("input", function() {
var obj = canvas.getActiveObject();
if (obj && obj.type === "text") {
obj.set("fontSize", parseInt(this.value) / obj.scaleY);
canvas.renderAll();
}
});
document.getElementById("fieldColor").addEventListener("input", function() {
var obj = canvas.getActiveObject();
if (obj && obj.type === "text") {
obj.set("fill", this.value);
canvas.renderAll();
}
});
document.getElementById("saveLayoutBtn").addEventListener("click", function() {
if (!templateId) {
Swal.fire("แจ้งเตือน", "กรุณาอัปโหลดไฟล์ PDF ต้นฉบับก่อนตั้งค่าพิกัด", "warning");
return;
}
var btn = this;
btn.innerHTML = "<i class=\'fa-solid fa-spinner fa-spin\'></i> กำลังบันทึก...";
btn.disabled = true;
var objects = canvas.getObjects();
var layoutData = [];
objects.forEach(function(obj) {
if(obj.customField) {
var isText = obj.type === "text";
layoutData.push({
field: obj.customField,
x: Math.round(obj.left),
y: Math.round(obj.top),
width: Math.round(obj.width * obj.scaleX),
height: Math.round(obj.height * obj.scaleY),
font_size: isText ? Math.round(obj.fontSize * obj.scaleY) : 0,
color: obj.fill || "#000000"
});
}
});
fetch("' . BASE_URL . '/settings/pdf-designer/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template_id: templateId, fields: layoutData })
})
.then(res => res.json())
.then(data => {
btn.innerHTML = "<i class=\'fa-solid fa-floppy-disk me-1\'></i> บันทึกตำแหน่งฟิลด์";
btn.disabled = false;
if(data.status === "success") {
Swal.fire("สำเร็จ", "บันทึกตำแหน่ง (X,Y) เข้าสู่ฐานข้อมูลเรียบร้อยแล้ว จำนวน " + layoutData.length + " ฟิลด์", "success");
} else {
Swal.fire("ผิดพลาด", data.message || "เกิดข้อผิดพลาดในการบันทึก", "error");
}
});
});
});
</script>
';
?>
@@ -0,0 +1,71 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">ตั้งค่า Telegram Notify</h1>
<a href="<?= BASE_URL ?>/settings" class="btn btn-secondary"><i class="fa-solid fa-arrow-left me-1"></i> กลับ</a>
</div>
<div class="row">
<div class="col-lg-6">
<div class="card shadow border-0 mb-4">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-info"><i class="fa-brands fa-telegram me-2"></i>การเชื่อมต่อ Telegram Bot</h6>
</div>
<div class="card-body">
<p class="text-muted">ระบบจะส่งการแจ้งเตือนไปยังแชทที่ระบุเมื่อมีคำขอใหม่ หรือมีการพิจารณาอนุมัติ/ปฏิเสธ</p>
<form action="<?= BASE_URL ?>/settings/telegram-notify/save" method="POST">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<div class="mb-3">
<label class="form-label">Telegram Bot Token <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="bot_token" value="<?= htmlspecialchars($bot_token ?? '') ?>" placeholder="e.g. 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
<div class="form-text">รับได้จาก <a href="https://t.me/BotFather" target="_blank">@BotFather</a> ใน Telegram</div>
</div>
<div class="mb-4">
<label class="form-label">Chat ID <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="chat_id" value="<?= htmlspecialchars($chat_id ?? '') ?>" placeholder="e.g. 123456789 หรือ -100123456789">
<div class="form-text">รหัสห้องแชทที่จะให้บอทส่งข้อความไปหา</div>
</div>
<button type="submit" class="btn btn-info text-white"><i class="fa-solid fa-save me-1"></i> บันทึกตั้งค่า</button>
<button type="button" class="btn btn-outline-primary ms-2" id="testTelegramBtn"><i class="fa-solid fa-paper-plane me-1"></i> ทดสอบส่งข้อความ</button>
</form>
</div>
</div>
</div>
</div>
<?php
$extraScripts = '
<script>
document.getElementById("testTelegramBtn").addEventListener("click", function() {
const btn = this;
btn.disabled = true;
btn.innerHTML = \'<i class="fa-solid fa-spinner fa-spin me-1"></i> กำลังทดสอบ...\';
fetch("' . BASE_URL . '/settings/telegram-notify/test", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: "csrf_token=' . $csrf_token . '"
})
.then(res => res.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = \'<i class="fa-solid fa-paper-plane me-1"></i> ทดสอบส่งข้อความ\';
if (data.status === "success") {
alert(data.message);
} else {
alert("เกิดข้อผิดพลาด: " + data.message);
}
})
.catch(err => {
btn.disabled = false;
btn.innerHTML = \'<i class="fa-solid fa-paper-plane me-1"></i> ทดสอบส่งข้อความ\';
alert("Error: " + err.message);
});
});
</script>
';
?>
@@ -0,0 +1,132 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">พิจารณาอนุมัติ</h1>
</div>
<div class="card shadow border-0 rounded-4 mb-4">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle" id="approvalsTable">
<thead class="table-light">
<tr>
<th>วันที่ขอ</th>
<th>เลขที่หนังสือ</th>
<th>เรื่อง</th>
<th>สถานที่</th>
<th>วันที่เดินทาง</th>
<th>สถานะ</th>
<th>จัดการ</th>
</tr>
</thead>
<tbody>
<?php if (!empty($approvals)): ?>
<?php foreach ($approvals as $row): ?>
<tr>
<td><?= date('d/m/Y', strtotime($row['created_at'])) ?></td>
<td><?= \App\Core\Security::escape($row['doc_number'] ?: '-') ?></td>
<td>
<strong><?= \App\Core\Security::escape($row['title']) ?></strong>
<div class="small text-muted text-truncate" style="max-width: 250px;">
<?= \App\Core\Security::escape($row['objective']) ?>
</div>
</td>
<td><?= \App\Core\Security::escape($row['location']) ?></td>
<td>
<?= date('d/m/Y', strtotime($row['start_date'])) ?>
- <?= date('d/m/Y', strtotime($row['end_date'])) ?>
</td>
<td>
<?php if ($row['req_status'] === 'Pending Cancel'): ?>
<span class="badge bg-danger"><i class="fa-solid fa-ban me-1"></i> ขอยกเลิก</span>
<?php elseif ($row['req_status'] === 'Pending Revise'): ?>
<span class="badge bg-warning text-dark"><i class="fa-solid fa-pen me-1"></i> ขอเปลี่ยนแปลง</span>
<?php else: ?>
<span class="badge bg-info text-dark"><i class="fa-solid fa-clock me-1"></i> รอพิจารณา</span>
<?php endif; ?>
</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" title="พิมพ์ได้เมื่ออนุมัติแล้วเท่านั้น" disabled>
<i class="fa-solid fa-print"></i>
</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#approveModal<?= $row['approval_id'] ?>">
พิจารณา
</button>
</div>
</td>
</tr>
<!-- Modal for each approval -->
<div class="modal fade" id="approveModal<?= $row['approval_id'] ?>" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form action="<?= BASE_URL ?>/trip/approvals/process" method="POST">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<input type="hidden" name="approval_id" value="<?= $row['approval_id'] ?>">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title">พิจารณาคำขอเดินทาง</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p><strong>เรื่อง:</strong> <?= \App\Core\Security::escape($row['title']) ?></p>
<p><strong>สถานที่:</strong> <?= \App\Core\Security::escape($row['location']) ?></p>
<p><strong>งบประมาณ:</strong> <?= number_format($row['budget'], 2) ?> บาท</p>
<?php if (!empty($row['doc_link'])): ?>
<p>
<a href="<?= \App\Core\Security::escape($row['doc_link']) ?>" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="fa-solid fa-arrow-up-right-from-square me-1"></i> เปิดไฟล์เอกสาร
</a>
</p>
<?php endif; ?>
<?php if ($row['req_status'] === 'Pending Cancel'): ?>
<div class="alert alert-danger mt-3 mb-0">
<strong>เหตุผลที่ขอยกเลิก:</strong><br>
<?= nl2br(\App\Core\Security::escape($row['cancel_reason'])) ?>
</div>
<?php elseif ($row['req_status'] === 'Pending Revise'): ?>
<div class="alert alert-warning text-dark mt-3 mb-0">
<strong>รายละเอียดที่ขอเปลี่ยนแปลง:</strong><br>
<?= nl2br(\App\Core\Security::escape($row['revise_reason'])) ?>
</div>
<?php endif; ?>
<hr>
<div class="mb-3">
<label class="form-label">ความคิดเห็น (ถ้ามี)</label>
<textarea class="form-control" name="comments" rows="2" placeholder="ระบุเหตุผลการอนุมัติ/ไม่อนุมัติ"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="action" value="reject" class="btn btn-danger">ไม่อนุมัติ</button>
<button type="submit" name="action" value="approve" class="btn btn-success"><i class="fa-solid fa-check me-1"></i> อนุมัติ</button>
</div>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php
$extraScripts = '
<script>
$(document).ready(function() {
$("#approvalsTable").DataTable({
"language": {
"url": "//cdn.datatables.net/plug-ins/1.13.6/i18n/th.json",
"emptyTable": "ไม่มีรายการรออนุมัติ"
},
"order": [[0, "desc"]],
"pageLength": 10
});
});
</script>
';
?>
@@ -0,0 +1,592 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">ขออนุมัติเดินทางไปราชการ/ประชุม</h1>
<a href="<?= BASE_URL ?>/" class="btn btn-outline-secondary shadow-sm"><i class="fa-solid fa-arrow-left me-1"></i> กลับ</a>
</div>
<div class="card shadow border-0 rounded-4 mb-5">
<div class="card-body p-4 p-md-5">
<form action="<?= BASE_URL ?>/trip/create" method="POST" id="tripForm">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<!-- ส่วนที่ 1: ข้อมูลหนังสือ -->
<h5 class="fw-bold mb-4 text-primary border-bottom pb-2"><i class="fa-solid fa-file-lines me-2"></i>อ้างถึงหนังสือ</h5>
<div class="row mb-4">
<div class="col-md-5 mb-3 mb-md-0">
<label class="form-label">เลขอ้างอิงหนังสือ</label>
<div class="input-group">
<select class="form-select" id="doc_year" style="max-width: 120px;">
<?php
$currentYearBE = date('Y') + 543;
echo '<option value="'.$currentYearBE.'">ปี '.$currentYearBE.'</option>';
echo '<option value="'.($currentYearBE - 1).'">ปี '.($currentYearBE - 1).'</option>';
?>
</select>
<input type="text" class="form-control" name="doc_number" id="doc_number" placeholder="เลขอ้างอิง (เช่น 123)">
<button class="btn btn-outline-primary" type="button" id="btnSearchDoc"><i class="fa-solid fa-magnifying-glass"></i> ค้นหา</button>
</div>
<div class="form-text text-muted small"><i class="fa-solid fa-circle-info"></i> ระบบจะค้นหาเลขอ้างอิงจากปีที่เลือก</div>
</div>
<div class="col-md-4 mb-3 mb-md-0">
<label class="form-label">เลขที่หนังสือ</label>
<input type="text" class="form-control" name="doc_no" id="doc_no" placeholder="เช่น ศธ 1234/567">
</div>
<div class="col-md-3">
<label class="form-label">ลงวันที่</label>
<input type="date" class="form-control" name="doc_date" id="doc_date">
</div>
</div>
<input type="hidden" name="doc_link" id="doc_link" value="">
<!-- ส่วนที่ 2: ข้อมูลการประชุม/ไปราชการ -->
<h5 class="fw-bold mb-4 text-primary border-bottom pb-2 mt-5"><i class="fa-solid fa-briefcase me-2"></i>รายละเอียดการเดินทาง</h5>
<div class="mb-4">
<label class="form-label text-danger">เรื่อง *</label>
<input type="text" class="form-control" name="title" id="title" required placeholder="หัวข้อการประชุมหรือเรื่องที่ไปราชการ">
</div>
<div class="row mb-4">
<div class="col-md-4 mb-3 mb-md-0">
<label class="form-label">ประเภทบุคลากร</label>
<select class="form-select" name="personnel_type">
<option value="ข้าราชการ">ข้าราชการ</option>
<option value="ลูกจ้างประจำ">ลูกจ้างประจำ</option>
<option value="พนักงานราชการ">พนักงานราชการ</option>
<option value="พนักงานกระทรวงฯ">พนักงานกระทรวงฯ</option>
<option value="ลูกจ้างชั่วคราว">ลูกจ้างชั่วคราว</option>
</select>
</div>
<div class="col-md-4 mb-3 mb-md-0">
<label class="form-label text-danger">วันที่เริ่มต้น *</label>
<input type="date" class="form-control" name="start_date" required>
</div>
<div class="col-md-4">
<label class="form-label text-danger">วันที่สิ้นสุด *</label>
<input type="date" class="form-control" name="end_date" required>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6 mb-3 mb-md-0">
<label class="form-label text-danger">สถานที่ *</label>
<input type="text" class="form-control" name="location" list="locationList" required placeholder="สถานที่จัดงาน / จังหวัด">
<datalist id="locationList">
<?php foreach($locations as $loc): ?>
<option value="<?= htmlspecialchars($loc) ?>">
<?php endforeach; ?>
</datalist>
</div>
<div class="col-md-6">
<label class="form-label">ผู้จัด</label>
<input type="text" class="form-control" name="organizer" list="organizerList" placeholder="ระบุหน่วยงานที่จัด">
<datalist id="organizerList">
<?php foreach($organizers as $org): ?>
<option value="<?= htmlspecialchars($org) ?>">
<?php endforeach; ?>
</datalist>
</div>
</div>
<div class="mb-4">
<label class="form-label text-danger">วัตถุประสงค์ (เหตุผลที่ต้องไป) *</label>
<textarea class="form-control" name="objective" rows="2" required placeholder="ระบุเหตุผลและวัตถุประสงค์ในการเดินทาง"></textarea>
</div>
<!-- ส่วนที่ 3: กำหนดการเดินทาง (ไป-กลับ) -->
<h5 class="fw-bold mb-4 text-primary border-bottom pb-2 mt-5"><i class="fa-solid fa-route me-2"></i>กำหนดการเดินทาง</h5>
<!-- ขาไป -->
<div class="card bg-light border-0 mb-4">
<div class="card-body">
<h6 class="fw-bold mb-3">โดยเดินทางออกจาก</h6>
<div class="row g-3">
<div class="col-md-3">
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="depart_from" id="df1" value="สำนักงาน" checked>
<label class="form-check-label" for="df1">สำนักงาน</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="depart_from" id="df2" value="บ้านพัก">
<label class="form-check-label" for="df2">บ้านพัก</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="depart_from" id="df3" value="อื่นๆ">
<label class="form-check-label" for="df3">อื่นๆ (ระบุที่อยู่)</label>
</div>
</div>
<div class="col-md-5">
<label class="form-label small">ประเทศไทย เลขที่ / ตำบล</label>
<select class="form-select form-select-sm tambon-select" data-target="depart_address">
<option value="">-- เลือกตำบล --</option>
<option value="อ่างทอง">อ่างทอง</option>
<option value="ลิปะน้อย">ลิปะน้อย</option>
<option value="ตลิ่งงาม">ตลิ่งงาม</option>
<option value="หน้าเมือง">หน้าเมือง</option>
<option value="มะเร็ต">มะเร็ต</option>
<option value="บ่อผุด">บ่อผุด</option>
<option value="แม่น้ำ">แม่น้ำ</option>
<option value="อื่นๆ">อื่นๆ (ระบุเอง)</option>
</select>
<input type="text" class="form-control form-control-sm mt-2 d-none" name="depart_address" id="depart_address" placeholder="ระบุที่อยู่ / ตำบลที่ต้องการ">
</div>
<div class="col-md-4">
<label class="form-label small">วันที่และเวลาออกเดินทาง</label>
<input type="datetime-local" class="form-control form-control-sm" name="depart_datetime">
</div>
</div>
</div>
</div>
<!-- ขากลับ -->
<div class="card bg-light border-0 mb-4">
<div class="card-body">
<h6 class="fw-bold mb-3">และกลับถึง</h6>
<div class="row g-3">
<div class="col-md-3">
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="arrive_at" id="ar1" value="สำนักงาน" checked>
<label class="form-check-label" for="ar1">สำนักงาน</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="arrive_at" id="ar2" value="บ้านพัก">
<label class="form-check-label" for="ar2">บ้านพัก</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="arrive_at" id="ar3" value="อื่นๆ">
<label class="form-check-label" for="ar3">อื่นๆ (ระบุที่อยู่)</label>
</div>
</div>
<div class="col-md-5">
<label class="form-label small">ประเทศไทย เลขที่ / ตำบล</label>
<select class="form-select form-select-sm tambon-select" data-target="arrive_address">
<option value="">-- เลือกตำบล --</option>
<option value="อ่างทอง">อ่างทอง</option>
<option value="ลิปะน้อย">ลิปะน้อย</option>
<option value="ตลิ่งงาม">ตลิ่งงาม</option>
<option value="หน้าเมือง">หน้าเมือง</option>
<option value="มะเร็ต">มะเร็ต</option>
<option value="บ่อผุด">บ่อผุด</option>
<option value="แม่น้ำ">แม่น้ำ</option>
<option value="อื่นๆ">อื่นๆ (ระบุเอง)</option>
</select>
<input type="text" class="form-control form-control-sm mt-2 d-none" name="arrive_address" id="arrive_address" placeholder="ระบุที่อยู่ / ตำบลที่ต้องการ">
</div>
<div class="col-md-4">
<label class="form-label small">วันที่และเวลากลับถึง</label>
<input type="datetime-local" class="form-control form-control-sm" name="arrive_datetime">
</div>
</div>
</div>
</div>
<!-- ส่วนที่ 4: พาหนะและค่าใช้จ่าย -->
<h5 class="fw-bold mb-4 text-primary border-bottom pb-2 mt-5"><i class="fa-solid fa-car me-2"></i>พาหนะ และ แหล่งงบประมาณ</h5>
<div class="row mb-4">
<div class="col-md-6 mb-3 mb-md-0">
<label class="form-label">โดยสารพาหนะ</label>
<select class="form-select" name="vehicle_type">
<option value="">-- ระบุพาหนะ --</option>
<option value="เครื่องบิน">เครื่องบิน</option>
<option value="รถยนต์ของทางราชการ">รถยนต์ของทางราชการ</option>
<option value="รถโดยสารประจำทาง">รถโดยสารประจำทาง</option>
<option value="พาหนะส่วนตัว">พาหนะส่วนตัว</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">ทะเบียนรถ (กรณีรถส่วนตัว/รถราชการ)</label>
<input type="text" class="form-control" name="vehicle_plate" placeholder="เช่น กข 1234 สมุย">
</div>
</div>
<div class="card bg-light border-0 mb-5">
<div class="card-body">
<h6 class="fw-bold mb-3">รายละเอียดการเบิกจ่าย</h6>
<div class="row mb-4">
<div class="col-md-5 mb-3 mb-md-0">
<label class="form-label">ขอเบิกค่าใช้จ่ายในการเดินทางจาก</label>
<select class="form-select" name="budget_source" id="budget_source">
<option value="เงินบำรุง รพ.">เงินบำรุง รพ.</option>
<option value="ผู้จัดสนับสนุนทั้งหมด">ผู้จัดสนับสนุนทั้งหมด (ไม่ขอเบิก)</option>
<option value="ผู้จัดสนับสนุนบางส่วน">ผู้จัดสนับสนุนบางส่วน</option>
</select>
</div>
<div class="col-md-4 mb-3 mb-md-0 d-none" id="organizer_support_container">
<label class="form-label text-danger">ระบุจำนวนเงินที่สนับสนุน (บาท) *</label>
<input type="number" step="0.01" min="0" class="form-control" name="organizer_support_amount" id="organizer_support_amount" value="0.00">
</div>
<div class="col-md-3">
<label class="form-label">รายละเอียดเพิ่มเติม (ถ้ามี)</label>
<input type="text" class="form-control" name="budget_remark" placeholder="เช่น เบิกเฉพาะค่าที่พัก">
</div>
</div>
<div id="expense_section">
<h6 class="fw-bold mb-3 border-top pt-3">รูปแบบการประมาณการค่าใช้จ่าย</h6>
<div class="mb-3">
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="budget_type" id="budget_type_lump" value="lump_sum" checked>
<label class="form-check-label" for="budget_type_lump">แบบประมาณการรวม (กรอกแค่ยอดรวม)</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="budget_type" id="budget_type_detail" value="detailed">
<label class="form-check-label" for="budget_type_detail">แบบระบุรายละเอียด</label>
</div>
</div>
<!-- แบบประมาณการรวม -->
<div id="lump_sum_container" class="mb-3">
<label class="form-label">ประมาณการค่าใช้จ่ายรวม (บาท)</label>
<div class="input-group" style="max-width: 300px;">
<span class="input-group-text">฿</span>
<input type="number" step="0.01" min="0" class="form-control calc-total" name="budget" id="budget_lump_sum" value="0.00">
</div>
</div>
<!-- แบบระบุรายละเอียด -->
<div id="detailed_expense_container" class="d-none">
<div class="row">
<!-- ค่าที่พัก -->
<div class="col-12 mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold"><i class="fa-solid fa-bed me-1"></i> ค่าที่พัก</label>
<button type="button" class="btn btn-sm btn-outline-primary" id="add_accommodation"><i class="fa-solid fa-plus"></i> เพิ่มที่พัก</button>
</div>
<div id="accommodations_list">
<!-- Items will be generated here -->
<div class="row g-2 align-items-center mb-2 accommodation-item">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">จำนวนคืน</span>
<input type="number" min="0" step="1" name="acc_days[]" class="form-control acc-days calc-trigger" placeholder="0">
</div>
</div>
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">ราคา/คืน</span>
<input type="number" min="0" step="0.01" name="acc_prices[]" class="form-control acc-prices calc-trigger" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<div class="col-md-2 text-end">
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-row" disabled><i class="fa-solid fa-trash"></i></button>
</div>
</div>
</div>
</div>
<!-- ค่าเบี้ยเลี้ยง -->
<div class="col-12 mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold"><i class="fa-solid fa-utensils me-1"></i> ค่าเบี้ยเลี้ยง</label>
<button type="button" class="btn btn-sm btn-outline-primary" id="add_allowance"><i class="fa-solid fa-plus"></i> เพิ่มเบี้ยเลี้ยง</button>
</div>
<div id="allowances_list">
<!-- Items will be generated here -->
<div class="row g-2 align-items-center mb-2 allowance-item">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">จำนวนวัน</span>
<input type="number" min="0" step="0.5" name="alw_days[]" class="form-control alw-days calc-trigger" placeholder="0">
</div>
</div>
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">อัตรา/วัน</span>
<input type="number" min="0" step="0.01" name="alw_prices[]" class="form-control alw-prices calc-trigger" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<div class="col-md-2 text-end">
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-row" disabled><i class="fa-solid fa-trash"></i></button>
</div>
</div>
</div>
</div>
<!-- ค่าโดยสารและค่าลงทะเบียน -->
<div class="col-md-6 mb-3">
<label class="form-label fw-bold"><i class="fa-solid fa-ticket me-1"></i> ค่าโดยสารทั้งหมด</label>
<div class="input-group input-group-sm">
<input type="number" min="0" step="0.01" name="expense_transport" id="expense_transport" class="form-control calc-trigger" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-bold"><i class="fa-solid fa-address-card me-1"></i> ค่าลงทะเบียน</label>
<div class="input-group input-group-sm">
<input type="number" min="0" step="0.01" name="expense_registration" id="expense_registration" class="form-control calc-trigger" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<!-- สรุปยอดรวม -->
<div class="col-12 mt-3 pt-3 border-top">
<div class="d-flex justify-content-end align-items-center">
<h5 class="mb-0 me-3">รวมเป็นเงินทั้งสิ้น:</h5>
<h4 class="mb-0 text-primary fw-bold" id="detailed_total_display">0.00</h4>
<span class="ms-2">บาท</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="text-end border-top pt-4">
<button type="reset" class="btn btn-light me-2 px-4">ล้างข้อมูล</button>
<button type="submit" class="btn btn-primary px-5 shadow"><i class="fa-solid fa-paper-plane me-1"></i> เสนอขออนุมัติ</button>
</div>
</form>
</div>
</div>
<?php
$extraScripts = '
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
// ฟังก์ชันแปลงวันที่รูปแบบไทย/ทั่วไป เป็น YYYY-MM-DD สำหรับ input[type="date"]
function parseDateToInputFormat(dateStr) {
if (!dateStr) return "";
// ถ้ามาเป็น YYYY-MM-DD อยู่แล้วก็คืนค่าได้เลย
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr;
// ลองแยกด้วย / หรือ -
var parts = dateStr.split(/[\/\-]/);
if (parts.length === 3) {
var d = parseInt(parts[0], 10);
var m = parseInt(parts[1], 10);
var y = parseInt(parts[2], 10);
// ถ้าปีเป็น 25xx ให้แปลงเป็น ค.ศ.
if (y > 2400) y -= 543;
// ถ้าปีมาแค่ 2 หลัก (เช่น 66) ถือว่าเป็น 2566
else if (y > 50 && y < 100) y = (y + 2500) - 543;
// ถ้า d > 31 แสดงว่ามาในรูปแบบ YYYY/MM/DD
if (d > 31) {
var temp = d;
d = y;
y = temp;
}
var mm = m < 10 ? "0" + m : m;
var dd = d < 10 ? "0" + d : d;
return y + "-" + mm + "-" + dd;
}
return ""; // ถ้าแปลงไม่ได้
}
$(document).ready(function() {
// จัดการ Select ตำบลเกาะสมุย
$(".tambon-select").on("change", function() {
var targetId = $(this).data("target");
var val = $(this).val();
if (val === "อื่นๆ") {
$("#" + targetId).removeClass("d-none").val("").focus();
} else {
$("#" + targetId).addClass("d-none").val(val);
}
});
$("#btnSearchDoc").on("click", function() {
var q = $("#doc_number").val().trim();
var year = $("#doc_year").val();
if (!q) {
Swal.fire("กรุณาระบุ", "โปรดกรอกเลขที่หนังสือที่ต้องการค้นหา", "warning");
return;
}
var btn = $(this);
var originalHtml = btn.html();
btn.html(\'<i class="fa-solid fa-spinner fa-spin"></i>\').prop("disabled", true);
$.ajax({
url: "' . BASE_URL . '/trip/search-docs",
type: "GET",
dataType: "json",
data: { q: q, year: year },
success: function(res) {
btn.html(originalHtml).prop("disabled", false);
if (res.results && res.results.length > 0) {
// ดึงรายการแรกที่ตรงที่สุด
var data = res.results[0];
if (data.title) {
$("#title").val(data.title);
$("#title").addClass("is-valid");
setTimeout(() => $("#title").removeClass("is-valid"), 2000);
}
if (data.doc_no) {
$("#doc_no").val(data.doc_no);
$("#doc_no").addClass("is-valid");
setTimeout(() => $("#doc_no").removeClass("is-valid"), 2000);
}
if (data.doc_link) {
$("#doc_link").val(data.doc_link);
}
if (data.doc_date) {
var parsedDate = parseDateToInputFormat(data.doc_date);
if (parsedDate) {
$("#doc_date").val(parsedDate);
$("#doc_date").addClass("is-valid");
setTimeout(() => $("#doc_date").removeClass("is-valid"), 2000);
} else {
// ถ้าแปลงอัตโนมัติไม่ได้
Swal.fire("แจ้งเตือน", "ดึงวันที่ได้คือ \'" + data.doc_date + "\' แต่ระบบไม่สามารถจัดรูปแบบอัตโนมัติได้ กรุณาเลือกวันที่ด้วยตนเอง", "info");
}
}
Swal.fire({
icon: "success",
title: "ดึงข้อมูลสำเร็จ",
text: "พบหนังสือเรื่อง: " + (data.title || "-"),
timer: 1500,
showConfirmButton: false
});
} else {
Swal.fire("ไม่พบข้อมูล", "ไม่พบเลขที่หนังสือ \'" + q + "\' ในระบบ", "error");
}
},
error: function() {
btn.html(originalHtml).prop("disabled", false);
Swal.fire("เกิดข้อผิดพลาด", "ไม่สามารถเชื่อมต่อระบบค้นหาได้", "error");
}
});
});
// --- Expense Logic ---
function toggleExpenseUI() {
var budgetSource = $("#budget_source").val();
if (budgetSource === "ผู้จัดสนับสนุนบางส่วน") {
$("#organizer_support_container").removeClass("d-none");
} else {
$("#organizer_support_container").addClass("d-none");
$("#organizer_support_amount").val("0.00");
}
if (budgetSource === "ผู้จัดสนับสนุนทั้งหมด") {
$("#expense_section").addClass("d-none");
// clear budgets
$("#budget_lump_sum").val("0.00");
} else {
$("#expense_section").removeClass("d-none");
var budgetType = $("input[name=\'budget_type\']:checked").val();
if (budgetType === "detailed") {
$("#lump_sum_container").addClass("d-none");
$("#detailed_expense_container").removeClass("d-none");
calculateDetailedTotal();
} else {
$("#lump_sum_container").removeClass("d-none");
$("#detailed_expense_container").addClass("d-none");
}
}
}
$("#budget_source, input[name=\'budget_type\']").on("change", toggleExpenseUI);
// Add Row functions
$("#add_accommodation").click(function() {
var template = `
<div class="row g-2 align-items-center mb-2 accommodation-item">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">จำนวนคืน</span>
<input type="number" min="0" step="1" name="acc_days[]" class="form-control acc-days calc-trigger" placeholder="0">
</div>
</div>
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">ราคา/คืน</span>
<input type="number" min="0" step="0.01" name="acc_prices[]" class="form-control acc-prices calc-trigger" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<div class="col-md-2 text-end">
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-row"><i class="fa-solid fa-trash"></i></button>
</div>
</div>`;
$("#accommodations_list").append(template);
updateRemoveButtons("#accommodations_list");
});
$("#add_allowance").click(function() {
var template = `
<div class="row g-2 align-items-center mb-2 allowance-item">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">จำนวนวัน</span>
<input type="number" min="0" step="0.5" name="alw_days[]" class="form-control alw-days calc-trigger" placeholder="0">
</div>
</div>
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">อัตรา/วัน</span>
<input type="number" min="0" step="0.01" name="alw_prices[]" class="form-control alw-prices calc-trigger" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<div class="col-md-2 text-end">
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-row"><i class="fa-solid fa-trash"></i></button>
</div>
</div>`;
$("#allowances_list").append(template);
updateRemoveButtons("#allowances_list");
});
function updateRemoveButtons(listId) {
var items = $(listId).find(".btn-remove-row");
if (items.length > 1) {
items.prop("disabled", false);
} else {
items.prop("disabled", true);
}
}
$(document).on("click", ".btn-remove-row", function() {
var list = $(this).closest("div[id$=\'_list\']");
$(this).closest(".row").remove();
updateRemoveButtons("#" + list.attr("id"));
calculateDetailedTotal();
});
function calculateDetailedTotal() {
var total = 0;
// Accommodations
$(".accommodation-item").each(function() {
var days = parseFloat($(this).find(".acc-days").val()) || 0;
var price = parseFloat($(this).find(".acc-prices").val()) || 0;
total += (days * price);
});
// Allowances
$(".allowance-item").each(function() {
var days = parseFloat($(this).find(".alw-days").val()) || 0;
var price = parseFloat($(this).find(".alw-prices").val()) || 0;
total += (days * price);
});
// Transport
var transport = parseFloat($("#expense_transport").val()) || 0;
total += transport;
// Registration
var registration = parseFloat($("#expense_registration").val()) || 0;
total += registration;
$("#detailed_total_display").text(total.toLocaleString("en-US", {minimumFractionDigits: 2, maximumFractionDigits: 2}));
// Also sync it to budget_lump_sum so the backend can just read \'budget\'
$("#budget_lump_sum").val(total.toFixed(2));
}
$(document).on("input", ".calc-trigger", calculateDetailedTotal);
// Initial setup
toggleExpenseUI();
});
</script>
';
$extraStyles = '';
?>
@@ -0,0 +1,667 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">แก้ไขคำขอเดินทางไปราชการ/ประชุม</h1>
<a href="<?= BASE_URL ?>/" class="btn btn-outline-secondary shadow-sm"><i class="fa-solid fa-arrow-left me-1"></i> กลับ</a>
</div>
<div class="card shadow border-0 rounded-4 mb-5">
<div class="card-body p-4 p-md-5">
<form action="<?= BASE_URL ?>/trip/edit" method="POST" id="tripForm">
<input type="hidden" name="csrf_token" value="<?= $csrf_token ?>">
<input type="hidden" name="id" value="<?= $trip['id'] ?>">
<!-- ส่วนที่ 1: ข้อมูลหนังสือ -->
<h5 class="fw-bold mb-4 text-primary border-bottom pb-2"><i class="fa-solid fa-file-lines me-2"></i>อ้างถึงหนังสือ</h5>
<div class="row mb-4">
<div class="col-md-5 mb-3 mb-md-0">
<label class="form-label">เลขอ้างอิงหนังสือ</label>
<div class="input-group">
<select class="form-select" id="doc_year" style="max-width: 120px;">
<?php
$currentYearBE = date('Y') + 543;
echo '<option value="'.$currentYearBE.'">ปี '.$currentYearBE.'</option>';
echo '<option value="'.($currentYearBE - 1).'">ปี '.($currentYearBE - 1).'</option>';
?>
</select>
<input type="text" class="form-control" name="doc_number" id="doc_number" value="<?= htmlspecialchars($trip['doc_number'] ?? '') ?>" placeholder="เลขอ้างอิง (เช่น 123)">
<button class="btn btn-outline-primary" type="button" id="btnSearchDoc"><i class="fa-solid fa-magnifying-glass"></i> ค้นหา</button>
</div>
<div class="form-text text-muted small"><i class="fa-solid fa-circle-info"></i> ระบบจะค้นหาเลขอ้างอิงจากปีที่เลือก</div>
</div>
<div class="col-md-4 mb-3 mb-md-0">
<label class="form-label">เลขที่หนังสือ</label>
<input type="text" class="form-control" name="doc_no" id="doc_no" value="<?= htmlspecialchars($trip['doc_no'] ?? '') ?>" placeholder="เช่น ศธ 1234/567">
</div>
<div class="col-md-3">
<label class="form-label">ลงวันที่</label>
<input type="date" class="form-control" name="doc_date" id="doc_date" value="<?= htmlspecialchars($trip['doc_date'] ?? '') ?>">
</div>
</div>
<input type="hidden" name="doc_link" id="doc_link" value="<?= htmlspecialchars($trip['doc_link'] ?? '') ?>">
<!-- ส่วนที่ 2: ข้อมูลการประชุม/ไปราชการ -->
<h5 class="fw-bold mb-4 text-primary border-bottom pb-2 mt-5"><i class="fa-solid fa-briefcase me-2"></i>รายละเอียดการเดินทาง</h5>
<div class="mb-4">
<label class="form-label text-danger">เรื่อง *</label>
<input type="text" class="form-control" name="title" id="title" value="<?= htmlspecialchars($trip['title'] ?? '') ?>" required placeholder="หัวข้อการประชุมหรือเรื่องที่ไปราชการ">
</div>
<div class="row mb-4">
<div class="col-md-4 mb-3 mb-md-0">
<label class="form-label">ประเภทบุคลากร</label>
<select class="form-select" name="personnel_type">
<option value="ข้าราชการ" <?= ($trip['personnel_type'] ?? '') === 'ข้าราชการ' ? 'selected' : '' ?>>ข้าราชการ</option>
<option value="ลูกจ้างประจำ" <?= ($trip['personnel_type'] ?? '') === 'ลูกจ้างประจำ' ? 'selected' : '' ?>>ลูกจ้างประจำ</option>
<option value="พนักงานราชการ" <?= ($trip['personnel_type'] ?? '') === 'พนักงานราชการ' ? 'selected' : '' ?>>พนักงานราชการ</option>
<option value="พนักงานกระทรวงฯ" <?= ($trip['personnel_type'] ?? '') === 'พนักงานกระทรวงฯ' ? 'selected' : '' ?>>พนักงานกระทรวงฯ</option>
<option value="ลูกจ้างชั่วคราว" <?= ($trip['personnel_type'] ?? '') === 'ลูกจ้างชั่วคราว' ? 'selected' : '' ?>>ลูกจ้างชั่วคราว</option>
</select>
</div>
<div class="col-md-4 mb-3 mb-md-0">
<label class="form-label text-danger">วันที่เริ่มต้น *</label>
<input type="date" class="form-control" name="start_date" value="<?= htmlspecialchars($trip['start_date'] ?? '') ?>" required>
</div>
<div class="col-md-4">
<label class="form-label text-danger">วันที่สิ้นสุด *</label>
<input type="date" class="form-control" name="end_date" value="<?= htmlspecialchars($trip['end_date'] ?? '') ?>" required>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6 mb-3 mb-md-0">
<label class="form-label text-danger">สถานที่ *</label>
<input type="text" class="form-control" name="location" list="locationList" value="<?= htmlspecialchars($trip['location'] ?? '') ?>" required placeholder="สถานที่จัดงาน / จังหวัด">
<datalist id="locationList">
<?php foreach($locations as $loc): ?>
<option value="<?= htmlspecialchars($loc) ?>">
<?php endforeach; ?>
</datalist>
</div>
<div class="col-md-6">
<label class="form-label">ผู้จัด</label>
<input type="text" class="form-control" name="organizer" list="organizerList" value="<?= htmlspecialchars($trip['organizer'] ?? '') ?>" placeholder="ระบุหน่วยงานที่จัด">
<datalist id="organizerList">
<?php foreach($organizers as $org): ?>
<option value="<?= htmlspecialchars($org) ?>">
<?php endforeach; ?>
</datalist>
</div>
</div>
<div class="mb-4">
<label class="form-label text-danger">วัตถุประสงค์ (เหตุผลที่ต้องไป) *</label>
<textarea class="form-control" name="objective" rows="2" required placeholder="ระบุเหตุผลและวัตถุประสงค์ในการเดินทาง"><?= htmlspecialchars($trip['objective'] ?? '') ?></textarea>
</div>
<!-- ส่วนที่ 3: กำหนดการเดินทาง (ไป-กลับ) -->
<h5 class="fw-bold mb-4 text-primary border-bottom pb-2 mt-5"><i class="fa-solid fa-route me-2"></i>กำหนดการเดินทาง</h5>
<!-- ขาไป -->
<div class="card bg-light border-0 mb-4">
<div class="card-body">
<h6 class="fw-bold mb-3">โดยเดินทางออกจาก</h6>
<div class="row g-3">
<div class="col-md-3">
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="depart_from" id="df1" value="สำนักงาน" <?= ($trip['depart_from'] ?? '') === 'สำนักงาน' ? 'checked' : '' ?>>
<label class="form-check-label" for="df1">สำนักงาน</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="depart_from" id="df2" value="บ้านพัก" <?= ($trip['depart_from'] ?? '') === 'บ้านพัก' ? 'checked' : '' ?>>
<label class="form-check-label" for="df2">บ้านพัก</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="depart_from" id="df3" value="อื่นๆ" <?= ($trip['depart_from'] ?? '') === 'อื่นๆ' ? 'checked' : '' ?>>
<label class="form-check-label" for="df3">อื่นๆ (ระบุที่อยู่)</label>
</div>
</div>
<div class="col-md-5">
<label class="form-label small">ประเทศไทย เลขที่ / ตำบล</label>
<?php
$dtambon = $trip['depart_address'] ?? '';
$isCustomDTambon = !in_array($dtambon, ['', 'อ่างทอง', 'ลิปะน้อย', 'ตลิ่งงาม', 'หน้าเมือง', 'มะเร็ต', 'บ่อผุด', 'แม่น้ำ']);
?>
<select class="form-select form-select-sm tambon-select" data-target="depart_address">
<option value="" <?= $dtambon == '' ? 'selected' : '' ?>>-- เลือกตำบล --</option>
<option value="อ่างทอง" <?= $dtambon == 'อ่างทอง' ? 'selected' : '' ?>>อ่างทอง</option>
<option value="ลิปะน้อย" <?= $dtambon == 'ลิปะน้อย' ? 'selected' : '' ?>>ลิปะน้อย</option>
<option value="ตลิ่งงาม" <?= $dtambon == 'ตลิ่งงาม' ? 'selected' : '' ?>>ตลิ่งงาม</option>
<option value="หน้าเมือง" <?= $dtambon == 'หน้าเมือง' ? 'selected' : '' ?>>หน้าเมือง</option>
<option value="มะเร็ต" <?= $dtambon == 'มะเร็ต' ? 'selected' : '' ?>>มะเร็ต</option>
<option value="บ่อผุด" <?= $dtambon == 'บ่อผุด' ? 'selected' : '' ?>>บ่อผุด</option>
<option value="แม่น้ำ" <?= $dtambon == 'แม่น้ำ' ? 'selected' : '' ?>>แม่น้ำ</option>
<option value="อื่นๆ" <?= $isCustomDTambon ? 'selected' : '' ?>>อื่นๆ (ระบุเอง)</option>
</select>
<input type="text" class="form-control form-control-sm mt-2 <?= $isCustomDTambon ? '' : 'd-none' ?>" name="depart_address" id="depart_address" value="<?= htmlspecialchars($dtambon) ?>" placeholder="ระบุที่อยู่ / ตำบลที่ต้องการ">
</div>
<div class="col-md-4">
<label class="form-label small">วันที่และเวลาออกเดินทาง</label>
<?php
$dt = $trip['depart_datetime'] ?? '';
if ($dt) {
$dt = date('Y-m-d\TH:i', strtotime($dt));
}
?>
<input type="datetime-local" class="form-control form-control-sm" name="depart_datetime" value="<?= $dt ?>">
</div>
</div>
</div>
</div>
<!-- ขากลับ -->
<div class="card bg-light border-0 mb-4">
<div class="card-body">
<h6 class="fw-bold mb-3">และกลับถึง</h6>
<div class="row g-3">
<div class="col-md-3">
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="arrive_at" id="ar1" value="สำนักงาน" <?= ($trip['arrive_at'] ?? '') === 'สำนักงาน' ? 'checked' : '' ?>>
<label class="form-check-label" for="ar1">สำนักงาน</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="arrive_at" id="ar2" value="บ้านพัก" <?= ($trip['arrive_at'] ?? '') === 'บ้านพัก' ? 'checked' : '' ?>>
<label class="form-check-label" for="ar2">บ้านพัก</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="arrive_at" id="ar3" value="อื่นๆ" <?= ($trip['arrive_at'] ?? '') === 'อื่นๆ' ? 'checked' : '' ?>>
<label class="form-check-label" for="ar3">อื่นๆ (ระบุที่อยู่)</label>
</div>
</div>
<div class="col-md-5">
<label class="form-label small">ประเทศไทย เลขที่ / ตำบล</label>
<?php
$atambon = $trip['arrive_address'] ?? '';
$isCustomATambon = !in_array($atambon, ['', 'อ่างทอง', 'ลิปะน้อย', 'ตลิ่งงาม', 'หน้าเมือง', 'มะเร็ต', 'บ่อผุด', 'แม่น้ำ']);
?>
<select class="form-select form-select-sm tambon-select" data-target="arrive_address">
<option value="" <?= $atambon == '' ? 'selected' : '' ?>>-- เลือกตำบล --</option>
<option value="อ่างทอง" <?= $atambon == 'อ่างทอง' ? 'selected' : '' ?>>อ่างทอง</option>
<option value="ลิปะน้อย" <?= $atambon == 'ลิปะน้อย' ? 'selected' : '' ?>>ลิปะน้อย</option>
<option value="ตลิ่งงาม" <?= $atambon == 'ตลิ่งงาม' ? 'selected' : '' ?>>ตลิ่งงาม</option>
<option value="หน้าเมือง" <?= $atambon == 'หน้าเมือง' ? 'selected' : '' ?>>หน้าเมือง</option>
<option value="มะเร็ต" <?= $atambon == 'มะเร็ต' ? 'selected' : '' ?>>มะเร็ต</option>
<option value="บ่อผุด" <?= $atambon == 'บ่อผุด' ? 'selected' : '' ?>>บ่อผุด</option>
<option value="แม่น้ำ" <?= $atambon == 'แม่น้ำ' ? 'selected' : '' ?>>แม่น้ำ</option>
<option value="อื่นๆ" <?= $isCustomATambon ? 'selected' : '' ?>>อื่นๆ (ระบุเอง)</option>
</select>
<input type="text" class="form-control form-control-sm mt-2 <?= $isCustomATambon ? '' : 'd-none' ?>" name="arrive_address" id="arrive_address" value="<?= htmlspecialchars($atambon) ?>" placeholder="ระบุที่อยู่ / ตำบลที่ต้องการ">
</div>
<div class="col-md-4">
<label class="form-label small">วันที่และเวลากลับถึง</label>
<?php
$at = $trip['arrive_datetime'] ?? '';
if ($at) {
$at = date('Y-m-d\TH:i', strtotime($at));
}
?>
<input type="datetime-local" class="form-control form-control-sm" name="arrive_datetime" value="<?= $at ?>">
</div>
</div>
</div>
</div>
<!-- ส่วนที่ 4: พาหนะและค่าใช้จ่าย -->
<h5 class="fw-bold mb-4 text-primary border-bottom pb-2 mt-5"><i class="fa-solid fa-car me-2"></i>พาหนะ และ แหล่งงบประมาณ</h5>
<div class="row mb-4">
<div class="col-md-6 mb-3 mb-md-0">
<label class="form-label">โดยสารพาหนะ</label>
<select class="form-select" name="vehicle_type">
<option value="">-- ระบุพาหนะ --</option>
<option value="เครื่องบิน" <?= ($trip['vehicle_type'] ?? '') === 'เครื่องบิน' ? 'selected' : '' ?>>เครื่องบิน</option>
<option value="รถยนต์ของทางราชการ" <?= ($trip['vehicle_type'] ?? '') === 'รถยนต์ของทางราชการ' ? 'selected' : '' ?>>รถยนต์ของทางราชการ</option>
<option value="รถโดยสารประจำทาง" <?= ($trip['vehicle_type'] ?? '') === 'รถโดยสารประจำทาง' ? 'selected' : '' ?>>รถโดยสารประจำทาง</option>
<option value="พาหนะส่วนตัว" <?= ($trip['vehicle_type'] ?? '') === 'พาหนะส่วนตัว' ? 'selected' : '' ?>>พาหนะส่วนตัว</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">ทะเบียนรถ (กรณีรถส่วนตัว/รถราชการ)</label>
<input type="text" class="form-control" name="vehicle_plate" value="<?= htmlspecialchars($trip['vehicle_plate'] ?? '') ?>" placeholder="เช่น กข 1234 สมุย">
</div>
</div>
<?php
$budgetSource = $trip['budget_source'] ?? '';
$orgSupport = $trip['organizer_support_amount'] ?? '0.00';
$budgetType = $trip['budget_type'] ?? 'lump_sum';
$budgetLump = $trip['budget'] ?? '0.00';
$accItems = json_decode($trip['expense_accommodations'] ?? '[]', true) ?: [];
$alwItems = json_decode($trip['expense_allowances'] ?? '[]', true) ?: [];
$expTransport = $trip['expense_transport'] ?? '0.00';
$expRegistration = $trip['expense_registration'] ?? '0.00';
?>
<div class="card bg-light border-0 mb-5">
<div class="card-body">
<h6 class="fw-bold mb-3">รายละเอียดการเบิกจ่าย</h6>
<div class="row mb-4">
<div class="col-md-5 mb-3 mb-md-0">
<label class="form-label">ขอเบิกค่าใช้จ่ายในการเดินทางจาก</label>
<select class="form-select" name="budget_source" id="budget_source">
<option value="เงินบำรุง รพ." <?= $budgetSource === 'เงินบำรุง รพ.' ? 'selected' : '' ?>>เงินบำรุง รพ.</option>
<option value="ผู้จัดสนับสนุนทั้งหมด" <?= $budgetSource === 'ผู้จัดสนับสนุนทั้งหมด' ? 'selected' : '' ?>>ผู้จัดสนับสนุนทั้งหมด (ไม่ขอเบิก)</option>
<option value="ผู้จัดสนับสนุนบางส่วน" <?= $budgetSource === 'ผู้จัดสนับสนุนบางส่วน' ? 'selected' : '' ?>>ผู้จัดสนับสนุนบางส่วน</option>
</select>
</div>
<div class="col-md-4 mb-3 mb-md-0 <?= $budgetSource === 'ผู้จัดสนับสนุนบางส่วน' ? '' : 'd-none' ?>" id="organizer_support_container">
<label class="form-label text-danger">ระบุจำนวนเงินที่สนับสนุน (บาท) *</label>
<input type="number" step="0.01" min="0" class="form-control" name="organizer_support_amount" id="organizer_support_amount" value="<?= htmlspecialchars($orgSupport) ?>">
</div>
<div class="col-md-3">
<label class="form-label">รายละเอียดเพิ่มเติม (ถ้ามี)</label>
<input type="text" class="form-control" name="budget_remark" value="<?= htmlspecialchars($trip['budget_remark'] ?? '') ?>" placeholder="เช่น เบิกเฉพาะค่าที่พัก">
</div>
</div>
<div id="expense_section" class="<?= $budgetSource === 'ผู้จัดสนับสนุนทั้งหมด' ? 'd-none' : '' ?>">
<h6 class="fw-bold mb-3 border-top pt-3">รูปแบบการประมาณการค่าใช้จ่าย</h6>
<div class="mb-3">
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="budget_type" id="budget_type_lump" value="lump_sum" <?= $budgetType !== 'detailed' ? 'checked' : '' ?>>
<label class="form-check-label" for="budget_type_lump">แบบประมาณการรวม (กรอกแค่ยอดรวม)</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="budget_type" id="budget_type_detail" value="detailed" <?= $budgetType === 'detailed' ? 'checked' : '' ?>>
<label class="form-check-label" for="budget_type_detail">แบบระบุรายละเอียด</label>
</div>
</div>
<!-- แบบประมาณการรวม -->
<div id="lump_sum_container" class="mb-3 <?= $budgetType === 'detailed' ? 'd-none' : '' ?>">
<label class="form-label">ประมาณการค่าใช้จ่ายรวม (บาท)</label>
<div class="input-group" style="max-width: 300px;">
<span class="input-group-text">฿</span>
<input type="number" step="0.01" min="0" class="form-control calc-total" name="budget" id="budget_lump_sum" value="<?= htmlspecialchars($budgetLump) ?>">
</div>
</div>
<!-- แบบระบุรายละเอียด -->
<div id="detailed_expense_container" class="<?= $budgetType === 'detailed' ? '' : 'd-none' ?>">
<div class="row">
<!-- ค่าที่พัก -->
<div class="col-12 mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold"><i class="fa-solid fa-bed me-1"></i> ค่าที่พัก</label>
<button type="button" class="btn btn-sm btn-outline-primary" id="add_accommodation"><i class="fa-solid fa-plus"></i> เพิ่มที่พัก</button>
</div>
<div id="accommodations_list">
<?php if(empty($accItems)): ?>
<div class="row g-2 align-items-center mb-2 accommodation-item">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">จำนวนคืน</span>
<input type="number" min="0" step="1" name="acc_days[]" class="form-control acc-days calc-trigger" placeholder="0">
</div>
</div>
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">ราคา/คืน</span>
<input type="number" min="0" step="0.01" name="acc_prices[]" class="form-control acc-prices calc-trigger" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<div class="col-md-2 text-end">
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-row" disabled><i class="fa-solid fa-trash"></i></button>
</div>
</div>
<?php else: ?>
<?php foreach($accItems as $index => $acc): ?>
<div class="row g-2 align-items-center mb-2 accommodation-item">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">จำนวนคืน</span>
<input type="number" min="0" step="1" name="acc_days[]" class="form-control acc-days calc-trigger" value="<?= htmlspecialchars($acc['days']) ?>" placeholder="0">
</div>
</div>
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">ราคา/คืน</span>
<input type="number" min="0" step="0.01" name="acc_prices[]" class="form-control acc-prices calc-trigger" value="<?= htmlspecialchars($acc['price']) ?>" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<div class="col-md-2 text-end">
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-row" <?= count($accItems) > 1 ? '' : 'disabled' ?>><i class="fa-solid fa-trash"></i></button>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- ค่าเบี้ยเลี้ยง -->
<div class="col-12 mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold"><i class="fa-solid fa-utensils me-1"></i> ค่าเบี้ยเลี้ยง</label>
<button type="button" class="btn btn-sm btn-outline-primary" id="add_allowance"><i class="fa-solid fa-plus"></i> เพิ่มเบี้ยเลี้ยง</button>
</div>
<div id="allowances_list">
<?php if(empty($alwItems)): ?>
<div class="row g-2 align-items-center mb-2 allowance-item">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">จำนวนวัน</span>
<input type="number" min="0" step="0.5" name="alw_days[]" class="form-control alw-days calc-trigger" placeholder="0">
</div>
</div>
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">อัตรา/วัน</span>
<input type="number" min="0" step="0.01" name="alw_prices[]" class="form-control alw-prices calc-trigger" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<div class="col-md-2 text-end">
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-row" disabled><i class="fa-solid fa-trash"></i></button>
</div>
</div>
<?php else: ?>
<?php foreach($alwItems as $index => $alw): ?>
<div class="row g-2 align-items-center mb-2 allowance-item">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">จำนวนวัน</span>
<input type="number" min="0" step="0.5" name="alw_days[]" class="form-control alw-days calc-trigger" value="<?= htmlspecialchars($alw['days']) ?>" placeholder="0">
</div>
</div>
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">อัตรา/วัน</span>
<input type="number" min="0" step="0.01" name="alw_prices[]" class="form-control alw-prices calc-trigger" value="<?= htmlspecialchars($alw['price']) ?>" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<div class="col-md-2 text-end">
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-row" <?= count($alwItems) > 1 ? '' : 'disabled' ?>><i class="fa-solid fa-trash"></i></button>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- ค่าโดยสารและค่าลงทะเบียน -->
<div class="col-md-6 mb-3">
<label class="form-label fw-bold"><i class="fa-solid fa-ticket me-1"></i> ค่าโดยสารทั้งหมด</label>
<div class="input-group input-group-sm">
<input type="number" min="0" step="0.01" name="expense_transport" id="expense_transport" class="form-control calc-trigger" value="<?= htmlspecialchars($expTransport) ?>" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-bold"><i class="fa-solid fa-address-card me-1"></i> ค่าลงทะเบียน</label>
<div class="input-group input-group-sm">
<input type="number" min="0" step="0.01" name="expense_registration" id="expense_registration" class="form-control calc-trigger" value="<?= htmlspecialchars($expRegistration) ?>" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<!-- สรุปยอดรวม -->
<div class="col-12 mt-3 pt-3 border-top">
<div class="d-flex justify-content-end align-items-center">
<h5 class="mb-0 me-3">รวมเป็นเงินทั้งสิ้น:</h5>
<h4 class="mb-0 text-primary fw-bold" id="detailed_total_display">0.00</h4>
<span class="ms-2">บาท</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between border-top pt-4">
<button type="button" class="btn btn-danger px-4 shadow" id="btnDeleteTrip"><i class="fa-solid fa-trash me-1"></i> ลบคำขอนี้</button>
<button type="submit" class="btn btn-primary px-5 shadow"><i class="fa-solid fa-save me-1"></i> บันทึกการแก้ไข</button>
</div>
</form>
</div>
</div>
<?php
$extraScripts = '
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
function parseDateToInputFormat(dateStr) {
if (!dateStr) return "";
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr;
var parts = dateStr.split(/[\/\-]/);
if (parts.length === 3) {
var d = parseInt(parts[0], 10);
var m = parseInt(parts[1], 10);
var y = parseInt(parts[2], 10);
if (y > 2400) y -= 543;
else if (y > 50 && y < 100) y = (y + 2500) - 543;
if (d > 31) {
var temp = d;
d = y;
y = temp;
}
var mm = m < 10 ? "0" + m : m;
var dd = d < 10 ? "0" + d : d;
return y + "-" + mm + "-" + dd;
}
return "";
}
$(document).ready(function() {
$(".tambon-select").on("change", function() {
var targetId = $(this).data("target");
var val = $(this).val();
if (val === "อื่นๆ") {
$("#" + targetId).removeClass("d-none").focus();
if ($("#" + targetId).val() === "อื่นๆ") $("#" + targetId).val("");
} else {
$("#" + targetId).addClass("d-none").val(val);
}
});
$("#btnSearchDoc").on("click", function() {
var q = $("#doc_number").val().trim();
var year = $("#doc_year").val();
if (!q) {
Swal.fire("กรุณาระบุ", "โปรดกรอกเลขที่หนังสือที่ต้องการค้นหา", "warning");
return;
}
var btn = $(this);
var originalHtml = btn.html();
btn.html(\'<i class="fa-solid fa-spinner fa-spin"></i>\').prop("disabled", true);
$.ajax({
url: "' . BASE_URL . '/trip/search-docs",
type: "GET",
dataType: "json",
data: { q: q, year: year },
success: function(res) {
btn.html(originalHtml).prop("disabled", false);
if (res.results && res.results.length > 0) {
var data = res.results[0];
if (data.title) {
$("#title").val(data.title);
$("#title").addClass("is-valid");
setTimeout(() => $("#title").removeClass("is-valid"), 2000);
}
if (data.doc_no) {
$("#doc_no").val(data.doc_no);
$("#doc_no").addClass("is-valid");
setTimeout(() => $("#doc_no").removeClass("is-valid"), 2000);
}
if (data.doc_link) {
$("#doc_link").val(data.doc_link);
}
if (data.doc_date) {
var parsedDate = parseDateToInputFormat(data.doc_date);
if (parsedDate) {
$("#doc_date").val(parsedDate).addClass("is-valid");
setTimeout(() => $("#doc_date").removeClass("is-valid"), 2000);
}
}
Swal.fire({
icon: "success",
title: "ดึงข้อมูลสำเร็จ",
text: "อัปเดตข้อมูลจากระบบส่วนกลางเรียบร้อย",
timer: 1500,
showConfirmButton: false
});
} else {
Swal.fire("ไม่พบข้อมูล", "ไม่พบเลขที่หนังสือ \'" + q + "\' ในระบบ", "error");
}
},
error: function() {
btn.html(originalHtml).prop("disabled", false);
Swal.fire("เกิดข้อผิดพลาด", "ไม่สามารถเชื่อมต่อระบบค้นหาได้", "error");
}
});
});
$("#btnDeleteTrip").on("click", function() {
Swal.fire({
title: "ยืนยันการลบ?",
text: "คุณต้องการลบคำขอเดินทางนี้ใช่หรือไม่? การกระทำนี้ไม่สามารถกู้คืนได้",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#d33",
cancelButtonColor: "#6c757d",
confirmButtonText: "ใช่, ลบเลย!",
cancelButtonText: "ยกเลิก"
}).then((result) => {
if (result.isConfirmed) {
var form = $("<form action=\'" . BASE_URL . "/trip/delete\' method=\'POST\'>" +
"<input type=\'hidden\' name=\'csrf_token\' value=\'" . $csrf_token . "\'>" +
"<input type=\'hidden\' name=\'id\' value=\'" . $trip['id'] . "\'>" +
"</form>");
$("body").append(form);
form.submit();
}
});
});
// --- Expense Logic ---
function toggleExpenseUI() {
var budgetSource = $("#budget_source").val();
if (budgetSource === "ผู้จัดสนับสนุนบางส่วน") {
$("#organizer_support_container").removeClass("d-none");
} else {
$("#organizer_support_container").addClass("d-none");
$("#organizer_support_amount").val("0.00");
}
if (budgetSource === "ผู้จัดสนับสนุนทั้งหมด") {
$("#expense_section").addClass("d-none");
$("#budget_lump_sum").val("0.00");
} else {
$("#expense_section").removeClass("d-none");
var budgetType = $("input[name=\'budget_type\']:checked").val();
if (budgetType === "detailed") {
$("#lump_sum_container").addClass("d-none");
$("#detailed_expense_container").removeClass("d-none");
calculateDetailedTotal();
} else {
$("#lump_sum_container").removeClass("d-none");
$("#detailed_expense_container").addClass("d-none");
}
}
}
$("#budget_source, input[name=\'budget_type\']").on("change", toggleExpenseUI);
// Add Row functions
$("#add_accommodation").click(function() {
var template = `
<div class="row g-2 align-items-center mb-2 accommodation-item">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">จำนวนคืน</span>
<input type="number" min="0" step="1" name="acc_days[]" class="form-control acc-days calc-trigger" placeholder="0">
</div>
</div>
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">ราคา/คืน</span>
<input type="number" min="0" step="0.01" name="acc_prices[]" class="form-control acc-prices calc-trigger" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<div class="col-md-2 text-end">
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-row"><i class="fa-solid fa-trash"></i></button>
</div>
</div>`;
$("#accommodations_list").append(template);
updateRemoveButtons("#accommodations_list");
});
$("#add_allowance").click(function() {
var template = `
<div class="row g-2 align-items-center mb-2 allowance-item">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">จำนวนวัน</span>
<input type="number" min="0" step="0.5" name="alw_days[]" class="form-control alw-days calc-trigger" placeholder="0">
</div>
</div>
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text">อัตรา/วัน</span>
<input type="number" min="0" step="0.01" name="alw_prices[]" class="form-control alw-prices calc-trigger" placeholder="0.00">
<span class="input-group-text">บาท</span>
</div>
</div>
<div class="col-md-2 text-end">
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-row"><i class="fa-solid fa-trash"></i></button>
</div>
</div>`;
$("#allowances_list").append(template);
updateRemoveButtons("#allowances_list");
});
function updateRemoveButtons(listId) {
var items = $(listId).find(".btn-remove-row");
if (items.length > 1) {
items.prop("disabled", false);
} else {
items.prop("disabled", true);
}
}
$(document).on("click", ".btn-remove-row", function() {
var list = $(this).closest("div[id$=\'_list\']");
$(this).closest(".row").remove();
updateRemoveButtons("#" + list.attr("id"));
calculateDetailedTotal();
});
function calculateDetailedTotal() {
var total = 0;
$(".accommodation-item").each(function() {
var days = parseFloat($(this).find(".acc-days").val()) || 0;
var price = parseFloat($(this).find(".acc-prices").val()) || 0;
total += (days * price);
});
$(".allowance-item").each(function() {
var days = parseFloat($(this).find(".alw-days").val()) || 0;
var price = parseFloat($(this).find(".alw-prices").val()) || 0;
total += (days * price);
});
var transport = parseFloat($("#expense_transport").val()) || 0;
total += transport;
var registration = parseFloat($("#expense_registration").val()) || 0;
total += registration;
$("#detailed_total_display").text(total.toLocaleString("en-US", {minimumFractionDigits: 2, maximumFractionDigits: 2}));
$("#budget_lump_sum").val(total.toFixed(2));
}
$(document).on("input", ".calc-trigger", calculateDetailedTotal);
// Initial setup
toggleExpenseUI();
if ($("input[name=\'budget_type\']:checked").val() === "detailed") {
calculateDetailedTotal();
}
});
</script>
';
$extraStyles = '';
?>