พัฒนา Frontend (Web) และ Backend (Django DRF)

This commit is contained in:
Flook 2025-11-09 09:12:57 +07:00
parent e49d6fabbd
commit 8a2a4db69a
59 changed files with 2215 additions and 31 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@ -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

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

9
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.12" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="Python 3.12 (2)" project-jdk-type="Python SDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/monorepo-starter-template.iml" filepath="$PROJECT_DIR$/.idea/monorepo-starter-template.iml" />
</modules>
</component>
</project>

9
.idea/monorepo-starter-template.iml generated Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.12" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -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)

View File

@ -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),
),
]

View File

@ -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

View File

@ -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')

View File

@ -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',
@ -229,3 +234,60 @@ CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
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',
],
# ตั้งค่าอื่น ๆ (ถ้าจำเป็น)
}

View File

@ -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

View File

@ -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)

View File

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ModelRegistryConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'model_registry'

View File

@ -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',
},
),
]

View File

@ -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) ได้ในภายหลัง

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

View File

@ -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)

View File

@ -12,3 +12,5 @@ redis # ไคลเอนต์ Python สำหรับ Redi
celery # ตัว Worker
boto3
python-dotenv
drf-spectacular
drf-spectacular-sidecar

569
web/package-lock.json generated
View File

@ -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"
}
}
}
}

View File

@ -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",

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="34" height="34" viewBox="0 0 34 34" xml:space="preserve">
<desc>Created with Fabric.js 2.4.6</desc>
<defs>
</defs>
<rect x="0" y="0" width="100%" height="100%" fill="#ffffff"></rect>
<g transform="matrix(1 0 0 1 17.18 16.5)" style="" >
<g transform="matrix(1 0 0 1 -0.68 0)" >
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(104,66,255); fill-rule: nonzero; opacity: 1;" x="-18" y="-18" rx="0" ry="0" width="36" height="36" />
</g>
<g transform="matrix(0.36 0 0 0.36 0 0.48)" style="" >
<text xml:space="preserve" font-family="Poppins-SemiBold" font-size="40" font-style="normal" font-weight="normal" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(252,248,248); fill-rule: nonzero; opacity: 1; white-space: pre;" ><tspan x="-44.88" y="12.57" >DDO</tspan></text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
web/public/intro.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

BIN
web/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@ -1,10 +1,21 @@
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 (
<>
<h1 className={"text-lg"}>ทดสอบนำเขาฟอนตในโปรเจกต Daisy UI</h1>
<button className="btn btn-neutral">Neutral</button>
</>
// Provider Redux QueryClient main.jsx
<BrowserRouter>
<Routes>
{/* Private Routes: ตรวจสอบล็อกอินก่อนเข้า /dashboard/* */}
<Route path="/dashboard/*" element={<PrivateRoutes/>}/>
{/* Auth Routes: สำหรับ /login, /register และเป็น Fallback หลัก */}
<Route path="/*" element={<AuthRoutes/>}/>
</Routes>
</BrowserRouter>
)
}

23
web/src/app/store.js Normal file
View File

@ -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;

View File

@ -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 && (
<p className={`${defaultClasses} ${styleClass || ""}`}>
{children}
</p>
)}
</>
);
}
export default ErrorText;

View File

@ -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(
<div className={`form-control w-full ${containerStyle || ''}`}>
<label className="label">
<span className={"label-text text-base-content " + (labelStyle || '')}>{labelTitle}</span>
</label>
<input
type={type || "text"}
ref={ref} // ref RHF register
{...rest} // Props RHF register (value, onChange, name, etc.)
placeholder={placeholder || ""}
className={`input input-bordered w-full ${error ? 'input-error' : ''}`}
/>
{/* แสดงข้อความ Error ที่ส่งมาจาก RHF errors object */}
{error && <p className="text-error text-xs mt-1">{error.message}</p>}
</div>
);
}
);
export default InputText;

View File

