fix UX and add check Setup folder route
This commit is contained in:
parent
df49bde2c4
commit
67f5dbbb08
|
|
@ -1,174 +0,0 @@
|
||||||
# API Services Documentation
|
|
||||||
|
|
||||||
Tất cả logic gọi API đã được tách riêng vào folder `services`. Mỗi service tương ứng với một nhóm API.
|
|
||||||
|
|
||||||
## Cấu trúc Services
|
|
||||||
|
|
||||||
```
|
|
||||||
src/services/
|
|
||||||
├── index.ts # Export tất cả services
|
|
||||||
├── auth.service.ts # API xác thực
|
|
||||||
├── app-version.service.ts # API quản lý phần mềm
|
|
||||||
├── device-comm.service.ts # API thiết bị
|
|
||||||
├── command.service.ts # API lệnh
|
|
||||||
└── device.service.ts # Helper functions
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cách Sử Dụng
|
|
||||||
|
|
||||||
### 1. Auth Service (Xác thực)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { authService } from '@/services'
|
|
||||||
|
|
||||||
// Đăng nhập
|
|
||||||
const response = await authService.login({
|
|
||||||
username: 'user',
|
|
||||||
password: 'pass'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Đăng xuất
|
|
||||||
await authService.logout()
|
|
||||||
|
|
||||||
// Kiểm tra session
|
|
||||||
const pingResult = await authService.ping(token)
|
|
||||||
|
|
||||||
// Thay đổi mật khẩu
|
|
||||||
await authService.changePassword({
|
|
||||||
currentPassword: 'old',
|
|
||||||
newPassword: 'new'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Tạo tài khoản mới (admin)
|
|
||||||
await authService.createAccount({
|
|
||||||
userName: 'newuser',
|
|
||||||
password: 'pass',
|
|
||||||
name: 'John Doe',
|
|
||||||
roleId: 1,
|
|
||||||
accessBuildings: [1, 2, 3]
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. App Version Service (Quản lý phần mềm)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { appVersionService } from '@/services'
|
|
||||||
|
|
||||||
// Lấy danh sách agent
|
|
||||||
const agents = await appVersionService.getAgentVersion()
|
|
||||||
|
|
||||||
// Lấy danh sách phần mềm
|
|
||||||
const software = await appVersionService.getSoftwareList()
|
|
||||||
|
|
||||||
// Upload file
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', fileInput.files[0])
|
|
||||||
await appVersionService.uploadSoftware(formData, (progressEvent) => {
|
|
||||||
console.log(`Progress: ${progressEvent.loaded}/${progressEvent.total}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Lấy blacklist
|
|
||||||
const blacklist = await appVersionService.getBlacklist()
|
|
||||||
|
|
||||||
// Thêm vào blacklist
|
|
||||||
await appVersionService.addBlacklist({ appId: 1, reason: 'virus' })
|
|
||||||
|
|
||||||
// Xóa khỏi blacklist
|
|
||||||
await appVersionService.deleteBlacklist(1)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Device Comm Service (Thiết bị)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { deviceCommService } from '@/services'
|
|
||||||
|
|
||||||
// Lấy tất cả thiết bị
|
|
||||||
const allDevices = await deviceCommService.getAllDevices()
|
|
||||||
|
|
||||||
// Lấy danh sách phòng
|
|
||||||
const rooms = await deviceCommService.getRoomList()
|
|
||||||
|
|
||||||
// Lấy thiết bị trong phòng
|
|
||||||
const devices = await deviceCommService.getDeviceFromRoom('Room A')
|
|
||||||
|
|
||||||
// Gửi lệnh
|
|
||||||
await deviceCommService.sendCommand('Room A', {
|
|
||||||
command: 'dir'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cập nhật agent
|
|
||||||
await deviceCommService.updateAgent('Room A', { version: '1.0.0' })
|
|
||||||
|
|
||||||
// Cài đặt MSI
|
|
||||||
await deviceCommService.installMsi('Room A', { msiFileId: 1 })
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Command Service (Lệnh)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { commandService } from '@/services'
|
|
||||||
|
|
||||||
// Lấy danh sách lệnh
|
|
||||||
const commands = await commandService.getCommandList()
|
|
||||||
|
|
||||||
// Thêm lệnh
|
|
||||||
await commandService.addCommand({ name: 'cmd1', command: 'echo hello' })
|
|
||||||
|
|
||||||
// Cập nhật lệnh
|
|
||||||
await commandService.updateCommand(1, { name: 'cmd1 updated' })
|
|
||||||
|
|
||||||
// Xóa lệnh
|
|
||||||
await commandService.deleteCommand(1)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Sử dụng với React Query/Hooks
|
|
||||||
|
|
||||||
### Cách cũ (trực tiếp gọi từ component):
|
|
||||||
```tsx
|
|
||||||
const { data } = useQueryData({
|
|
||||||
queryKey: ["software-version"],
|
|
||||||
url: API_ENDPOINTS.APP_VERSION.GET_SOFTWARE,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cách mới (tách biệt logic):
|
|
||||||
|
|
||||||
Có thể tạo custom hooks bao quanh services:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// hooks/useGetSoftware.ts
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { appVersionService } from '@/services'
|
|
||||||
|
|
||||||
export function useGetSoftware() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['software-version'],
|
|
||||||
queryFn: () => appVersionService.getSoftwareList(),
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Sau đó sử dụng trong component:
|
|
||||||
```tsx
|
|
||||||
function AppsComponent() {
|
|
||||||
const { data, isLoading } = useGetSoftware()
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lợi ích của cách sử dụng mới
|
|
||||||
|
|
||||||
1. **Tách biệt logic** - Logic API nằm riêng, dễ bảo trì
|
|
||||||
2. **Tái sử dụng** - Có thể sử dụng service từ bất kỳ nơi
|
|
||||||
3. **Dễ test** - Dễ mock services khi viết unit tests
|
|
||||||
4. **Centralized error handling** - Có thể xử lý lỗi chung
|
|
||||||
5. **Type safety** - TypeScript types cho tất cả API requests/responses
|
|
||||||
|
|
||||||
## Cải tiến trong tương lai
|
|
||||||
|
|
||||||
Có thể thêm:
|
|
||||||
- Global error handling middleware trong axios
|
|
||||||
- Request/response interceptors cho authentication
|
|
||||||
- Retry logic cho failed requests
|
|
||||||
- Request cancellation
|
|
||||||
|
|
@ -1,328 +0,0 @@
|
||||||
# Khác biệt giữa Services và Query Hooks
|
|
||||||
|
|
||||||
## Tóm tắt nhanh
|
|
||||||
|
|
||||||
| Aspect | Services | Query Hooks |
|
|
||||||
|--------|----------|-------------|
|
|
||||||
| **Location** | `src/services/` | `src/hooks/queries/` |
|
|
||||||
| **Mục đích** | Gọi API trực tiếp | Wrapper TanStack Query |
|
|
||||||
| **Caching** | ❌ Không | ✅ Có |
|
|
||||||
| **Background Refetch** | ❌ Không | ✅ Có |
|
|
||||||
| **Auto Invalidation** | ❌ Không | ✅ Có |
|
|
||||||
| **Type** | Async functions | React Hooks |
|
|
||||||
| **Dùng trong** | Non-React code, utilities | React components |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chi tiết Từng Layer
|
|
||||||
|
|
||||||
### 1. Services Layer (`src/services/`)
|
|
||||||
|
|
||||||
**Mục đích:** Đơn thuần gọi API và trả về dữ liệu
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// app-version.service.ts
|
|
||||||
export async function getSoftwareList(): Promise<Version[]> {
|
|
||||||
const response = await axios.get<Version[]>(
|
|
||||||
API_ENDPOINTS.APP_VERSION.GET_SOFTWARE
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Đặc điểm:**
|
|
||||||
- ✅ Pure async functions
|
|
||||||
- ✅ Không phụ thuộc vào React
|
|
||||||
- ✅ Có thể sử dụng ở bất kỳ đâu (utils, servers, non-React code)
|
|
||||||
- ❌ Không có caching
|
|
||||||
- ❌ Phải tự quản lý state loading/error
|
|
||||||
- ❌ Phải tự gọi lại khi dữ liệu thay đổi
|
|
||||||
|
|
||||||
**Khi nào dùng:**
|
|
||||||
```typescript
|
|
||||||
// Dùng trong utility functions
|
|
||||||
export async function initializeApp() {
|
|
||||||
const software = await appVersionService.getSoftwareList();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hoặc trong services khác
|
|
||||||
export async function validateSoftware() {
|
|
||||||
const list = await appVersionService.getSoftwareList();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Query Hooks Layer (`src/hooks/queries/`)
|
|
||||||
|
|
||||||
**Mục đích:** Wrapper TanStack Query bên trên services
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// useAppVersionQueries.ts
|
|
||||||
export function useGetSoftwareList(enabled = true) {
|
|
||||||
return useQuery<Version[]>({
|
|
||||||
queryKey: ["app-version", "software"],
|
|
||||||
queryFn: () => appVersionService.getSoftwareList(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 60 * 1000, // 1 minute
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Đặc điểm:**
|
|
||||||
- ✅ React hooks
|
|
||||||
- ✅ Automatic caching
|
|
||||||
- ✅ Background refetching
|
|
||||||
- ✅ Automatic invalidation sau mutations
|
|
||||||
- ✅ Built-in loading/error states
|
|
||||||
- ✅ Deduplication (gộp requests giống nhau)
|
|
||||||
- ❌ Chỉ dùng được trong React components
|
|
||||||
- ❌ Phức tạp hơn services
|
|
||||||
|
|
||||||
**Khi nào dùng:**
|
|
||||||
```typescript
|
|
||||||
// Dùng trong React components
|
|
||||||
function SoftwareList() {
|
|
||||||
const { data: software, isLoading } = useGetSoftwareList()
|
|
||||||
|
|
||||||
if (isLoading) return <div>Loading...</div>
|
|
||||||
return software?.map(item => <div>{item.name}</div>)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## So sánh cụ thể
|
|
||||||
|
|
||||||
### Ví dụ 1: Lấy danh sách
|
|
||||||
|
|
||||||
**Service - Raw API call:**
|
|
||||||
```typescript
|
|
||||||
// services/app-version.service.ts
|
|
||||||
export async function getSoftwareList(): Promise<Version[]> {
|
|
||||||
const response = await axios.get(API_ENDPOINTS.APP_VERSION.GET_SOFTWARE);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hook - TanStack Query wrapper:**
|
|
||||||
```typescript
|
|
||||||
// hooks/queries/useAppVersionQueries.ts
|
|
||||||
export function useGetSoftwareList(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["app-version", "software"],
|
|
||||||
queryFn: () => appVersionService.getSoftwareList(),
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Sử dụng trong component:**
|
|
||||||
```typescript
|
|
||||||
function Component() {
|
|
||||||
// ❌ KHÔNG nên dùng service trực tiếp
|
|
||||||
const [data, setData] = useState<Version[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
useEffect(() => {
|
|
||||||
appVersionService.getSoftwareList().then(d => {
|
|
||||||
setData(d);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ✅ NÊN dùng hook thay vì
|
|
||||||
const { data, isLoading } = useGetSoftwareList();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Ví dụ 2: Upload file
|
|
||||||
|
|
||||||
**Service:**
|
|
||||||
```typescript
|
|
||||||
// services/app-version.service.ts
|
|
||||||
export async function uploadSoftware(
|
|
||||||
formData: FormData,
|
|
||||||
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
return axios.post(API_ENDPOINTS.APP_VERSION.UPLOAD, formData, {
|
|
||||||
onUploadProgress,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hook:**
|
|
||||||
```typescript
|
|
||||||
// hooks/queries/useAppVersionQueries.ts
|
|
||||||
export function useUploadSoftware() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: { formData: FormData; onUploadProgress?: ... }) =>
|
|
||||||
appVersionService.uploadSoftware(data.formData, data.onUploadProgress),
|
|
||||||
onSuccess: () => {
|
|
||||||
// Tự động invalidate software list
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["app-version", "software"],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Sử dụng:**
|
|
||||||
```typescript
|
|
||||||
function UploadForm() {
|
|
||||||
const uploadMutation = useUploadSoftware();
|
|
||||||
|
|
||||||
const handleUpload = async (file: File) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
await uploadMutation.mutateAsync({
|
|
||||||
formData,
|
|
||||||
onUploadProgress: (e) => console.log(`${e.loaded}/${e.total}`)
|
|
||||||
});
|
|
||||||
|
|
||||||
// ✅ Software list tự động update
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button disabled={uploadMutation.isPending}>
|
|
||||||
{uploadMutation.isPending ? "Uploading..." : "Upload"}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ React Component │
|
|
||||||
│ (SoftwareList, UploadForm, etc.) │
|
|
||||||
└──────────────┬──────────────────────────┘
|
|
||||||
│
|
|
||||||
│ uses
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Query Hooks (src/hooks/queries/) │
|
|
||||||
│ - useGetSoftwareList() │
|
|
||||||
│ - useUploadSoftware() │
|
|
||||||
│ - useDeleteBlacklist() │
|
|
||||||
│ - Features: │
|
|
||||||
│ - Caching │
|
|
||||||
│ - Auto invalidation │
|
|
||||||
│ - Loading states │
|
|
||||||
└──────────────┬──────────────────────────┘
|
|
||||||
│
|
|
||||||
│ wraps
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Service Functions (src/services/) │
|
|
||||||
│ - getSoftwareList() │
|
|
||||||
│ - uploadSoftware() │
|
|
||||||
│ - deleteBlacklist() │
|
|
||||||
│ - Features: │
|
|
||||||
│ - Pure async functions │
|
|
||||||
│ - Direct API calls │
|
|
||||||
└──────────────┬──────────────────────────┘
|
|
||||||
│
|
|
||||||
│ uses
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Axios (HTTP Client) │
|
|
||||||
└──────────────┬──────────────────────────┘
|
|
||||||
│
|
|
||||||
│ requests
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Backend API Server │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nguyên tắc sử dụng
|
|
||||||
|
|
||||||
### ✅ NÊN dùng Services khi:
|
|
||||||
- Gọi API từ non-React code (utilities, event handlers, etc.)
|
|
||||||
- Cần gọi API một lần rồi không cần tracking state
|
|
||||||
- Không cần caching hay background refetch
|
|
||||||
- Viết code không phụ thuộc React
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ OK - utility function
|
|
||||||
export async function syncData() {
|
|
||||||
const software = await appVersionService.getSoftwareList();
|
|
||||||
const commands = await commandService.getCommandList();
|
|
||||||
return { software, commands };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ NÊN dùng Hooks khi:
|
|
||||||
- Lấy/update dữ liệu trong React components
|
|
||||||
- Cần caching và background refetch
|
|
||||||
- Muốn dữ liệu tự động update
|
|
||||||
- Cần tracking loading/error states
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ OK - React component
|
|
||||||
function Dashboard() {
|
|
||||||
const { data: software, isLoading } = useGetSoftwareList();
|
|
||||||
const uploadMutation = useUploadSoftware();
|
|
||||||
|
|
||||||
return <div>{/* ... */}</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ❌ KHÔNG nên dùng Services khi:
|
|
||||||
- Đang trong React component và cần state management
|
|
||||||
- Cần automatic refetching
|
|
||||||
- Cần auto-invalidation sau mutations
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ WRONG
|
|
||||||
function Component() {
|
|
||||||
const [data, setData] = useState([]);
|
|
||||||
useEffect(() => {
|
|
||||||
appVersionService.getSoftwareList().then(setData);
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ RIGHT
|
|
||||||
function Component() {
|
|
||||||
const { data } = useGetSoftwareList();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ❌ KHÔNG nên dùng Hooks khi:
|
|
||||||
- Không phải trong React component
|
|
||||||
- Không có React context
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ WRONG - không thể gọi hooks ở đây
|
|
||||||
export function initApp() {
|
|
||||||
const { data } = useGetSoftwareList(); // ERROR!
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ RIGHT
|
|
||||||
export async function initApp() {
|
|
||||||
const data = await appVersionService.getSoftwareList();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
**Services** = Cơ sở API calls, có thể tái sử dụng ở bất kỳ đâu
|
|
||||||
**Hooks** = Lớp React trên services, tối ưu cho React components
|
|
||||||
|
|
||||||
**Dùng Services** khi bạn cần tính linh hoạt và độc lập với React
|
|
||||||
**Dùng Hooks** khi bạn muốn TanStack Query quản lý state và caching tự động
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
# System Admin Priority Logic - Hướng dẫn
|
|
||||||
|
|
||||||
## Tổng quan
|
|
||||||
|
|
||||||
Đã cập nhật logic để **System Admin** (Priority = 0) trở thành quyền cao nhất trong hệ thống.
|
|
||||||
|
|
||||||
### Quy tắc Priority
|
|
||||||
|
|
||||||
```
|
|
||||||
Priority càng thấp = Quyền càng cao
|
|
||||||
Priority = 0 (System Admin) = Quyền cao nhất
|
|
||||||
```
|
|
||||||
|
|
||||||
## Các thay đổi đã thực hiện
|
|
||||||
|
|
||||||
### 1. Constants mới (`src/config/constants.ts`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const SYSTEM_ADMIN_PRIORITY = 0;
|
|
||||||
|
|
||||||
export const RolePriority = {
|
|
||||||
SYSTEM_ADMIN: 0,
|
|
||||||
} as const;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mục đích**: Định nghĩa giá trị priority của System Admin, tránh hardcode số 0 trong code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Helper Functions (`src/helpers/roleHelpers.ts`)
|
|
||||||
|
|
||||||
#### `isSystemAdminPriority(priority: number): boolean`
|
|
||||||
Kiểm tra xem priority có phải là System Admin không.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { isSystemAdminPriority } from '@/helpers/roleHelpers';
|
|
||||||
|
|
||||||
if (isSystemAdminPriority(userPriority)) {
|
|
||||||
// User là System Admin
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `hasHigherOrEqualPriority(priority1, priority2): boolean`
|
|
||||||
So sánh 2 priority (nhỏ hơn = cao hơn).
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (hasHigherOrEqualPriority(userPriority, requiredPriority)) {
|
|
||||||
// User có đủ quyền
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `getPriorityLabel(priority: number): string`
|
|
||||||
Lấy nhãn mô tả cho priority.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
getPriorityLabel(0) // "System Admin (Highest)"
|
|
||||||
getPriorityLabel(5) // "Priority 5"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. useAuth Hook (`src/hooks/useAuth.tsx`)
|
|
||||||
|
|
||||||
Thêm method mới: `isSystemAdmin()`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const { isSystemAdmin } = useAuth();
|
|
||||||
|
|
||||||
if (isSystemAdmin()) {
|
|
||||||
// User là System Admin (priority = 0)
|
|
||||||
console.log('You have highest permission!');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Interface cập nhật:**
|
|
||||||
```typescript
|
|
||||||
export interface IAuthContext {
|
|
||||||
// ... các field cũ
|
|
||||||
isSystemAdmin: () => boolean; // ← Mới
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Sidebar Logic (`src/components/sidebars/app-sidebar.tsx`)
|
|
||||||
|
|
||||||
Cập nhật logic kiểm tra admin:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// TRƯỚC
|
|
||||||
const isAdmin = acs.includes(PermissionEnum.ALLOW_ALL);
|
|
||||||
|
|
||||||
// SAU
|
|
||||||
const isAdmin = acs.includes(PermissionEnum.ALLOW_ALL) || isSystemAdmin();
|
|
||||||
```
|
|
||||||
|
|
||||||
**Lợi ích:**
|
|
||||||
- System Admin (Priority = 0) thấy tất cả menu items
|
|
||||||
- Không cần phải có permission `ALLOW_ALL` trong database
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cách sử dụng
|
|
||||||
|
|
||||||
### Kiểm tra System Admin trong component
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { isSystemAdmin, role } = useAuth();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{isSystemAdmin() && (
|
|
||||||
<AdminOnlyFeature />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p>Role: {role.roleName}</p>
|
|
||||||
<p>Priority: {role.priority}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kiểm tra priority trong logic nghiệp vụ
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { isSystemAdminPriority, hasHigherOrEqualPriority } from '@/helpers/roleHelpers';
|
|
||||||
|
|
||||||
function canDeleteUser(currentUserPriority: number, targetUserPriority: number): boolean {
|
|
||||||
// System Admin có thể xóa bất kỳ ai
|
|
||||||
if (isSystemAdminPriority(currentUserPriority)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User chỉ có thể xóa user có priority thấp hơn (số lớn hơn)
|
|
||||||
return hasHigherOrEqualPriority(currentUserPriority, targetUserPriority);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hiển thị label priority
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { getPriorityLabel } from '@/helpers/roleHelpers';
|
|
||||||
|
|
||||||
<Badge>{getPriorityLabel(role.priority)}</Badge>
|
|
||||||
// System Admin sẽ hiển thị: "System Admin (Highest)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Luồng kiểm tra quyền
|
|
||||||
|
|
||||||
```
|
|
||||||
User đăng nhập
|
|
||||||
↓
|
|
||||||
Priority được lưu vào localStorage
|
|
||||||
↓
|
|
||||||
useAuth hook load priority
|
|
||||||
↓
|
|
||||||
isSystemAdmin() kiểm tra priority === 0
|
|
||||||
↓
|
|
||||||
Sidebar check: ALLOW_ALL || isSystemAdmin()
|
|
||||||
↓
|
|
||||||
Hiển thị menu items phù hợp
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ví dụ thực tế
|
|
||||||
|
|
||||||
### Ví dụ 1: Ẩn/hiện nút Delete dựa trên priority
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function UserManagement() {
|
|
||||||
const { role, isSystemAdmin } = useAuth();
|
|
||||||
const currentUserPriority = role.priority;
|
|
||||||
|
|
||||||
function canDelete(targetUserPriority: number): boolean {
|
|
||||||
// System Admin xóa được tất cả
|
|
||||||
if (isSystemAdmin()) return true;
|
|
||||||
|
|
||||||
// Priority thấp hơn (số nhỏ hơn) mới xóa được
|
|
||||||
return currentUserPriority < targetUserPriority;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table>
|
|
||||||
{users.map(user => (
|
|
||||||
<TableRow key={user.id}>
|
|
||||||
<TableCell>{user.name}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{canDelete(user.role.priority) && (
|
|
||||||
<DeleteButton userId={user.id} />
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ví dụ 2: Route protection
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
|
||||||
import { redirect } from '@tanstack/react-router';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/admin-panel')({
|
|
||||||
beforeLoad: ({ context }) => {
|
|
||||||
const { isSystemAdmin } = context.auth;
|
|
||||||
|
|
||||||
if (!isSystemAdmin()) {
|
|
||||||
throw redirect({
|
|
||||||
to: '/unauthorized',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
component: AdminPanel,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tóm tắt
|
|
||||||
|
|
||||||
✅ **Priority = 0** là System Admin (quyền cao nhất)
|
|
||||||
✅ **Priority thấp hơn** = Quyền cao hơn
|
|
||||||
✅ Có constants và helpers để tái sử dụng
|
|
||||||
✅ `isSystemAdmin()` method trong useAuth hook
|
|
||||||
✅ Sidebar tự động nhận biết System Admin
|
|
||||||
✅ Không cần hardcode giá trị priority nữa
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files đã thay đổi
|
|
||||||
|
|
||||||
1. ✅ `src/config/constants.ts` - Constants mới
|
|
||||||
2. ✅ `src/helpers/roleHelpers.ts` - Helper functions
|
|
||||||
3. ✅ `src/hooks/useAuth.tsx` - Thêm isSystemAdmin()
|
|
||||||
4. ✅ `src/types/auth.ts` - Cập nhật interface
|
|
||||||
5. ✅ `src/components/sidebars/app-sidebar.tsx` - Logic admin check
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Lưu ý quan trọng:**
|
|
||||||
Backend cũng cần implement logic tương tự để đảm bảo consistency giữa frontend và backend!
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Debug Permissions</title>
|
|
||||||
<script>
|
|
||||||
// Paste this in browser console to debug
|
|
||||||
const acs = localStorage.getItem('acs');
|
|
||||||
const role = localStorage.getItem('role');
|
|
||||||
console.log('Current Role:', role);
|
|
||||||
console.log('Current ACS (raw):', acs);
|
|
||||||
console.log('Current ACS (parsed):', acs ? acs.split(',').map(Number) : []);
|
|
||||||
console.log('VIEW_AGENT permission code:', 171);
|
|
||||||
console.log('Has VIEW_AGENT?', acs ? acs.split(',').map(Number).includes(171) : false);
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Open Browser Console (F12)</h1>
|
|
||||||
<p>Or run this in console:</p>
|
|
||||||
<pre>
|
|
||||||
const acs = localStorage.getItem('acs');
|
|
||||||
console.log('Your permissions:', acs);
|
|
||||||
console.log('As array:', acs ? acs.split(',').map(Number) : []);
|
|
||||||
console.log('Has VIEW_AGENT (171)?', acs ? acs.split(',').includes('171') : false);
|
|
||||||
</pre>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -3,8 +3,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { Monitor, Wifi, WifiOff } from "lucide-react";
|
import { Monitor, Wifi, WifiOff } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FolderStatusPopover } from "../folder-status-popover";
|
import { FolderStatusPopover } from "../folder-status-popover";
|
||||||
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
import type { ClientFolderStatus } from "@/types/folder";
|
||||||
|
|
||||||
export function ComputerCard({
|
export function ComputerCard({
|
||||||
device,
|
device,
|
||||||
position,
|
position,
|
||||||
|
|
@ -30,6 +29,7 @@ export function ComputerCard({
|
||||||
|
|
||||||
const isOffline = device.isOffline;
|
const isOffline = device.isOffline;
|
||||||
const firstNetworkInfo = device.networkInfos?.[0];
|
const firstNetworkInfo = device.networkInfos?.[0];
|
||||||
|
const agentVersion = device.version;
|
||||||
|
|
||||||
const DeviceInfo = () => (
|
const DeviceInfo = () => (
|
||||||
<div className="space-y-3 min-w-[280px]">
|
<div className="space-y-3 min-w-[280px]">
|
||||||
|
|
@ -119,14 +119,14 @@ export function ComputerCard({
|
||||||
{firstNetworkInfo?.ipAddress && (
|
{firstNetworkInfo?.ipAddress && (
|
||||||
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
||||||
{firstNetworkInfo.ipAddress}
|
{firstNetworkInfo.ipAddress}
|
||||||
|
{agentVersion && (
|
||||||
|
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
||||||
|
v{agentVersion}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{isOffline ? (
|
|
||||||
<WifiOff className="h-3 w-3 text-red-600" />
|
|
||||||
) : (
|
|
||||||
<Wifi className="h-3 w-3 text-green-600" />
|
|
||||||
)}
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs font-medium",
|
"text-xs font-medium",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { CheckCircle2, AlertCircle, Loader2, AlertTriangle } from "lucide-react";
|
import { CheckCircle2, AlertCircle, Loader2, AlertTriangle } from "lucide-react";
|
||||||
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
import type { ClientFolderStatus } from "@/types/folder";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
|
@ -15,8 +15,10 @@ export function FolderStatusPopover({
|
||||||
status,
|
status,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: FolderStatusPopoverProps) {
|
}: FolderStatusPopoverProps) {
|
||||||
const hasMissing = status && status.missingFiles.length > 0;
|
const missing = status?.missingFiles ?? [];
|
||||||
const hasExtra = status && status.extraFiles.length > 0;
|
const extra = status?.extraFiles ?? [];
|
||||||
|
const hasMissing = missing.length > 0;
|
||||||
|
const hasExtra = extra.length > 0;
|
||||||
const hasIssues = hasMissing || hasExtra;
|
const hasIssues = hasMissing || hasExtra;
|
||||||
|
|
||||||
// Xác định màu sắc và icon dựa trên trạng thái
|
// Xác định màu sắc và icon dựa trên trạng thái
|
||||||
|
|
@ -80,11 +82,11 @@ export function FolderStatusPopover({
|
||||||
<div className="border-l-4 border-red-500 pl-3">
|
<div className="border-l-4 border-red-500 pl-3">
|
||||||
<h4 className="text-sm font-semibold text-red-600 mb-2 flex items-center gap-2">
|
<h4 className="text-sm font-semibold text-red-600 mb-2 flex items-center gap-2">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
File thiếu ({status.missingFiles.length})
|
File thiếu ({missing.length})
|
||||||
</h4>
|
</h4>
|
||||||
<ScrollArea className="h-32 rounded-md border bg-red-50/30 p-2">
|
<ScrollArea className="h-32 rounded-md border bg-red-50/30 p-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{status.missingFiles.map((file, idx) => (
|
{missing.map((file, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="text-xs bg-white rounded p-2 border border-red-200"
|
className="text-xs bg-white rounded p-2 border border-red-200"
|
||||||
|
|
@ -107,11 +109,11 @@ export function FolderStatusPopover({
|
||||||
<div className="border-l-4 border-orange-500 pl-3">
|
<div className="border-l-4 border-orange-500 pl-3">
|
||||||
<h4 className="text-sm font-semibold text-orange-600 mb-2 flex items-center gap-2">
|
<h4 className="text-sm font-semibold text-orange-600 mb-2 flex items-center gap-2">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
File thừa ({status.extraFiles.length})
|
File thừa ({extra.length})
|
||||||
</h4>
|
</h4>
|
||||||
<ScrollArea className="h-32 rounded-md border bg-orange-50/30 p-2">
|
<ScrollArea className="h-32 rounded-md border bg-orange-50/30 p-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{status.extraFiles.map((file, idx) => (
|
{extra.map((file, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="text-xs bg-white rounded p-2 border border-orange-200"
|
className="text-xs bg-white rounded p-2 border border-orange-200"
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ export function CommandRegistryForm({
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-6">
|
<div className="w-full max-w-[90vw] sm:max-w-[70vw] md:max-w-[50vw] mx-auto space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Monitor, DoorOpen } from "lucide-react";
|
import { Monitor, DoorOpen } from "lucide-react";
|
||||||
import { ComputerCard } from "../cards/computer-card";
|
import { ComputerCard } from "../cards/computer-card";
|
||||||
import { useMachineNumber } from "../../hooks/useMachineNumber";
|
import { useMachineNumber } from "../../hooks/useMachineNumber";
|
||||||
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
import type { ClientFolderStatus } from "@/types/folder";
|
||||||
|
|
||||||
export function DeviceGrid({
|
export function DeviceGrid({
|
||||||
devices,
|
devices,
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,9 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||||
import { FolderStatusPopover } from "../folder-status-popover";
|
import { FolderStatusPopover } from "../folder-status-popover";
|
||||||
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
|
||||||
|
|
||||||
interface DeviceTableProps {
|
interface DeviceTableProps {
|
||||||
devices: any[];
|
devices: any[];
|
||||||
folderStatuses?: Map<string, ClientFolderStatus>;
|
|
||||||
isCheckingFolder?: boolean;
|
isCheckingFolder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,7 +30,6 @@ interface DeviceTableProps {
|
||||||
*/
|
*/
|
||||||
export function DeviceTable({
|
export function DeviceTable({
|
||||||
devices,
|
devices,
|
||||||
folderStatuses,
|
|
||||||
isCheckingFolder,
|
isCheckingFolder,
|
||||||
}: DeviceTableProps) {
|
}: DeviceTableProps) {
|
||||||
const getMachineNumber = useMachineNumber();
|
const getMachineNumber = useMachineNumber();
|
||||||
|
|
@ -151,7 +148,6 @@ export function DeviceTable({
|
||||||
const device = row.original;
|
const device = row.original;
|
||||||
const isOffline = device.isOffline;
|
const isOffline = device.isOffline;
|
||||||
const macAddress = device.networkInfos?.[0]?.macAddress || device.id;
|
const macAddress = device.networkInfos?.[0]?.macAddress || device.id;
|
||||||
const folderStatus = folderStatuses?.get(macAddress);
|
|
||||||
|
|
||||||
if (isOffline) {
|
if (isOffline) {
|
||||||
return <span className="text-muted-foreground text-sm">-</span>;
|
return <span className="text-muted-foreground text-sm">-</span>;
|
||||||
|
|
@ -160,7 +156,6 @@ export function DeviceTable({
|
||||||
return (
|
return (
|
||||||
<FolderStatusPopover
|
<FolderStatusPopover
|
||||||
deviceId={macAddress}
|
deviceId={macAddress}
|
||||||
status={folderStatus}
|
|
||||||
isLoading={isCheckingFolder}
|
isLoading={isCheckingFolder}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export const API_ENDPOINTS = {
|
||||||
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
|
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
|
||||||
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
|
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
|
||||||
REQUEST_GET_CLIENT_FOLDER_STATUS: (roomName: string) =>
|
REQUEST_GET_CLIENT_FOLDER_STATUS: (roomName: string) =>
|
||||||
`${BASE_URL}/DeviceComm/clientfolderstatus/${roomName}`,
|
`${BASE_URL}/DeviceComm/folderstatuses/${roomName}`,
|
||||||
},
|
},
|
||||||
COMMAND: {
|
COMMAND: {
|
||||||
ADD_COMMAND: `${BASE_URL}/Command/add`,
|
ADD_COMMAND: `${BASE_URL}/Command/add`,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import * as deviceCommService from "@/services/device-comm.service";
|
import * as deviceCommService from "@/services/device-comm.service";
|
||||||
import type { DeviceHealthCheck } from "@/types/device";
|
import type { DeviceHealthCheck } from "@/types/device";
|
||||||
|
import type { ClientFolderStatus } from "@/types/folder";
|
||||||
|
|
||||||
const DEVICE_COMM_QUERY_KEYS = {
|
const DEVICE_COMM_QUERY_KEYS = {
|
||||||
all: ["device-comm"] as const,
|
all: ["device-comm"] as const,
|
||||||
|
|
@ -158,7 +159,7 @@ export function useChangeDeviceRoom() {
|
||||||
* Hook để lấy trạng thái folder client
|
* Hook để lấy trạng thái folder client
|
||||||
*/
|
*/
|
||||||
export function useGetClientFolderStatus(roomName?: string, enabled = true) {
|
export function useGetClientFolderStatus(roomName?: string, enabled = true) {
|
||||||
return useQuery({
|
return useQuery<ClientFolderStatus[]>({
|
||||||
queryKey: roomName
|
queryKey: roomName
|
||||||
? DEVICE_COMM_QUERY_KEYS.clientFolderStatus(roomName)
|
? DEVICE_COMM_QUERY_KEYS.clientFolderStatus(roomName)
|
||||||
: ["disabled"],
|
: ["disabled"],
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
|
||||||
|
|
||||||
export interface MissingFiles {
|
|
||||||
fileName: string;
|
|
||||||
folderPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExtraFiles {
|
|
||||||
fileName: string;
|
|
||||||
folderPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClientFolderStatus {
|
|
||||||
id: number;
|
|
||||||
deviceId: string;
|
|
||||||
missingFiles: MissingFiles[];
|
|
||||||
extraFiles: ExtraFiles[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useClientFolderStatus(roomName?: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const [folderStatuses, setFolderStatuses] = useState<
|
|
||||||
Map<string, ClientFolderStatus>
|
|
||||||
>(new Map());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let eventSource: EventSource | null = null;
|
|
||||||
|
|
||||||
const connect = () => {
|
|
||||||
eventSource = new EventSource(
|
|
||||||
API_ENDPOINTS.SSE_EVENTS.GET_CLIENT_FOLDER_STATUS
|
|
||||||
);
|
|
||||||
|
|
||||||
eventSource.addEventListener("clientFolderStatus", (event) => {
|
|
||||||
try {
|
|
||||||
const data: ClientFolderStatus = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (roomName && data.deviceId) {
|
|
||||||
setFolderStatuses((prev) => {
|
|
||||||
const newMap = new Map(prev);
|
|
||||||
newMap.set(data.deviceId, data);
|
|
||||||
return newMap;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also cache in React Query for persistence
|
|
||||||
queryClient.setQueryData(
|
|
||||||
["folderStatus", data.deviceId],
|
|
||||||
data
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error parsing clientFolderStatus event:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const onError = (err: any) => {
|
|
||||||
console.error("SSE connection error:", err);
|
|
||||||
cleanup();
|
|
||||||
reconnectTimeout.current = setTimeout(connect, 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.onerror = onError;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
if (eventSource) eventSource.close();
|
|
||||||
if (reconnectTimeout.current) {
|
|
||||||
clearTimeout(reconnectTimeout.current);
|
|
||||||
reconnectTimeout.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
connect();
|
|
||||||
return cleanup;
|
|
||||||
}, [roomName, queryClient]);
|
|
||||||
|
|
||||||
return folderStatuses;
|
|
||||||
}
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as AuthRouteImport } from './routes/_auth'
|
import { Route as AuthRouteImport } from './routes/_auth'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as AuthUserIndexRouteImport } from './routes/_auth/user/index'
|
||||||
import { Route as AuthRoomsIndexRouteImport } from './routes/_auth/rooms/index'
|
import { Route as AuthRoomsIndexRouteImport } from './routes/_auth/rooms/index'
|
||||||
import { Route as AuthRoleIndexRouteImport } from './routes/_auth/role/index'
|
import { Route as AuthRoleIndexRouteImport } from './routes/_auth/role/index'
|
||||||
import { Route as AuthDeviceIndexRouteImport } from './routes/_auth/device/index'
|
import { Route as AuthDeviceIndexRouteImport } from './routes/_auth/device/index'
|
||||||
|
|
@ -28,6 +29,7 @@ import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_au
|
||||||
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
|
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
|
||||||
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
|
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
|
||||||
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
|
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
|
||||||
|
import { Route as AuthRoomsRoomNameFolderStatusIndexRouteImport } from './routes/_auth/rooms/$roomName/folder-status/index'
|
||||||
import { Route as AuthRoleIdEditIndexRouteImport } from './routes/_auth/role/$id/edit/index'
|
import { Route as AuthRoleIdEditIndexRouteImport } from './routes/_auth/role/$id/edit/index'
|
||||||
|
|
||||||
const AuthRoute = AuthRouteImport.update({
|
const AuthRoute = AuthRouteImport.update({
|
||||||
|
|
@ -39,6 +41,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthUserIndexRoute = AuthUserIndexRouteImport.update({
|
||||||
|
id: '/user/',
|
||||||
|
path: '/user/',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
const AuthRoomsIndexRoute = AuthRoomsIndexRouteImport.update({
|
const AuthRoomsIndexRoute = AuthRoomsIndexRouteImport.update({
|
||||||
id: '/rooms/',
|
id: '/rooms/',
|
||||||
path: '/rooms/',
|
path: '/rooms/',
|
||||||
|
|
@ -127,6 +134,12 @@ const AuthUserChangePasswordUserNameIndexRoute =
|
||||||
path: '/user/change-password/$userName/',
|
path: '/user/change-password/$userName/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthRoomsRoomNameFolderStatusIndexRoute =
|
||||||
|
AuthRoomsRoomNameFolderStatusIndexRouteImport.update({
|
||||||
|
id: '/rooms/$roomName/folder-status/',
|
||||||
|
path: '/rooms/$roomName/folder-status/',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
const AuthRoleIdEditIndexRoute = AuthRoleIdEditIndexRouteImport.update({
|
const AuthRoleIdEditIndexRoute = AuthRoleIdEditIndexRouteImport.update({
|
||||||
id: '/role/$id/edit/',
|
id: '/role/$id/edit/',
|
||||||
path: '/role/$id/edit/',
|
path: '/role/$id/edit/',
|
||||||
|
|
@ -144,6 +157,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/device': typeof AuthDeviceIndexRoute
|
'/device': typeof AuthDeviceIndexRoute
|
||||||
'/role': typeof AuthRoleIndexRoute
|
'/role': typeof AuthRoleIndexRoute
|
||||||
'/rooms': typeof AuthRoomsIndexRoute
|
'/rooms': typeof AuthRoomsIndexRoute
|
||||||
|
'/user': typeof AuthUserIndexRoute
|
||||||
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||||
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||||
|
|
@ -151,6 +165,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/user/create': typeof AuthUserCreateIndexRoute
|
'/user/create': typeof AuthUserCreateIndexRoute
|
||||||
'/user/role': typeof AuthUserRoleIndexRoute
|
'/user/role': typeof AuthUserRoleIndexRoute
|
||||||
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
|
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
|
||||||
|
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
||||||
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
|
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
|
||||||
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
|
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
|
||||||
}
|
}
|
||||||
|
|
@ -165,6 +180,7 @@ export interface FileRoutesByTo {
|
||||||
'/device': typeof AuthDeviceIndexRoute
|
'/device': typeof AuthDeviceIndexRoute
|
||||||
'/role': typeof AuthRoleIndexRoute
|
'/role': typeof AuthRoleIndexRoute
|
||||||
'/rooms': typeof AuthRoomsIndexRoute
|
'/rooms': typeof AuthRoomsIndexRoute
|
||||||
|
'/user': typeof AuthUserIndexRoute
|
||||||
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||||
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||||
|
|
@ -172,6 +188,7 @@ export interface FileRoutesByTo {
|
||||||
'/user/create': typeof AuthUserCreateIndexRoute
|
'/user/create': typeof AuthUserCreateIndexRoute
|
||||||
'/user/role': typeof AuthUserRoleIndexRoute
|
'/user/role': typeof AuthUserRoleIndexRoute
|
||||||
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
|
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
|
||||||
|
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
||||||
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
|
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
|
||||||
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
|
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
|
||||||
}
|
}
|
||||||
|
|
@ -188,6 +205,7 @@ export interface FileRoutesById {
|
||||||
'/_auth/device/': typeof AuthDeviceIndexRoute
|
'/_auth/device/': typeof AuthDeviceIndexRoute
|
||||||
'/_auth/role/': typeof AuthRoleIndexRoute
|
'/_auth/role/': typeof AuthRoleIndexRoute
|
||||||
'/_auth/rooms/': typeof AuthRoomsIndexRoute
|
'/_auth/rooms/': typeof AuthRoomsIndexRoute
|
||||||
|
'/_auth/user/': typeof AuthUserIndexRoute
|
||||||
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
|
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
|
||||||
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
|
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
|
||||||
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
|
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
|
||||||
|
|
@ -195,6 +213,7 @@ export interface FileRoutesById {
|
||||||
'/_auth/user/create/': typeof AuthUserCreateIndexRoute
|
'/_auth/user/create/': typeof AuthUserCreateIndexRoute
|
||||||
'/_auth/user/role/': typeof AuthUserRoleIndexRoute
|
'/_auth/user/role/': typeof AuthUserRoleIndexRoute
|
||||||
'/_auth/role/$id/edit/': typeof AuthRoleIdEditIndexRoute
|
'/_auth/role/$id/edit/': typeof AuthRoleIdEditIndexRoute
|
||||||
|
'/_auth/rooms/$roomName/folder-status/': typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
||||||
'/_auth/user/change-password/$userName/': typeof AuthUserChangePasswordUserNameIndexRoute
|
'/_auth/user/change-password/$userName/': typeof AuthUserChangePasswordUserNameIndexRoute
|
||||||
'/_auth/user/role/$roleId/': typeof AuthUserRoleRoleIdIndexRoute
|
'/_auth/user/role/$roleId/': typeof AuthUserRoleRoleIdIndexRoute
|
||||||
}
|
}
|
||||||
|
|
@ -211,6 +230,7 @@ export interface FileRouteTypes {
|
||||||
| '/device'
|
| '/device'
|
||||||
| '/role'
|
| '/role'
|
||||||
| '/rooms'
|
| '/rooms'
|
||||||
|
| '/user'
|
||||||
| '/profile/$userName'
|
| '/profile/$userName'
|
||||||
| '/profile/change-password'
|
| '/profile/change-password'
|
||||||
| '/role/create'
|
| '/role/create'
|
||||||
|
|
@ -218,6 +238,7 @@ export interface FileRouteTypes {
|
||||||
| '/user/create'
|
| '/user/create'
|
||||||
| '/user/role'
|
| '/user/role'
|
||||||
| '/role/$id/edit'
|
| '/role/$id/edit'
|
||||||
|
| '/rooms/$roomName/folder-status'
|
||||||
| '/user/change-password/$userName'
|
| '/user/change-password/$userName'
|
||||||
| '/user/role/$roleId'
|
| '/user/role/$roleId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
|
@ -232,6 +253,7 @@ export interface FileRouteTypes {
|
||||||
| '/device'
|
| '/device'
|
||||||
| '/role'
|
| '/role'
|
||||||
| '/rooms'
|
| '/rooms'
|
||||||
|
| '/user'
|
||||||
| '/profile/$userName'
|
| '/profile/$userName'
|
||||||
| '/profile/change-password'
|
| '/profile/change-password'
|
||||||
| '/role/create'
|
| '/role/create'
|
||||||
|
|
@ -239,6 +261,7 @@ export interface FileRouteTypes {
|
||||||
| '/user/create'
|
| '/user/create'
|
||||||
| '/user/role'
|
| '/user/role'
|
||||||
| '/role/$id/edit'
|
| '/role/$id/edit'
|
||||||
|
| '/rooms/$roomName/folder-status'
|
||||||
| '/user/change-password/$userName'
|
| '/user/change-password/$userName'
|
||||||
| '/user/role/$roleId'
|
| '/user/role/$roleId'
|
||||||
id:
|
id:
|
||||||
|
|
@ -254,6 +277,7 @@ export interface FileRouteTypes {
|
||||||
| '/_auth/device/'
|
| '/_auth/device/'
|
||||||
| '/_auth/role/'
|
| '/_auth/role/'
|
||||||
| '/_auth/rooms/'
|
| '/_auth/rooms/'
|
||||||
|
| '/_auth/user/'
|
||||||
| '/_auth/profile/$userName/'
|
| '/_auth/profile/$userName/'
|
||||||
| '/_auth/profile/change-password/'
|
| '/_auth/profile/change-password/'
|
||||||
| '/_auth/role/create/'
|
| '/_auth/role/create/'
|
||||||
|
|
@ -261,6 +285,7 @@ export interface FileRouteTypes {
|
||||||
| '/_auth/user/create/'
|
| '/_auth/user/create/'
|
||||||
| '/_auth/user/role/'
|
| '/_auth/user/role/'
|
||||||
| '/_auth/role/$id/edit/'
|
| '/_auth/role/$id/edit/'
|
||||||
|
| '/_auth/rooms/$roomName/folder-status/'
|
||||||
| '/_auth/user/change-password/$userName/'
|
| '/_auth/user/change-password/$userName/'
|
||||||
| '/_auth/user/role/$roleId/'
|
| '/_auth/user/role/$roleId/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
|
|
@ -287,6 +312,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/_auth/user/': {
|
||||||
|
id: '/_auth/user/'
|
||||||
|
path: '/user'
|
||||||
|
fullPath: '/user'
|
||||||
|
preLoaderRoute: typeof AuthUserIndexRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
'/_auth/rooms/': {
|
'/_auth/rooms/': {
|
||||||
id: '/_auth/rooms/'
|
id: '/_auth/rooms/'
|
||||||
path: '/rooms'
|
path: '/rooms'
|
||||||
|
|
@ -406,6 +438,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof AuthUserChangePasswordUserNameIndexRouteImport
|
preLoaderRoute: typeof AuthUserChangePasswordUserNameIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/rooms/$roomName/folder-status/': {
|
||||||
|
id: '/_auth/rooms/$roomName/folder-status/'
|
||||||
|
path: '/rooms/$roomName/folder-status'
|
||||||
|
fullPath: '/rooms/$roomName/folder-status'
|
||||||
|
preLoaderRoute: typeof AuthRoomsRoomNameFolderStatusIndexRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
'/_auth/role/$id/edit/': {
|
'/_auth/role/$id/edit/': {
|
||||||
id: '/_auth/role/$id/edit/'
|
id: '/_auth/role/$id/edit/'
|
||||||
path: '/role/$id/edit'
|
path: '/role/$id/edit'
|
||||||
|
|
@ -425,6 +464,7 @@ interface AuthRouteChildren {
|
||||||
AuthDeviceIndexRoute: typeof AuthDeviceIndexRoute
|
AuthDeviceIndexRoute: typeof AuthDeviceIndexRoute
|
||||||
AuthRoleIndexRoute: typeof AuthRoleIndexRoute
|
AuthRoleIndexRoute: typeof AuthRoleIndexRoute
|
||||||
AuthRoomsIndexRoute: typeof AuthRoomsIndexRoute
|
AuthRoomsIndexRoute: typeof AuthRoomsIndexRoute
|
||||||
|
AuthUserIndexRoute: typeof AuthUserIndexRoute
|
||||||
AuthProfileUserNameIndexRoute: typeof AuthProfileUserNameIndexRoute
|
AuthProfileUserNameIndexRoute: typeof AuthProfileUserNameIndexRoute
|
||||||
AuthProfileChangePasswordIndexRoute: typeof AuthProfileChangePasswordIndexRoute
|
AuthProfileChangePasswordIndexRoute: typeof AuthProfileChangePasswordIndexRoute
|
||||||
AuthRoleCreateIndexRoute: typeof AuthRoleCreateIndexRoute
|
AuthRoleCreateIndexRoute: typeof AuthRoleCreateIndexRoute
|
||||||
|
|
@ -432,6 +472,7 @@ interface AuthRouteChildren {
|
||||||
AuthUserCreateIndexRoute: typeof AuthUserCreateIndexRoute
|
AuthUserCreateIndexRoute: typeof AuthUserCreateIndexRoute
|
||||||
AuthUserRoleIndexRoute: typeof AuthUserRoleIndexRoute
|
AuthUserRoleIndexRoute: typeof AuthUserRoleIndexRoute
|
||||||
AuthRoleIdEditIndexRoute: typeof AuthRoleIdEditIndexRoute
|
AuthRoleIdEditIndexRoute: typeof AuthRoleIdEditIndexRoute
|
||||||
|
AuthRoomsRoomNameFolderStatusIndexRoute: typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
||||||
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
|
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
|
||||||
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
|
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
|
||||||
}
|
}
|
||||||
|
|
@ -445,6 +486,7 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||||
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
|
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
|
||||||
AuthRoleIndexRoute: AuthRoleIndexRoute,
|
AuthRoleIndexRoute: AuthRoleIndexRoute,
|
||||||
AuthRoomsIndexRoute: AuthRoomsIndexRoute,
|
AuthRoomsIndexRoute: AuthRoomsIndexRoute,
|
||||||
|
AuthUserIndexRoute: AuthUserIndexRoute,
|
||||||
AuthProfileUserNameIndexRoute: AuthProfileUserNameIndexRoute,
|
AuthProfileUserNameIndexRoute: AuthProfileUserNameIndexRoute,
|
||||||
AuthProfileChangePasswordIndexRoute: AuthProfileChangePasswordIndexRoute,
|
AuthProfileChangePasswordIndexRoute: AuthProfileChangePasswordIndexRoute,
|
||||||
AuthRoleCreateIndexRoute: AuthRoleCreateIndexRoute,
|
AuthRoleCreateIndexRoute: AuthRoleCreateIndexRoute,
|
||||||
|
|
@ -452,6 +494,8 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||||
AuthUserCreateIndexRoute: AuthUserCreateIndexRoute,
|
AuthUserCreateIndexRoute: AuthUserCreateIndexRoute,
|
||||||
AuthUserRoleIndexRoute: AuthUserRoleIndexRoute,
|
AuthUserRoleIndexRoute: AuthUserRoleIndexRoute,
|
||||||
AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute,
|
AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute,
|
||||||
|
AuthRoomsRoomNameFolderStatusIndexRoute:
|
||||||
|
AuthRoomsRoomNameFolderStatusIndexRoute,
|
||||||
AuthUserChangePasswordUserNameIndexRoute:
|
AuthUserChangePasswordUserNameIndexRoute:
|
||||||
AuthUserChangePasswordUserNameIndexRoute,
|
AuthUserChangePasswordUserNameIndexRoute,
|
||||||
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
|
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Check, X, Edit2, Trash2 } from "lucide-react";
|
import { Check, X, Edit2, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import type { ShellCommandData } from "@/components/forms/command-form";
|
import type { ShellCommandData } from "@/components/forms/command-form";
|
||||||
import type { CommandRegistry } from "@/types/command-registry";
|
import type { CommandRegistry } from "@/types/command-registry";
|
||||||
|
|
@ -22,8 +21,23 @@ export const Route = createFileRoute("/_auth/commands/")({
|
||||||
head: () => ({ meta: [{ title: "Gửi lệnh từ xa" }] }),
|
head: () => ({ meta: [{ title: "Gửi lệnh từ xa" }] }),
|
||||||
component: CommandPage,
|
component: CommandPage,
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
|
// Read active tab from URL search params (client-side) to reflect breadcrumb
|
||||||
|
let activeTab = "list";
|
||||||
|
try {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
activeTab = params.get("tab") || "list";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
activeTab = "list";
|
||||||
|
}
|
||||||
|
|
||||||
context.breadcrumbs = [
|
context.breadcrumbs = [
|
||||||
{ title: "Quản lý lệnh", path: "/_auth/commands/" },
|
{ title: "Quản lý lệnh", path: "/_auth/commands/" },
|
||||||
|
{
|
||||||
|
title: activeTab === "execute" ? "Lệnh thủ công" : "Danh sách",
|
||||||
|
path: `/ _auth/commands/?tab=${activeTab}`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -31,7 +45,6 @@ export const Route = createFileRoute("/_auth/commands/")({
|
||||||
function CommandPage() {
|
function CommandPage() {
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [selectedCommand, setSelectedCommand] = useState<CommandRegistry | null>(null);
|
const [selectedCommand, setSelectedCommand] = useState<CommandRegistry | null>(null);
|
||||||
const [detailPanelCommand, setDetailPanelCommand] = useState<CommandRegistry | null>(null);
|
|
||||||
const [table, setTable] = useState<any>();
|
const [table, setTable] = useState<any>();
|
||||||
|
|
||||||
// Fetch commands
|
// Fetch commands
|
||||||
|
|
@ -283,7 +296,7 @@ function CommandPage() {
|
||||||
<>
|
<>
|
||||||
<CommandSubmitTemplate
|
<CommandSubmitTemplate
|
||||||
title="Gửi lệnh từ xa"
|
title="Gửi lệnh từ xa"
|
||||||
description="Quản lý và thực thi các lệnh trên thiết bị"
|
description="Quản lý và gửi yêu cầu thực thi các lệnh trên thiết bị"
|
||||||
data={commandList}
|
data={commandList}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|
@ -307,100 +320,11 @@ function CommandPage() {
|
||||||
onExecuteCustom={handleExecuteCustom}
|
onExecuteCustom={handleExecuteCustom}
|
||||||
isExecuting={sendCommandMutation.isPending}
|
isExecuting={sendCommandMutation.isPending}
|
||||||
rooms={roomData}
|
rooms={roomData}
|
||||||
onRowClick={(row) => setDetailPanelCommand(row)}
|
|
||||||
scrollable={true}
|
scrollable={true}
|
||||||
maxHeight="500px"
|
maxHeight="500px"
|
||||||
enablePagination
|
enablePagination={false}
|
||||||
defaultPageSize={10}
|
defaultPageSize={10}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Detail Dialog Popup */}
|
|
||||||
<Dialog open={!!detailPanelCommand} onOpenChange={(open) => !open && setDetailPanelCommand(null)}>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[85vh]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Chi tiết lệnh</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{detailPanelCommand && (
|
|
||||||
<div className="space-y-6 max-h-[calc(85vh-120px)] overflow-y-auto pr-2">
|
|
||||||
{/* Command Name */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tên lệnh</h3>
|
|
||||||
<p className="text-base font-medium break-words">{detailPanelCommand.commandName}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Command Type */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Loại lệnh</h3>
|
|
||||||
<p className="text-base">
|
|
||||||
{
|
|
||||||
{
|
|
||||||
1: "RESTART",
|
|
||||||
2: "SHUTDOWN",
|
|
||||||
3: "TASKKILL",
|
|
||||||
4: "BLOCK",
|
|
||||||
}[detailPanelCommand.commandType] || "UNKNOWN"
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Mô tả</h3>
|
|
||||||
<p className="text-sm text-foreground whitespace-pre-wrap break-words">
|
|
||||||
{detailPanelCommand.description || "-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Command Content */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Nội dung lệnh</h3>
|
|
||||||
<div className="bg-muted/50 p-4 rounded-md border">
|
|
||||||
<code className="text-sm whitespace-pre-wrap break-all block font-mono">
|
|
||||||
{detailPanelCommand.commandContent}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* QoS */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">QoS</h3>
|
|
||||||
<p className="text-base">
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
{
|
|
||||||
0: "text-blue-600",
|
|
||||||
1: "text-amber-600",
|
|
||||||
2: "text-red-600",
|
|
||||||
}[(detailPanelCommand.qoS ?? 0) as 0 | 1 | 2]
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{detailPanelCommand.qoS ?? 0}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Retention */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Lưu trữ</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{detailPanelCommand.isRetained ? (
|
|
||||||
<>
|
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
|
||||||
<span className="text-sm text-green-600">Có</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<X className="h-4 w-4 text-gray-400" />
|
|
||||||
<span className="text-sm text-gray-400">Không</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
99
src/routes/_auth/rooms/$roomName/folder-status/index.tsx
Normal file
99
src/routes/_auth/rooms/$roomName/folder-status/index.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import {
|
||||||
|
createFileRoute,
|
||||||
|
useParams,
|
||||||
|
useNavigate,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useGetClientFolderStatus } from "@/hooks/queries";
|
||||||
|
import type { ClientFolderStatus } from "@/types/folder";
|
||||||
|
import FolderStatusTemplate from "@/template/folder-status-template";
|
||||||
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_auth/rooms/$roomName/folder-status/")({
|
||||||
|
head: ({ params }) => ({
|
||||||
|
meta: [{ title: `Trạng thái thư mục Setup phòng ${params.roomName}` }],
|
||||||
|
}),
|
||||||
|
loader: async ({ context, params }) => {
|
||||||
|
context.breadcrumbs = [
|
||||||
|
{ title: "Danh sách phòng", path: "/rooms/" },
|
||||||
|
{ title: `Phòng ${params.roomName}`, path: `/rooms/${params.roomName}/` },
|
||||||
|
{ title: `Trạng thái thư mục Setup phòng ${params.roomName}`, path: `/rooms/${params.roomName}/folder-status/` },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { roomName } = useParams({
|
||||||
|
from: "/_auth/rooms/$roomName/folder-status/",
|
||||||
|
});
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: folderStatusList = [], isLoading } = useGetClientFolderStatus(
|
||||||
|
roomName as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<ClientFolderStatus>();
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.accessor("deviceId", {
|
||||||
|
header: "Máy tính",
|
||||||
|
cell: (info) => info.getValue() ?? "-",
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: "missing",
|
||||||
|
header: "Số lượng file thiếu",
|
||||||
|
cell: (info) =>
|
||||||
|
(info.row.original.missingFiles?.length ?? 0).toString(),
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: "extra",
|
||||||
|
header: "Số lượng file thừa",
|
||||||
|
cell: (info) => (info.row.original.extraFiles?.length ?? 0).toString(),
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: "current",
|
||||||
|
header: "Số lượng file hiện tại",
|
||||||
|
cell: (info) =>
|
||||||
|
(info.row.original.currentFiles?.length ?? 0).toString(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("updatedAt", {
|
||||||
|
header: "Updated",
|
||||||
|
cell: (info) => {
|
||||||
|
const v = info.getValue();
|
||||||
|
try {
|
||||||
|
const d = new Date(v as string);
|
||||||
|
return isNaN(d.getTime())
|
||||||
|
? (v as string)
|
||||||
|
: d.toLocaleString("vi-VN");
|
||||||
|
} catch {
|
||||||
|
return v as string;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: folderStatusList ?? [],
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FolderStatusTemplate
|
||||||
|
roomName={roomName as string}
|
||||||
|
data={folderStatusList}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onBack={() =>
|
||||||
|
navigate({ to: "/rooms/$roomName/", params: { roomName } } as any)
|
||||||
|
}
|
||||||
|
table={table}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,62 +1,41 @@
|
||||||
import { createFileRoute, useParams } from "@tanstack/react-router";
|
import { createFileRoute, useParams, useNavigate } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { LayoutGrid, TableIcon, Monitor, FolderCheck, Loader2 } from "lucide-react";
|
import { LayoutGrid, TableIcon, Monitor, FolderCheck } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useGetDeviceFromRoom } from "@/hooks/queries";
|
import { useGetDeviceFromRoom } from "@/hooks/queries";
|
||||||
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
||||||
import { useClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
|
||||||
import { DeviceGrid } from "@/components/grids/device-grid";
|
import { DeviceGrid } from "@/components/grids/device-grid";
|
||||||
import { DeviceTable } from "@/components/tables/device-table";
|
import { DeviceTable } from "@/components/tables/device-table";
|
||||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { CommandActionButtons } from "@/components/buttons/command-action-buttons";
|
import { CommandActionButtons } from "@/components/buttons/command-action-buttons";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/rooms/$roomName/")({
|
export const Route = createFileRoute("/_auth/rooms/$roomName/")({
|
||||||
head: ({ params }) => ({
|
head: ({ params }) => ({
|
||||||
meta: [{ title: `Danh sách thiết bị phòng ${params.roomName}` }],
|
meta: [{ title: `Danh sách thiết bị phòng ${params.roomName}` }],
|
||||||
}),
|
}),
|
||||||
|
loader: async ({ context, params }) => {
|
||||||
|
context.breadcrumbs = [
|
||||||
|
{ title: "Danh sách phòng", path: "/rooms/" },
|
||||||
|
{ title: `Phòng ${params.roomName}`, path: `/rooms/${params.roomName}/` },
|
||||||
|
];
|
||||||
|
},
|
||||||
component: RoomDetailPage,
|
component: RoomDetailPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function RoomDetailPage() {
|
function RoomDetailPage() {
|
||||||
const { roomName } = useParams({ from: "/_auth/rooms/$roomName/" });
|
const { roomName } = useParams({ from: "/_auth/rooms/$roomName/" });
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
||||||
const [isCheckingFolder, setIsCheckingFolder] = useState(false);
|
|
||||||
|
|
||||||
// SSE real-time updates
|
// SSE real-time updates
|
||||||
useDeviceEvents(roomName);
|
useDeviceEvents(roomName);
|
||||||
|
|
||||||
// Folder status from SSE
|
// Folder status from SS
|
||||||
const folderStatuses = useClientFolderStatus(roomName);
|
|
||||||
|
|
||||||
const { data: devices = [] } = useGetDeviceFromRoom(roomName);
|
const { data: devices = [] } = useGetDeviceFromRoom(roomName);
|
||||||
|
|
||||||
const parseMachineNumber = useMachineNumber();
|
const parseMachineNumber = useMachineNumber();
|
||||||
|
|
||||||
const handleCheckFolderStatus = async () => {
|
const navigate = useNavigate();
|
||||||
try {
|
|
||||||
setIsCheckingFolder(true);
|
|
||||||
// Trigger folder status check via the service
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/device-comm/request-get-client-folder-status?roomName=${encodeURIComponent(roomName)}`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to request folder status");
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Đang kiểm tra thư mục Setup...");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Check folder error:", error);
|
|
||||||
toast.error("Lỗi khi kiểm tra thư mục!");
|
|
||||||
} finally {
|
|
||||||
setIsCheckingFolder(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedDevices = [...devices].sort((a, b) => {
|
const sortedDevices = [...devices].sort((a, b) => {
|
||||||
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
|
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
|
||||||
|
|
@ -110,18 +89,18 @@ function RoomDetailPage() {
|
||||||
<div className="h-8 w-px bg-border" />
|
<div className="h-8 w-px bg-border" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCheckFolderStatus}
|
onClick={() =>
|
||||||
disabled={isCheckingFolder}
|
navigate({
|
||||||
|
to: "/rooms/$roomName/folder-status/",
|
||||||
|
params: { roomName },
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex items-center gap-2 shrink-0"
|
className="flex items-center gap-2 shrink-0"
|
||||||
>
|
>
|
||||||
{isCheckingFolder ? (
|
<FolderCheck className="h-4 w-4" />
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
Kiểm tra thư mục Setup
|
||||||
) : (
|
|
||||||
<FolderCheck className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{isCheckingFolder ? "Đang kiểm tra..." : "Kiểm tra thư mục Setup"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -141,18 +120,15 @@ function RoomDetailPage() {
|
||||||
) : viewMode === "grid" ? (
|
) : viewMode === "grid" ? (
|
||||||
<DeviceGrid
|
<DeviceGrid
|
||||||
devices={sortedDevices}
|
devices={sortedDevices}
|
||||||
folderStatuses={folderStatuses}
|
|
||||||
isCheckingFolder={isCheckingFolder}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DeviceTable
|
<DeviceTable
|
||||||
devices={sortedDevices}
|
devices={sortedDevices}
|
||||||
folderStatuses={folderStatuses}
|
|
||||||
isCheckingFolder={isCheckingFolder}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ export const Route = createFileRoute("/_auth/rooms/")({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [{ title: "Danh sách phòng" }],
|
meta: [{ title: "Danh sách phòng" }],
|
||||||
}),
|
}),
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
context.breadcrumbs = [{ title: "Danh sách phòng", path: "#" }];
|
||||||
|
},
|
||||||
component: RoomComponent,
|
component: RoomComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
9
src/routes/_auth/user/index.tsx
Normal file
9
src/routes/_auth/user/index.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/user/')({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <div>Hello "/_auth/user/"!</div>
|
||||||
|
}
|
||||||
|
|
@ -96,12 +96,28 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
pageSizeOptions = [5, 10, 15, 20],
|
pageSizeOptions = [5, 10, 15, 20],
|
||||||
}: CommandSubmitTemplateProps<T>) {
|
}: CommandSubmitTemplateProps<T>) {
|
||||||
const [activeTab, setActiveTab] = useState<"list" | "execute">("list");
|
const [activeTab, setActiveTab] = useState<"list" | "execute">("list");
|
||||||
|
|
||||||
|
// Sync tab to URL search param so route breadcrumbs can reflect active tab
|
||||||
|
const setTab = (t: "list" | "execute") => {
|
||||||
|
setActiveTab(t);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("tab", t);
|
||||||
|
window.history.replaceState({}, "", url.toString());
|
||||||
|
} catch (e) {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
const [customCommand, setCustomCommand] = useState("");
|
const [customCommand, setCustomCommand] = useState("");
|
||||||
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
|
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
|
||||||
const [customRetained, setCustomRetained] = useState(false);
|
const [customRetained, setCustomRetained] = useState(false);
|
||||||
const [table, setTable] = useState<any>();
|
const [table, setTable] = useState<any>();
|
||||||
const [dialogOpen2, setDialogOpen2] = useState(false);
|
const [dialogOpen2, setDialogOpen2] = useState(false);
|
||||||
const [dialogType, setDialogType] = useState<"room" | "device" | "room-custom" | "device-custom" | null>(null);
|
const [dialogType, setDialogType] = useState<
|
||||||
|
"room" | "device" | "room-custom" | "device-custom" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const handleTableInit = (t: any) => {
|
const handleTableInit = (t: any) => {
|
||||||
setTable(t);
|
setTable(t);
|
||||||
|
|
@ -141,7 +157,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
if (!onExecuteSelected) return;
|
if (!onExecuteSelected) return;
|
||||||
try {
|
try {
|
||||||
const roomNames = rooms.map((room) =>
|
const roomNames = rooms.map((room) =>
|
||||||
typeof room === "string" ? room : room.name
|
typeof room === "string" ? room : room.name,
|
||||||
);
|
);
|
||||||
const allTargets = [...roomNames, ...devices];
|
const allTargets = [...roomNames, ...devices];
|
||||||
onExecuteSelected(allTargets);
|
onExecuteSelected(allTargets);
|
||||||
|
|
@ -176,7 +192,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
if (!onExecuteCustom) return;
|
if (!onExecuteCustom) return;
|
||||||
try {
|
try {
|
||||||
const roomNames = rooms.map((room) =>
|
const roomNames = rooms.map((room) =>
|
||||||
typeof room === "string" ? room : room.name
|
typeof room === "string" ? room : room.name,
|
||||||
);
|
);
|
||||||
const allTargets = [...roomNames, ...devices];
|
const allTargets = [...roomNames, ...devices];
|
||||||
handleExecuteCustom(allTargets);
|
handleExecuteCustom(allTargets);
|
||||||
|
|
@ -227,7 +243,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
{/* Tabs Navigation */}
|
{/* Tabs Navigation */}
|
||||||
<div className="flex gap-4 border-b">
|
<div className="flex gap-4 border-b">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("list")}
|
onClick={() => setTab("list")}
|
||||||
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
||||||
activeTab === "list"
|
activeTab === "list"
|
||||||
? "text-primary border-b-2 border-primary"
|
? "text-primary border-b-2 border-primary"
|
||||||
|
|
@ -238,7 +254,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
Danh sách lệnh có sẵn
|
Danh sách lệnh có sẵn
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("execute")}
|
onClick={() => setTab("execute")}
|
||||||
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
||||||
activeTab === "execute"
|
activeTab === "execute"
|
||||||
? "text-primary border-b-2 border-primary"
|
? "text-primary border-b-2 border-primary"
|
||||||
|
|
@ -436,7 +452,12 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
{/* Dialog for add/edit */}
|
{/* Dialog for add/edit */}
|
||||||
{formContent && (
|
{formContent && (
|
||||||
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>
|
||||||
<DialogContent className={dialogContentClassName || "max-w-2xl max-h-[90vh] overflow-y-auto"}>
|
<DialogContent
|
||||||
|
className={
|
||||||
|
dialogContentClassName ||
|
||||||
|
"w-full max-w-[90vw] sm:max-w-[70vw] md:max-w-[50vw] max-h-[90vh] overflow-y-auto"
|
||||||
|
}
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
|
||||||
124
src/template/folder-status-template.tsx
Normal file
124
src/template/folder-status-template.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { flexRender } from "@tanstack/react-table";
|
||||||
|
import type { ClientFolderStatus, CurrentFile } from "@/types/folder";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { useState, Fragment } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
roomName: string;
|
||||||
|
data: ClientFolderStatus[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
table: any; // react-table instance (optional)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FolderStatusTemplate({ roomName, data, isLoading, onBack, table }: Props) {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
// If a table instance is provided (pre-built), use it; otherwise render simple table
|
||||||
|
if (table) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Trở về
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-lg font-semibold">Kết quả kiểm tra thư mục Setup — {roomName}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/30">
|
||||||
|
{table.getHeaderGroups().map((hg: any) => (
|
||||||
|
<tr key={hg.id}>
|
||||||
|
{hg.headers.map((h: any) => (
|
||||||
|
<th key={h.id} className="text-left p-2">
|
||||||
|
{flexRender(h.column.columnDef.header, h.getContext())}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{table.getRowModel().rows.map((row: any) => (
|
||||||
|
<Fragment key={row.id}>
|
||||||
|
<tr
|
||||||
|
onClick={() => setExpandedId((prev) => (prev === row.id ? null : row.id))}
|
||||||
|
className="hover:bg-muted/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell: any) => (
|
||||||
|
<td key={cell.id} className="p-2 align-top">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Detail row - shown when expanded */}
|
||||||
|
{expandedId === row.id && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={row.getVisibleCells().length} className="p-4 bg-muted/10">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Các file đang có trong thư mục ({row.original.currentFiles?.length ?? 0})</div>
|
||||||
|
{(row.original.currentFiles ?? []).map((f: CurrentFile) => (
|
||||||
|
<div key={f.fileName} className="text-xs font-mono">{f.fileName}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback simple list view if table not provided
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Trở về
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-lg font-semibold">Kết quả kiểm tra thư mục Setup — {roomName}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded border p-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center text-muted-foreground">Đang tải...</div>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground">Không có dữ liệu</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.map((item) => (
|
||||||
|
<div key={item.deviceId} className="p-3 rounded border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-semibold">{item.deviceId}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{new Date(item.updatedAt).toLocaleString("vi-VN")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Các file đang có trong thư mục ({item.currentFiles?.length ?? 0})</div>
|
||||||
|
{(item.currentFiles ?? []).map((f: CurrentFile) => (
|
||||||
|
<div key={f.fileName} className="text-xs font-mono">{f.fileName}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -87,10 +87,10 @@ export const appSidebarSection = {
|
||||||
permissions: [PermissionEnum.VIEW_ROLES],
|
permissions: [PermissionEnum.VIEW_ROLES],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Tạo người dùng",
|
title: "Danh sách người dùng",
|
||||||
url: "/user/create",
|
url: "/user",
|
||||||
icon: UserPlus,
|
icon: UserPlus,
|
||||||
permissions: [PermissionEnum.CRE_USER],
|
permissions: [PermissionEnum.VIEW_USER],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
src/types/folder.ts
Normal file
31
src/types/folder.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
export type Folder = {
|
||||||
|
id: number;
|
||||||
|
deviceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MissingFile = {
|
||||||
|
fileName: string;
|
||||||
|
folderPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExtraFile = {
|
||||||
|
fileName: string;
|
||||||
|
folderPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CurrentFile = {
|
||||||
|
fileName: string;
|
||||||
|
lastModified: string; // ISO date string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientFolderStatus = {
|
||||||
|
id: number;
|
||||||
|
deviceId: string;
|
||||||
|
missingFiles?: MissingFile[];
|
||||||
|
extraFiles?: ExtraFile[];
|
||||||
|
currentFiles?: CurrentFile[];
|
||||||
|
createdAt: string; // ISO date string
|
||||||
|
updatedAt: string; // ISO date string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientFolderStatusList = ClientFolderStatus[];
|
||||||
Loading…
Reference in New Issue
Block a user