อธิบายการแก้ไขล่าสุด เช่น เพิ่มฟีเจอร์ v2.0 สำหรับ Frontend

This commit is contained in:
Flook 2025-09-18 10:56:15 +07:00
parent bf7692fc5f
commit 9cca7dcaa6
26 changed files with 1896 additions and 161 deletions

136
README.md
View File

@ -1,69 +1,135 @@
# 🏥 ระบบบริหารจัดการโรงพยาบาล - ฝั่งผู้ใช้งาน (Frontend)
โปรเจคระบบบริหารจัดการโรงพยาบาล (Hospital Management System)
ส่วน Frontend พัฒนาโดยใช้ **React (Vite)** สำหรับให้บริการทั้งผู้ใช้งานทั่วไป (เช่น ผู้ป่วย) และผู้ดูแลระบบ (Admin)
โปรเจคนี้เป็น **ส่วน Frontend ของระบบบริหารจัดการโรงพยาบาล (Hospital Management System)**
Frontend พัฒนาโดยใช้ **React (Vite)** ทำงานร่วมกับ **Backend API v2.0** ที่เก็บอยู่ที่:
🌐 [Hospital Management API - Gitea](https://gitea.softwarecraft.tech/gitea/hospital-management-api)
ส่วน Frontend ให้บริการทั้งผู้ใช้งานทั่วไป (ผู้ป่วย) และผู้ดูแลระบบ (Admin) โดยเชื่อมต่อกับ API สำหรับฟีเจอร์ต่าง ๆ ตามเวอร์ชัน 2.0
---
## 🚀 ฟีเจอร์ที่มีให้ใช้งาน
## 🚀 ฟีเจอร์หลักที่รองรับ (v2.0)
### 👥 ส่วนของผู้ใช้งานทั่วไป (ผู้ป่วย)
### 👨‍⚕️ การจัดการข้อมูลหลัก (Core Data Management)
- 📝 **จองนัดหมายแพทย์ (Book Appointment):**
ใช้สำหรับการจองคิวนัดพบแพทย์ได้อย่างสะดวก
**แพทย์ (Doctor)**
- R1 - ลงทะเบียนแพทย์
- R2 - รายชื่อแพทย์
- R3 - แก้ไขข้อมูลแพทย์
- R4 - ลบข้อมูลแพทย์
- 📩 **ส่งข้อความถึงแอดมิน (Send Message):**
ผู้ใช้งานสามารถติดต่อสอบถามข้อมูลกับแอดมินผ่านแบบฟอร์ม
**ผู้ป่วย (Patient)**
- R5 - ลงทะเบียนผู้ป่วย
- R6 - รายชื่อผู้ป่วย
- R7 - แก้ไขข้อมูลผู้ป่วย
- R8 - ลบข้อมูลผู้ป่วย
- 🔐 **เข้าสู่ระบบ / สมัครสมาชิก (Login / Register):**
ระบบล็อกอินและลงทะเบียนใช้งานด้วยความปลอดภัย
**พนักงาน / นางพยาบาล (Staff / Nurse)**
- R9 - เพิ่มพนักงาน
- R10 - ลงทะเบียนนางพยาบาล
- R11 - รายชื่อนางพยาบาล
- R12 - แก้ไขข้อมูลนางพยาบาล
- R13 - ลบข้อมูลนางพยาบาล
---
### 🛠️ ส่วนแดชบอร์ดสำหรับแอดมิน (Admin Dashboard)
### 🩺 การจัดการเฉพาะทาง (Specialized Management)
แดชบอร์ดนี้ออกแบบมาเพื่อใช้บริหารจัดการระบบโดยแอดมิน
**การนัดหมาย (Consultation)**
- R14 - จัดตารางการนัดหมาย
- R15 - ยกเลิกนัดหมาย
- R16 - ดูรายละเอียดการนัดหมายแต่ละรายการ
- 📊 **ภาพรวมระบบ (Dashboard Overview):**
แสดงสถิติ เช่น จำนวนแพทย์ จำนวนผู้ป่วย การนัดหมาย ฯลฯ
**ตารางแพทย์ / นางพยาบาล (Doctor / Nurse Schedule)**
- R17 - เพิ่มตารางแพทย์
- R18 - เพิ่มตารางนางพยาบาล
- R19 - ดูตารางนางพยาบาล (Pagination)
- **เพิ่มผู้ใช้งาน (Add Admin / Doctor / Receptionist):**
เพิ่มผู้ใช้งานตามบทบาทในระบบได้อย่างง่ายดาย
**ห้องผ่าตัด (Operating Room)**
- R20 - เพิ่ม / แก้ไข / ลบ ห้องผ่าตัด
- R21 - ดูรายชื่อห้องผ่าตัด
- R22 - จัดตารางใช้งานห้องผ่าตัด
- 👨‍⚕️ **ดูรายชื่อแพทย์ (See all doctors):**
ตรวจสอบรายชื่อและรายละเอียดของแพทย์ที่ลงทะเบียนแล้ว
**เวชระเบียน / ใบสั่งยา / ผลวินิจฉัย (Medical Record / Prescription / Lab Result / Medical Image)**
- R23 - สร้างเวชระเบียน
- R24 - แก้ไขเวชระเบียน
- R25 - ลบเวชระเบียน
- R26 - ดูเวชระเบียนตาม ID
- R27 - สร้างใบสั่งยาใหม่
- R28 - อัปโหลด / ดาวน์โหลดภาพทางการแพทย์
- R29 - สร้าง / ดูผล Lab ตามเวชระเบียน
- 📅 **จัดการนัดหมาย (Manage Appointments):**
ตรวจสอบ อนุมัติ แก้ไข หรือยกเลิกการนัดหมาย
**การจัดการคลังสินค้า (Inventory Management)**
- R30-R33 - จัดการ Inventory Item
- R34 - สร้าง Inventory Transaction
- R35 - จัดการ Supplier
- R36 - จัดการ Item Type
**การประกัน / การเรียกร้อง (Insurance Management)**
- R37-R38 - จัดการ Insurance Provider
- R39-R40 - จัดการ Insurance Claim
**การเรียกเก็บเงิน / การชำระเงิน (Billing & Payment)**
- R41-R45 - จัดการ Billing / Payment
**รายงาน (Reports)**
- R46 - รายงานสินค้าคงเหลือต่ำ
- R47 - รายงานสรุปการเงิน
- R48 - รายงานการนัดหมาย
**การจัดการผู้ใช้งาน (User Management)**
- R49-R56 - อัปเดตบัญชีผู้ใช้ / ตั้งค่าบทบาท / เชื่อมต่อผู้ป่วยและผู้ใช้งานอื่น ๆ
---
## 🔑 Default Credentials
สำหรับเข้าระบบครั้งแรก (Admin)
- Username: `admin@softwarecraft.tech`
- Password: `pasword123`
---
## 📖 API Endpoints
Frontend ใช้ API v2.0 สำหรับฟีเจอร์ต่าง ๆ เช่น:
**Authentication**
- POST `/api/auth/register-patient` - ลงทะเบียนผู้ป่วย
- POST `/api/auth/register-doctor-and-link` - ลงทะเบียนแพทย์และเชื่อมบัญชี
- POST `/api/auth/register-staff` - เพิ่มพนักงาน
- POST `/api/auth/login` - เข้าสู่ระบบ
- POST `/api/auth/link-patient-to-user` - เชื่อมผู้ป่วยกับบัญชี
**User Management**
- PUT `/api/v1.0/users/{id}/username` - อัปเดต username
- PUT `/api/v1.0/users/{id}/role` - อัปเดต role
- PUT `/api/v1.0/users/{id}/password` - อัปเดต password
- PATCH `/api/v1.0/users/{id}/deactivate` - ปิดใช้งานบัญชี
- PATCH `/api/v1.0/users/{id}/activate` - เปิดใช้งานบัญชี
**Doctor / Patient / Staff / Consultation / Medical Record / Prescription / Payment / Inventory**
- รองรับ CRUD ตามฟีเจอร์ v2.0 (ดูรายละเอียด API ด้านบน)
---
## 🧰 เทคโนโลยีที่ใช้
- **Frontend Framework:** React (Vite)
- **การจัดการสถานะ (State):** React Hooks
- **UI Library:** Tailwind CSS / Daisy UI
- **Routing:** React Router
- **การเชื่อมต่อ API:** Fetch (เชื่อมต่อกับ Backend)
- **ระบบยืนยันตัวตน:** JWT (JSON Web Token)
- **State Management:** React Hooks
- **API Integration:** Fetch / Axios (เชื่อมต่อกับ [Hospital Management API](https://gitea.softwarecraft.tech/gitea/hospital-management-api))
- **Authentication:** JWT
---
## 🔄 การพัฒนาแบบ CI/CD
โปรเจคนี้จะถูกพัฒนาโดยใช้แนวทาง **CI/CD** เพื่อให้สามารถ Build และ Deploy ได้อัตโนมัติผ่านระบบ Drone CI
สามารถเข้าใช้งานระบบเพื่อทดสอบฟีเจอร์ได้ที่:
🌐 [https://frontend-sandbox.softwarecraft.tech/](https://frontend-sandbox.softwarecraft.tech/)
---
## 🧪 วิธีใช้งานในเครื่องนักพัฒนา (Dev Environment)
## 🧪 การรันในเครื่องนักพัฒนา (Dev Environment)
```bash
# ติดตั้งแพ็กเกจที่จำเป็น
# ติดตั้ง dependencies
npm install
# รันโปรเจคบนโหมดพัฒนา
# รัน Frontend บนโหมดพัฒนา
npm run dev
```

274
package-lock.json generated
View File

@ -8,13 +8,17 @@
"name": "healthcare_app",
"version": "0.0.0",
"dependencies": {
"@reduxjs/toolkit": "^2.8.2",
"@tailwindcss/vite": "^4.1.4",
"@tanstack/react-query": "^5.74.4",
"axios": "^1.8.4",
"daisyui": "^5.0.20",
"prop-types": "^15.8.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.1",
"react-icons": "^5.5.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.5.1",
"tailwindcss": "^4.1.4"
},
@ -769,9 +773,9 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz",
"integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==",
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -784,9 +788,9 @@
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz",
"integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==",
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz",
"integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@ -794,9 +798,9 @@
}
},
"node_modules/@eslint/core": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz",
"integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==",
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -844,13 +848,16 @@
}
},
"node_modules/@eslint/js": {
"version": "9.24.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz",
"integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==",
"version": "9.34.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz",
"integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/object-schema": {
@ -864,32 +871,19 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz",
"integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==",
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.13.0",
"@eslint/core": "^0.15.2",
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz",
"integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -1009,6 +1003,32 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
"integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz",
@ -1269,6 +1289,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.4.tgz",
@ -1611,7 +1643,7 @@
"version": "19.1.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@ -1627,6 +1659,12 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.0.tgz",
@ -1648,9 +1686,9 @@
}
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
@ -1735,9 +1773,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1913,7 +1951,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/daisyui": {
@ -2111,20 +2149,20 @@
}
},
"node_modules/eslint": {
"version": "9.24.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz",
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
"version": "9.34.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz",
"integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.20.0",
"@eslint/config-helpers": "^0.2.0",
"@eslint/core": "^0.12.0",
"@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.3.1",
"@eslint/core": "^0.15.2",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.24.0",
"@eslint/plugin-kit": "^0.2.7",
"@eslint/js": "9.34.0",
"@eslint/plugin-kit": "^0.3.5",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@ -2135,9 +2173,9 @@
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.3.0",
"eslint-visitor-keys": "^4.2.0",
"espree": "^10.3.0",
"eslint-scope": "^8.4.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
@ -2195,9 +2233,9 @@
}
},
"node_modules/eslint-scope": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
"integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@ -2212,9 +2250,9 @@
}
},
"node_modules/eslint-visitor-keys": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@ -2225,15 +2263,15 @@
}
},
"node_modules/espree": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
"integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.14.0",
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.0"
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2395,14 +2433,15 @@
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@ -2582,6 +2621,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -2652,7 +2701,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@ -2990,6 +3038,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -3082,6 +3142,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -3221,6 +3290,17 @@
"node": ">= 0.8.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -3274,6 +3354,44 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -3322,6 +3440,27 @@
"react-dom": ">=18"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -3536,6 +3675,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",