@ -0,0 +1,31 @@
import TemplatePointers from "./TemplatePointers.jsx"
function LandingIntro(){
return(
<div className="hero min-h-full rounded-l-xl bg-base-200">
<div className="hero-content py-6">
<div className="max-w-md">
<h1 className='text-3xl text-center font-bold '>
{/* ไฟล์ logo192.png ใน /public */}
<img src="/logo192.png" className="w-12 inline-block mr-2 mask mask-circle" alt="App Logo" />
DDO Console
</h1>
{/* ไฟล์ intro.png ใน /public */}
<div className="text-center mt-12">
<img src="./intro.png" alt="Admin Template" className="w-48 inline-block"></img>
</div>
<TemplatePointers />
</div>
</div>
</div>
)
}
export default LandingIntro

View File

@ -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 (
<div className="card w-full shadow-xl bg-base-100">
<div className="grid md:grid-cols-2 grid-cols-1">
<div className="hidden md:block">
<LandingIntro />
</div>
<div className='py-12 px-10'>
<h2 className='text-2xl font-semibold mb-6 text-center text-base-content'>เขาสระบบ DDO Console</h2>
<form onSubmit={handleSubmit(onSubmit)}>
{/* API Error Alert */}
{apiErrorMessage && (
<div className="alert alert-error text-sm my-4">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{apiErrorMessage}</span>
</div>
)}
<div className="mb-4">
{/* Username Input (เชื่อม RHF) */}
<InputText
type="text"
containerStyle="mt-4"
labelTitle="ชื่อผู้ใช้งาน"
{...register('username')}
error={errors.username}
/>
{/* Password Input (เชื่อม RHF) */}
<InputText
type="password"
containerStyle="mt-4"
labelTitle="รหัสผ่าน"
{...register('password')}
error={errors.password}
/>
</div>
<div className='text-right'>
<Link to="/forgot-password">
<span className="text-sm inline-block hover:text-primary hover:underline hover:cursor-pointer transition duration-200">
มรหสผาน?
</span>
</Link>
</div>
{/* ปุ่ม Submit */}
<button
type="submit"
className={"btn mt-6 w-full btn-primary" + (loading ? " loading" : "")}
disabled={loading}
>
{loading ? 'กำลังเข้าสู่ระบบ...' : 'เข้าสู่ระบบ'}
</button>
<div className='text-center mt-4 text-base-content/80'>
งไมญชใชไหม?
<Link to="/register">
<span className="inline-block text-primary hover:underline hover:cursor-pointer transition duration-200 ml-1">
ลงทะเบยน
</span>
</Link>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@ -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 (
<div className='flex flex-col'>
{/** Route header */}
<a
className='w-full block cursor-pointer flex items-center justify-between text-base'
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center">
{icon}
<span className="ml-3 whitespace-nowrap">{name}</span>
</div>
<ChevronDownIcon
className={'w-5 h-5 mt-1 float-right delay-400 duration-500 transition-all ' +
(isExpanded ? 'rotate-180' : '')}
/>
</a>
{/** Submenu list */}
<div className={` w-full ` + (isExpanded ? "" : "hidden")}>
<ul className={`menu menu-compact`}>
{submenu.map((m, k) => {
// (RBAC)
if (!canAccess(m.requiredRole)) return null;
return (
<li key={k}>
{/* ใช้ NavLink เพื่อแสดงสถานะ Active และใช้ onClick เพื่อปิด Sidebar (สำหรับ Mobile) */}
<NavLink
to={m.path}
onClick={closeMobileSidebar}
className={({ isActive }) =>
`text-base ${isActive ? 'bg-base-200 text-primary font-semibold' : 'hover:bg-base-400'}`
}
>
{m.icon} {m.name}
{/* แถบสี Primary แสดงสถานะ Active */}
{location.pathname === m.path ? (
<span
className="absolute mt-1 mb-1 inset-y-0 left-0 w-1 rounded-tr-md rounded-br-md bg-primary "
aria-hidden="true"
></span>
) : null}
</NavLink>
</li>
);
})}
</ul>
</div>
</div>
);
}
export default SidebarSubmenu;

View File

