Merge branch 'main' into feat/meshcentral

This commit is contained in:
Do Manh Phuong 2026-03-24 10:32:50 +07:00
commit 6d8f85501f
42 changed files with 914 additions and 1352 deletions

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

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

View File

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

View File

@ -1,74 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="94f75d5b-3c15-4d7e-9da7-ad6f5328faf8" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/src/hooks/useAuth.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/hooks/useAuth.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/routeTree.gen.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/routeTree.gen.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/_auth/blacklist/index.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/_auth/command/index.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/_auth/room/$roomName/index.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/_auth/room/index.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/types/app-sidebar.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/types/app-sidebar.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/types/permission.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/types/permission.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 1
}]]></component>
<component name="ProjectId" id="3AQVfIkiaizPRlnpMICDG3COfJV" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"dart.analysis.tool.window.visible": "false",
"git-widget-placeholder": "main",
"ignore.virus.scanning.warn.message": "true",
"javascript.preferred.runtime.type.id": "node",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"ts.external.directory.path": "D:\\MyProject\\NAVISProject\\TTMT.ManageWebGUI\\node_modules\\typescript\\lib",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-WS-253.31033.133" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="94f75d5b-3c15-4d7e-9da7-ad6f5328faf8" name="Changes" comment="" />
<created>1772524885874</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1772524885874</updated>
<workItem from="1772524887267" duration="1839000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
</project>

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