View File

@ -10,13 +10,17 @@
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.8.2",
"@tailwindcss/vite": "^4.1.4",
"@tanstack/react-query": "^5.74.4",
"axios": "^1.8.4",
"daisyui": "^5.0.20",
"prop-types": "^15.8.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.1",
"react-icons": "^5.5.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.5.1",
"tailwindcss": "^4.1.4"
},

View File

@ -1,15 +1,29 @@
import {BrowserRouter, Route, Routes} from "react-router-dom";
// src/App.jsx
import { Route, Routes } from "react-router-dom";
import PublicRoutes from "./routes/PublicRoutes.jsx";
import AdminRoutes from "./routes/AdminRoutes.jsx";
import ProtectedRoute from "./components/ProtectedRoute.jsx"; // Import ProtectedRoute
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/*" element={<PublicRoutes/>}/>
</Routes>
</BrowserRouter>
)
<Routes>
{/* เส้นทางสาธารณะที่ทุกคนเข้าถึงได้ */}
<Route path="/*" element={<PublicRoutes />} />
{/*
เสนทางสำหรบผแลระบบทองมการลอกอนเทาน
ProtectedRoute จะทำหนาทตรวจสอบสทธอนทจะแสดง AdminRoutes
*/}
<Route
path="/admin/*"
element={
<ProtectedRoute>
<AdminRoutes />
</ProtectedRoute>
}
/>
</Routes>
);
}
export default App
export default App;

52
src/api/axios.js Normal file
View File

@ -0,0 +1,52 @@
import axios from 'axios';
// 1. สร้าง Axios Instance
// กำหนด URL พื้นฐานของ API และตั้งค่าเริ่มต้นอื่น ๆ
const api = axios.create({
baseURL: 'http://localhost:8080/api/v1.0',
headers: {
'Content-Type': 'application/json',
},
});
// 2. สร้าง Request Interceptor
// Interceptor นี้จะทำงานก่อนที่ทุก ๆ Request จะถูกส่งออกไป
api.interceptors.request.use(
(config) => {
// ดึง JWT Token จาก Local Storage
const token = localStorage.getItem('jwtToken');
// ถ้ามี Token อยู่ ให้แนบ Token เข้าไปใน Header ของ Request
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => {
// จัดการข้อผิดพลาดที่อาจเกิดขึ้นก่อนการส่ง Request
return Promise.reject(error);
}
);
// 3. สร้าง Response Interceptor (Optional แต่มีประโยชน์)
// Interceptor นี้จะทำงานเมื่อได้รับ Response จาก Server
api.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// ถ้า Response บอกว่า Unauthorized (401)
// ให้ทำการลบ Token และเปลี่ยนเส้นทางกลับไปหน้า Login
if (error.response && error.response.status === 401) {
console.log("Unauthorized, logging out...");
localStorage.removeItem('jwtToken');
// window.location.href = '/login'; // วิธีนี้จะรีโหลดหน้าเว็บใหม่
// การใช้ Redux dispatch จะเป็นวิธีที่ดีกว่า
// แต่ต้องจัดการใน Component ที่เหมาะสม เช่น ใน App.jsx
}
return Promise.reject(error);
}
);
export default api;