@ -0,0 +1,12 @@
import React from 'react';
function Subtitle({ styleClass, children }) {
return (
// text-2xl
<div className={`text-xl md:text-2xl font-semibold text-base-content ${styleClass || ''}`}>
{children}
</div>
);
}
export default Subtitle;

View File

@ -0,0 +1,34 @@
function TemplatePointers(){
return(
<>
<h1 className="text-2xl mt-8 font-bold text-base-content">DDO Console Features</h1>
{/* 1. ส่วน A: Dashboard & Monitoring */}
<p className="py-2 mt-4">
<span className="font-semibold text-primary">ความนาเชอถอของขอม (HA Data Layer)</span>: ใช CockroachDB Cluster (3 Node HA) เพอรบประกนความตอเนองของบรการฐานขอม
</p>
{/* 2. ส่วน B: Control & Management (MLOps Flow) */}
<p className="py-2 ">
<span className="font-semibold text-primary">Asynchronous AI Processing</span>: ประมวลผลงานหนกผาน Celery เพอให Frontend ตอบสนองทนท และจดการควงานได
</p>
{/* 3. ส่วน C: Data Governance & Discovery (เน้น Medical/AI) */}
<p className="py-2">
<span className="font-semibold text-primary">AI Model Serving Layer</span>: บรการ Model Inference พรอมจดการไฟลภาพขนาดใหญวย MinIO (S3)
</p>
{/* 4. ส่วน Security (การยืนยันตัวตนและการเข้าถึง) */}
<p className="py-2 ">
<span className="font-semibold text-primary">Security & Gateway</span>: การจดการผใชและสทธ (JWT/RBAC) าน Django DRF งทำหนาทเป Lightweight Gateway หล
</p>
{/* 5. ส่วนสถาปัตยกรรมกระจายศูนย์ */}
<p className="py-2 mb-4">
<span className="font-semibold text-primary">สถาปตยกรรมกระจายศนย (Distributed)</span>: สรางบนเทคโนโลยหล เช MONAI, Django DRF, CockroachDB HA, Redis Cache และ MinIO Object Storage
</p>
</>
)
}
export default TemplatePointers

View File

@ -0,0 +1,33 @@
import React from 'react';
import Subtitle from './Subtitle';
function TitleCard({ title, children, topMargin, TopSideButtons }) {
return (
<div className={`card w-full p-6 bg-base-100 shadow-xl ${topMargin || "mt-6"}`}>
{/* Title และ Top Side Buttons */}
<div className="flex justify-between items-center">
<Subtitle styleClass={TopSideButtons ? "flex-grow" : ""}>
{title}
</Subtitle>
{/* Top side button, show only if present */}
{TopSideButtons && (
<div className="flex-shrink-0 ml-4">
{TopSideButtons}
</div>
)}
</div>
{/* Divider (เส้นแบ่ง) */}
<div className="divider mt-2 mb-4"></div>
{/* Card Body */}
<div className='h-full w-full'>
{children}
</div>
</div>
);
}
export default TitleCard;

View File

@ -0,0 +1,108 @@
import { FaTachometerAlt, FaCog, FaDatabase, FaCogs, FaProjectDiagram, FaFlask,
FaClipboardList, FaHeartbeat } from 'react-icons/fa';
const routes = [
{
path: '/dashboard',
icon: <FaTachometerAlt
className="w-5 h-5 flex-shrink-0" />,
name: 'แดชบอร์ด/ภาพรวม',
// requiredRole (viewer/admin )
},
// ----------------================--
// : Data & MLOps
// ----------------------------------
{
path: '',
icon: <FaDatabase
className="w-5 h-5 flex-shrink-0" />,
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: <FaProjectDiagram
className="w-5 h-5 flex-shrink-0" />,
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: <FaHeartbeat
className="w-5 h-5 flex-shrink-0" />,
name: 'สถานะระบบ (Health)',
requiredRole: ['viewer', 'admin', 'manager'],
},
{
path: '/dashboard/settings',
icon: <FaCog
className="w-5 h-5 flex-shrink-0" />,
name: 'ตั้งค่าระบบ',
requiredRole: ['admin'], // Superuser/Admin
},
];
export default routes;

