diff --git a/web/package-lock.json b/web/package-lock.json index 4019587..a0e9cec 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -23,6 +23,7 @@ "react-redux": "^9.2.0", "react-router-dom": "^7.9.5", "tailwindcss": "^4.1.16", + "uuid": "^13.0.0", "yup": "^1.7.1" }, "devDependencies": { @@ -3834,6 +3835,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "7.1.12", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", diff --git a/web/package.json b/web/package.json index c222e1c..48da0d4 100644 --- a/web/package.json +++ b/web/package.json @@ -25,6 +25,7 @@ "react-redux": "^9.2.0", "react-router-dom": "^7.9.5", "tailwindcss": "^4.1.16", + "uuid": "^13.0.0", "yup": "^1.7.1" }, "devDependencies": { diff --git a/web/src/App.jsx b/web/src/App.jsx index eda475f..a355717 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -2,12 +2,13 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import AuthRoutes from "./routes/AuthRoutes.jsx"; import PrivateRoutes from "./routes/PrivateRoutes.jsx"; - +import ToastNotification from './components/ToastNotification.jsx'; function App() { return ( // ต้องห่อหุ้มด้วย Provider ของ Redux และ QueryClient ใน main.jsx + {/* Private Routes: ตรวจสอบล็อกอินก่อนเข้า /dashboard/* */} }/> diff --git a/web/src/app/store.js b/web/src/app/store.js index d7f53cf..244f389 100644 --- a/web/src/app/store.js +++ b/web/src/app/store.js @@ -1,10 +1,12 @@ import { configureStore } from '@reduxjs/toolkit'; import authReducer from '../features/auth/authSlice'; +import toastReducer from '../features/toast/toastSlice'; export const store = configureStore({ reducer: { // กำหนด Reducer หลัก auth: authReducer, + toast: toastReducer, // [เพิ่ม Reducer อื่น ๆ ที่นี่] }, // ปิด serializableCheck ใน Production เพื่อประสิทธิภาพ diff --git a/web/src/components/ModelRegistry/ModelTable.jsx b/web/src/components/ModelRegistry/ModelTable.jsx index 86d29db..6073a4c 100644 --- a/web/src/components/ModelRegistry/ModelTable.jsx +++ b/web/src/components/ModelRegistry/ModelTable.jsx @@ -1,7 +1,8 @@ import React from 'react'; import { useTestConnection } from '../../services/modelRegistryApi'; // Hook สำหรับ Test Connection import { FaCheckCircle, FaExclamationTriangle, FaTrash, FaEdit } from 'react-icons/fa'; - +import { useDispatch } from 'react-redux'; +import { addToast } from '../../features/toast/toastSlice'; // ---------------------------------------------------- // Helper Function: แสดง Badge สถานะ @@ -17,7 +18,10 @@ const getStatusBadge = (status) => { }; function ModelTable({ models, handleOpenEdit, handleDelete, deleteLoading }) { - // 1. Hook สำหรับทดสอบการเชื่อมต่อ (Test Connection) + // ประกาศ useDispatch Hook + const dispatch = useDispatch(); + + // Hook สำหรับทดสอบการเชื่อมต่อ (Test Connection) // ใช้ Hook ตรงนี้ เพราะเป็น Logic ที่เกี่ยวข้องกับ Action ในตารางโดยตรง const testConnectionMutation = useTestConnection(); @@ -26,10 +30,18 @@ function ModelTable({ models, handleOpenEdit, handleDelete, deleteLoading }) { testConnectionMutation.mutate(modelId, { onSuccess: (result) => { const status = result.status === 'success' ? 'SUCCESS' : 'FAILED'; - alert(`Test Result: ${status}\nMessage: ${result.message || JSON.stringify(result.response_data)}`); + const message = `Test Result: ${status}. Service responded successfully.`; + dispatch(addToast({ + message: message, + type: status === 'SUCCESS' ? 'success' : 'error' + })); }, onError: (error) => { - alert(`Test Failed! Error: ${error.message || 'Check network or base URL'}`); + const errorMessage = error.message || 'Check network or base URL'; + dispatch(addToast({ + message: `Test Failed: ${errorMessage}`, + type: 'error' + })); }, }); }; diff --git a/web/src/components/ToastNotification.jsx b/web/src/components/ToastNotification.jsx new file mode 100644 index 0000000..4155707 --- /dev/null +++ b/web/src/components/ToastNotification.jsx @@ -0,0 +1,137 @@ +import React, { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { removeToast } from '../features/toast/toastSlice'; + +const TOAST_DURATION = 3000; + +// ---------------------------------------------------- +// Helper Functions +// ---------------------------------------------------- + +const getColors = (type) => { + switch (type) { + case 'success': + return 'alert-success'; + case 'error': + return 'alert-error'; + case 'warning': + return 'alert-warning'; + case 'info': + return 'alert-info'; + default: + return 'alert-info'; + } +}; + +const getIcon = (type) => { + switch (type) { + case 'success': + return ( + + + + ); + case 'error': + return ( + + + + ); + case 'warning': + return ( + + + + ); + case 'info': + return ( + + + + ); + default: + return null; + } +}; + +// ---------------------------------------------------- +// ToastNotification Component +// ---------------------------------------------------- + +export default function ToastNotification() { + const dispatch = useDispatch(); + const queue = useSelector((state) => state.toast.queue); + + useEffect(() => { + if (queue.length > 0) { + queue.forEach((toast) => { + if (!toast.timerId) { + setTimeout(() => { + dispatch(removeToast({ id: toast.id })); + }, TOAST_DURATION); + } + }); + } + }, [queue, dispatch]); + + return ( +
+ {queue.map((toast) => ( +
dispatch(removeToast({ id: toast.id }))} + > +
+ {getIcon(toast.type)} + {toast.message} +
+
+ ))} +
+ ); +} diff --git a/web/src/features/toast/toastSlice.js b/web/src/features/toast/toastSlice.js new file mode 100644 index 0000000..c1148df --- /dev/null +++ b/web/src/features/toast/toastSlice.js @@ -0,0 +1,31 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { v4 as uuidv4 } from 'uuid'; + +const TOAST_DURATION = 3000; // 3 วินาที + +const toastSlice = createSlice({ + name: 'toast', + initialState: { + queue: [], // สถานะเก็บรายการ Toast ทั้งหมดที่ต้องแสดง + }, + reducers: { + // Action สำหรับเพิ่ม Toast เข้าคิว + addToast: (state, action) => { + const newToast = { + id: uuidv4(), + message: action.payload.message, + type: action.payload.type || 'info', + }; + state.queue.push(newToast); + + }, + + // Action สำหรับลบ Toast ออกจากคิว + removeToast: (state, action) => { + state.queue = state.queue.filter(toast => toast.id !== action.payload.id); + } + }, +}); + +export const { addToast, removeToast } = toastSlice.actions; +export default toastSlice.reducer; \ No newline at end of file diff --git a/web/src/services/modelRegistryApi.js b/web/src/services/modelRegistryApi.js index adf4450..5a46462 100644 --- a/web/src/services/modelRegistryApi.js +++ b/web/src/services/modelRegistryApi.js @@ -1,5 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import axiosClient from './axiosClient'; // Axios Client ที่มี JWT Interceptor +import { useDispatch } from 'react-redux'; +import { addToast } from '../features/toast/toastSlice'; const STALE_TIME = 60 * 1000; // 1 นาที @@ -32,6 +34,7 @@ export const useModelList = () => { */ export const useCreateModel = () => { const queryClient = useQueryClient(); + const dispatch = useDispatch(); return useMutation({ mutationFn: async (modelData) => { // modelData: { name, model_version, base_url, inference_path, ... } @@ -39,11 +42,12 @@ export const useCreateModel = () => { return response.data; }, onSuccess: () => { - alert('Model ถูกลงทะเบียนสำเร็จแล้ว!'); + dispatch(addToast({ message: 'Model ถูกลงทะเบียนสำเร็จแล้ว!', type: 'success' })); queryClient.invalidateQueries({ queryKey: ['modelList'] }); }, onError: (error) => { - alert(`การลงทะเบียนล้มเหลว: ${error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล'}`); + const errorMessage = error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล'; + dispatch(addToast({ message: `การลงทะเบียนล้มเหลว: ${errorMessage}`, type: 'error' })); } }); }; @@ -54,17 +58,19 @@ export const useCreateModel = () => { */ export const useDeleteModel = () => { const queryClient = useQueryClient(); + const dispatch = useDispatch(); return useMutation({ mutationFn: async (modelId) => { const response = await axiosClient.delete(`/api/v1/models/${modelId}/`); return response.data; }, onSuccess: () => { - alert('Model ถูกลบสำเร็จแล้ว!'); + dispatch(addToast({ message: 'Model ถูกลบสำเร็จแล้ว!', type: 'success' })); queryClient.invalidateQueries({ queryKey: ['modelList'] }); }, onError: (error) => { - alert(`การลบล้มเหลว: ${error.response?.data?.detail || 'คุณอาจไม่มีสิทธิ์'}`); + const errorMessage = error.response?.data?.detail || 'คุณอาจไม่มีสิทธิ์'; + dispatch(addToast({ message: `การลบล้มเหลว: ${errorMessage}`, type: 'error' })); } }); }; @@ -89,6 +95,7 @@ export const useTestConnection = () => { */ export const useUpdateModel = () => { const queryClient = useQueryClient(); + const dispatch = useDispatch(); return useMutation({ // mutationFn รับ object ที่มี { id: number, data: object } mutationFn: async ({ id, data }) => { @@ -97,12 +104,13 @@ export const useUpdateModel = () => { return response.data; }, onSuccess: () => { - alert('Model ถูกแก้ไขสำเร็จแล้ว!'); + dispatch(addToast({ message: 'Model ถูกแก้ไขสำเร็จแล้ว!', type: 'success' })); // Invalidate query list เพื่อบังคับให้ตารางอัปเดตข้อมูล queryClient.invalidateQueries({ queryKey: ['modelList'] }); }, onError: (error) => { - alert(`การแก้ไขล้มเหลว: ${error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล'}`); + const errorMessage = error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล'; + dispatch(addToast({ message: `การแก้ไขล้มเหลว: ${errorMessage}`, type: 'error' })); } }); }; \ No newline at end of file