9
src/app/store.js Normal file
View File

@ -0,0 +1,9 @@
import { configureStore } from '@reduxjs/toolkit';
import authReducer from '../features/auth/authSlice';
export const store = configureStore({
reducer: {
auth: authReducer,
// เพิ่ม Reducer อื่น ๆ ที่นี่ในอนาคต
},
});

36
src/components/About.jsx Normal file
View File

@ -0,0 +1,36 @@
// src/components/About.jsx
import React from 'react';
import PropTypes from 'prop-types';
export default function About({ title, description, image }) {
return (
<div className="bg-white py-20 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto flex flex-col-reverse lg:flex-row-reverse items-center justify-between gap-12">
{/* Text Section */}
<div className="w-full lg:w-1/2 text-center lg:text-left">
<h2 className="text-4xl font-extrabold text-gray-900 sm:text-5xl mb-4">
{title}
</h2>
<p className="text-lg text-gray-600 max-w-2xl mx-auto lg:mx-0 leading-relaxed">
{description}
</p>
</div>
{/* Image Section */}
<div className="w-full lg:w-1/2">
<img
className="w-full h-auto rounded-xl shadow-2xl"
src={image}
alt={title}
/>
</div>
</div>
</div>
);
}
About.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
};

36
src/components/CTA.jsx Normal file
View File

@ -0,0 +1,36 @@
// src/components/CTA.jsx
import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
export default function CTA({ title, subtitle, linkText, linkTo }) {
return (
<div className="bg-indigo-600">
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:flex lg:items-center lg:justify-between lg:py-16 lg:px-8">
{/* Text Section */}
<h2 className="text-3xl font-extrabold tracking-tight text-white sm:text-4xl">
<span className="block">{title}</span>
<span className="block text-indigo-200">{subtitle}</span>
</h2>
{/* CTA Button */}
<div className="mt-8 flex lg:mt-0 lg:flex-shrink-0">
<div className="inline-flex rounded-md shadow">
<Link
to={linkTo}
className="inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-700 hover:bg-indigo-800"
>
{linkText}
</Link>
</div>
</div>
</div>
</div>
);
}
CTA.propTypes = {
title: PropTypes.string.isRequired,
subtitle: PropTypes.string.isRequired,
linkText: PropTypes.string.isRequired,
linkTo: PropTypes.string.isRequired,
};

View File

@ -0,0 +1,31 @@
// src/components/Feature.jsx
import React from 'react';
import PropTypes from 'prop-types';
export default function Feature({ title, description, icon }) {
return (
<div className="bg-white p-6 rounded-lg shadow-md text-center">
{/* SVG Icon */}
<div className="flex items-center justify-center w-12 h-12 mx-auto mb-4 text-indigo-600 bg-indigo-100 rounded-full">
<svg
className="w-6 h-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={icon} />
</svg>
</div>
{/* Title & Description */}
<h3 className="text-xl font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-gray-600">{description}</p>
</div>
);
}
Feature.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
};

44
src/components/Hero.jsx Normal file
View File

@ -0,0 +1,44 @@
// src/components/Hero.jsx
import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
export default function Hero({ title, description, image, primaryLink, secondaryLink }) {
return (
<div className="bg-white py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto flex flex-col-reverse lg:flex-row items-center">
<div className="lg:w-1/2 mt-10 lg:mt-0 text-center lg:text-left">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-gray-900">
{title}
</h1>
<p className="mt-6 text-lg text-gray-500 max-w-2xl mx-auto lg:mx-0">
{description}
</p>
<div className="mt-8 flex justify-center lg:justify-start">
{primaryLink && (
<Link {...primaryLink} className="inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700">
{primaryLink.text}
</Link>
)}
{secondaryLink && (
<Link {...secondaryLink} className="ml-4 inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200">
{secondaryLink.text}
</Link>
)}
</div>
</div>
<div className="lg:w-1/2">
<img className="w-full h-auto" src={image} alt="Hero" />
</div>
</div>
</div>
);
}
Hero.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
image: PropTypes.string,
primaryLink: PropTypes.object,
secondaryLink: PropTypes.object,
};

View File

@ -0,0 +1,22 @@
// src/routes/ProtectedRoute.jsx
import React from 'react';
import { useSelector } from 'react-redux';
import { Navigate } from 'react-router-dom';
import PropTypes from 'prop-types';
export default function ProtectedRoute({ children }) {
// Redux Store
const { isAuthenticated } = useSelector((state) => state.auth);
// Redirect Login
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
// Component
return children;
}
ProtectedRoute.propTypes = {
children: PropTypes.node.isRequired,
};

View File

@ -0,0 +1,81 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import api from '../../api/axios'; // ใช้ axios instance เดิม
// Thunk สำหรับการ Login (จัดการ API Call)
export const loginUser = createAsyncThunk(
'auth/loginUser',
async (credentials, { rejectWithValue }) => {
try {
const response = await api.post('/auth/login', credentials);
localStorage.setItem('jwtToken', response.data.token);
return response.data;
} catch (error) {
return rejectWithValue(error.response.data);
}
}
);
// Thunk สำหรับการ Logout
export const logoutUser = createAsyncThunk(
'auth/logoutUser',
async () => {
localStorage.removeItem('jwtToken');
// ถ้ามี API สำหรับ Logout ก็สามารถเรียกได้ที่นี่
return null;
}
);
const initialState = {
user: null,
isLoggedIn: false,
loading: false,
error: null,
};
// สร้าง Slice
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
// Reducer ที่ไม่มี async logic
// เช่น การตั้งค่าผู้ใช้จาก Token ที่มีอยู่แล้ว
setUserFromToken: (state) => {
const token = localStorage.getItem('jwtToken');
if (token) {
state.user = { token };
state.isLoggedIn = true;
} else {
state.user = null;
state.isLoggedIn = false;
}
},
},
extraReducers: (builder) => {
builder
// กรณี Login สำเร็จ
.addCase(loginUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(loginUser.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload;
state.isLoggedIn = true;
})
.addCase(loginUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
state.isLoggedIn = false;
})
// กรณี Logout สำเร็จ
.addCase(logoutUser.fulfilled, (state) => {
state.user = null;
state.isLoggedIn = false;
state.loading = false;
});
},
});
export const { setUserFromToken } = authSlice.actions;
export default authSlice.reducer;

View File