View File

@ -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;

View File

@ -0,0 +1,16 @@
import {Outlet} from "react-router-dom";
export default function AuthLayout(){
return(
<>
{/* 1. Div หลัก: จัดให้อยู่ตรงกลางจอ (ใช้ flex h-screen) */}
<div className="flex h-screen items-center justify-center bg-base-200 p-4">
{/* 2. Div ภาชนะสำหรับเนื้อหา: กำหนดขนาดสูงสุด */}
<div className="w-full max-w-5xl">
<Outlet/>
</div>
</div>
</>
)
}

View File

@ -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 (
<div className="drawer lg:drawer-open">
{/* 1. Drawer Toggle */}
<input id="my-drawer-2" type="checkbox" className="drawer-toggle" />
{/* 2. Main Content Area */}
<div className="drawer-content flex flex-col bg-base-100">
{/* Header/Navbar */}
<div className="navbar bg-base-100 shadow-md sticky top-0 z-10">
<div className="flex-none lg:hidden">
{/* Mobile Toggle Button (ใช้ Heroicon) */}
<label htmlFor="my-drawer-2" className="btn btn-square btn-ghost">
<Bars3Icon className="h-5 w-5" />
</label>
</div>
{/* แสดงชื่อหน้าปัจจุบัน */}
<div className="flex-1 px-2 mx-2 text-xl font-bold text-primary">
{currentTitle}
</div>
{/* User Profile/Logout Dropdown */}
<div className="flex-none">
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-ghost hover:bg-base-300 rounded-lg">
<FaUserCircle className="h-5 w-5 mr-2" />
{user ? user.username : 'ผู้ใช้งาน'}
<span className="badge badge-sm badge-primary ml-2">{role}</span>
</div>
<ul tabIndex={0} className="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52 z-[1]">
<li>
<Link to="/dashboard/profile" className="hover:bg-base-200">
<FaUserCircle /> โปรไฟล
</Link>
</li>
<div className="divider my-0"></div>
<li>
<button onClick={handleLogout} className="text-error hover:bg-error/10 hover:text-error">
<FaSignOutAlt /> ออกจากระบบ
</button>
</li>
</ul>
</div>
</div>
</div>
{/* Page Content (Outlet) */}
<main className="p-4 md:p-8 flex-grow">
<Outlet />
</main>
{/* Footer (Optional) */}
<footer className="footer footer-center p-4 bg-base-100 text-base-content border-t border-base-content/10">
<aside>
<p>Copyright © {new Date().getFullYear()} - Data Decision Operations Console</p>
</aside>
</footer>
</div>
{/* 3. Sidebar (ปรับโครงสร้างใหม่) */}
<div className="drawer-side z-20 border-r border-base-content/10">
<label htmlFor="my-drawer-2" aria-label="close sidebar" className="drawer-overlay"></label>
<ul className="menu pt-2 w-64 min-h-full bg-base-100 text-base-content">
{/* ปุ่มปิด Sidebar สำหรับ Mobile */}
<button
className="btn btn-ghost btn-circle z-50 top-0 right-0 mt-4 mr-2 absolute lg:hidden"
onClick={closeMobileSidebar}
>
<XMarkIcon className="h-5 inline-block w-5" />
</button>
{/* ส่วนโลโก้ DDO Console */}
<li className="mb-4 font-semibold text-xl">
<Link to={'/dashboard'} className="text-primary">
<img className="mask mask-squircle w-10" src="/logo192.png" alt="DDO Console Logo"/>
DDO Console
</Link>
</li>
{/* การวนลูปเมนูจาก sidebarRoutes.jsx */}
<li className="menu-title">เมนหล</li>
{routes.map((route, k) => {
// 1.
if (!canAccess(route.requiredRole)) return null;
return (
<li key={k} >
{route.submenu ?
// 2. Submenu SidebarSubmenu Component
<SidebarSubmenu
{...route}
closeMobileSidebar={closeMobileSidebar} // Component
/> :
(
// 3. Submenu NavLink
<NavLink
end
to={route.path}
onClick={closeMobileSidebar}
className={({ isActive }) =>
`text-base ${isActive ? 'bg-base-200 text-primary font-semibold' : 'hover:bg-base-400'}`
}
>
{route.icon} {route.name}
</NavLink>
)
}
</li>
);
})}
</ul>
</div>
</div>
);
}

