fix UX and add check Setup folder route

This commit is contained in:
Do Manh Phuong 2026-03-18 13:58:59 +07:00
parent df49bde2c4
commit 67f5dbbb08
22 changed files with 400 additions and 1031 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}
/>
);
}

View File

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

View File

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

View 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>
}

View File

@ -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 sẵn Danh sách lệnh 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>

View 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 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 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 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>
);
}

View File

@ -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
View 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[];