@ -0,0 +1,80 @@
import { useState } from 'react';
import { Link, Outlet } from 'react-router-dom';
import { FaBars, FaTimes, FaTachometerAlt, FaUserMd, FaUsers, FaCalendarCheck, FaSignOutAlt, FaUserCog } from 'react-icons/fa';
import { useDispatch } from 'react-redux';
import { logoutUser } from '../features/auth/authSlice';
export default function AdminLayout() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const dispatch = useDispatch();
const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen);
};
const handleLogout = () => {
dispatch(logoutUser());
};
return (
<div className="flex min-h-screen bg-gray-100 font-sans">
{/* Overlay for Mobile */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={toggleSidebar}
></div>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 transform ${
isSidebarOpen ? 'translate-x-0' : '-translate-x-full'
} lg:relative lg:translate-x-0 w-64 bg-white transition-transform duration-300 ease-in-out z-50 shadow-lg flex flex-col`}
>
<div className="p-6 text-center border-b border-gray-200">
<Link to="/admin/dashboard" className="text-2xl font-extrabold text-indigo-600 tracking-tight">
Admin Panel
</Link>
</div>
<nav className="flex-1 px-4 py-6 space-y-2">
<NavLink to="/admin/dashboard" icon={FaTachometerAlt} label="Dashboard" />
<NavLink to="/admin/doctors" icon={FaUserMd} label="จัดการแพทย์" />
<NavLink to="/admin/patients" icon={FaUsers} label="จัดการผู้ป่วย" />
<NavLink to="/admin/consultations" icon={FaCalendarCheck} label="จัดการนัดหมาย" />
<NavLink to="/admin/users" icon={FaUserCog} label="จัดการผู้ใช้งาน" />
</nav>
<div className="p-4 border-t border-gray-200">
<button onClick={handleLogout} className="w-full flex items-center p-3 text-sm font-medium text-red-600 rounded-lg hover:bg-gray-100 transition-colors duration-200">
<FaSignOutAlt className="w-5 h-5 mr-3" />
ออกจากระบบ
</button>
</div>
</aside>
{/* Main Content Area */}
<div className="flex-1 flex flex-col">
{/* Mobile Header */}
<header className="flex items-center justify-between p-4 bg-white shadow-md lg:hidden sticky top-0 z-30">
<h1 className="text-xl font-bold text-indigo-600">Admin Dashboard</h1>
<button onClick={toggleSidebar} className="p-2 text-gray-600">
{isSidebarOpen ? <FaTimes className="w-6 h-6" /> : <FaBars className="w-6 h-6" />}
</button>
</header>
{/* Page Content */}
<main className="flex-1 p-6 lg:p-10 overflow-y-auto">
<Outlet />
</main>
</div>
</div>
);
}
// Reusable NavLink Component
const NavLink = ({ to, icon: Icon, label }) => (
<Link to={to} className="flex items-center p-3 text-sm font-medium text-gray-700 rounded-lg hover:bg-indigo-100 hover:text-indigo-800 transition-colors duration-200">
<Icon className="w-5 h-5 mr-3 text-indigo-500" />
<span>{label}</span>
</Link>
);

View File

@ -1,20 +1,31 @@
import {Outlet} from "react-router-dom";
// src/layouts/AuthLayout.jsx
import { Outlet } from "react-router-dom";
import PropTypes from 'prop-types';
export default function AuthLayout(){
return(
<>
<div className="flex h-screen items-center justify-center bg-base-200">
<div className="w-full max-w-5xl shadow-xl rounded-xl bg-white p-6 flex flex-col lg:flex-row">
<div className="flex-1 hidden lg:flex flex-col justify-center items-center p-6">
<h2 className="text-3xl font-bold mb-4 text-primary">My App</h2>
<p className="text-gray-500">เขาสระบบเพอเขาถงแดชบอร ดการขอม และสำรวจฟเจอรงหมดทเราพรอมมอบให ปลอดภ รวดเร และงายตอการใชงาน
export default function AuthLayout({ description }) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-4">
<div className="w-full max-w-5xl overflow-hidden rounded-xl bg-white shadow-2xl flex flex-col lg:flex-row">
{/* Section ด้านซ้ายสำหรับแสดงข้อมูล */}
<div className="flex-1 hidden lg:flex flex-col justify-center items-center p-8 bg-primary-50 text-center">
<h2 className="text-4xl font-extrabold mb-4 text-primary">
Hospital Management System
</h2>
{description && (
<p className="text-gray-600 text-lg">
{description}
</p>
</div>
<div className="flex-1">
<Outlet/>
</div>
)}
</div>
{/* Section ด้านขวาสำหรับแสดงฟอร์ม */}
<div className="flex-1 p-8">
<Outlet />
</div>
</div>
</>
)
</div>
);
}
AuthLayout.propTypes = {
description: PropTypes.string,
};

View File

@ -1,31 +1,58 @@
import {Link, Outlet} from "react-router-dom";
import { Link, Outlet } from "react-router-dom";
import { FaHeartbeat } from 'react-icons/fa';
export default function PublicLayout() {
const currentYear = new Date().getFullYear();
export default function PublicLayout(){
return (
<>
{/* แสดงทั้งหน้าจอ ใช้ Flex จัดเรียงแนวตั้ง*/}
<div className="min-h-screen flex flex-col bg-base-200 ">
{/* Navbar */}
<div className="navbar bg-base-100 shadow-md">
<div className="flex-1">
<Link to="/" className="btn btn-ghost text-xl text-primary">MyAPP</Link>
</div>
<div>
<Link to="/about" className="btn btn-ghost">เกยวกบเรา</Link>
<Link to="/login" className="btn btn-outline btn-primary">เขาสระบบ</Link>
<div className="flex flex-col min-h-screen bg-gray-50 text-gray-800">
{/* Navbar */}
<nav className="sticky top-0 z-50 bg-white shadow-sm">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo and App Name */}
<div className="flex items-center">
<Link to="/" className="flex items-center space-x-2 text-xl font-bold text-indigo-600 hover:text-indigo-700 transition-colors duration-200">
<FaHeartbeat className="h-6 w-6" />
<span>Hospital Management</span>
</Link>
</div>
{/* Navigation Links */}
<div className="hidden sm:block">
<div className="ml-10 flex items-center space-x-4">
{/* Adjusted Login Button to match Hero.jsx */}
<Link
to="/login"
className="px-5 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 transition-colors duration-200"
>
เขาสระบบ
</Link>
{/* Adjusted Register Button to match Hero.jsx */}
<Link
to="/register"
className="ml-4 px-5 py-3 border border-transparent text-base font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 transition-colors duration-200"
>
ลงทะเบยน
</Link>
</div>
</div>
</div>
</div>
{/* Content */}
<main className="flex-1 container mx-auto px-4 py-6">
<Outlet/>
</main>
{/* Footer */}
<footer className="footer footer-center p-4 bg-base-100 text-base-content border-t">
<aside>
<p>© {new Date().getFullYear()} MyApp. All rights reserved.</p>
</aside>
</footer>
</div>
</>
)
</nav>
{/* Main Content */}
<main className="flex-1 container mx-auto px-4 py-8">
<Outlet />
</main>
{/* Footer */}
<footer className="bg-gray-800 text-gray-300 py-6 text-center">
<div className="container mx-auto px-4">
<p>&copy; {currentYear} Hospital Management System. All rights reserved.</p>
</div>
</footer>
</div>
);
}

View File

@ -1,11 +1,33 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
import './styles.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
// Import BrowserRouter App Component
import { BrowserRouter } from 'react-router-dom';
// Import Redux Toolkit TanStack Query Provider
import { Provider } from 'react-redux';
import { store } from './app/store.js';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Query Client
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
{/*
<BrowserRouter> ควรอยระดบบนสดของ Component Tree
เพอให Component อยางในใชงานได
*/}
<BrowserRouter>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</Provider>
</BrowserRouter>
</React.StrictMode>
);

View File

@ -0,0 +1,176 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import api from '../api/axios';
import { FaPlus, FaTrash } from 'react-icons/fa';
//
const fetchConsultations = async () => {
const { data } = await api.get('/consultations');
return data;
};
const fetchDoctors = async () => {
const { data } = await api.get('/doctors');
return data;
};
const fetchPatients = async () => {
const { data } = await api.get('/patients');
return data;
};
//
const createConsultation = async (newConsultation) => {
const { data } = await api.post('/consultations', newConsultation);
return data;
};
//
const deleteConsultation = async (id) => {
await api.delete(`/consultations/${id}`);
};
export default function ConsultationsPage() {
const queryClient = useQueryClient();
const [isModalOpen, setIsModalOpen] = useState(false);
//
const { data: consultations, isLoading, isError, error } = useQuery({
queryKey: ['consultations'],
queryFn: fetchConsultations,
});
// dropdown
const { data: doctors } = useQuery({ queryKey: ['doctors'], queryFn: fetchDoctors });
const { data: patients } = useQuery({ queryKey: ['patients'], queryFn: fetchPatients });
const { register, handleSubmit, reset } = useForm();
const createConsultationMutation = useMutation({
mutationFn: createConsultation,
onSuccess: () => {
queryClient.invalidateQueries(['consultations']);
reset();
setIsModalOpen(false);
},
});
const deleteConsultationMutation = useMutation({
mutationFn: deleteConsultation,
onSuccess: () => {
queryClient.invalidateQueries(['consultations']);
},
});
const onSubmit = (data) => {
// API Endpoint
const consultationData = {
...data,
doctorId: parseInt(data.doctorId),
patientId: parseInt(data.patientId),
};
createConsultationMutation.mutate(consultationData);
};
const handleDelete = (id) => {
if (window.confirm('คุณต้องการยกเลิกนัดหมายนี้หรือไม่?')) {
deleteConsultationMutation.mutate(id);
}
};
if (isLoading) return <div>กำลงโหลดขอม...</div>;
if (isError) return <div>เกดขอผดพลาด: {error.message}</div>;
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">ดการนดหมาย</h1>
<div className="flex justify-end mb-4">
<button
className="btn btn-primary"
onClick={() => setIsModalOpen(true)}
>
<FaPlus className="mr-2" /> สรางนดหมายใหม
</button>
</div>
<div className="overflow-x-auto shadow-xl rounded-lg">
<table className="table w-full">
<thead>
<tr className="bg-gray-100">
<th>แพทย</th>
<th>วย</th>
<th>นท</th>
<th>เวลา</th>
<th>การดำเนนการ</th>
</tr>
</thead>
<tbody>
{consultations.map((consultation) => (
<tr key={consultation.id}>
<td>{consultation.doctor.name}</td>
<td>{consultation.patient.name}</td>
<td>{consultation.date}</td>
<td>{consultation.time}</td>
<td>
<button
className="btn btn-error btn-sm"
onClick={() => handleDelete(consultation.id)}
disabled={deleteConsultationMutation.isLoading}
>
{deleteConsultationMutation.isLoading ? 'กำลังลบ...' : <FaTrash />}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{isModalOpen && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg mb-4">สรางนดหมายใหม</h3>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="form-control">
<label className="label">แพทย</label>
<select className="select select-bordered" {...register("doctorId", { required: true })}>
<option value="">เลอกแพทย</option>
{doctors?.map(doc => (
<option key={doc.id} value={doc.id}>{doc.name}</option>
))}
</select>
</div>
<div className="form-control mt-2">
<label className="label">วย</label>
<select className="select select-bordered" {...register("patientId", { required: true })}>
<option value="">เลอกผวย</option>
{patients?.map(pat => (
<option key={pat.id} value={pat.id}>{pat.name}</option>
))}
</select>
</div>
<div className="form-control mt-2">
<label className="label">นท</label>
<input type="date" className="input input-bordered" {...register("date", { required: true })} />
</div>
<div className="form-control mt-2">
<label className="label">เวลา</label>
<input type="time" className="input input-bordered" {...register("time", { required: true })} />
</div>
<div className="modal-action mt-4">
<button type="submit" className="btn btn-primary" disabled={createConsultationMutation.isLoading}>
{createConsultationMutation.isLoading ? 'กำลังสร้าง...' : 'สร้างนัดหมาย'}
</button>
<button type="button" className="btn" onClick={() => setIsModalOpen(false)}>
ยกเล
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,88 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import api from '../api/axios';
import { FaUserMd, FaUsers, FaCalendarAlt } from 'react-icons/fa';
// API
const fetchDoctorsCount = async () => {
const { data } = await api.get('/doctors');
return data.length; //
};
const fetchPatientsCount = async () => {
const { data } = await api.get('/patients');
return data.length; //
};
const fetchConsultations = async () => {
const { data } = await api.get('/consultations');
//
// data.filter(c => new Date(c.date) > new Date()).length
return data.length; //
};
export default function DashboardPage() {
// useQuery
const { data: doctorsCount, isLoading: isDoctorsLoading, isError: isDoctorsError } = useQuery({
queryKey: ['doctorsCount'],
queryFn: fetchDoctorsCount,
});
const { data: patientsCount, isLoading: isPatientsLoading, isError: isPatientsError } = useQuery({
queryKey: ['patientsCount'],
queryFn: fetchPatientsCount,
});
const { data: consultationsCount, isLoading: isConsultationsLoading, isError: isConsultationsError } = useQuery({
queryKey: ['consultationsCount'],
queryFn: fetchConsultations,
});
if (isDoctorsLoading || isPatientsLoading || isConsultationsLoading) {
return <div className="text-center p-8">กำลงโหลดขอม...</div>;
}
if (isDoctorsError || isPatientsError || isConsultationsError) {
return <div className="alert alert-error">เกดขอผดพลาดในการโหลดขอม</div>;
}
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-6">ภาพรวมระบบ</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Card แสดงจำนวนแพทย์ */}
<div className="stats shadow bg-white">
<div className="stat">
<div className="stat-figure text-primary">
<FaUserMd size={24} />
</div>
<div className="stat-title">จำนวนแพทย</div>
<div className="stat-value text-primary">{doctorsCount}</div>
</div>
</div>
{/* Card แสดงจำนวนผู้ป่วย */}
<div className="stats shadow bg-white">
<div className="stat">
<div className="stat-figure text-secondary">
<FaUsers size={24} />
</div>
<div className="stat-title">จำนวนผวย</div>
<div className="stat-value text-secondary">{patientsCount}</div>
</div>
</div>
{/* Card แสดงจำนวนนัดหมาย */}
<div className="stats shadow bg-white">
<div className="stat">
<div className="stat-figure text-accent">
<FaCalendarAlt size={24} />
</div>
<div className="stat-title">จำนวนนดหมาย</div>
<div className="stat-value text-accent">{consultationsCount}</div>
</div>
</div>
</div>
</div>
);
}

162
src/pages/DoctorsPage.jsx Normal file
View File

@ -0,0 +1,162 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import api from '../api/axios'; // Import Axios instance JWT Interceptor
import { FaPlus, FaTrash } from 'react-icons/fa';
// API (Read)
const fetchDoctors = async () => {
const { data } = await api.get('/doctors');
return data;
};
// (Create)
const addDoctor = async (newDoctor) => {
const { data } = await api.post('/doctors', newDoctor);
return data;
};
// (Delete)
const deleteDoctor = async (id) => {
await api.delete(`/doctors/${id}`);
};
export default function DoctorsPage() {
const queryClient = useQueryClient();
const [isModalOpen, setIsModalOpen] = useState(false);
// 1. API (Read)
const { data: doctors, isLoading, isError, error } = useQuery({
queryKey: ['doctors'],
queryFn: fetchDoctors,
});
// 2. (Create)
const { register, handleSubmit, reset, formState: { errors } } = useForm();
// 3. (Mutation)
const addDoctorMutation = useMutation({
mutationFn: addDoctor,
onSuccess: () => {
// Invalidate cache React Query
queryClient.invalidateQueries(['doctors']);
reset();
setIsModalOpen(false);
},
});
// 4. (Mutation)
const deleteDoctorMutation = useMutation({
mutationFn: deleteDoctor,
onSuccess: () => {
// Invalidate cache React Query
queryClient.invalidateQueries(['doctors']);
},
});
const onSubmit = (data) => {
addDoctorMutation.mutate(data);
};
const handleDelete = (id) => {
if (window.confirm('คุณต้องการลบแพทย์คนนี้หรือไม่?')) {
deleteDoctorMutation.mutate(id);
}
};
if (isLoading) return <div>กำลงโหลดขอม...</div>;
if (isError) return <div>เกดขอผดพลาด: {error.message}</div>;
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">ดการขอมลแพทย</h1>
{/* ปุ่มสำหรับเปิด Modal เพิ่มแพทย์ */}
<div className="flex justify-end mb-4">
<button
className="btn btn-primary"
onClick={() => setIsModalOpen(true)}
>
<FaPlus className="mr-2" /> เพมแพทยใหม
</button>
</div>
{/* ตารางแสดงข้อมูลแพทย์ */}
<div className="overflow-x-auto shadow-xl rounded-lg">
<table className="table w-full">
<thead>
<tr className="bg-gray-100">
<th></th>
<th>เมล</th>
<th>CRM</th>
<th>สาขา</th>
<th>เบอรโทรศพท</th>
<th>การดำเนนการ</th>
</tr>
</thead>
<tbody>
{doctors.map((doctor) => (
<tr key={doctor.id}>
<td>{doctor.name}</td>
<td>{doctor.email}</td>
<td>{doctor.crm}</td>
<td>{doctor.specialty}</td>
<td>{doctor.telephone}</td>
<td>
<button
className="btn btn-error btn-sm"
onClick={() => handleDelete(doctor.id)}
disabled={deleteDoctorMutation.isLoading}
>
{deleteDoctorMutation.isLoading ? 'กำลังลบ...' : <FaTrash />}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Modal สำหรับเพิ่มแพทย์ */}
{isModalOpen && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg mb-4">เพมแพทยใหม</h3>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="form-control">
<label className="label"></label>
<input type="text" placeholder="ชื่อ" className="input input-bordered" {...register("name", { required: true })} />
</div>
<div className="form-control mt-2">
<label className="label">เมล</label>
<input type="email" placeholder="อีเมล" className="input input-bordered" {...register("email", { required: true })} />
</div>
<div className="form-control mt-2">
<label className="label">CRM</label>
<input type="text" placeholder="CRM" className="input input-bordered" {...register("crm", { required: true })} />
</div>
<div className="form-control mt-2">
<label className="label">สาขา</label>
<input type="text" placeholder="สาขา" className="input input-bordered" {...register("specialty", { required: true })} />
</div>
<div className="form-control mt-2">
<label className="label">เบอรโทรศพท</label>
<input type="text" placeholder="เบอร์โทรศัพท์" className="input input-bordered" {...register("telephone", { required: true })} />
</div>
{/* ... เพิ่ม input สำหรับข้อมูลอื่น ๆ ... */}
<div className="modal-action mt-4">
<button type="submit" className="btn btn-primary" disabled={addDoctorMutation.isLoading}>
{addDoctorMutation.isLoading ? 'กำลังบันทึก...' : 'บันทึก'}
</button>
<button type="button" className="btn" onClick={() => setIsModalOpen(false)}>
ยกเล
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

58
src/pages/HomePage.jsx Normal file
View File

@ -0,0 +1,58 @@
// src/pages/HomePage.jsx
import React from 'react';
import Hero from '../components/Hero';
import Feature from '../components/Feature';
import About from '../components/About';
import CTA from '../components/CTA';
export default function HomePage() {
return (
<div className="bg-white">
<Hero
title="ระบบจัดการโรงพยาบาลอัจฉริยะ"
description="ยกระดับการจัดการข้อมูลผู้ป่วย, ตารางนัดหมาย, และการบริการทางการแพทย์ ให้ง่ายดายและมีประสิทธิภาพกว่าที่เคย"
image="https://placehold.co/600x400"
primaryLink={{ to: "/login", text: "เข้าสู่ระบบ" }}
secondaryLink={{ to: "/register", text: "ลงทะเบียน" }}
/>
<div className="bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<h2 className="text-3xl font-extrabold text-center text-gray-900 mb-8">
ณสมบเดนของระบบ
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<Feature
title="ข้อมูลผู้ป่วย"
description="จัดการและเข้าถึงประวัติผู้ป่วยได้อย่างรวดเร็วและปลอดภัย"
icon="M12 11c-2.21 0-4 1.79-4 4v2h8v-2c0-2.21-1.79-4-4-4zm-4 4c0-1.1.9-2 2-2s2 .9 2 2"
/>
<Feature
title="ตารางนัดหมาย"
description="วางแผนและจัดการนัดหมายแพทย์ได้อย่างเป็นระบบ"
icon="M12 11c-2.21 0-4 1.79-4 4v2h8v-2c0-2.21-1.79-4-4-4zm-4 4c0-1.1.9-2 2-2s2 .9 2 2"
/>
<Feature
title="การสื่อสาร"
description="ส่งข้อความแจ้งเตือนและอัปเดตข้อมูลสำคัญถึงผู้ป่วยได้โดยตรง"
icon="M12 11c-2.21 0-4 1.79-4 4v2h8v-2c0-2.21-1.79-4-4-4zm-4 4c0-1.1.9-2 2-2s2 .9 2 2"
/>
</div>
</div>
</div>
<About
title="เกี่ยวกับเรา"
description="เรามุ่งมั่นที่จะพัฒนาเทคโนโลยีเพื่อยกระดับการดูแลสุขภาพ ระบบของเราถูกออกแบบมาเพื่อช่วยให้บุคลากรทางการแพทย์สามารถทำงานได้อย่างมีประสิทธิภาพ"
image="https://placehold.co/600x400"
/>
<CTA
title="พร้อมที่จะเริ่มต้นหรือยัง?"
subtitle="เข้าร่วมกับเราวันนี้เพื่อการจัดการที่เหนือกว่า."
linkText="ลงทะเบียนตอนนี้"
linkTo="/register"
/>
</div>
);
}

96
src/pages/LoginPage.jsx Normal file
View File

@ -0,0 +1,96 @@
// src/pages/LoginPage.jsx
import { useForm } from 'react-hook-form';
import { useDispatch, useSelector } from 'react-redux';
import { loginUser } from '../features/auth/authSlice';
import { Link } from 'react-router-dom';
export default function LoginPage() {
const dispatch = useDispatch();
const { loading } = useSelector((state) => state.auth);
const {
register,
handleSubmit,
formState: { errors },
setError
} = useForm();
const onSubmit = async (data) => {
try {
await dispatch(loginUser(data)).unwrap();
} catch (err) {
setError('root.loginFailed', {
type: 'manual',
message: 'ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง',
});
}
};
return (
<div className="card w-full max-w-sm shrink-0 bg-white shadow-lg p-6">
<h2 className="text-3xl font-bold text-center mb-6 text-gray-800">
เขาสระบบ
</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Error จากการล็อกอิน */}
{errors.root?.loginFailed && (
<div className="alert alert-error text-sm">
{errors.root.loginFailed.message}
</div>
)}
{/* ช่องกรอกชื่อผู้ใช้ */}
<div className="form-control">
<label className="label">
<span className="label-text">อผใช</span>
</label>
<input
type="text"
placeholder="username"
className={`input input-bordered w-full ${errors.login ? 'input-error' : ''}`}
{...register('login', { required: 'กรุณากรอกชื่อผู้ใช้' })}
/>
{errors.login && <span className="text-red-500 text-sm mt-1">{errors.login.message}</span>}
</div>
{/* ช่องกรอกรหัสผ่าน */}
<div className="form-control">
<label className="label">
<span className="label-text">รหสผาน</span>
</label>
<input
type="password"
placeholder="password"
className={`input input-bordered w-full ${errors.password ? 'input-error' : ''}`}
{...register('password', { required: 'กรุณากรอกรหัสผ่าน' })}
/>
{errors.password && <span className="text-red-500 text-sm mt-1">{errors.password.message}</span>}
</div>
{/* ปุ่มเข้าสู่ระบบ */}
<div className="form-control pt-2">
<button
type="submit"
className="btn btn-primary w-full"
disabled={loading}
>
{loading ? 'กำลังเข้าสู่ระบบ...' : 'เข้าสู่ระบบ'}
</button>
</div>
</form>
{/* ลิงก์สำหรับเปลี่ยนหน้า */}
<p className="mt-6 text-center text-sm text-gray-600">
งไมญช?{' '}
<Link to="/register" className="text-primary hover:underline font-medium">
ลงทะเบยน
</Link>
</p>
<div className="mt-2 text-center">
<Link to="/" className="link link-secondary">
กลบไปหนาหล
</Link>
</div>
</div>
);
}

143
src/pages/PatientsPage.jsx Normal file
View File

@ -0,0 +1,143 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import api from '../api/axios';
import { FaPlus, FaTrash } from 'react-icons/fa';
// API (Read)
const fetchPatients = async () => {
const { data } = await api.get('/patients');
return data;
};
// (Create)
const addPatient = async (newPatient) => {
const { data } = await api.post('/patients', newPatient);
return data;
};
// (Delete)
const deletePatient = async (id) => {
await api.delete(`/patients/${id}`);
};
export default function PatientsPage() {
const queryClient = useQueryClient();
const [isModalOpen, setIsModalOpen] = useState(false);
// 1. API (Read)
const { data: patients, isLoading, isError, error } = useQuery({
queryKey: ['patients'],
queryFn: fetchPatients,
});
// 2. (Create)
const { register, handleSubmit, reset, formState: { errors } } = useForm();
// 3. (Mutation)
const addPatientMutation = useMutation({
mutationFn: addPatient,
onSuccess: () => {
queryClient.invalidateQueries(['patients']);
reset();
setIsModalOpen(false);
},
});
// 4. (Mutation)
const deletePatientMutation = useMutation({
mutationFn: deletePatient,
onSuccess: () => {
queryClient.invalidateQueries(['patients']);
},
});
const onSubmit = (data) => {
addPatientMutation.mutate(data);
};
const handleDelete = (id) => {
if (window.confirm('คุณต้องการลบผู้ป่วยคนนี้หรือไม่?')) {
deletePatientMutation.mutate(id);
}
};
if (isLoading) return <div>กำลงโหลดขอม...</div>;
if (isError) return <div>เกดขอผดพลาด: {error.message}</div>;
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">ดการขอมลผวย</h1>
<div className="flex justify-end mb-4">
<button
className="btn btn-primary"
onClick={() => setIsModalOpen(true)}
>
<FaPlus className="mr-2" /> เพมผวยใหม
</button>
</div>
<div className="overflow-x-auto shadow-xl rounded-lg">
<table className="table w-full">
<thead>
<tr className="bg-gray-100">
<th></th>
<th>เมล</th>
<th>นเก</th>
<th>การดำเนนการ</th>
</tr>
</thead>
<tbody>
{patients.map((patient) => (
<tr key={patient.id}>
<td>{patient.name}</td>
<td>{patient.email}</td>
<td>{patient.birthday}</td>
<td>
<button
className="btn btn-error btn-sm"
onClick={() => handleDelete(patient.id)}
disabled={deletePatientMutation.isLoading}
>
{deletePatientMutation.isLoading ? 'กำลังลบ...' : <FaTrash />}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{isModalOpen && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg mb-4">เพมผวยใหม</h3>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="form-control">
<label className="label"></label>
<input type="text" placeholder="ชื่อ" className="input input-bordered" {...register("name", { required: true })} />
</div>
<div className="form-control mt-2">
<label className="label">เมล</label>
<input type="email" placeholder="อีเมล" className="input input-bordered" {...register("email", { required: true })} />
</div>
<div className="form-control mt-2">
<label className="label">นเก</label>
<input type="date" className="input input-bordered" {...register("birthday", { required: true })} />
</div>
<div className="modal-action mt-4">
<button type="submit" className="btn btn-primary" disabled={addPatientMutation.isLoading}>
{addPatientMutation.isLoading ? 'กำลังบันทึก...' : 'บันทึก'}
</button>
<button type="button" className="btn" onClick={() => setIsModalOpen(false)}>
ยกเล
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

116
src/pages/RegisterPage.jsx Normal file
View File

@ -0,0 +1,116 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { useMutation } from '@tanstack/react-query';
import { useNavigate, Link } from 'react-router-dom';
import api from '../api/axios';
// API
const registerUser = async (userData) => {
// API username password
// confirmPassword
const { confirmPassword, ...dataToSend } = userData;
const { data } = await api.post('/auth/register', dataToSend);
return data;
};
export default function RegisterPage() {
const navigate = useNavigate();
const {
register,
handleSubmit,
formState: { errors },
watch
} = useForm();
// watch() password real-time
const password = watch("password", "");
const registerMutation = useMutation({
mutationFn: registerUser,
onSuccess: () => {
console.log("Registration successful!");
alert("ลงทะเบียนสำเร็จแล้ว! โปรดเข้าสู่ระบบ.");
navigate('/login');
},
onError: (error) => {
console.error("Registration failed:", error.response?.data || error.message);
alert("การลงทะเบียนไม่สำเร็จ: " + (error.response?.data?.message || "มีข้อผิดพลาดเกิดขึ้น"));
},
});
const onSubmit = (data) => {
registerMutation.mutate(data);
};
return (
<div className="flex flex-col items-center p-6">
<h3 className="text-2xl font-bold mb-4">ลงทะเบยน</h3>
<form onSubmit={handleSubmit(onSubmit)} className="w-full max-w-sm">
{/* ช่องสำหรับ Username หรือ Email */}
<div className="form-control mb-4">
<label className="label">
<span className="label-text">Username</span>
</label>
<input
type="text"
placeholder="ชื่อผู้ใช้"
className="input input-bordered w-full"
{...register("username", { required: "กรุณาใส่ชื่อผู้ใช้" })}
/>
{errors.username && <p className="text-red-500 text-sm mt-1">{errors.username.message}</p>}
</div>
{/* ช่องสำหรับ Password */}
<div className="form-control mb-4">
<label className="label">
<span className="label-text">Password</span>
</label>
<input
type="password"
placeholder="รหัสผ่าน"
className="input input-bordered w-full"
{...register("password", {
required: "กรุณาใส่รหัสผ่าน",
minLength: {
value: 6,
message: "รหัสผ่านต้องมีอย่างน้อย 6 ตัวอักษร"
}
})}
/>
{errors.password && <p className="text-red-500 text-sm mt-1">{errors.password.message}</p>}
</div>
{/* ช่องสำหรับ Confirm Password */}
<div className="form-control mb-4">
<label className="label">
<span className="label-text">Confirm Password</span>
</label>
<input
type="password"
placeholder="ยืนยันรหัสผ่าน"
className="input input-bordered w-full"
{...register("confirmPassword", {
required: "กรุณายืนยันรหัสผ่าน",
validate: (value) =>
value === password || "รหัสผ่านไม่ตรงกัน" // validate()
})}
/>
{errors.confirmPassword && <p className="text-red-500 text-sm mt-1">{errors.confirmPassword.message}</p>}
</div>
{/* ปุ่มสำหรับลงทะเบียน */}
<button
type="submit"
className="btn btn-primary w-full mt-4"
disabled={registerMutation.isLoading}
>
{registerMutation.isLoading ? 'กำลังลงทะเบียน...' : 'ลงทะเบียน'}
</button>
</form>
<div className="mt-4 text-center">
ญชอยแลวใชไหม? <Link to="/login" className="link link-primary">เขาสระบบท</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,187 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/axios';
// API
const fetchUsers = async () => {
const { data } = await api.get('/admin/users'); // Endpoint
return data;
};
const createUser = async (userData) => {
const { data } = await api.post('/admin/users', userData); // Endpoint
return data;
};
const deleteUser = async (userId) => {
await api.delete(`/admin/users/${userId}`); // Endpoint
};
export default function UserManagementPage() {
const queryClient = useQueryClient();
const [isFormVisible, setIsFormVisible] = useState(false); // /
// useQuery
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
// Mutation
const createMutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
alert('เพิ่มผู้ใช้ใหม่เรียบร้อยแล้ว');
queryClient.invalidateQueries({ queryKey: ['users'] }); //
setIsFormVisible(false); //
},
onError: (err) => {
console.error(err);
alert('เกิดข้อผิดพลาดในการเพิ่มผู้ใช้: ' + (err.response?.data?.message || err.message));
},
});
// Mutation
const deleteMutation = useMutation({
mutationFn: deleteUser,
onSuccess: () => {
alert('ลบผู้ใช้เรียบร้อยแล้ว');
queryClient.invalidateQueries({ queryKey: ['users'] }); //
},
onError: (err) => {
console.error(err);
alert('เกิดข้อผิดพลาดในการลบผู้ใช้: ' + (err.response?.data?.message || err.message));
},
});
const { register, handleSubmit, formState: { errors }, reset } = useForm();
const onSubmit = (data) => {
createMutation.mutate(data);
reset(); //
};
const handleDelete = (userId) => {
if (window.confirm('คุณแน่ใจหรือไม่ที่จะลบผู้ใช้นี้?')) {
deleteMutation.mutate(userId);
}
};
if (isLoading) return <div>กำลงโหลดขอมลผใชงาน...</div>;
if (error) return <div>เกดขอผดพลาด: {error.message}</div>;
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6 text-gray-800">ดการผใชงาน</h1>
{/* ปุ่มสำหรับแสดง/ซ่อนฟอร์มเพิ่มผู้ใช้ */}
<div className="mb-4">
<button
className="btn btn-primary"
onClick={() => setIsFormVisible(!isFormVisible)}
>
{isFormVisible ? 'ซ่อนฟอร์ม' : 'เพิ่มผู้ใช้งานใหม่'}
</button>
</div>
{/* ฟอร์มสำหรับเพิ่มผู้ใช้งานใหม่ */}
{isFormVisible && (
<div className="bg-white p-6 rounded-lg shadow-md mb-6 transition-all duration-300">
<h2 className="text-xl font-semibold mb-4">ฟอรมเพมผใชงาน</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* ฟิลด์สำหรับ Username */}
<div className="form-control">
<label className="label">อผใช</label>
<input
type="text"
placeholder="Username"
className="input input-bordered w-full"
{...register('username', { required: 'กรุณากรอกชื่อผู้ใช้' })}
/>
{errors.username && <p className="text-red-500 text-sm mt-1">{errors.username.message}</p>}
</div>
{/* ฟิลด์สำหรับ Password */}
<div className="form-control">
<label className="label">รหสผาน</label>
<input
type="password"
placeholder="Password"
className="input input-bordered w-full"
{...register('password', { required: 'กรุณากรอกรหัสผ่าน' })}
/>
{errors.password && <p className="text-red-500 text-sm mt-1">{errors.password.message}</p>}
</div>
{/* ฟิลด์สำหรับเลือกบทบาท (Role) */}
<div className="form-control">
<label className="label">บทบาท</label>
<select
className="select select-bordered w-full"
{...register('role', { required: 'กรุณาเลือกบทบาท' })}
>
<option value="">เลอกบทบาท</option>
<option value="admin">แลระบบ</option>
<option value="doctor">แพทย</option>
<option value="nurse">พยาบาล</option>
<option value="pharmacist">เภสชกร</option>
<option value="patient">คนไข</option>
</select>
{errors.role && <p className="text-red-500 text-sm mt-1">{errors.role.message}</p>}
</div>
{/* ปุ่ม Submit */}
<div className="form-control pt-2">
<button
type="submit"
className="btn btn-primary"
disabled={createMutation.isLoading}
>
{createMutation.isLoading ? 'กำลังเพิ่ม...' : 'เพิ่มผู้ใช้งาน'}
</button>
</div>
</form>
</div>
)}
{/* ตารางแสดงรายชื่อผู้ใช้งาน */}
<div className="overflow-x-auto bg-white rounded-lg shadow-md">
<table className="table w-full">
{/* ส่วนหัวตาราง */}
<thead>
<tr>
<th>อผใช</th>
<th>บทบาท</th>
<th>สถานะ</th>
<th className="text-center">การจดการ</th>
</tr>
</thead>
{/* ส่วนเนื้อหาตาราง */}
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>{user.username}</td>
<td>
<span className="badge badge-lg capitalize">
{user.role}
</span>
</td>
<td><span className="badge badge-success">Active</span></td>
<td className="text-center">
<button
className="btn btn-error btn-sm"
onClick={() => handleDelete(user.id)}
disabled={deleteMutation.isLoading}
>
ลบ
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,23 @@
// src/routes/AdminRoutes.jsx
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import AdminLayout from '../layouts/AdminLayout.jsx';
import DashboardPage from "../pages/DashboardPage.jsx";
import DoctorsPage from "../pages/DoctorsPage.jsx";
import PatientsPage from "../pages/PatientsPage.jsx";
import ConsultationsPage from "../pages/ConsultationsPage.jsx";
import UserManagementPage from "../pages/UserManagementPage.jsx";
export default function AdminRoutes() {
return (
<Routes>
<Route element={<AdminLayout />}>
<Route index element={<DashboardPage />} />
<Route path="users" element={<UserManagementPage />} />
<Route path="doctors" element={<DoctorsPage />} />
<Route path="patients" element={<PatientsPage />} />
<Route path="consultations" element={<ConsultationsPage />} />
</Route>
</Routes>
);
}

View File

@ -1,6 +1,9 @@
import {Route, Routes} from "react-router-dom";
import PublicLayout from "../layouts/PublicLayout.jsx";
import AuthLayout from "../layouts/AuthLayout.jsx";
import LoginPage from '../pages/LoginPage.jsx';
import RegisterPage from '../pages/RegisterPage.jsx';
import HomePage from "../pages/HomePage.jsx";
export default function PublicRoutes(){
return(
@ -8,12 +11,12 @@ export default function PublicRoutes(){
<Routes>
{/* PublicLayout สำหรับหน้าไม่ต้อง login */}
<Route element={<PublicLayout/>}>
<Route path="/" element={<div>หนาหล</div>}/>
<Route path="/about" element={<div>หนาเกยวกบเรา</div>}/>
<Route path="/" element={<HomePage />} />
</Route>
{/* AuthLayout สำหรับหน้า login */}
<Route element={<AuthLayout/>}>
<Route path="/login" element={<div>หนาลอกอ</div>}/>
<Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} />
</Route>
</Routes>
</>