@ -14,8 +14,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useGetCommandsByTypes } from "@/hooks/queries/useCommandQueries"; import { useGetSensitiveCommands, useExecuteSensitiveCommand } from "@/hooks/queries/useCommandQueries";
import { useSendCommand } from "@/hooks/queries";
import { CommandType } from "@/types/command-registry"; import { CommandType } from "@/types/command-registry";
import { import {
Power, Power,
@ -58,6 +57,12 @@ const COMMAND_TYPE_CONFIG = {
color: "text-purple-600", color: "text-purple-600",
bgColor: "bg-purple-50 hover:bg-purple-100", bgColor: "bg-purple-50 hover:bg-purple-100",
}, },
[CommandType.RESET]: {
label : "Reset",
icon: Loader2,
color: "text-green-600",
bgColor: "bg-green-50 hover:bg-green-100",
}
}; };
export function CommandActionButtons({ roomName, selectedDevices = [] }: CommandActionButtonsProps) { export function CommandActionButtons({ roomName, selectedDevices = [] }: CommandActionButtonsProps) {
@ -65,55 +70,52 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
open: boolean; open: boolean;
command: any; command: any;
commandType: CommandType; commandType: CommandType;
isSensitive?: boolean;
}>({ }>({
open: false, open: false,
command: null, command: null,
commandType: CommandType.RESTART, commandType: CommandType.RESTART,
}); });
const [isExecuting, setIsExecuting] = useState(false); const [isExecuting, setIsExecuting] = useState(false);
const [sensitivePassword, setSensitivePassword] = useState("");
// Query commands for each type // Query commands for each type
const { data: restartCommands = [] } = useGetCommandsByTypes(CommandType.RESTART.toString()); const { data: sensitiveCommands = [] } = useGetSensitiveCommands();
const { data: shutdownCommands = [] } = useGetCommandsByTypes(CommandType.SHUTDOWN.toString());
const { data: taskkillCommands = [] } = useGetCommandsByTypes(CommandType.TASKKILL.toString());
const { data: blockCommands = [] } = useGetCommandsByTypes(CommandType.BLOCK.toString());
// Send command mutation // Send command mutation (sensitive)
const sendCommandMutation = useSendCommand(); const executeSensitiveMutation = useExecuteSensitiveCommand();
const commandsByType = { // Build commands mapped by CommandType using the `command` field from sensitive data
[CommandType.RESTART]: restartCommands, const commandsByType: Record<number, any[]> = (Object.values(CommandType) as Array<number | string>)
[CommandType.SHUTDOWN]: shutdownCommands, .filter((v) => typeof v === "number")
[CommandType.TASKKILL]: taskkillCommands, .reduce((acc: Record<number, any[]>, type) => {
[CommandType.BLOCK]: blockCommands, acc[type as number] = (sensitiveCommands || []).filter((c: any) => Number(c.command) === Number(type));
}; return acc;
}, {} as Record<number, any[]>);
const handleCommandClick = (command: any, commandType: CommandType) => { const handleCommandClick = (command: any, commandType: CommandType) => {
// When building from sensitiveCommands, all items here are sensitive
setConfirmDialog({ setConfirmDialog({
open: true, open: true,
command, command,
commandType, commandType,
isSensitive: true,
}); });
}; };
const handleConfirmExecute = async () => { const handleConfirmExecute = async () => {
setIsExecuting(true); setIsExecuting(true);
try { try {
// Chuẩn bị data theo format API (PascalCase) // All rendered commands are sourced from sensitiveCommands — send via sensitive mutation
const apiData = { await executeSensitiveMutation.mutateAsync({
Command: confirmDialog.command.commandContent,
QoS: confirmDialog.command.qoS,
IsRetained: confirmDialog.command.isRetained,
};
// Gửi lệnh đến phòng
await sendCommandMutation.mutateAsync({
roomName, roomName,
data: apiData as any, command: confirmDialog.command.commandContent,
password: sensitivePassword,
}); });
toast.success(`Đã gửi lệnh: ${confirmDialog.command.commandName}`); toast.success(`Đã gửi lệnh: ${confirmDialog.command.commandName}`);
setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART }); setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART, isSensitive: false });
setSensitivePassword("");
// Reload page để tránh freeze // Reload page để tránh freeze
setTimeout(() => { setTimeout(() => {
@ -128,7 +130,8 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
const handleCloseDialog = () => { const handleCloseDialog = () => {
if (!isExecuting) { if (!isExecuting) {
setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART }); setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART, isSensitive: false });
setSensitivePassword("");
// Reload để tránh freeze // Reload để tránh freeze
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
@ -148,7 +151,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
variant="outline" variant="outline"
disabled disabled
size="sm" size="sm"
className="gap-2" className="gap-2 flex-shrink-0"
> >
<Icon className={`h-4 w-4 ${config.color}`} /> <Icon className={`h-4 w-4 ${config.color}`} />
{config.label} {config.label}
@ -163,7 +166,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className={`gap-2 ${config.bgColor} border-${config.color.split('-')[1]}-200`} className={`gap-2 ${config.bgColor} border-${config.color.split('-')[1]}-200 flex-shrink-0`}
> >
<Icon className={`h-4 w-4 ${config.color}`} /> <Icon className={`h-4 w-4 ${config.color}`} />
{config.label} {config.label}
@ -202,7 +205,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
return ( return (
<> <>
<div className="flex flex-wrap gap-2"> <div className="flex gap-2 flex-nowrap overflow-x-auto items-center whitespace-nowrap">
{Object.values(CommandType) {Object.values(CommandType)
.filter((value) => typeof value === "number") .filter((value) => typeof value === "number")
.map((commandType) => renderCommandButton(commandType as CommandType))} .map((commandType) => renderCommandButton(commandType as CommandType))}
@ -225,6 +228,18 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
{confirmDialog.command.description} {confirmDialog.command.description}
</p> </p>
)} )}
{confirmDialog.isSensitive && (
<div className="mt-2">
<label className="block text-sm font-medium mb-1">Mật khẩu</label>
<input
type="password"
value={sensitivePassword}
onChange={(e) => setSensitivePassword(e.target.value)}
className="w-full px-2 py-1 rounded border"
placeholder="Nhập mật khẩu để xác nhận"
/>
</div>
)}
<div className="bg-muted p-3 rounded-md space-y-1"> <div className="bg-muted p-3 rounded-md space-y-1">
<p className="text-sm"> <p className="text-sm">
<span className="font-medium">Phòng:</span> {roomName} <span className="font-medium">Phòng:</span> {roomName}
@ -255,7 +270,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
</Button> </Button>
<Button <Button
onClick={handleConfirmExecute} onClick={handleConfirmExecute}
disabled={isExecuting} disabled={isExecuting || (confirmDialog.isSensitive && !sensitivePassword)}
className="gap-2" className="gap-2"
> >
{isExecuting ? ( {isExecuting ? (

View File

@ -1,10 +1,11 @@
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Monitor, Wifi, WifiOff } from "lucide-react"; import { Monitor, Wifi, WifiOff, Loader2 } from "lucide-react";
import { useState } from "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 { useGetClientFolderStatusForDevice } from "@/hooks/queries";
import type { ClientFolderStatus } from "@/types/folder";
export function ComputerCard({ export function ComputerCard({
device, device,
position, position,
@ -30,6 +31,63 @@ 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;
function DeviceFolderCheck() {
const deviceId = device.id;
const room = device.room;
const [checking, setChecking] = useState(false);
const { data: status, isLoading } = useGetClientFolderStatusForDevice(
deviceId,
room,
checking
);
const handleCheck = () => setChecking((s) => !s);
return (
<div>
<button
onClick={handleCheck}
className="inline-flex items-center gap-2 px-3 py-1 rounded border bg-background text-sm"
>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Kiểm tra thư mục Setup
</button>
{checking && isLoading && (
<div className="text-xs text-muted-foreground mt-2">Đang kiểm tra...</div>
)}
{checking && !isLoading && status && (
<div className="text-xs mt-2">
<div className="font-medium">Các file trong thư mục Setup({status.currentFiles?.length ?? 0})</div>
<div className="mt-1 max-h-36 overflow-auto space-y-1">
{(status.currentFiles ?? []).length === 0 ? (
<div className="text-muted-foreground">Không file hiện tại</div>
) : (
(status.currentFiles ?? []).map((f: any) => (
<div key={f.fileName} className="font-mono text-xs">
<div className="truncate">{f.fileName}</div>
{f.lastModified && (
<div className="text-muted-foreground text-[10px]">
{new Date(f.lastModified).toLocaleString()}
</div>
)}
</div>
))
)}
</div>
</div>
)}
{checking && !isLoading && !status && (
<div className="text-xs text-muted-foreground mt-2">Không dữ liệu</div>
)}
</div>
);
}
const DeviceInfo = () => ( const DeviceInfo = () => (
<div className="space-y-3 min-w-[280px]"> <div className="space-y-3 min-w-[280px]">
@ -69,6 +127,11 @@ export function ComputerCard({
</div> </div>
)} )}
<div>
<div className="text-xs text-muted-foreground mb-1">Kiểm tra thư mục</div>
<DeviceFolderCheck />
</div>
<div> <div>
<div className="text-xs text-muted-foreground mb-1">Trạng thái</div> <div className="text-xs text-muted-foreground mb-1">Trạng thái</div>
<Badge <Badge
@ -119,14 +182,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

@ -13,6 +13,7 @@ export const API_ENDPOINTS = {
PING: `${BASE_URL}/ping`, PING: `${BASE_URL}/ping`,
CSRF_TOKEN: `${BASE_URL}/csrf-token`, CSRF_TOKEN: `${BASE_URL}/csrf-token`,
CREATE_ACCOUNT: `${BASE_URL}/auth/create-account`, CREATE_ACCOUNT: `${BASE_URL}/auth/create-account`,
GET_USERS_LIST: `${BASE_URL}/users-info`,
}, },
APP_VERSION: { APP_VERSION: {
//agent and app api //agent and app api
@ -45,7 +46,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`,
@ -53,6 +54,8 @@ export const API_ENDPOINTS = {
GET_COMMAND_BY_TYPES: (types: string) => `${BASE_URL}/Command/types/${types}`, GET_COMMAND_BY_TYPES: (types: string) => `${BASE_URL}/Command/types/${types}`,
UPDATE_COMMAND: (commandId: number) => `${BASE_URL}/Command/update/${commandId}`, UPDATE_COMMAND: (commandId: number) => `${BASE_URL}/Command/update/${commandId}`,
DELETE_COMMAND: (commandId: number) => `${BASE_URL}/Command/delete/${commandId}`, DELETE_COMMAND: (commandId: number) => `${BASE_URL}/Command/delete/${commandId}`,
GET_SENSITIVE_COMMANDS: `${BASE_URL}/Command/sensitive`,
REQUEST_SEND_SENSITIVE_COMMAND: `${BASE_URL}/Command/send-sensitive`,
}, },
SSE_EVENTS: { SSE_EVENTS: {
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`, DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,

View File

@ -15,3 +15,5 @@ export * from "./usePermissionQueries";
// Role Queries // Role Queries
export * from "./useRoleQueries"; export * from "./useRoleQueries";
// User Queries
export * from "./useUserQueries";

View File

@ -82,3 +82,47 @@ export function useDeleteCommand() {
}, },
}); });
} }
/**
* Hook đ lấy danh sách lệnh nhạy cảm
*/
export function useGetSensitiveCommands(enabled = true) {
return useQuery({
queryKey: [...COMMAND_QUERY_KEYS.all, "sensitive"],
queryFn: () => commandService.getSensitiveCommands(),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ gửi lệnh nhạy cảm
*/
export function useExecuteSensitiveCommand() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roomName,
command,
password,
}: {
roomName: string;
command: any;
password: string;
}) =>
// API expects a SensitiveCommandRequest with PascalCase keys
commandService.requestSendSensitiveCommand({
Command: command,
Password: password,
RoomName: roomName,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...COMMAND_QUERY_KEYS.all, "sensitive"],
});
},
});
}

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"],
@ -170,3 +171,28 @@ export function useGetClientFolderStatus(roomName?: string, enabled = true) {
staleTime: 30 * 1000, staleTime: 30 * 1000,
}); });
} }
/**
* Hook to get folder status for a single device. The hook will fetch the
* folder status list for the device's room and return the matching entry
* for the provided `deviceId`.
*/
export function useGetClientFolderStatusForDevice(
deviceId?: string,
roomName?: string,
enabled = true
) {
return useQuery<ClientFolderStatus | undefined>({
queryKey: deviceId
? [...DEVICE_COMM_QUERY_KEYS.all, "folder-status-device", deviceId]
: ["disabled"],
queryFn: async () => {
if (!roomName) return Promise.reject("No room");
const list = await deviceCommService.getClientFolderStatus(roomName);
if (!Array.isArray(list)) return undefined;
return list.find((s: ClientFolderStatus) => s.deviceId === deviceId);
},
enabled: enabled && !!deviceId && !!roomName,
staleTime: 30 * 1000,
});
}

