diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..03d9549
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..0b8763d
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..5f3a278
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/monorepo-starter-template.iml b/.idea/monorepo-starter-template.iml
new file mode 100644
index 0000000..67954c8
--- /dev/null
+++ b/.idea/monorepo-starter-template.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/accounts/admin.py b/backend/accounts/admin.py
index e80d26e..0e23c57 100644
--- a/backend/accounts/admin.py
+++ b/backend/accounts/admin.py
@@ -1,11 +1,34 @@
+# users/admin.py
+
from django.contrib import admin
-from django.contrib.auth.admin import UserAdmin
+from django.contrib.auth.admin import UserAdmin as BaseUserAdmin # เปลี่ยนชื่อเป็น BaseUserAdmin เพื่อป้องกันการซ้ำซ้อน
from .models import CustomUser
-# ใช้ UserAdmin เพื่อให้ได้ UI ที่ครบถ้วนสำหรับการจัดการผู้ใช้
-class CustomUserAdmin(UserAdmin):
- # Fieldsets หรือ list_display (ถ้าต้องการปรับแต่ง UI)
- pass
+# สร้าง Custom User Admin เพื่อจัดการฟิลด์เพิ่มเติม
+class CustomUserAdmin(BaseUserAdmin):
+ # 1. กำหนดฟิลด์ที่จะแสดงในหน้าลิสต์ผู้ใช้
+ list_display = (
+ 'username',
+ 'email',
+ 'first_name',
+ 'last_name',
+ 'is_staff',
+ 'is_superuser',
+ 'role'
+ )
-# ลงทะเบียน CustomUser ด้วย CustomUserAdmin
+ # 2. กำหนดโครงสร้างและการจัดกลุ่มฟิลด์ในหน้าแก้ไขผู้ใช้
+ fieldsets = (
+ (None, {'fields': ('username', 'password')}),
+ ('Personal info', {'fields': ('first_name', 'last_name', 'email', 'phone_number')}), # 🔑 เพิ่ม phone_number
+ ('Role and Permissions', { # สร้างกลุ่มใหม่เพื่อจัดระเบียบ
+ 'fields': ('role', 'is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'),
+ }),
+ ('Important dates', {'fields': ('last_login', 'date_joined')}),
+ )
+
+ # 3. กำหนดฟิลด์ที่ใช้ในการค้นหาในหน้าลิสต์ผู้ใช้
+ search_fields = ('username', 'email', 'phone_number')
+
+# ลงทะเบียน CustomUser ด้วย CustomUserAdmin ที่ถูกปรับแต่ง
admin.site.register(CustomUser, CustomUserAdmin)
\ No newline at end of file
diff --git a/backend/accounts/migrations/0002_customuser_role.py b/backend/accounts/migrations/0002_customuser_role.py
new file mode 100644
index 0000000..eea7bda
--- /dev/null
+++ b/backend/accounts/migrations/0002_customuser_role.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.7 on 2025-11-08 22:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('accounts', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='customuser',
+ name='role',
+ field=models.CharField(choices=[('ADMIN', 'Administrator'), ('OPERATOR', 'Operator'), ('VIEWER', 'Viewer')], default='VIEWER', max_length=20),
+ ),
+ ]
diff --git a/backend/accounts/models.py b/backend/accounts/models.py
index be1b122..a4262ea 100644
--- a/backend/accounts/models.py
+++ b/backend/accounts/models.py
@@ -1,20 +1,19 @@
from django.db import models
-# Create your models here.
from django.contrib.auth.models import AbstractUser
class CustomUser(AbstractUser):
# เพิ่มฟิลด์ที่ต้องการ เช่น phone_number
phone_number = models.CharField(max_length=20, blank=True, null=True, unique=True)
- # ตัวอย่าง:
- # is_customer = models.BooleanField(default=False)
-
- # ถ้าต้องการให้ email เป็น Unique (ไม่ซ้ำกัน)
email = models.EmailField(unique=True)
- # Field ที่ใช้สำหรับการล็อกอิน (ถ้าไม่ใช่ username)
- # USERNAME_FIELD = 'email'
- # REQUIRED_FIELDS = ['username'] # ถ้าเปลี่ยน USERNAME_FIELD ต้องกำหนด REQUIRED_FIELDS
+ # เพิ่มฟิลด์ Role สำหรับ RBAC ที่กำหนดเอง
+ ROLE_CHOICES = [
+ ('ADMIN', 'Administrator'),
+ ('OPERATOR', 'Operator'),
+ ('VIEWER', 'Viewer'),
+ ]
+ role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='VIEWER')
- # ไม่ต้องใส่ pass เพราะมันสืบทอดมาจาก AbstractUser
+ # ไม่ต้องเปลี่ยนส่วนอื่นๆ ถ้าสืบทอดจาก AbstractUser
pass
diff --git a/backend/accounts/serializers.py b/backend/accounts/serializers.py
index b99488a..28a85d0 100644
--- a/backend/accounts/serializers.py
+++ b/backend/accounts/serializers.py
@@ -12,5 +12,12 @@ class UserSerializer(serializers.ModelSerializer):
# Serializer สำหรับการดึงข้อมูล (ใช้แสดงข้อมูลผู้ใช้ปัจจุบัน)
class Meta:
model = CustomUser
- fields = ('id', 'username', 'email', 'phone_number', 'first_name', 'last_name')
- read_only_fields = ('id', 'username')
+ fields = (
+ 'id', 'username', 'email', 'phone_number', 'first_name', 'last_name',
+ # เพิ่มฟิลด์สถานะสิทธิ์/Role สำหรับ RBAC
+ 'is_active', 'is_staff', 'is_superuser',
+ # เพิ่ม 'role' ใน model
+ 'role',
+ )
+ # ตั้งค่า is_active, is_staff, is_superuser เป็น read_only
+ read_only_fields = ('id', 'username', 'is_active', 'is_staff', 'is_superuser', 'role')
diff --git a/backend/core/settings.py b/backend/core/settings.py
index 3ea7d36..f6c38ae 100644
--- a/backend/core/settings.py
+++ b/backend/core/settings.py
@@ -50,6 +50,8 @@ THIRD_PARTY_APPS = [
'django_bolt',
'rest_framework',
'corsheaders',
+ 'drf_spectacular',
+ 'drf_spectacular_sidecar',
]
LOCAL_APPS = [
@@ -57,6 +59,7 @@ LOCAL_APPS = [
'accounts',
'user_profile',
'permissions',
+ 'model_registry'
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
@@ -171,6 +174,8 @@ AUTH_USER_MODEL = 'accounts.CustomUser' # ต้องชี้ไปที่ M
# 2. ตั้งค่า REST Framework
REST_FRAMEWORK = {
+ 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
+
'DEFAULT_AUTHENTICATION_CLASSES': (
# ใช้ JWT เป็นวิธีการยืนยันตัวตนหลัก
'rest_framework_simplejwt.authentication.JWTAuthentication',
@@ -228,4 +233,61 @@ CELERY_RESULT_BACKEND = f'redis://{REDIS_HOST}:{REDIS_PORT}/0'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
-CELERY_TIMEZONE = 'Asia/Bangkok'
\ No newline at end of file
+CELERY_TIMEZONE = 'Asia/Bangkok'
+
+# ----------------------------------------------------------------------
+# SPECTACULAR CONFIGURATION
+# ----------------------------------------------------------------------
+
+SPECTACULAR_SETTINGS = {
+ # ชื่อโครงการของคุณ
+ 'TITLE': 'MONAI MLOps Service API',
+
+ # คำอธิบายสั้นๆ ของโครงการ
+ 'DESCRIPTION': 'API Gateway and Model Registry for Medical AI Inference Services (Django DRF + FastAPI/MONAI).',
+
+ # เวอร์ชัน API
+ 'VERSION': 'v1',
+
+ # กำหนด URL ที่ใช้ในการสร้าง Schema (เพื่อให้รวมทุก Endpoints)
+ 'SERVE_URLCONF': 'core.urls',
+
+ # กำหนดให้ใช้ Sidecar เพื่อการแสดงผลแบบออฟไลน์
+ 'SWAGGER_UI_DIST': 'SIDECAR',
+ 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR',
+ 'REDOC_DIST': 'SIDECAR',
+
+ # กำหนด Authentication สำหรับ Doc (แสดงช่องใส่ JWT Token ใน Swagger)
+ 'SECURITY': [
+ {
+ 'BearerAuth': { # ชื่อนี้จะใช้ใน Components/SecuritySchemes
+ 'type': 'http',
+ 'scheme': 'bearer',
+ 'bearerFormat': 'JWT',
+ }
+ }
+ ],
+ # -----------------------------------------------------------
+ # การจัดเรียงและกำหนดชื่อ Tags
+ # -----------------------------------------------------------
+ 'TAGS': [
+ {
+ 'name': '1. Authentication & User Management',
+ 'description': 'Endpoints for JWT token management, login, and user profile operations (Djoser).',
+ #'exclude': False, # ไม่ต้องระบุ, แต่ DRF Spectacular จะรู้ว่าต้องใช้ Tag นี้แทน 'v1'
+ },
+ {
+ # ให้เป็นชื่อใหม่ที่ไม่ซ้ำกัน หรือจัดการลำดับใหม่**
+ 'name': '2. Model Registry & Metadata Management',
+ 'description': 'CRUD operations for managing AI Service metadata, URLs, and versions.',
+ },
+ {
+ 'name': '3. MLOps Control & Service Orchestration',
+ 'description': 'Actions to test service health, set model status (Hot Swap), and proxy inference requests.',
+ },
+ ],
+ 'POSTPROCESSING_HOOKS': [
+ 'core.spectacular_hooks.rename_djoser_tags',
+ ],
+ # ตั้งค่าอื่น ๆ (ถ้าจำเป็น)
+}
\ No newline at end of file
diff --git a/backend/core/spectacular_hooks.py b/backend/core/spectacular_hooks.py
new file mode 100644
index 0000000..5a992e4
--- /dev/null
+++ b/backend/core/spectacular_hooks.py
@@ -0,0 +1,13 @@
+# core/spectacular_hooks.py
+def rename_djoser_tags(result, generator, request, public):
+ """
+ เปลี่ยน Tag 'v1' ของ Djoser ให้เป็นชื่อ Tag ที่ต้องการ
+ """
+ # result คือ OpenAPI spec เป็น dict
+ paths = result.get('paths', {})
+ for path, path_item in paths.items():
+ for method, operation in path_item.items():
+ tags = operation.get('tags', [])
+ if 'v1' in tags:
+ operation['tags'] = ['1. Authentication & User Management']
+ return result
diff --git a/backend/core/urls.py b/backend/core/urls.py
index a26824d..c3be368 100644
--- a/backend/core/urls.py
+++ b/backend/core/urls.py
@@ -18,13 +18,34 @@ from django.contrib import admin
from django.urls import path, include
from rest_framework.routers import DefaultRouter
+from model_registry.views.ai_model_viewset import AiModelRegistryViewSet
+from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
+
# 1. กำหนดตัวแปร router ก่อนใช้งาน
router = DefaultRouter()
+# 2. ลงทะเบียน API ViewSets (Project-Level Routing)
+# URL: /api/v1/models/
+router.register(
+ r'models',
+ AiModelRegistryViewSet,
+ basename='aimodel' # basename จำเป็นเมื่อ ViewSet ไม่ได้สืบทอดจาก Model
+)
+
+# 3. ลงทะเบียน ViewSet อื่น ๆ
urlpatterns = [
path('admin/', admin.site.urls),
- # 2. Endpoints สำหรับการยืนยันตัวตน (Login, Logout, Register)
+ # Schema OpenAPI
+ path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
+
+ # Swagger UI
+ path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
+
+ # Redoc UI
+ path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
+
+ # Endpoints สำหรับการยืนยันตัวตน (Login, Logout, Register)
path('api/v1/auth/', include('djoser.urls')), # /users/ (Register/Update/Me), /users/set_password
path('api/v1/auth/', include('djoser.urls.jwt')), # /jwt/create (Login), /jwt/refresh (Refresh Token)
diff --git a/backend/model_registry/__init__.py b/backend/model_registry/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/model_registry/admin.py b/backend/model_registry/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/backend/model_registry/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/backend/model_registry/apps.py b/backend/model_registry/apps.py
new file mode 100644
index 0000000..4ce609e
--- /dev/null
+++ b/backend/model_registry/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ModelRegistryConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'model_registry'
diff --git a/backend/model_registry/migrations/0001_initial.py b/backend/model_registry/migrations/0001_initial.py
new file mode 100644
index 0000000..b4f1eca
--- /dev/null
+++ b/backend/model_registry/migrations/0001_initial.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.2.7 on 2025-11-08 03:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='AiModel',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=255, unique=True, verbose_name='ชื่อโมเดล')),
+ ('model_version', models.CharField(default='v1.0.0', max_length=50, verbose_name='เวอร์ชัน')),
+ ('developer', models.CharField(blank=True, max_length=255, null=True, verbose_name='ผู้พัฒนา/ทีม')),
+ ('base_url', models.URLField(max_length=500, verbose_name='Base URL ของ AI Service (Internal)')),
+ ('inference_path', models.CharField(max_length=255, verbose_name='Endpoint Path สำหรับ Inference')),
+ ('status', models.CharField(choices=[('ACTIVE', 'ใช้งาน'), ('INACTIVE', 'ไม่ใช้งาน'), ('TESTING', 'กำลังทดสอบ')], default='INACTIVE', max_length=20, verbose_name='สถานะบริการ')),
+ ('auth_required', models.BooleanField(default=False, verbose_name='ต้องการ Internal Auth Key')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'verbose_name': 'AI Model Registry',
+ 'verbose_name_plural': 'AI Model Registry',
+ },
+ ),
+ ]
diff --git a/backend/model_registry/migrations/__init__.py b/backend/model_registry/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/model_registry/models.py b/backend/model_registry/models.py
new file mode 100644
index 0000000..a61987f
--- /dev/null
+++ b/backend/model_registry/models.py
@@ -0,0 +1,37 @@
+from django.db import models
+
+class AiModel(models.Model):
+ # 1. ข้อมูลพื้นฐานและการลงทะเบียน
+ name = models.CharField(max_length=255, unique=True, verbose_name="ชื่อโมเดล")
+ model_version = models.CharField(max_length=50, default="v1.0.0", verbose_name="เวอร์ชัน")
+ developer = models.CharField(max_length=255, blank=True, null=True, verbose_name="ผู้พัฒนา/ทีม")
+
+ # 2. ข้อมูลการเรียกใช้ (API Management)
+ base_url = models.URLField(max_length=500, verbose_name="Base URL ของ AI Service (Internal)")
+ inference_path = models.CharField(max_length=255, verbose_name="Endpoint Path สำหรับ Inference")
+ # ตัวอย่าง: base_url="http://ai_model_server:8001/", inference_path="inference/spleen/"
+
+ # 3. สถานะและการควบคุม
+ status_choices = [
+ ('ACTIVE', 'ใช้งาน'),
+ ('INACTIVE', 'ไม่ใช้งาน'),
+ ('TESTING', 'กำลังทดสอบ'),
+ ]
+ status = models.CharField(max_length=20, choices=status_choices, default='INACTIVE', verbose_name="สถานะบริการ")
+
+ auth_required = models.BooleanField(default=False, verbose_name="ต้องการ Internal Auth Key")
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ def full_inference_url(self):
+ # สร้าง Full URL สำหรับ DRF ใช้เรียก (Proxy)
+ return f"{self.base_url.rstrip('/')}/{self.inference_path.lstrip('/')}"
+
+ def __str__(self):
+ return f"{self.name} ({self.model_version}) - {self.status}"
+
+ class Meta:
+ verbose_name = "AI Model Registry"
+ verbose_name_plural = "AI Model Registry"
+ # สามารถเพิ่ม unique_together (name, model_version) ได้ในภายหลัง
\ No newline at end of file
diff --git a/backend/model_registry/repositories/__init__.py b/backend/model_registry/repositories/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/model_registry/repositories/ai_model_repository.py b/backend/model_registry/repositories/ai_model_repository.py
new file mode 100644
index 0000000..eb30a9e
--- /dev/null
+++ b/backend/model_registry/repositories/ai_model_repository.py
@@ -0,0 +1,23 @@
+from ..models import AiModel
+from typing import List, Optional
+
+class AiModelRepository:
+ """จัดการการเข้าถึงฐานข้อมูลสำหรับ AiModel โดยเฉพาะ"""
+
+ def get_all(self) -> List[AiModel]:
+ return AiModel.objects.all().order_by('name', '-model_version')
+
+ def get_by_id(self, pk: int) -> Optional[AiModel]:
+ try:
+ return AiModel.objects.get(pk=pk)
+ except AiModel.DoesNotExist:
+ return None
+
+ def save(self, model_instance: AiModel) -> AiModel:
+ """ใช้สำหรับ Create และ Update"""
+ model_instance.save()
+ return model_instance
+
+ def delete(self, model_instance: AiModel):
+ model_instance.delete()
+ # Logic อื่น ๆ ที่เกี่ยวข้องกับการเข้าถึง DB
\ No newline at end of file
diff --git a/backend/model_registry/serializers/__init__.py b/backend/model_registry/serializers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/model_registry/serializers/ai_model_serializer.py b/backend/model_registry/serializers/ai_model_serializer.py
new file mode 100644
index 0000000..7a9f1b7
--- /dev/null
+++ b/backend/model_registry/serializers/ai_model_serializer.py
@@ -0,0 +1,16 @@
+from rest_framework import serializers
+from ..models import AiModel
+
+class AiModelSerializer(serializers.ModelSerializer):
+ # Read-only field สำหรับแสดงผล Full URL ที่ DRF จะใช้ Proxy
+ full_endpoint = serializers.SerializerMethodField()
+
+ class Meta:
+ model = AiModel
+ fields = '__all__'
+ read_only_fields = ('created_at', 'updated_at', 'full_endpoint')
+
+ def get_full_endpoint(self, obj: AiModel) -> str:
+ return obj.full_inference_url()
+
+ # สามารถเพิ่ม Custom Validation ที่นี่: เช่น ตรวจสอบความถูกต้องของ base_url format
\ No newline at end of file
diff --git a/backend/model_registry/services/__init__.py b/backend/model_registry/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/model_registry/services/ai_model_service.py b/backend/model_registry/services/ai_model_service.py
new file mode 100644
index 0000000..6630a08
--- /dev/null
+++ b/backend/model_registry/services/ai_model_service.py
@@ -0,0 +1,58 @@
+from ..repositories.ai_model_repository import AiModelRepository
+from ..models import AiModel
+from typing import Optional, List
+import requests
+from django.conf import settings # ใช้สำหรับ Configs ภายนอก/Secrets
+
+class ConnectionError(Exception):
+ """Custom Exception สำหรับการเชื่อมต่อล้มเหลว"""
+ pass
+
+class AiModelService:
+ def __init__(self, repository: AiModelRepository):
+ self.repo = repository
+
+ def get_all_models(self) -> List[AiModel]:
+ return self.repo.get_all()
+
+ def create_model(self, validated_data: dict) -> AiModel:
+ # Business Logic: ตรวจสอบความซ้ำซ้อน, การตั้งค่าเริ่มต้น
+ new_model = AiModel(**validated_data)
+ return self.repo.save(new_model)
+
+ def test_connection(self, pk: int) -> dict:
+ """Logic สำหรับเรียก HTTP Ping ไปยัง AI Service ภายนอก"""
+ model = self.repo.get_by_id(pk)
+ if not model:
+ raise ValueError("Model not found")
+
+ # ใช้ Root URL ของ Service สำหรับ Ping
+ test_url = model.base_url.rstrip('/') + '/'
+
+ try:
+ # ใช้ requests เพื่อเรียก HTTP (ใช้ httpx ถ้าต้องการ Async)
+ response = requests.get(test_url, timeout=5)
+ response.raise_for_status() # Raise error for 4xx or 5xx
+
+ return {
+ "status": "success",
+ "message": f"Successfully pinged {test_url}",
+ "response_status": response.status_code,
+ "response_data": response.json()
+ }
+ except requests.exceptions.RequestException as e:
+ # แปลง HTTP Error เป็น Custom Exception ของ Business Logic
+ raise ConnectionError(f"Connection failed to {test_url}: {e}")
+
+ def set_status(self, pk: int, new_status: str) -> Optional[AiModel]:
+ # Business Logic: การเปลี่ยนสถานะต้องผ่าน Service
+ model = self.repo.get_by_id(pk)
+ if model:
+ # Logic: มีเงื่อนไขว่าต้องผ่าน TESTING ก่อนไป ACTIVE
+ if model.status == 'TESTING' and new_status == 'ACTIVE':
+ # Logic: Trigger Hot Swap ใน FastAPI Service ก่อนเปลี่ยนสถานะ (ถ้าต้องการ)
+ pass
+
+ model.status = new_status
+ return self.repo.save(model)
+ return None
\ No newline at end of file
diff --git a/backend/model_registry/tests.py b/backend/model_registry/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/backend/model_registry/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/backend/model_registry/views.py b/backend/model_registry/views.py
new file mode 100644
index 0000000..91ea44a
--- /dev/null
+++ b/backend/model_registry/views.py
@@ -0,0 +1,3 @@
+from django.shortcuts import render
+
+# Create your views here.
diff --git a/backend/model_registry/views/__init__.py b/backend/model_registry/views/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/model_registry/views/ai_model_viewset.py b/backend/model_registry/views/ai_model_viewset.py
new file mode 100644
index 0000000..42bc7fd
--- /dev/null
+++ b/backend/model_registry/views/ai_model_viewset.py
@@ -0,0 +1,72 @@
+from rest_framework import viewsets, permissions, status
+from rest_framework.response import Response
+from rest_framework.decorators import action
+from drf_spectacular.utils import extend_schema, extend_schema_view
+
+from ..models import AiModel
+from ..serializers.ai_model_serializer import AiModelSerializer
+from ..repositories.ai_model_repository import AiModelRepository
+from ..services.ai_model_service import AiModelService, ConnectionError
+
+# Dependency Injection: สร้าง Instance ของ Repository และ Service
+# สำหรับโปรเจกต์ขนาดเล็กสามารถทำแบบนี้ได้
+repo = AiModelRepository()
+service = AiModelService(repository=repo)
+
+@extend_schema_view(
+ # 1. การดำเนินการ CRUD ปกติ: จัดอยู่ใน Model Registry
+ list=extend_schema(tags=['2. Model Registry & Metadata Management']),
+ retrieve=extend_schema(tags=['2. Model Registry & Metadata Management']),
+ create=extend_schema(tags=['2. Model Registry & Metadata Management']),
+ update=extend_schema(tags=['2. Model Registry & Metadata Management']),
+ partial_update=extend_schema(tags=['2. Model Registry & Metadata Management']),
+ destroy=extend_schema(tags=['2. Model Registry & Metadata Management']),
+)
+class AiModelRegistryViewSet(viewsets.ModelViewSet):
+ queryset = AiModel.objects.all()
+ serializer_class = AiModelSerializer
+ permission_classes = [permissions.IsAdminUser]
+
+ # -----------------------------------------------
+ # Override Create/List (เรียกใช้ Service Layer)
+ # -----------------------------------------------
+ def get_queryset(self):
+ # List/Retrieve จะเรียก Service Layer แทนการเรียก ORM โดยตรง
+ return service.get_all_models()
+
+ def create(self, request, *args, **kwargs):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ try:
+ new_model = service.create_model(serializer.validated_data)
+ return Response(self.get_serializer(new_model).data, status=status.HTTP_201_CREATED)
+ except Exception as e:
+ return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
+
+ # -----------------------------------------------
+ # Custom Action: ทดสอบการเชื่อมต่อ
+ # -----------------------------------------------
+ @extend_schema(tags=['3. MLOps Control & Service Orchestration'])
+ @action(detail=True, methods=['post'], url_path='test-connection')
+ def test_connection(self, request, pk=None):
+ try:
+ result = service.test_connection(pk=int(pk))
+ return Response(result)
+ except ValueError as e:
+ return Response({"detail": str(e)}, status=status.HTTP_404_NOT_FOUND)
+ except ConnectionError as e:
+ # Response ด้วย error ที่ชัดเจนจากการเชื่อมต่อ
+ return Response({"status": "error", "detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
+
+ @extend_schema(tags=['3. MLOps Control & Service Orchestration'])
+ @action(detail=True, methods=['patch'], url_path='set-status')
+ def set_status(self, request, pk=None):
+ new_status = request.data.get('status')
+ if not new_status or new_status not in [choice[0] for choice in AiModel.status_choices]:
+ return Response({"detail": "Invalid status provided."}, status=status.HTTP_400_BAD_REQUEST)
+
+ updated_model = service.set_status(pk=int(pk), new_status=new_status)
+ if updated_model:
+ return Response(self.get_serializer(updated_model).data)
+ return Response({"detail": "Model not found."}, status=status.HTTP_404_NOT_FOUND)
\ No newline at end of file
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 84e2f83..ff1c0a9 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -11,4 +11,6 @@ django-redis # สำหรับเชื่อมต่อ Django
redis # ไคลเอนต์ Python สำหรับ Redis
celery # ตัว Worker
boto3
-python-dotenv
\ No newline at end of file
+python-dotenv
+drf-spectacular
+drf-spectacular-sidecar
\ No newline at end of file
diff --git a/web/package-lock.json b/web/package-lock.json
index 50b923e..5af736d 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -8,11 +8,21 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
+ "@heroicons/react": "^2.2.0",
+ "@hookform/resolvers": "^5.2.2",
+ "@reduxjs/toolkit": "^2.10.1",
"@tailwindcss/vite": "^4.1.16",
+ "@tanstack/react-query": "^5.90.7",
+ "axios": "^1.13.2",
"daisyui": "^5.3.10",
"react": "^19.1.1",
"react-dom": "^19.1.1",
- "tailwindcss": "^4.1.16"
+ "react-hook-form": "^7.66.0",
+ "react-icons": "^5.5.0",
+ "react-redux": "^9.2.0",
+ "react-router-dom": "^7.9.5",
+ "tailwindcss": "^4.1.16",
+ "yup": "^1.7.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@@ -907,6 +917,27 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@heroicons/react": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
+ "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">= 16 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/@hookform/resolvers": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
+ "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/utils": "^0.3.0"
+ },
+ "peerDependencies": {
+ "react-hook-form": "^7.55.0"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1004,6 +1035,32 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.10.1",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.10.1.tgz",
+ "integrity": "sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^10.2.0",
+ "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/@rolldown/pluginutils": {
"version": "1.0.0-beta.43",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz",
@@ -1297,6 +1354,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.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
@@ -1554,6 +1623,32 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.90.7",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.7.tgz",
+ "integrity": "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.90.7",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.7.tgz",
+ "integrity": "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.90.7"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1616,7 +1711,7 @@
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -1632,6 +1727,12 @@
"@types/react": "^19.2.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": "5.1.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz",
@@ -1716,6 +1817,23 @@
"dev": true,
"license": "Python-2.0"
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
+ "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1778,6 +1896,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -1846,6 +1977,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1860,6 +2003,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
+ "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1879,7 +2031,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": {
@@ -1916,6 +2068,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1925,6 +2086,20 @@
"node": ">=8"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.243",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.243.tgz",
@@ -1945,6 +2120,51 @@
"node": ">=10.13.0"
}
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/esbuild": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz",
@@ -2275,6 +2495,42 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "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": {
+ "node": ">= 6"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2289,6 +2545,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -2299,6 +2564,43 @@
"node": ">=6.9.0"
}
},
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2325,6 +2627,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -2341,6 +2655,45 @@
"node": ">=8"
}
},
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2351,6 +2704,16 @@
"node": ">= 4"
}
},
+ "node_modules/immer": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
+ "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",
@@ -2799,6 +3162,36 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2990,6 +3383,18 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/property-expr": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
+ "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3021,6 +3426,54 @@
"react": "^19.2.0"
}
},
+ "node_modules/react-hook-form": {
+ "version": "7.66.0",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
+ "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "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-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.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -3031,6 +3484,65 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "7.9.5",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz",
+ "integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.9.5",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.5.tgz",
+ "integrity": "sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.9.5"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "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",
@@ -3098,6 +3610,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3175,6 +3693,12 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tiny-case": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
+ "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -3191,6 +3715,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/toposort": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
+ "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
+ "license": "MIT"
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -3204,6 +3734,18 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/type-fest": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
+ "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
@@ -3245,6 +3787,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/vite": {
"version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
@@ -3364,6 +3915,18 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/yup": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
+ "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
+ "license": "MIT",
+ "dependencies": {
+ "property-expr": "^2.0.5",
+ "tiny-case": "^1.0.3",
+ "toposort": "^2.0.2",
+ "type-fest": "^2.19.0"
+ }
}
}
}
diff --git a/web/package.json b/web/package.json
index c6d48a9..d86172d 100644
--- a/web/package.json
+++ b/web/package.json
@@ -10,11 +10,21 @@
"preview": "vite preview"
},
"dependencies": {
+ "@heroicons/react": "^2.2.0",
+ "@hookform/resolvers": "^5.2.2",
+ "@reduxjs/toolkit": "^2.10.1",
"@tailwindcss/vite": "^4.1.16",
+ "@tanstack/react-query": "^5.90.7",
+ "axios": "^1.13.2",
"daisyui": "^5.3.10",
"react": "^19.1.1",
"react-dom": "^19.1.1",
- "tailwindcss": "^4.1.16"
+ "react-hook-form": "^7.66.0",
+ "react-icons": "^5.5.0",
+ "react-redux": "^9.2.0",
+ "react-router-dom": "^7.9.5",
+ "tailwindcss": "^4.1.16",
+ "yup": "^1.7.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
diff --git a/web/public/favicon-32x32.svg b/web/public/favicon-32x32.svg
new file mode 100644
index 0000000..70627f0
--- /dev/null
+++ b/web/public/favicon-32x32.svg
@@ -0,0 +1,16 @@
+
+
+
\ No newline at end of file
diff --git a/web/public/intro.png b/web/public/intro.png
new file mode 100644
index 0000000..001ec91
Binary files /dev/null and b/web/public/intro.png differ
diff --git a/web/public/logo192.png b/web/public/logo192.png
new file mode 100644
index 0000000..721bf3f
Binary files /dev/null and b/web/public/logo192.png differ
diff --git a/web/src/App.jsx b/web/src/App.jsx
index 3e4fa0f..eda475f 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -1,11 +1,22 @@
-function App() {
+import { BrowserRouter, Route, Routes } from "react-router-dom";
+import AuthRoutes from "./routes/AuthRoutes.jsx";
+import PrivateRoutes from "./routes/PrivateRoutes.jsx";
+
+
+function App() {
return (
- <>
-
ทดสอบนำเข้าฟอนต์ในโปรเจกต์ Daisy UI
-
- >
+ // ต้องห่อหุ้มด้วย Provider ของ Redux และ QueryClient ใน main.jsx
+
+
+ {/* Private Routes: ตรวจสอบล็อกอินก่อนเข้า /dashboard/* */}
+ }/>
+
+ {/* Auth Routes: สำหรับ /login, /register และเป็น Fallback หลัก */}
+ }/>
+
+
)
}
-export default App
+export default App
\ No newline at end of file
diff --git a/web/src/app/store.js b/web/src/app/store.js
new file mode 100644
index 0000000..d7f53cf
--- /dev/null
+++ b/web/src/app/store.js
@@ -0,0 +1,23 @@
+import { configureStore } from '@reduxjs/toolkit';
+import authReducer from '../features/auth/authSlice';
+
+export const store = configureStore({
+ reducer: {
+ // กำหนด Reducer หลัก
+ auth: authReducer,
+ // [เพิ่ม Reducer อื่น ๆ ที่นี่]
+ },
+ // ปิด serializableCheck ใน Production เพื่อประสิทธิภาพ
+ middleware: (getDefaultMiddleware) => getDefaultMiddleware({
+ serializableCheck: {
+ // อาจจะต้องยกเว้น action types บางตัวที่ไม่ serialize ได้
+ },
+ }),
+ // ใน Vite ตัวแปร import.meta.env.MODE จะถูก ตั้งค่าให้อัตโนมัติ ตามคำสั่งที่ใช้ตอนรัน เช่น
+ // vite หรือ npm run dev "development"
+ // vite build หรือ npm run build "production"
+ devTools: import.meta.env.MODE !== 'production',
+});
+
+// Export ฟังก์ชันเพื่อให้ Components ภายนอก (เช่น axiosClient) เข้าถึง store instance ได้
+export const getStore = () => store;
\ No newline at end of file
diff --git a/web/src/components/ErrorText.jsx b/web/src/components/ErrorText.jsx
new file mode 100644
index 0000000..a477498
--- /dev/null
+++ b/web/src/components/ErrorText.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+
+function ErrorText({ children, styleClass }) {
+ const defaultClasses = "p-3 bg-red-100 text-red-600 rounded-lg text-sm font-medium border border-red-300 break-words whitespace-normal";
+
+ return (
+ <>
+ {children && (
+
+ {children}
+
+ )}
+ >
+ );
+}
+
+export default ErrorText;
diff --git a/web/src/components/InputText.jsx b/web/src/components/InputText.jsx
new file mode 100644
index 0000000..32ec38c
--- /dev/null
+++ b/web/src/components/InputText.jsx
@@ -0,0 +1,28 @@
+import React, { forwardRef } from "react";
+
+// ใช้ forwardRef เพื่อให้ RHF สามารถส่ง ref มาที่ input ได้
+const InputText = forwardRef(
+ // รับ Props ที่จำเป็นสำหรับ RHF (ref, name, value, onChange, onBlur)
+ // และรับ error object เพื่อแสดงข้อความ Validation
+ ({ labelTitle, labelStyle, type, containerStyle, placeholder, error, ...rest }, ref) => {
+
+ return(
+
+
+
+ {/* แสดงข้อความ Error ที่ส่งมาจาก RHF errors object */}
+ {error &&
{error.message}
}
+
+ );
+ }
+);
+
+export default InputText;
\ No newline at end of file
diff --git a/web/src/components/LandingIntro.jsx b/web/src/components/LandingIntro.jsx
new file mode 100644
index 0000000..a309ed4
--- /dev/null
+++ b/web/src/components/LandingIntro.jsx
@@ -0,0 +1,31 @@
+import TemplatePointers from "./TemplatePointers.jsx"
+
+function LandingIntro(){
+
+ return(
+
+
+
+
+
+ {/* ไฟล์ logo192.png ใน /public */}
+
+ DDO Console
+
+
+ {/* ไฟล์ intro.png ใน /public */}
+
+

+
+
+
+
+
+
+
+
+ )
+
+}
+
+export default LandingIntro
diff --git a/web/src/components/LoginForm.jsx b/web/src/components/LoginForm.jsx
new file mode 100644
index 0000000..6b843bd
--- /dev/null
+++ b/web/src/components/LoginForm.jsx
@@ -0,0 +1,122 @@
+import { useState } from 'react';
+import { Link } from 'react-router-dom';
+
+// นำเข้า Hooks และ Library ที่จำเป็น
+import { useForm } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
+
+// นำเข้า Hook API และ Components
+import { useLoginMutation } from '../services/authApi';
+import LandingIntro from './LandingIntro';
+import InputText from './InputText';
+
+import { loginSchema } from '../schemas/authSchema';
+
+export default function LoginForm() {
+ // 1. Hook สำหรับเรียก API ล็อกอิน
+ const loginMutation = useLoginMutation();
+ const [apiErrorMessage, setApiErrorMessage] = useState("");
+
+ // 2. Hook Form Setup
+ const {
+ register,
+ handleSubmit,
+ formState: { errors }
+ } = useForm({
+ resolver: yupResolver(loginSchema),
+ defaultValues: {
+ username: '',
+ password: ''
+ }
+ });
+
+ // 3. Form Submission Logic
+ const onSubmit = (data) => {
+ setApiErrorMessage("");
+
+ // เรียกใช้ Mutation (Token + Fetch User)
+ loginMutation.mutate({
+ // ส่ง 'username' และ 'password' ตรงตาม API
+ username: data.username,
+ password: data.password
+ }, {
+ onError: (error) => {
+ setApiErrorMessage(error.message || "การล็อกอินล้มเหลว กรุณาตรวจสอบข้อมูล");
+ }
+ });
+ }
+
+ const loading = loginMutation.isPending;
+
+ return (
+
+
+
+
+
+
+
+
+
เข้าสู่ระบบ DDO Console
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/web/src/components/SidebarSubmenu.jsx b/web/src/components/SidebarSubmenu.jsx
new file mode 100644
index 0000000..9b78ccb
--- /dev/null
+++ b/web/src/components/SidebarSubmenu.jsx
@@ -0,0 +1,87 @@
+// src/components/SidebarSubmenu.jsx (ปรับปรุงสำหรับ RBAC)
+
+import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon';
+import { useEffect, useState } from 'react';
+import { NavLink, useLocation } from 'react-router-dom';
+import { useSelector } from 'react-redux';
+
+// Component นี้คาดหวังว่าฟังก์ชัน canAccess จะถูกส่งมาจาก MainLayout หรือสร้างขึ้นใหม่
+
+function SidebarSubmenu({ submenu, name, icon, closeMobileSidebar }) {
+ const location = useLocation();
+ const [isExpanded, setIsExpanded] = useState(false);
+ const role = useSelector(state => state.auth.role); // ดึง Role จาก Redux
+
+ // ฟังก์ชันตรวจสอบสิทธิ์ (ควรมาจาก MainLayout แต่เราจะใช้ Logic พื้นฐาน)
+ const canAccess = (requiredRole) => {
+ if (role === 'admin') return true;
+ if (!requiredRole) return true;
+ const allowedRoles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
+ return allowedRoles.includes(role);
+ };
+
+ // เปิด Submenu หาก Path ปัจจุบันอยู่ใน Submenu นั้น
+ useEffect(() => {
+ // ตรวจสอบว่ามีเมนูย่อยใดที่ผู้ใช้เข้าถึงได้และเป็น Path ปัจจุบันหรือไม่
+ const isActive = submenu.some(
+ m => canAccess(m.requiredRole) && m.path === location.pathname
+ );
+ if (isActive) {
+ setIsExpanded(true);
+ }
+ }, [location.pathname, submenu, role]); // เพิ่ม role ใน dependency array
+
+ return (
+
+ {/** Route header */}
+
setIsExpanded(!isExpanded)}
+ >
+
+ {icon}
+ {name}
+
+
+
+
+ {/** Submenu list */}
+
+
+ {submenu.map((m, k) => {
+ // กรองเมนูย่อยตามสิทธิ์ (RBAC)
+ if (!canAccess(m.requiredRole)) return null;
+
+ return (
+ -
+ {/* ใช้ NavLink เพื่อแสดงสถานะ Active และใช้ onClick เพื่อปิด Sidebar (สำหรับ Mobile) */}
+
+ `text-base ${isActive ? 'bg-base-200 text-primary font-semibold' : 'hover:bg-base-400'}`
+ }
+ >
+ {m.icon} {m.name}
+
+ {/* แถบสี Primary แสดงสถานะ Active */}
+ {location.pathname === m.path ? (
+
+ ) : null}
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+export default SidebarSubmenu;
\ No newline at end of file
diff --git a/web/src/components/Subtitle.jsx b/web/src/components/Subtitle.jsx
new file mode 100644
index 0000000..c2db806
--- /dev/null
+++ b/web/src/components/Subtitle.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+
+function Subtitle({ styleClass, children }) {
+ return (
+ // ใช้ text-2xl เพื่อให้ดูโดดเด่นขึ้นสำหรับหัวข้อหน้า
+
+ {children}
+
+ );
+}
+
+export default Subtitle;
diff --git a/web/src/components/TemplatePointers.jsx b/web/src/components/TemplatePointers.jsx
new file mode 100644
index 0000000..3b2e94f
--- /dev/null
+++ b/web/src/components/TemplatePointers.jsx
@@ -0,0 +1,34 @@
+function TemplatePointers(){
+ return(
+ <>
+ DDO Console Features
+
+ {/* 1. ส่วน A: Dashboard & Monitoring */}
+
+ ✓ ความน่าเชื่อถือของข้อมูล (HA Data Layer): ใช้ CockroachDB Cluster (3 Node HA) เพื่อรับประกันความต่อเนื่องของบริการฐานข้อมูล
+
+
+ {/* 2. ส่วน B: Control & Management (MLOps Flow) */}
+
+ ✓ Asynchronous AI Processing: ประมวลผลงานหนักผ่าน Celery เพื่อให้ Frontend ตอบสนองทันที และจัดการคิวงานได้
+
+
+ {/* 3. ส่วน C: Data Governance & Discovery (เน้น Medical/AI) */}
+
+ ✓ AI Model Serving Layer: บริการ Model Inference พร้อมจัดการไฟล์ภาพขนาดใหญ่ด้วย MinIO (S3)
+
+
+ {/* 4. ส่วน Security (การยืนยันตัวตนและการเข้าถึง) */}
+
+ ✓ Security & Gateway: การจัดการผู้ใช้และสิทธิ์ (JWT/RBAC) ผ่าน Django DRF ซึ่งทำหน้าที่เป็น Lightweight Gateway หลัก
+
+
+ {/* 5. ส่วนสถาปัตยกรรมกระจายศูนย์ */}
+
+ ✓ สถาปัตยกรรมกระจายศูนย์ (Distributed): สร้างบนเทคโนโลยีหลัก เช่น MONAI, Django DRF, CockroachDB HA, Redis Cache และ MinIO Object Storage
+
+ >
+ )
+}
+
+export default TemplatePointers
\ No newline at end of file
diff --git a/web/src/components/TitleCard.jsx b/web/src/components/TitleCard.jsx
new file mode 100644
index 0000000..ecfd5bc
--- /dev/null
+++ b/web/src/components/TitleCard.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import Subtitle from './Subtitle';
+
+function TitleCard({ title, children, topMargin, TopSideButtons }) {
+ return (
+
+
+ {/* Title และ Top Side Buttons */}
+
+
+ {title}
+
+
+ {/* Top side button, show only if present */}
+ {TopSideButtons && (
+
+ {TopSideButtons}
+
+ )}
+
+
+ {/* Divider (เส้นแบ่ง) */}
+
+
+ {/* Card Body */}
+
+ {children}
+
+
+ );
+}
+
+export default TitleCard;
diff --git a/web/src/config/sidebarRoutes.jsx b/web/src/config/sidebarRoutes.jsx
new file mode 100644
index 0000000..a70ff52
--- /dev/null
+++ b/web/src/config/sidebarRoutes.jsx
@@ -0,0 +1,108 @@
+import { FaTachometerAlt, FaCog, FaDatabase, FaCogs, FaProjectDiagram, FaFlask,
+ FaClipboardList, FaHeartbeat } from 'react-icons/fa';
+
+
+const routes = [
+ {
+ path: '/dashboard',
+ icon: ,
+ name: 'แดชบอร์ด/ภาพรวม',
+ // ไม่ต้องระบุ requiredRole (viewer/admin เข้าถึงได้เสมอ)
+ },
+
+ // ----------------================--
+ // กลุ่ม: Data & MLOps
+ // ----------------------------------
+ {
+ path: '',
+ icon: ,
+ name: 'ข้อมูล & AI/MLOps', // ปรับชื่อเล็กน้อย
+ // Roles: ใครก็ตามที่จัดการข้อมูล/โมเดล ควรเข้าถึงได้
+ requiredRole: ['viewer', 'admin', 'manager'],
+ submenu: [
+ // เพิ่ม Endpoint สำหรับเรียกใช้ AI Inference Proxy
+ {
+ path:
+ '/dashboard/inference-run',
+ name: 'AI Inference (Run)', // ชื่อสำหรับเรียกใช้ AI จริง
+ requiredRole: ['viewer', 'admin', 'manager'], // ผู้ใช้ทั่วไปก็ควรเรียกได้
+ },
+ {
+ path:
+ '/dashboard/model-registry',
+ name: 'Model Registry & Control', // รวม CRUD และ Control ไว้ในหน้าเดียว
+ // Roles: จำกัดเฉพาะผู้ที่มีสิทธิ์จัดการโมเดล (Admin/Manager)
+ requiredRole: ['manager', 'admin'],
+ },
+ {
+ path:
+ '/dashboard/datasets',
+ name: 'Dataset Catalog (CKAN)',
+ requiredRole: ['viewer', 'admin', 'manager'],
+ },
+ {
+ path:
+ '/dashboard/lineage',
+ name: 'Dataset Lineage',
+ requiredRole: ['viewer', 'admin', 'manager'],
+ },
+ // Note: ลบ /dashboard/predict ออก เนื่องจาก /inference-run ควรจะครอบคลุม
+ ],
+ },
+
+ // ----------------------------------
+ // กลุ่ม: Pipelines & Logs
+ // ----------------------------------
+ {
+ path: '',
+ icon: ,
+ name: 'Pipelines & Log',
+ requiredRole: ['viewer', 'admin', 'manager'],
+ submenu: [
+ {
+ path:
+ '/dashboard/pipeline/trigger',
+ name: 'Pipeline Control',
+ // Roles: จำกัดเฉพาะผู้ที่สั่งรันได้ (Admin/Manager)
+ requiredRole: ['manager', 'admin'],
+ },
+ {
+ path:
+ '/dashboard/pipeline/logs',
+ name: 'Pipeline Logs',
+ requiredRole: ['viewer', 'admin', 'manager'],
+ },
+ {
+ path:
+ '/dashboard/pipeline/status',
+ name: 'Pipeline Run Status',
+ requiredRole: ['viewer', 'admin', 'manager'],
+ },
+ ],
+ },
+
+ // ----------------------------------
+ // กลุ่ม: การดูแลระบบ
+ // ----------------------------------
+ {
+ path: '/dashboard/health',
+ icon: ,
+ name: 'สถานะระบบ (Health)',
+ requiredRole: ['viewer', 'admin', 'manager'],
+ },
+ {
+ path: '/dashboard/settings',
+ icon: ,
+ name: 'ตั้งค่าระบบ',
+ requiredRole: ['admin'], // สำหรับ Superuser/Admin เท่านั้น
+ },
+
+];
+
+
+export default routes;
\ No newline at end of file
diff --git a/web/src/features/auth/authSlice.js b/web/src/features/auth/authSlice.js
new file mode 100644
index 0000000..b49d7a8
--- /dev/null
+++ b/web/src/features/auth/authSlice.js
@@ -0,0 +1,81 @@
+import { createSlice } from '@reduxjs/toolkit';
+
+// ----------------------------------------------------
+// Helper Function: กำหนดบทบาท (Role) จาก Django User Object
+// ----------------------------------------------------
+const determineRole = (user) => {
+ if (!user || !user.id) {
+ return 'guest';
+ }
+
+ // 1. ใช้ฟิลด์ 'role' ที่ส่งมาจาก Backend โดยตรง (ถ้ามี)
+ if (user.role) {
+ return user.role.toLowerCase(); // เช่น 'ADMIN' -> 'admin'
+ }
+
+ // 2. ใช้ฟิลด์ is_superuser/is_staff เป็น Fallback
+ if (user.is_superuser) {
+ return 'admin';
+ }
+ if (user.is_staff) {
+ return 'manager';
+ }
+ // ผู้ใช้ทั่วไป
+ return 'viewer';
+};
+
+// ----------------------------------------------------
+// ฟังก์ชันช่วยในการอ่านค่าจาก Local Storage เพื่อกำหนดสถานะเริ่มต้น
+// ----------------------------------------------------
+const getInitialAuthState = () => {
+ const token = localStorage.getItem('token');
+ const user = JSON.parse(localStorage.getItem('user'));
+
+ // ดึง Role
+ const role = determineRole(user);
+
+ return {
+ isAuthenticated: !!token,
+ user: user || null,
+ role: role,
+ };
+};
+
+const initialState = getInitialAuthState();
+
+const authSlice = createSlice({
+ name: 'auth',
+ initialState,
+ reducers: {
+ // Reducer สำหรับการล็อกอินสำเร็จ
+ loginSuccess: (state, action) => {
+ state.isAuthenticated = true;
+ state.user = action.payload.user;
+ state.role = action.payload.role;
+ },
+
+ // Reducer สำหรับการออกจากระบบ
+ logout: (state) => {
+ state.isAuthenticated = false;
+ state.user = null;
+ state.role = 'guest';
+ // ลบข้อมูลจาก localStorage
+ localStorage.removeItem('token');
+ localStorage.removeItem('user');
+ },
+
+ // Reducer สำหรับอัปเดตข้อมูลผู้ใช้ (เช่น หลังอัปเดตโปรไฟล์)
+ updateUser: (state, action) => {
+ state.user = action.payload.user;
+ // คำนวณ Role ใหม่จากข้อมูลที่อัปเดต
+ const newRole = determineRole(action.payload.user);
+ state.role = newRole;
+ localStorage.setItem('user', JSON.stringify(action.payload.user));
+ // ไม่ต้องยุ่งกับ token/role storage ในนี้ถ้ามี loginSuccess/logout จัดการแล้ว
+ }
+ },
+});
+
+export const { loginSuccess, logout, updateUser } = authSlice.actions;
+
+export default authSlice.reducer;
\ No newline at end of file
diff --git a/web/src/layouts/AuthLayout.jsx b/web/src/layouts/AuthLayout.jsx
new file mode 100644
index 0000000..8d7faac
--- /dev/null
+++ b/web/src/layouts/AuthLayout.jsx
@@ -0,0 +1,16 @@
+import {Outlet} from "react-router-dom";
+
+export default function AuthLayout(){
+ return(
+ <>
+ {/* 1. Div หลัก: จัดให้อยู่ตรงกลางจอ (ใช้ flex h-screen) */}
+
+
+ {/* 2. Div ภาชนะสำหรับเนื้อหา: กำหนดขนาดสูงสุด */}
+
+
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/web/src/layouts/MainLayout.jsx b/web/src/layouts/MainLayout.jsx
new file mode 100644
index 0000000..b80a1fa
--- /dev/null
+++ b/web/src/layouts/MainLayout.jsx
@@ -0,0 +1,176 @@
+import React from 'react';
+import { Link, Outlet, useNavigate, NavLink, useLocation } from 'react-router-dom';
+import { useSelector, useDispatch } from 'react-redux';
+import { logout } from '../features/auth/authSlice';
+import { FaSignOutAlt, FaUserCircle } from 'react-icons/fa';
+import Bars3Icon from '@heroicons/react/24/outline/Bars3Icon';
+import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
+
+import routes from '../config/sidebarRoutes';
+import SidebarSubmenu from '../components/SidebarSubmenu';
+
+
+export default function MainLayout() {
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+ const location = useLocation(); // Hook สำหรับตรวจสอบ Path ปัจจุบัน
+
+ // ดึงข้อมูลผู้ใช้และ Role จาก Redux Store
+ const user = useSelector(state => state.auth.user);
+ const role = useSelector(state => state.auth.role);
+
+ const handleLogout = () => {
+ dispatch(logout());
+ navigate('/login', { replace: true });
+ };
+
+ // Logic: ฟังก์ชันตรวจสอบสิทธิ์ (RBAC)
+ const canAccess = (requiredRole) => {
+ if (role === 'admin') return true;
+ if (!requiredRole) return true;
+
+ // ตรวจสอบ Role ของผู้ใช้กับ Role ที่จำเป็น
+ const allowedRoles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
+ return allowedRoles.includes(role);
+ };
+
+ // ฟังก์ชันปิด Sidebar ใน Mobile
+ const closeMobileSidebar = () => {
+ document.getElementById('my-drawer-2').checked = false;
+ }
+
+ // ฟังก์ชันสำหรับดึงชื่อเมนูตาม Path ปัจจุบัน (สำหรับ Header)
+ const getPageTitle = (pathname) => {
+ let title = "DDO Console";
+ routes.forEach((route) => {
+ if (route.path && route.path === pathname) {
+ title = route.name;
+ } else if (route.submenu) {
+ route.submenu.forEach((submenuRoute) => {
+ if (submenuRoute.path === pathname) {
+ title = submenuRoute.name;
+ }
+ });
+ }
+ });
+ return title;
+ };
+
+ const currentTitle = getPageTitle(location.pathname);
+
+
+ return (
+
+ {/* 1. Drawer Toggle */}
+
+
+ {/* 2. Main Content Area */}
+
+ {/* Header/Navbar */}
+
+
+ {/* Mobile Toggle Button (ใช้ Heroicon) */}
+
+
+ {/* แสดงชื่อหน้าปัจจุบัน */}
+
+ {currentTitle}
+
+
+ {/* User Profile/Logout Dropdown */}
+
+
+
+
+ {user ? user.username : 'ผู้ใช้งาน'}
+ {role}
+
+
+ -
+
+ โปรไฟล์
+
+
+
+ -
+
+
+
+
+
+
+
+ {/* Page Content (Outlet) */}
+
+
+
+
+ {/* Footer (Optional) */}
+
+
+
+ {/* 3. Sidebar (ปรับโครงสร้างใหม่) */}
+
+
+
+
+ {/* ปุ่มปิด Sidebar สำหรับ Mobile */}
+
+
+ {/* ส่วนโลโก้ DDO Console */}
+ -
+
+
+ DDO Console
+
+
+
+ {/* การวนลูปเมนูจาก sidebarRoutes.jsx */}
+ - เมนูหลัก
+ {routes.map((route, k) => {
+ // 1. ตรวจสอบสิทธิ์เมนูหลัก
+ if (!canAccess(route.requiredRole)) return null;
+
+ return (
+ -
+ {route.submenu ?
+ // 2. ถ้ามี Submenu ใช้ SidebarSubmenu Component
+ :
+ (
+ // 3. ถ้าไม่มี Submenu ใช้ NavLink ธรรมดา
+
+ `text-base ${isActive ? 'bg-base-200 text-primary font-semibold' : 'hover:bg-base-400'}`
+ }
+ >
+ {route.icon} {route.name}
+
+ )
+ }
+
+ );
+ })}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/main.jsx b/web/src/main.jsx
index ba17548..9ab579f 100644
--- a/web/src/main.jsx
+++ b/web/src/main.jsx
@@ -4,8 +4,19 @@ import './index.css'
import App from './App.jsx'
import './styles.css'
+import { Provider } from 'react-redux';
+import { store } from './app/store';
+// TanStack Query
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+const queryClient = new QueryClient();
+
createRoot(document.getElementById('root')).render(
-
+
+
+
+
+
,
)
diff --git a/web/src/pages/BlankPage.jsx b/web/src/pages/BlankPage.jsx
new file mode 100644
index 0000000..90a2a75
--- /dev/null
+++ b/web/src/pages/BlankPage.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import TitleCard from '../components/TitleCard';
+import { useLocation } from 'react-router-dom';
+
+function BlankPage({ title, message }) {
+ const location = useLocation();
+
+ // ตั้งค่า Default Message ตามประเภทของหน้า
+ const defaultTitle = title || "Feature Under Development";
+ const defaultMessage = message ||
+ `เส้นทางปัจจุบัน: ${location.pathname} ยังไม่มีเนื้อหาที่พร้อมใช้งาน กรุณากลับไปที่หน้าหลักหรือเมนูอื่น`;
+
+ return (
+ // ใช้ TitleCard ห่อหุ้มเนื้อหาเพื่อให้มีโครงสร้างเหมือน Page อื่นๆ
+
+
+
+ {/* Icon แจ้งเตือน */}
+
+
+
+ {defaultTitle}
+
+
+
+ {defaultMessage}
+
+
+
+
+
+ );
+}
+
+export default BlankPage;
diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx
new file mode 100644
index 0000000..b67052c
--- /dev/null
+++ b/web/src/pages/Dashboard.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import TitleCard from '../components/TitleCard';
+
+function Dashboard() {
+ const TopButtons = (
+
+ );
+
+ return (
+
+ {/* ใช้งาน TitleCard พร้อมปุ่มด้านบน */}
+
+
+ แสดงสถิติสำคัญและ Model ทั้งหมด
+
+ {/* เพิ่มส่วนของการแสดง Metric สำคัญ เช่น Stats Component */}
+
+
+ );
+}
+
+export default Dashboard;
diff --git a/web/src/routes/AuthRoutes.jsx b/web/src/routes/AuthRoutes.jsx
new file mode 100644
index 0000000..0714003
--- /dev/null
+++ b/web/src/routes/AuthRoutes.jsx
@@ -0,0 +1,23 @@
+import { Route, Routes, Navigate } from "react-router-dom";
+import AuthLayout from "../layouts/AuthLayout.jsx";
+import LoginForm from "../components/LoginForm.jsx";
+
+
+export default function AuthRoutes() {
+ return(
+
+ {/* 1. Root Path ('/') นำทางไปยัง /login ทันที */}
+ } />
+
+ {/* 2. AuthLayout สำหรับหน้า Login, Register, Forgot Password */}
+ }>
+ }/>
+ หน้าลงทะเบียน}/>
+ หน้าลืมรหัสผ่าน}/>
+
+
+ {/* 3. Fallback สำหรับเส้นทางที่ไม่รู้จักในส่วน Public */}
+ 404 Not Found}/>
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/routes/PrivateRoutes.jsx b/web/src/routes/PrivateRoutes.jsx
new file mode 100644
index 0000000..60c1dfc
--- /dev/null
+++ b/web/src/routes/PrivateRoutes.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import { Routes, Route } from "react-router-dom";
+import ProtectedRoute from "./ProtectedRoute.jsx";
+import MainLayout from "../layouts/MainLayout.jsx";
+
+// Import เส้นทางย่อยทั้งหมดที่กำหนดไว้ภายนอก
+import pageRoutes from './pageRoutes.jsx';
+
+
+export default function PrivateRoutes() {
+ return (
+ // 1. Routes หลักที่ครอบคลุมทั้งหมดภายใต้ /dashboard/*
+
+ {/* 2. Layer ความปลอดภัย: MainLayout ถูกครอบด้วย ProtectedRoute */}
+ } />}>
+
+ {/* 3. Layer เส้นทางย่อย: วนซ้ำ pageRoutes เพื่อสร้าง s */}
+ {pageRoutes.map((route, index) => (
+ // สร้าง Route สำหรับแต่ละรายการใน pageRoutes
+
+ ))}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/routes/ProtectedRoute.jsx b/web/src/routes/ProtectedRoute.jsx
new file mode 100644
index 0000000..b2debaf
--- /dev/null
+++ b/web/src/routes/ProtectedRoute.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { Navigate } from 'react-router-dom';
+
+export default function ProtectedRoute({ element: Element }) {
+ // ดึงสถานะการล็อกอินจาก Redux Store
+ const isAuthenticated = useSelector(state => state.auth.isAuthenticated);
+
+ if (!isAuthenticated) {
+ // ถ้ายังไม่ได้ล็อกอิน ให้นำทางกลับไปที่หน้า /login
+ // replace: true ป้องกันการย้อนกลับไปหน้า Dashboard ใน History
+ return ;
+ }
+
+ // ถ้าล็อกอินแล้ว อนุญาตให้แสดง Element (Layout/Page)
+ return Element;
+}
diff --git a/web/src/routes/pageRoutes.jsx b/web/src/routes/pageRoutes.jsx
new file mode 100644
index 0000000..3febe42
--- /dev/null
+++ b/web/src/routes/pageRoutes.jsx
@@ -0,0 +1,18 @@
+import Dashboard from '../pages/Dashboard';
+import BlankPage from '../pages/BlankPage';
+
+// Array ของเส้นทางย่อยภายใต้ /dashboard/
+const pageRoutes = [
+ // --- Dashboard ---
+ {
+ path: '', // ตรงกับ /dashboard
+ element: ,
+ },
+ // Fallback สำหรับเส้นทางที่ไม่ตรงกับเมนูใดๆ
+ {
+ path: '*',
+ element: ,
+ }
+];
+
+export default pageRoutes;
diff --git a/web/src/schemas/authSchema.js b/web/src/schemas/authSchema.js
new file mode 100644
index 0000000..d663bc8
--- /dev/null
+++ b/web/src/schemas/authSchema.js
@@ -0,0 +1,9 @@
+import * as yup from 'yup';
+
+// ----------------------------------------------------------------------
+// Schema สำหรับตรวจสอบข้อมูล Login (ใช้ Yup)
+// ----------------------------------------------------------------------
+export const loginSchema = yup.object().shape({
+ username: yup.string().required('กรุณากรอกชื่อผู้ใช้งาน'),
+ password: yup.string().required('กรุณากรอกรหัสผ่าน'),
+});
\ No newline at end of file
diff --git a/web/src/services/authApi.js b/web/src/services/authApi.js
new file mode 100644
index 0000000..ef97530
--- /dev/null
+++ b/web/src/services/authApi.js
@@ -0,0 +1,139 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import { loginSuccess } from '../features/auth/authSlice';
+import { useNavigate } from 'react-router-dom';
+import axios from 'axios'; // ใช้ Axios ธรรมดาสำหรับการเรียกที่ยังไม่มี Token
+
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
+
+// ----------------------------------------------------
+// Helper Functions
+// ----------------------------------------------------
+
+/**
+ * แปลง Object ให้เป็นฟอร์มข้อมูล (x-www-form-urlencoded)
+ */
+const toFormUrlEncoded = (data) => {
+ return Object.keys(data)
+ .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(data[key]))
+ .join('&');
+};
+
+/**
+ * กำหนดบทบาท (Role) จาก Django User Object ที่สมบูรณ์
+ * @param {object} user - User Object จาก /users/me/
+ */
+const determineRole = (user) => {
+ if (!user || !user.id) {
+ return 'guest';
+ }
+
+ // 1. ใช้ฟิลด์ 'role' ที่ส่งมาจาก Backend โดยตรง (ถ้ามี)
+ if (user.role) {
+ return user.role.toLowerCase(); // เช่น 'ADMIN' -> 'admin'
+ }
+
+ // 2. ใช้ฟิลด์ is_superuser/is_staff เป็น Fallback/เกณฑ์มาตรฐาน
+ if (user.is_superuser) {
+ return 'admin';
+ }
+ if (user.is_staff) {
+ // ถ้าเป็น is_staff แต่ไม่ใช่ superuser
+ return 'manager';
+ }
+
+ // ผู้ใช้ทั่วไป
+ return 'viewer';
+};
+
+
+/**
+ * ฟังก์ชันหลักในการล็อกอิน: ขั้นตอนที่ 1 (รับ Token) และ ขั้นตอนที่ 2 (รับ User Object)
+ */
+const loginUser = async (credentials) => {
+ // credentials จะเป็น { username, password }
+ const formData = toFormUrlEncoded(credentials);
+
+ let access, refresh;
+
+ // ---------------------------------------------
+ // ขั้นตอนที่ 1: รับ Access/Refresh Token (POST /jwt/create/)
+ // ---------------------------------------------
+ try {
+ const tokenResponse = await axios.post(`${API_BASE_URL}/api/v1/auth/jwt/create/`, formData, {
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
+ });
+
+ access = tokenResponse.data.access;
+ refresh = tokenResponse.data.refresh;
+
+ } catch (error) {
+ // จัดการ Error จากการล็อกอิน
+ const errorMessage = error.response?.data?.detail || "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง";
+ throw new Error(errorMessage);
+ }
+
+ // ---------------------------------------------
+ // ขั้นตอนที่ 2: ใช้ Access Token เพื่อดึง User Object (GET /users/me/)
+ // ---------------------------------------------
+ let user;
+ try {
+ const userResponse = await axios.get(`${API_BASE_URL}/api/v1/auth/users/me/`, {
+ headers: {
+ // ใช้ JWT เพื่อยืนยันตัวตน
+ 'Authorization': `Bearer ${access}`,
+ },
+ });
+
+ user = userResponse.data;
+
+ } catch (error) {
+ console.error("Failed to fetch user data after token creation:", error);
+ throw new Error("ล็อกอินสำเร็จ แต่ไม่สามารถดึงข้อมูลผู้ใช้ได้ (กรุณาติดต่อผู้ดูแลระบบ)");
+ }
+
+ // ---------------------------------------------
+ // ขั้นตอนที่ 3: คำนวณ Role และส่งกลับข้อมูล
+ // ---------------------------------------------
+ const userRole = determineRole(user);
+
+ return {
+ access_token: access,
+ refresh_token: refresh,
+ user: user,
+ role: userRole,
+ };
+};
+
+
+// ----------------------------------------------------
+// Custom Hook สำหรับจัดการการล็อกอิน
+// ----------------------------------------------------
+export const useLoginMutation = () => {
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: loginUser,
+ onSuccess: (data) => {
+
+ // 1. จัดการ Token และ User Data
+ localStorage.setItem('token', data.access_token);
+ localStorage.setItem('user', JSON.stringify(data.user));
+ localStorage.setItem('role', data.role); // บันทึก Role ที่ถูกต้อง
+
+ // 2. อัปเดต Redux State
+ dispatch(loginSuccess({ user: data.user, role: data.role }));
+
+ // 3. นำทางผู้ใช้
+ navigate('/dashboard', { replace: true });
+ queryClient.invalidateQueries({ queryKey: ['userData'] });
+ },
+ onError: (error) => {
+ // error.message ถูกโยนมาจาก loginUser function
+ console.error('Login API Error:', error.message);
+ throw new Error(error.message);
+ },
+ });
+};
\ No newline at end of file
diff --git a/web/src/services/axiosClient.js b/web/src/services/axiosClient.js
new file mode 100644
index 0000000..bff0a1c
--- /dev/null
+++ b/web/src/services/axiosClient.js
@@ -0,0 +1,58 @@
+import axios from 'axios';
+import { getStore } from '../app/store';
+import { logout } from '../features/auth/authSlice';
+
+// 1. สร้าง Axios Instance โดยใช้ Base URL จาก .env
+
+const axiosClient = axios.create({
+ baseURL: import.meta.env.VITE_API_BASE_URL,
+ // กำหนด Timeout เพื่อป้องกันการค้าง
+ timeout: 15000,
+});
+
+
+// 2. เพิ่ม Interceptor เพื่อแนบ Token ใน Header
+axiosClient.interceptors.request.use(
+ (config) => {
+ // ดึง Token จาก Local Storage
+ const token = localStorage.getItem('token');
+
+ // ถ้ามี Token ให้แนบไปใน Header 'Authorization'
+ if (token) {
+ // รูปแบบจากเป็น 'Bearer'
+ // ตามที่กำหนดใน Django DRF/Djoser settings (JWTAuthentication)
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+
+ return config;
+ },
+ (error) => {
+ return Promise.reject(error);
+ }
+);
+
+
+// 3. (Optional) Interceptor สำหรับ Response (จัดการ Token Expired)
+
+axiosClient.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ const status = error.response ? error.response.status : null;
+
+ // ถ้าได้รับ 401 หรือ 403 (Token หมดอายุ/ไม่มีสิทธิ์)
+ if (status === 401 || status === 403) {
+ // Logic สำหรับการจัดการ Token หมดอายุ (เช่น Redirect ไปหน้า Login)
+ console.error("Authorization Failed: Token expired or insufficient roles.");
+
+ // บังคับ Logout
+ const storeInstance = getStore(); // เรียกใช้ store instance
+ if (storeInstance) {
+ storeInstance.dispatch(logout()); // เรียก Redux Action
+ }
+ }
+ return Promise.reject(error);
+ }
+);
+
+
+export default axiosClient;
\ No newline at end of file