View File

@ -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(
<StrictMode>
<App />
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</Provider>
</StrictMode>,
)

View File

@ -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
<TitleCard title={defaultTitle} topMargin="mt-0">
<div className="text-center p-16 bg-base-200 rounded-lg">
{/* Icon แจ้งเตือน */}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-16 h-16 mx-auto text-primary mb-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<h1 className="text-2xl font-bold text-base-content/80 mt-4">
{defaultTitle}
</h1>
<p className="text-gray-600 mt-2 max-w-lg mx-auto whitespace-normal break-words">
{defaultMessage}
</p>
<div className="mt-8">
<a href="/dashboard" className="btn btn-primary">
กลบส Dashboard
</a>
</div>
</div>
</TitleCard>
);
}
export default BlankPage;

View File

@ -0,0 +1,25 @@
import React from 'react';
import TitleCard from '../components/TitleCard';
function Dashboard() {
const TopButtons = (
<button className="btn btn-primary btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5 mr-2"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
สรางรายงาน
</button>
);
return (
<div className="grid grid-cols-1 gap-6">
{/* ใช้งาน TitleCard พร้อมปุ่มด้านบน */}
<TitleCard title="ภาพรวมประสิทธิภาพระบบ" TopSideButtons={TopButtons} topMargin="mt-0">
<p className="text-gray-600">
แสดงสถสำคญและ Model งหมด
</p>
{/* เพิ่มส่วนของการแสดง Metric สำคัญ เช่น Stats Component */}
</TitleCard>
</div>
);
}
export default Dashboard;

View File

@ -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(
<Routes>
{/* 1. Root Path ('/') นำทางไปยัง /login ทันที */}
<Route path="/" element={<Navigate to="/login" replace />} />
{/* 2. AuthLayout สำหรับหน้า Login, Register, Forgot Password */}
<Route element={<AuthLayout/>}>
<Route path="/login" element={<LoginForm/>}/>
<Route path="/register" element={<div>หนาลงทะเบยน</div>}/>
<Route path="/forgot-password" element={<div>หนาลมรหสผาน</div>}/>
</Route>
{/* 3. Fallback สำหรับเส้นทางที่ไม่รู้จักในส่วน Public */}
<Route path="*" element={<div>404 Not Found</div>}/>
</Routes>
);
}

View File

@ -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/*
<Routes>
{/* 2. Layer ความปลอดภัย: MainLayout ถูกครอบด้วย ProtectedRoute */}
<Route element={<ProtectedRoute element={<MainLayout />} />}>
{/* 3. Layer เส้นทางย่อย: วนซ้ำ pageRoutes เพื่อสร้าง <Route>s */}
{pageRoutes.map((route, index) => (
// Route pageRoutes
<Route
key={index}
path={route.path}
element={route.element}
/>
))}
</Route>
</Routes>
);
}

View File

@ -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 <Navigate to="/login" replace />;
}
// Element (Layout/Page)
return Element;
}

View File

@ -0,0 +1,18 @@
import Dashboard from '../pages/Dashboard';
import BlankPage from '../pages/BlankPage';
// Array /dashboard/
const pageRoutes = [
// --- Dashboard ---
{
path: '', // /dashboard
element: <Dashboard />,
},
// Fallback
{
path: '*',
element: <BlankPage title="404 Not Found" />,
}
];
export default pageRoutes;

View File

@ -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('กรุณากรอกรหัสผ่าน'),
});

139
web/src/services/authApi.js Normal file
View File

@ -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);
},
});
};

View File

@ -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;