View File

@ -0,0 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import * as userService from "@/services/user.service";
import type { UserProfile } from "@/types/user-profile";
const USER_QUERY_KEYS = {
all: ["users"] as const,
list: () => [...USER_QUERY_KEYS.all, "list"] as const,
};
/**
* Hook đ lấy danh sách thông tin người dùng
*/
export function useGetUsersInfo(enabled = true) {
return useQuery<UserProfile[]>({
queryKey: USER_QUERY_KEYS.list(),
queryFn: () => userService.getUsersInfo(),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}

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'
@ -20,7 +21,6 @@ import { Route as AuthBlacklistsIndexRouteImport } from './routes/_auth/blacklis
import { Route as AuthAppsIndexRouteImport } from './routes/_auth/apps/index' import { Route as AuthAppsIndexRouteImport } from './routes/_auth/apps/index'
import { Route as AuthAgentIndexRouteImport } from './routes/_auth/agent/index' import { Route as AuthAgentIndexRouteImport } from './routes/_auth/agent/index'
import { Route as authLoginIndexRouteImport } from './routes/(auth)/login/index' import { Route as authLoginIndexRouteImport } from './routes/(auth)/login/index'
import { Route as AuthUserRoleIndexRouteImport } from './routes/_auth/user/role/index'
import { Route as AuthUserCreateIndexRouteImport } from './routes/_auth/user/create/index' import { Route as AuthUserCreateIndexRouteImport } from './routes/_auth/user/create/index'
import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms/$roomName/index' import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms/$roomName/index'
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index' import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
@ -28,6 +28,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 +40,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/',
@ -84,11 +90,6 @@ const authLoginIndexRoute = authLoginIndexRouteImport.update({
path: '/login/', path: '/login/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AuthUserRoleIndexRoute = AuthUserRoleIndexRouteImport.update({
id: '/user/role/',
path: '/user/role/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserCreateIndexRoute = AuthUserCreateIndexRouteImport.update({ const AuthUserCreateIndexRoute = AuthUserCreateIndexRouteImport.update({
id: '/user/create/', id: '/user/create/',
path: '/user/create/', path: '/user/create/',
@ -127,6 +128,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,13 +151,14 @@ 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
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute '/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
'/user/create': typeof AuthUserCreateIndexRoute '/user/create': typeof AuthUserCreateIndexRoute
'/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,13 +173,14 @@ 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
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute '/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
'/user/create': typeof AuthUserCreateIndexRoute '/user/create': typeof AuthUserCreateIndexRoute
'/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,13 +197,14 @@ 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
'/_auth/rooms/$roomName/': typeof AuthRoomsRoomNameIndexRoute '/_auth/rooms/$roomName/': typeof AuthRoomsRoomNameIndexRoute
'/_auth/user/create/': typeof AuthUserCreateIndexRoute '/_auth/user/create/': typeof AuthUserCreateIndexRoute
'/_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,13 +221,14 @@ export interface FileRouteTypes {
| '/device' | '/device'
| '/role' | '/role'
| '/rooms' | '/rooms'
| '/user'
| '/profile/$userName' | '/profile/$userName'
| '/profile/change-password' | '/profile/change-password'
| '/role/create' | '/role/create'
| '/rooms/$roomName' | '/rooms/$roomName'
| '/user/create' | '/user/create'
| '/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,13 +243,14 @@ export interface FileRouteTypes {
| '/device' | '/device'
| '/role' | '/role'
| '/rooms' | '/rooms'
| '/user'
| '/profile/$userName' | '/profile/$userName'
| '/profile/change-password' | '/profile/change-password'
| '/role/create' | '/role/create'
| '/rooms/$roomName' | '/rooms/$roomName'
| '/user/create' | '/user/create'
| '/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,13 +266,14 @@ 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/'
| '/_auth/rooms/$roomName/' | '/_auth/rooms/$roomName/'
| '/_auth/user/create/' | '/_auth/user/create/'
| '/_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 +300,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'
@ -350,13 +370,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof authLoginIndexRouteImport preLoaderRoute: typeof authLoginIndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_auth/user/role/': {
id: '/_auth/user/role/'
path: '/user/role'
fullPath: '/user/role'
preLoaderRoute: typeof AuthUserRoleIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/create/': { '/_auth/user/create/': {
id: '/_auth/user/create/' id: '/_auth/user/create/'
path: '/user/create' path: '/user/create'
@ -406,6 +419,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,13 +445,14 @@ 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
AuthRoomsRoomNameIndexRoute: typeof AuthRoomsRoomNameIndexRoute AuthRoomsRoomNameIndexRoute: typeof AuthRoomsRoomNameIndexRoute
AuthUserCreateIndexRoute: typeof AuthUserCreateIndexRoute AuthUserCreateIndexRoute: typeof AuthUserCreateIndexRoute
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,13 +466,15 @@ 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,
AuthRoomsRoomNameIndexRoute: AuthRoomsRoomNameIndexRoute, AuthRoomsRoomNameIndexRoute: AuthRoomsRoomNameIndexRoute,
AuthUserCreateIndexRoute: AuthUserCreateIndexRoute, AuthUserCreateIndexRoute: AuthUserCreateIndexRoute,
AuthUserRoleIndexRoute: AuthUserRoleIndexRoute,
AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute, AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute,
AuthRoomsRoomNameFolderStatusIndexRoute:
AuthRoomsRoomNameFolderStatusIndexRoute,
AuthUserChangePasswordUserNameIndexRoute: AuthUserChangePasswordUserNameIndexRoute:
AuthUserChangePasswordUserNameIndexRoute, AuthUserChangePasswordUserNameIndexRoute,
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute, AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,

View File

@ -74,7 +74,6 @@ function AgentsPage() {
const columns: ColumnDef<Version>[] = [ const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" }, { accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" }, { accessorKey: "fileName", header: "Tên file" },
{ accessorKey: "folderPath", header: "Đường dẫn" },
{ {
accessorKey: "updatedAt", accessorKey: "updatedAt",
header: "Thời gian cập nhật", header: "Thời gian cập nhật",

View File

@ -55,7 +55,6 @@ function AppsComponent() {
const columns: ColumnDef<Version>[] = [ const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" }, { accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" }, { accessorKey: "fileName", header: "Tên file" },
{ accessorKey: "folderPath", header: "Đường dẫn" },
{ {
accessorKey: "updatedAt", accessorKey: "updatedAt",
header: () => <div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>, header: () => <div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>,

View File

@ -11,9 +11,9 @@ import {
useSendCommand, useSendCommand,
} from "@/hooks/queries"; } from "@/hooks/queries";
import { toast } from "sonner"; import { toast } from "sonner";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
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 +22,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 +46,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
@ -58,13 +72,16 @@ function CommandPage() {
const columns: ColumnDef<CommandRegistry>[] = [ const columns: ColumnDef<CommandRegistry>[] = [
{ {
accessorKey: "commandName", accessorKey: "commandName",
header: "Tên lệnh", header: () => <div className="min-w-[220px] whitespace-normal">Tên lệnh</div>,
size: 100, size: 100,
cell: ({ getValue }) => ( cell: ({ getValue, row }) => {
<div className="max-w-[100px]"> const full = (getValue() as string) || row.original.commandName || "";
<span className="font-semibold truncate block">{getValue() as string}</span> return (
</div> <div className="min-w-[220px] whitespace-normal break-words">
), <span className="font-semibold block leading-tight">{full}</span>
</div>
);
},
}, },
{ {
accessorKey: "commandType", accessorKey: "commandType",
@ -80,18 +97,6 @@ function CommandPage() {
return <span>{typeMap[type] || "UNKNOWN"}</span>; return <span>{typeMap[type] || "UNKNOWN"}</span>;
}, },
}, },
{
accessorKey: "description",
header: "Mô tả",
size: 120,
cell: ({ getValue }) => (
<div className="max-w-[120px]">
<span className="text-sm text-muted-foreground truncate block">
{(getValue() as string) || "-"}
</span>
</div>
),
},
{ {
accessorKey: "commandContent", accessorKey: "commandContent",
header: "Nội dung lệnh", header: "Nội dung lệnh",
@ -140,7 +145,7 @@ function CommandPage() {
}, },
{ {
id: "select", id: "select",
header: () => <div className="text-center text-xs">Chọn đ thực thi</div>, header: () => <div className="text-center text-xs">Thực thi</div>,
cell: ({ row }) => ( cell: ({ row }) => (
<input <input
type="checkbox" type="checkbox"
@ -283,7 +288,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 +312,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);
@ -101,7 +80,7 @@ function RoomDetailPage() {
Thực thi lệnh Thực thi lệnh
</div> </div>
<div className="flex items-center gap-3 flex-wrap justify-end"> <div className="flex items-center gap-3 justify-end">
{/* Command Action Buttons */} {/* Command Action Buttons */}
{devices.length > 0 && ( {devices.length > 0 && (
<> <>
@ -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

@ -135,7 +135,7 @@ function CreateUserComponent() {
</div> </div>
<Button <Button
variant="outline" variant="outline"
onClick={() => navigate({ to: "/dashboard" })} onClick={() => navigate({ to: "/user" })}
> >
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Quay lại Quay lại

View File

@ -0,0 +1,165 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useMemo, useState } from "react";
import { UserListTemplate } from "@/template/user-list-template";
import { useGetUsersInfo } from "@/hooks/queries";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import type { ColumnDef } from "@tanstack/react-table";
import { VersionTable } from "@/components/tables/version-table";
import { Edit2, Trash2, Shield } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/_auth/user/")({
component: RouteComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Quản lý người dùng", path: "#" },
];
},
});
function RouteComponent() {
const navigate = useNavigate();
const { data: users = [], isLoading } = useGetUsersInfo();
const [table, setTable] = useState<any>();
const columns = useMemo<ColumnDef<any>[]>(
() => [
{
accessorKey: "userName",
header: () => <div className="text-center whitespace-normal max-w-xs">Tên đăng nhập</div>,
cell: ({ getValue }) => (
<div className="flex justify-center"><span className="font-medium">{getValue() as string}</span></div>
),
},
{
accessorKey: "name",
header: () => <div className="text-center whitespace-normal max-w-xs">Họ tên</div>,
cell: ({ getValue }) => (
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate block max-w-[240px]">
{getValue() as string}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{getValue() as string}
</TooltipContent>
</Tooltip>
),
},
{
accessorKey: "role",
header: () => <div className="text-center whitespace-normal max-w-xs">Vai trò</div>,
cell: ({ getValue }) => <div className="flex justify-center">{getValue() as string}</div>,
},
{
accessorKey: "accessRooms",
header: () => <div className="text-center whitespace-normal max-w-xs">Phòng</div>,
cell: ({ getValue }) => (
<div className="flex justify-center">{Array.isArray(getValue()) ? (getValue() as number[]).join(", ") : "-"}</div>
),
},
{
id: "select",
header: () => <div className="text-center whitespace-normal max-w-xs">Chọn</div>,
cell: ({ row }) => (
<div className="flex justify-center">
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
id: "actions",
header: () => (
<div className="text-center whitespace-normal max-w-xs">Hành đng</div>
),
cell: ({ row }) => (
<div className="flex gap-2 justify-center items-center">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({
to: "/user/change-password/$userName",
params: { userName: row.original.userName },
} as any);
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({ to: "/user/role/$roleId", params: { roleId: String(row.original.roleId) } } as any);
}}
>
<Shield className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={async (e) => {
e.stopPropagation();
if (!confirm("Bạn có chắc muốn xóa người dùng này?")) return;
// Placeholder delete - implement API call as needed
toast.success("Xóa người dùng (chưa thực thi API)");
if (table) table.setRowSelection({});
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
),
enableSorting: false,
enableHiding: false,
},
],
[navigate],
);
const tableData = users.map((u) => ({ ...u, id: u.userName }));
return (
<UserListTemplate
title="Người dùng"
description="Danh sách tài khoản hệ thống"
headerAction={
<div className="flex items-center gap-2">
<Button
onClick={() => navigate({ to: "/user/create" } as any)}
className="gap-2"
>
Thêm người dùng
</Button>
</div>
}
>
<div className="p-4">
<VersionTable
data={tableData}
isLoading={isLoading}
columns={columns as ColumnDef<any, any>[]}
scrollable={true}
maxHeight="400px"
enablePagination={false}
onTableInit={(t) => setTable(t)}
/>
</div>
</UserListTemplate>
);
}

View File

@ -13,11 +13,24 @@ import { Shield, ArrowLeft, Check, X } from "lucide-react";
import type { PermissionOnRole } from "@/types/permission"; import type { PermissionOnRole } from "@/types/permission";
export const Route = createFileRoute("/_auth/user/role/$roleId/")({ export const Route = createFileRoute("/_auth/user/role/$roleId/")({
head: () => ({
meta: [{ title: "Quyền của người dùng | AccessControl" }]
}),
component: ViewRolePermissionsComponent, component: ViewRolePermissionsComponent,
loader: async ({ context, params }) => { loader: async ({ context, params }) => {
context.breadcrumbs = [ context.breadcrumbs = [
{ title: "Quản lý người dùng", path: "#" }, {
{ title: `Quyền của Role #${params.roleId}`, path: `/user/role/${params.roleId}` }, title: "Quản lý tài khoản",
path: "#"
},
{
title: "Danh sách người dùng",
path: "/user"
},
{
title: "Quyền của người dùng",
path: `/user/role/${params.roleId}`
}
]; ];
}, },
}); });
@ -60,7 +73,7 @@ function ViewRolePermissionsComponent() {
return ( return (
<div className="w-full px-6 space-y-4"> <div className="w-full px-6 space-y-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: "/dashboard" })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: "/user" })}>
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Quay lại Quay lại
</Button> </Button>
@ -88,11 +101,11 @@ function ViewRolePermissionsComponent() {
<div key={parent?.permisionId} className="border rounded-lg p-4"> <div key={parent?.permisionId} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-semibold text-lg">{parent?.permissionName || "Unknown"}</span> <span className="font-semibold text-lg">{parent?.permissionName || "Allow all"}</span>
<Badge variant="secondary" className="text-xs">{parent?.permissionCode}</Badge> <Badge variant="secondary" className="text-xs">{parent?.permissionCode}</Badge>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{parent?.isChecked === 1 ? ( {(parent?.isChecked === 1 || parent === null) ? (
<Badge variant="default" className="bg-green-600"> <Badge variant="default" className="bg-green-600">
<Check className="h-3 w-3 mr-1" />Đã bật <Check className="h-3 w-3 mr-1" />Đã bật
</Badge> </Badge>

View File

@ -1,133 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useGetRoleList } from "@/hooks/queries";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Shield, Eye, ArrowLeft, Loader2 } from "lucide-react";
export const Route = createFileRoute("/_auth/user/role/")({
component: RoleListComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Quản lý người dùng", path: "#" },
{ title: "Danh sách vai trò", path: "/user/role" },
];
},
});
function RoleListComponent() {
const navigate = useNavigate();
const { data: roles, isLoading, isError } = useGetRoleList();
if (isLoading) {
return (
<div className="w-full px-6 flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (isError) {
return (
<div className="w-full px-6 space-y-4">
<Card className="max-w-2xl mx-auto">
<CardContent className="pt-6">
<div className="text-center text-destructive">
Không thể tải danh sách vai trò
</div>
<div className="flex justify-center mt-4">
<Button variant="outline" onClick={() => navigate({ to: "/dashboard" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="w-full px-6 space-y-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: "/dashboard" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
</div>
<Card className="max-w-4xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Danh sách vai trò
</CardTitle>
<CardDescription>
Quản các vai trò quyền hạn trong hệ thống
</CardDescription>
</CardHeader>
<CardContent>
{roles && roles.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Tên vai trò</TableHead>
<TableHead>Đ ưu tiên</TableHead>
<TableHead>Ngày tạo</TableHead>
<TableHead className="text-right">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.map((role) => (
<TableRow key={role.id}>
<TableCell>
<Badge variant="outline">{role.id}</Badge>
</TableCell>
<TableCell className="font-medium">{role.roleName}</TableCell>
<TableCell>
<Badge variant="secondary">{role.priority}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{role.createdAt ? new Date(role.createdAt).toLocaleDateString("vi-VN") : "—"}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
navigate({ to: "/user/role/$roleId", params: { roleId: String(role.id) } } as any)
}
>
<Eye className="h-4 w-4 mr-1" />
Xem quyền
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground">
Không vai trò nào trong hệ thống
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -111,7 +111,7 @@ export async function addRequiredFile(data: any): Promise<{ message: string }> {
* @param fileId - ID file * @param fileId - ID file
*/ */
export async function deleteRequiredFile(fileId: number): Promise<{ message: string }> { export async function deleteRequiredFile(fileId: number): Promise<{ message: string }> {
const response = await axios.delete( const response = await axios.post(
API_ENDPOINTS.APP_VERSION.DELETE_REQUIRED_FILE(fileId) API_ENDPOINTS.APP_VERSION.DELETE_REQUIRED_FILE(fileId)
); );
return response.data; return response.data;

View File

@ -51,3 +51,22 @@ export async function deleteCommand(commandId: number): Promise<any> {
); );
return response.data; return response.data;
} }
/**
* Lấy danh sách lệnh nhạy cảm
* @return - Danh sách lệnh nhạy cảm
* */
export async function getSensitiveCommands(): Promise<any[]> {
const response = await axios.get<any[]>(API_ENDPOINTS.COMMAND.GET_SENSITIVE_COMMANDS);
return response.data;
}
/**
* Gửi yêu cầu thực thi lệnh nhạy cảm
* @param data - Dữ liệu lệnh nhạy cảm
* @return - Kết quả thực thi
* */
export async function requestSendSensitiveCommand(data: any): Promise<any> {
const response = await axios.post(API_ENDPOINTS.COMMAND.REQUEST_SEND_SENSITIVE_COMMAND, data);
return response.data;
}

View File

@ -0,0 +1,34 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { UserProfile } from "@/types/user-profile";
/**
* Lấy danh sách thông tin người dùng chuyển sang camelCase keys
*/
export async function getUsersInfo(): Promise<UserProfile[]> {
try {
const response = await axios.get<any[]>(API_ENDPOINTS.AUTH.GET_USERS_LIST);
const list = Array.isArray(response.data) ? response.data : [];
return list.map((u: any) => ({
userName: u.userName ?? u.UserName ?? "",
name: u.name ?? u.Name ?? "",
role: u.role ?? u.Role ?? "",
roleId: u.roleId !== undefined ? Number(u.roleId) : u.RoleId !== undefined ? Number(u.RoleId) : 0,
accessRooms: Array.isArray(u.accessRooms)
? u.accessRooms.map((v: any) => Number(v))
: Array.isArray(u.AccessRooms)
? u.AccessRooms.map((v: any) => Number(v))
: [],
createdAt: u.createdAt ?? u.CreatedAt ?? null,
createdBy: u.createdBy ?? u.CreatedBy ?? null,
updatedAt: u.updatedAt ?? u.UpdatedAt ?? null,
updatedBy: u.updatedBy ?? u.UpdatedBy ?? null,
} as UserProfile));
} catch (error) {
console.error("getUsersInfo error:", error);
throw error;
}
}
export default { getUsersInfo };

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"
@ -235,10 +251,10 @@ export function CommandSubmitTemplate<T extends { id: number }>({
}`} }`}
> >
<CommandIcon className="h-4 w-4" /> <CommandIcon className="h-4 w-4" />
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"
@ -246,7 +262,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
}`} }`}
> >
<Zap className="h-4 w-4" /> <Zap className="h-4 w-4" />
Lệnh thủ công Lệnh thủ công
</button> </button>
</div> </div>
{/* Tab 1: Danh sách */} {/* Tab 1: Danh sách */}
@ -432,11 +448,16 @@ 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

@ -0,0 +1,38 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter} from "@/components/ui/card";
type Props = {
title?: string;
description?: string;
children?: React.ReactNode;
headerAction?: React.ReactNode;
footer?: React.ReactNode;
};
export function UserListTemplate({ title, description, children, headerAction, footer }: Props) {
return (
<div className="w-full px-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">{title}</h1>
{description && <div className="text-muted-foreground mt-2">{description}</div>}
</div>
<div>{headerAction}</div>
</div>
<Card className="w-full">
<CardHeader>
<div>
<CardTitle className="flex items-center gap-2">Danh sách người dùng</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</div>
</CardHeader>
<CardContent>{children}</CardContent>
{footer && <CardFooter>{footer}</CardFooter>}
</Card>
</div>
);
}
export default UserListTemplate;

View File

@ -1,4 +1,4 @@
import { AppWindow, Building, CircleX, Home, ShieldCheck, Terminal, UserPlus} from "lucide-react"; import { AppWindow, Building, CircleX, Folder, Home, ShieldCheck, Terminal, UserPlus} from "lucide-react";
import { PermissionEnum } from "./permission"; import { PermissionEnum } from "./permission";
enum AppSidebarSectionCode { enum AppSidebarSectionCode {
@ -18,10 +18,10 @@ export const appSidebarSection = {
versions: ["1.0.1", "1.1.0-alpha", "2.0.0-beta1"], versions: ["1.0.1", "1.1.0-alpha", "2.0.0-beta1"],
navMain: [ navMain: [
{ {
title: "Thống kê tổng quan", title: "Tổng quan",
items: [ items: [
{ {
title: "Dashboard", title: "Thống kê",
url: "/dashboard", url: "/dashboard",
code: AppSidebarSectionCode.DASHBOARD, code: AppSidebarSectionCode.DASHBOARD,
icon: Home, icon: Home,
@ -30,7 +30,7 @@ export const appSidebarSection = {
], ],
}, },
{ {
title: "Quan lý phòng máy", title: "Qun lý phòng máy",
items: [ items: [
{ {
title: "Danh sách phòng máy", title: "Danh sách phòng máy",
@ -42,25 +42,25 @@ export const appSidebarSection = {
], ],
}, },
{ {
title: "Agent và phần mềm", title: "Quản lý agent/thư mục Setup",
items: [ items: [
{ {
title: "Danh sách Agent", title: "Agent",
url: "/agent", url: "/agent",
code: AppSidebarSectionCode.AGENT_MANAGEMENT, code: AppSidebarSectionCode.AGENT_MANAGEMENT,
icon: AppWindow, icon: AppWindow,
permissions: [PermissionEnum.VIEW_AGENT], permissions: [PermissionEnum.VIEW_AGENT],
}, },
{ {
title: "Quản lý phần mềm", title: "Thư mục Setup",
url: "/apps", url: "/apps",
icon: AppWindow, icon: Folder,
permissions: [PermissionEnum.VIEW_APPS], permissions: [PermissionEnum.VIEW_APPS],
} }
], ],
}, },
{ {
title: "Lệnh và các ứng dụng bị chặn", title: "Quản lý lệnh/blacklist",
items: items:
[ [
{ {
@ -70,7 +70,7 @@ export const appSidebarSection = {
permissions: [PermissionEnum.VIEW_COMMAND], permissions: [PermissionEnum.VIEW_COMMAND],
}, },
{ {
title: "Danh sách ứng dụng/web bị chặn", title: "Chặn ứng dụng/website",
url: "/blacklists", url: "/blacklists",
icon: CircleX, icon: CircleX,
permissions: [PermissionEnum.ALLOW_ALL], permissions: [PermissionEnum.ALLOW_ALL],
@ -78,7 +78,7 @@ export const appSidebarSection = {
] ]
}, },
{ {
title: "Phân quyền và người dùng", title: "Quản lý tài khoản/phân quyền",
items: [ items: [
{ {
title: "Danh sách roles", title: "Danh sách roles",
@ -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],
} }
] ]
} }

View File

@ -15,4 +15,5 @@ export enum CommandType {
SHUTDOWN = 2, SHUTDOWN = 2,
TASKKILL = 3, TASKKILL = 3,
BLOCK = 4, BLOCK = 4,
RESET = 5,
} }

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

View File

@ -0,0 +1,9 @@
import type { CommandType } from "./command-registry";
export type SensitiveCommand = {
commandName: string;
commandType: CommandType;
commandContent: string;
qoS: 0 | 1 | 2;
isRetained: boolean;
}

11
src/types/user-profile.ts Normal file
View File

@ -0,0 +1,11 @@
export type UserProfile = {
userName: string;
name: string;
role: string;
roleId: number;
accessRooms: number[];
createdAt?: string | null;
createdBy?: string | null;
updatedAt?: string | null;
updatedBy?: string | null;
};