Compare commits

..

No commits in common. "main" and "feature_update_button" have entirely different histories.

88 changed files with 1663 additions and 6492 deletions

View File

@ -1,341 +0,0 @@
# TanStack Query Hooks Documentation
Tất cả các API đã được tách riêng thành TanStack Query hooks trong folder `src/hooks/queries/`.
## Cấu trúc
```
src/hooks/queries/
├── index.ts # Export tất cả hooks
├── useAuthQueries.ts # Auth hooks
├── useAppVersionQueries.ts # App/Software hooks
├── useDeviceCommQueries.ts # Device communication hooks
└── useCommandQueries.ts # Command hooks
```
## Cách Sử Dụng
### 1. Auth Queries (Xác thực)
#### Đăng nhập
```tsx
import { useLogin } from '@/hooks/queries'
function LoginPage() {
const loginMutation = useLogin()
const handleLogin = async () => {
try {
await loginMutation.mutateAsync({
username: 'user',
password: 'password'
})
// Tự động lưu token vào localStorage
} catch (error) {
console.error(error)
}
}
return (
<button
onClick={handleLogin}
disabled={loginMutation.isPending}
>
{loginMutation.isPending ? 'Đang đăng nhập...' : 'Đăng nhập'}
</button>
)
}
```
#### Đăng xuất
```tsx
import { useLogout } from '@/hooks/queries'
function LogoutButton() {
const logoutMutation = useLogout()
return (
<button onClick={() => logoutMutation.mutate()}>
Đăng xuất
</button>
)
}
```
#### Kiểm tra phiên
```tsx
import { usePing } from '@/hooks/queries'
function CheckSession() {
const { data, isLoading } = usePing(token, true)
if (isLoading) return <div>Checking...</div>
return <div>Session: {data?.message}</div>
}
```
#### Thay đổi mật khẩu
```tsx
import { useChangePassword } from '@/hooks/queries'
function ChangePasswordForm() {
const changePasswordMutation = useChangePassword()
const handleSubmit = async () => {
await changePasswordMutation.mutateAsync({
currentPassword: 'old',
newPassword: 'new'
})
}
return <button onClick={handleSubmit}>Thay đổi</button>
}
```
### 2. App Version Queries (Phần mềm/Agent)
#### Lấy danh sách agent
```tsx
import { useGetAgentVersion } from '@/hooks/queries'
function AgentList() {
const { data: agents, isLoading } = useGetAgentVersion()
if (isLoading) return <div>Loading...</div>
return <div>{agents?.length} agents</div>
}
```
#### Lấy danh sách phần mềm
```tsx
import { useGetSoftwareList } from '@/hooks/queries'
function SoftwareList() {
const { data: software, isLoading } = useGetSoftwareList()
return software?.map(item => <div key={item.id}>{item.name}</div>)
}
```
#### Upload file
```tsx
import { useUploadSoftware } from '@/hooks/queries'
function UploadForm() {
const uploadMutation = useUploadSoftware()
const handleUpload = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
await uploadMutation.mutateAsync({
formData,
onUploadProgress: (event) => {
const percent = (event.loaded / event.total) * 100
console.log(`Upload: ${percent}%`)
}
})
}
return <input type="file" onChange={(e) => e.target.files && handleUpload(e.target.files[0])} />
}
```
#### Quản lý blacklist
```tsx
import {
useGetBlacklist,
useAddBlacklist,
useDeleteBlacklist
} from '@/hooks/queries'
function BlacklistManager() {
const { data: blacklist } = useGetBlacklist()
const addMutation = useAddBlacklist()
const deleteMutation = useDeleteBlacklist()
const handleAdd = async () => {
await addMutation.mutateAsync({ appId: 1 })
}
const handleDelete = async (appId: number) => {
await deleteMutation.mutateAsync(appId)
}
return (
<>
{blacklist?.map(item => (
<div key={item.id}>
{item.name}
<button onClick={() => handleDelete(item.id)}>Delete</button>
</div>
))}
<button onClick={handleAdd}>Add</button>
</>
)
}
```
### 3. Device Communication Queries
#### Lấy danh sách phòng
```tsx
import { useGetRoomList } from '@/hooks/queries'
function RoomSelector() {
const { data: rooms } = useGetRoomList()
return (
<select>
{rooms?.map(room => (
<option key={room.id} value={room.id}>{room.name}</option>
))}
</select>
)
}
```
#### Lấy thiết bị trong phòng
```tsx
import { useGetDeviceFromRoom } from '@/hooks/queries'
function DeviceList({ roomName }: { roomName: string }) {
const { data: devices, isLoading } = useGetDeviceFromRoom(roomName, true)
if (isLoading) return <div>Loading devices...</div>
return devices?.map(device => (
<div key={device.id}>{device.name}</div>
))
}
```
#### Gửi lệnh
```tsx
import { useSendCommand } from '@/hooks/queries'
function CommandForm() {
const sendMutation = useSendCommand()
const handleSend = async () => {
await sendMutation.mutateAsync({
roomName: 'Room A',
data: { command: 'dir' }
})
}
return <button onClick={handleSend}>Gửi lệnh</button>
}
```
#### Cài đặt phần mềm
```tsx
import { useInstallMsi } from '@/hooks/queries'
function InstallSoftware() {
const installMutation = useInstallMsi()
const handleInstall = async () => {
await installMutation.mutateAsync({
roomName: 'Room A',
data: { msiFileId: 1 }
})
}
return <button onClick={handleInstall}>Cài đặt</button>
}
```
### 4. Command Queries
#### Lấy danh sách lệnh
```tsx
import { useGetCommandList } from '@/hooks/queries'
function CommandList() {
const { data: commands } = useGetCommandList()
return commands?.map(cmd => <div key={cmd.id}>{cmd.name}</div>)
}
```
#### Thêm lệnh
```tsx
import { useAddCommand } from '@/hooks/queries'
function AddCommandForm() {
const addMutation = useAddCommand()
const handleAdd = async () => {
await addMutation.mutateAsync({
name: 'My Command',
command: 'echo hello'
})
}
return <button onClick={handleAdd}>Add Command</button>
}
```
## Lợi ích
1. **Automatic Caching** - TanStack Query tự động cache dữ liệu
2. **Background Refetching** - Cập nhật dữ liệu trong background
3. **Stale Time Management** - Kiểm soát thời gian dữ liệu còn "fresh"
4. **Automatic Invalidation** - Tự động update dữ liệu sau mutations
5. **Deduplication** - Gộp các request giống nhau
6. **Error Handling** - Xử lý lỗi tập trung
7. **Loading States** - Tracking loading/pending/error states
## Advanced Usage
### Dependent Queries
```tsx
function DeviceDetails({ deviceId }: { deviceId: number }) {
const { data: device } = useGetDeviceFromRoom(deviceId, true)
// Chỉ fetch khi có device
const { data: status } = useGetClientFolderStatus(
device?.roomName,
!!device
)
return <div>{status?.path}</div>
}
```
### Prefetching
```tsx
import { useQueryClient } from '@tanstack/react-query'
import { useGetSoftwareList } from '@/hooks/queries'
function PrefetchOnHover() {
const queryClient = useQueryClient()
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: ['app-version', 'software'],
queryFn: () => useGetSoftwareList
})
}
return <div onMouseEnter={handleMouseEnter}>Hover me</div>
}
```
## Migration từ cách cũ
**Trước:**
```tsx
const { data } = useQueryData({
queryKey: ["software-version"],
url: API_ENDPOINTS.APP_VERSION.GET_SOFTWARE,
})
```
**Sau:**
```tsx
const { data } = useGetSoftwareList()
```
Đơn giản hơn, type-safe hơn, và dễ bảo trì hơn!

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,6 +1,6 @@
upstream backend {
server 100.66.170.15:8080;
server 127.0.0.1:8080;
server 127.0.0.1:5218;
server 172.18.10.8:8080;
}
server {
@ -25,7 +25,7 @@ server {
}
location /api/ {
proxy_pass http://100.66.170.15:8080;
proxy_pass http://backend/;
# Cho phép upload file lớn (vd: 200MB)
client_max_body_size 200M;

60
package-lock.json generated
View File

@ -26,7 +26,6 @@
"@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.121.2",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -129,7 +128,6 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@ -659,7 +657,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -683,7 +680,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -3257,7 +3253,6 @@
"version": "1.129.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.129.8.tgz",
"integrity": "sha512-d5mfM+67h3wq7aHkLjRKXD1ddbzx1YuxaEbNvW45jjZXMgaikZSVfJrZBiUWXE/nhV1sTdbMQ48JcPagvGPmYQ==",
"peer": true,
"dependencies": {
"@tanstack/history": "1.129.7",
"@tanstack/react-store": "^0.7.0",
@ -3338,7 +3333,6 @@
"version": "1.129.8",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
"integrity": "sha512-Izqf5q8TzJv0DJURynitJioPJT3dPAefrzHi2wlY/Q5+7nEG41SkjYMotTX2Q9i/Pjl91lW8gERCHpksszRdRw==",
"peer": true,
"dependencies": {
"@tanstack/history": "1.129.7",
"@tanstack/store": "^0.7.0",
@ -3511,40 +3505,12 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/zod-form-adapter": {
"version": "0.42.1",
"resolved": "https://registry.npmjs.org/@tanstack/zod-form-adapter/-/zod-form-adapter-0.42.1.tgz",
"integrity": "sha512-hPRM0lawVKP64yurW4c6KHZH6altMo2MQN14hfi+GMBTKjO9S7bW1x5LPZ5cayoJE3mBvdlahpSGT5rYZtSbXQ==",
"dependencies": {
"@tanstack/form-core": "0.42.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"zod": "^3.x"
}
},
"node_modules/@tanstack/zod-form-adapter/node_modules/@tanstack/form-core": {
"version": "0.42.1",
"resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-0.42.1.tgz",
"integrity": "sha512-jTU0jyHqFceujdtPNv3jPVej1dTqBwa8TYdIyWB5BCwRVUBZEp1PiYEBkC9r92xu5fMpBiKc+JKud3eeVjuMiA==",
"dependencies": {
"@tanstack/store": "^0.7.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -3697,7 +3663,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"devOptional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.8.0"
}
@ -3708,7 +3673,6 @@
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -3719,7 +3683,6 @@
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@ -4161,7 +4124,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@ -4601,8 +4563,7 @@
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"peer": true
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
@ -4998,7 +4959,6 @@
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
@ -5730,7 +5690,6 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@ -6814,7 +6773,6 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -6823,7 +6781,6 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -7206,7 +7163,6 @@
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
"peer": true,
"engines": {
"node": ">=10"
}
@ -7665,8 +7621,7 @@
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"peer": true
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/tiny-warning": {
"version": "1.0.3",
@ -7722,7 +7677,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -7910,7 +7864,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -8088,10 +8041,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"peer": true,
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -8203,7 +8155,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -8537,7 +8488,6 @@
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -30,7 +30,6 @@
"@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.121.2",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

38
src/App.css Normal file
View File

@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -1,325 +0,0 @@
import { useState, useMemo } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Building2,
Monitor,
ChevronDown,
ChevronRight,
Loader2,
} from "lucide-react";
import type { Room } from "@/types/room";
import type { DeviceHealthCheck } from "@/types/device";
interface DeviceSearchDialogProps {
open: boolean;
onClose: () => void;
rooms: Room[];
onSelect: (deviceIds: string[]) => void | Promise<void>;
fetchDevices: (roomName: string) => Promise<DeviceHealthCheck[]>; // API fetch
}
export function DeviceSearchDialog({
open,
onClose,
rooms,
onSelect,
fetchDevices,
}: DeviceSearchDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const [expandedRoom, setExpandedRoom] = useState<string | null>(null);
const [roomDevices, setRoomDevices] = useState<
Record<string, DeviceHealthCheck[]>
>({});
const [loadingRoom, setLoadingRoom] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const sortedRooms = useMemo(() => {
return [...rooms].sort((a, b) => {
const nameA = typeof a.name === "string" ? a.name : "";
const nameB = typeof b.name === "string" ? b.name : "";
return nameA.localeCompare(nameB);
});
}, [rooms]);
const filteredRooms = useMemo(() => {
if (!searchQuery) return sortedRooms;
return sortedRooms.filter((room) =>
room.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [sortedRooms, searchQuery]);
const handleRoomClick = async (roomName: string) => {
// Nếu đang mở thì đóng lại
if (expandedRoom === roomName) {
setExpandedRoom(null);
return;
}
// Nếu chưa fetch devices của room này thì gọi API
if (!roomDevices[roomName]) {
setLoadingRoom(roomName);
try {
const devices = await fetchDevices(roomName);
setRoomDevices((prev) => ({ ...prev, [roomName]: devices }));
setExpandedRoom(roomName);
} catch (error) {
console.error("Failed to fetch devices:", error);
// Có thể thêm toast notification ở đây
} finally {
setLoadingRoom(null);
}
} else {
// Đã có data rồi thì chỉ toggle
setExpandedRoom(roomName);
}
};
const toggleDevice = (deviceId: string) => {
setSelected((prev) =>
prev.includes(deviceId)
? prev.filter((id) => id !== deviceId)
: [...prev, deviceId]
);
};
const toggleAllInRoom = (roomName: string) => {
const devices = roomDevices[roomName] || [];
const deviceIds = devices.map((d) => d.id);
const allSelected = deviceIds.every((id) => selected.includes(id));
if (allSelected) {
setSelected((prev) => prev.filter((id) => !deviceIds.includes(id)));
} else {
setSelected((prev) => [...new Set([...prev, ...deviceIds])]);
}
};
const handleConfirm = async () => {
try {
await onSelect(selected);
} catch (e) {
console.error("Error on select:", e);
} finally {
setSelected([]);
setExpandedRoom(null);
setRoomDevices({});
setSearchQuery("");
onClose();
}
};
const handleClose = () => {
setSelected([]);
setExpandedRoom(null);
setRoomDevices({});
setSearchQuery("");
onClose();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-none w-[95vw] max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Monitor className="w-6 h-6 text-primary" />
Chọn thiết bị
</DialogTitle>
</DialogHeader>
{/* Search bar */}
<Input
placeholder="Tìm kiếm phòng..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="my-2"
/>
{/* Room list */}
<ScrollArea className="max-h-[500px] rounded-lg border p-2">
<div className="space-y-1">
{filteredRooms.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
Không tìm thấy phòng
</p>
)}
{filteredRooms.map((room) => {
const isExpanded = expandedRoom === room.name;
const isLoading = loadingRoom === room.name;
const devices = roomDevices[room.name] || [];
const allSelected =
devices.length > 0 &&
devices.every((d) => selected.includes(d.id));
const someSelected = devices.some((d) => selected.includes(d.id));
const selectedCount = devices.filter((d) =>
selected.includes(d.id)
).length;
return (
<div
key={room.name}
className="border rounded-lg overflow-hidden"
>
{/* Room header - clickable */}
<div
className="flex items-center gap-1 px-2 py-1.5 hover:bg-muted/50 cursor-pointer"
onClick={() => handleRoomClick(room.name)}
>
{/* Expand icon or loading */}
{isLoading ? (
<Loader2 className="w-4 h-4 text-muted-foreground flex-shrink-0 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
{/* Select all checkbox - chỉ hiện khi đã load devices */}
{devices.length > 0 && (
<Checkbox
checked={allSelected}
onCheckedChange={() => {
toggleAllInRoom(room.name);
}}
onClick={(e) => e.stopPropagation()}
className={
someSelected && !allSelected ? "opacity-50" : ""
}
/>
)}
<Building2 className="w-4 h-4 text-primary flex-shrink-0" />
<span className="font-semibold flex-1 text-sm">
{room.name}
</span>
<div className="flex items-center gap-1 text-xs text-muted-foreground flex-shrink-0">
{selectedCount > 0 && (
<span className="text-primary font-medium">
{selectedCount}/
</span>
)}
<span>{room.numberOfDevices}</span>
{room.numberOfOfflineDevices > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-red-100 text-red-700">
{room.numberOfOfflineDevices}
</span>
)}
</div>
</div>
{/* Device table - collapsible */}
{isExpanded && devices.length > 0 && (
<div className="border-t bg-muted/20 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-muted/50 border-b sticky top-0">
<tr>
<th className="w-8 px-1 py-1"></th>
<th className="text-left px-1 py-1 font-medium min-w-20 text-xs">
Thiết bị
</th>
<th className="text-left px-1 py-1 font-medium min-w-24 text-xs">
IP
</th>
<th className="text-left px-1 py-1 font-medium min-w-28 text-xs">
MAC
</th>
<th className="text-left px-1 py-1 font-medium min-w-12 text-xs">
Ver
</th>
<th className="text-left px-1 py-1 font-medium min-w-16 text-xs">
Trạng thái
</th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr
key={device.id}
className="border-b last:border-b-0 hover:bg-muted/50"
>
<td className="px-1 py-1">
<Checkbox
checked={selected.includes(device.id)}
onCheckedChange={() =>
toggleDevice(device.id)
}
/>
</td>
<td className="px-1 py-1">
<div className="flex items-center gap-0.5">
<Monitor className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="font-mono text-xs truncate">
{device.id}
</span>
</div>
</td>
<td className="px-1 py-1 font-mono text-xs truncate">
{device.networkInfos[0]?.ipAddress || "-"}
</td>
<td className="px-1 py-1 font-mono text-xs truncate">
{device.networkInfos[0]?.macAddress || "-"}
</td>
<td className="px-1 py-1 text-xs whitespace-nowrap">
{device.version ? `v${device.version}` : "-"}
</td>
<td className="px-1 py-1 text-xs">
{device.isOffline ? (
<span className="text-xs px-1 py-0.5 rounded-full bg-red-100 text-red-700 font-medium whitespace-nowrap inline-block">
Offline
</span>
) : (
<span className="text-xs px-1 py-0.5 rounded-full bg-green-100 text-green-700 font-medium whitespace-nowrap inline-block">
Online
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
})}
</div>
</ScrollArea>
{/* Selected count */}
{selected.length > 0 && (
<div className="text-xs text-muted-foreground bg-muted/50 px-2 py-1.5 rounded">
Đã chọn:{" "}
<span className="font-semibold text-foreground">
{selected.length}
</span>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleClose} size="sm">
Hủy
</Button>
<Button
onClick={handleConfirm}
disabled={selected.length === 0}
size="sm"
>
Xác nhận ({selected.length})
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,97 +0,0 @@
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface DeleteButtonProps {
onClick: () => void | Promise<void>;
loading?: boolean;
disabled?: boolean;
label?: string;
title?: string;
description?: string;
}
export function DeleteButton({
onClick,
loading = false,
disabled = false,
label = "Xóa khỏi server",
title = "Xóa khỏi server",
description = "Bạn có chắc chắn muốn xóa các phần mềm này khỏi server không? Hành động này không thể hoàn tác.",
}: DeleteButtonProps) {
const [open, setOpen] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const handleConfirm = async () => {
setIsConfirming(true);
try {
await onClick();
} finally {
setIsConfirming(false);
setOpen(false);
}
};
return (
<>
<Button
variant="destructive"
onClick={() => setOpen(true)}
disabled={loading || disabled}
className="gap-2 px-4"
>
{loading || isConfirming ? (
<span className="animate-spin"></span>
) : (
<Trash2 className="h-4 w-4" />
)}
{loading || isConfirming ? "Đang xóa..." : label}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-lg">{title}</DialogTitle>
<DialogDescription className="text-base">{description}</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isConfirming}
className="flex-1"
>
Hủy
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={isConfirming || loading}
className="flex-1 gap-2"
>
{isConfirming ? (
<>
<span className="animate-spin"></span>
Đang xóa...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{label}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,68 @@
"use client";
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
interface ShellCommandFormProps {
command: string;
onCommandChange: (value: string) => void;
disabled?: boolean;
}
export function ShellCommandForm({
command,
onCommandChange,
disabled,
}: ShellCommandFormProps) {
const form = useForm({
defaultValues: { command },
onSubmit: () => {},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-5"
>
<form.Field
name="command"
validators={{
onChange: ({ value }: { value: string }) => {
const schema = z
.string()
.min(1, "Nhập command để thực thi")
.max(500, "Command quá dài");
const result = schema.safeParse(value);
if (!result.success) {
return result.error.issues.map((i) => i.message);
}
return [];
},
}}
children={(field) => (
<div className="w-full px-0">
<Textarea
className="w-full h-[25vh]"
placeholder="Nhập lệnh..."
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
onCommandChange(e.target.value);
}}
disabled={disabled}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
/>
</form>
);
}

View File

@ -2,19 +2,13 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Badge } from "@/components/ui/badge";
import { Monitor, Wifi, WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { FolderStatusPopover } from "../folder-status-popover";
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
export function ComputerCard({
device,
position,
folderStatus,
isCheckingFolder,
}: {
device: any | undefined;
position: number;
folderStatus?: ClientFolderStatus;
isCheckingFolder?: boolean;
}) {
if (!device) {
return (
@ -104,17 +98,6 @@ export function ComputerCard({
{position}
</div>
{/* Folder Status Icon */}
{device && !isOffline && (
<div className="absolute -top-2 -right-2">
<FolderStatusPopover
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
status={folderStatus}
isLoading={isCheckingFolder}
/>
</div>
)}
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
{firstNetworkInfo?.ipAddress && (
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">

View File

@ -1,17 +1,8 @@
import { Monitor, DoorOpen } from "lucide-react";
import { ComputerCard } from "../cards/computer-card";
import { useMachineNumber } from "../../hooks/useMachineNumber";
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
import { ComputerCard } from "./computer-card";
import { useMachineNumber } from "../hooks/useMachineNumber";
export function DeviceGrid({
devices,
folderStatuses,
isCheckingFolder,
}: {
devices: any[];
folderStatuses?: Map<string, ClientFolderStatus>;
isCheckingFolder?: boolean;
}) {
export function DeviceGrid({ devices }: { devices: any[] }) {
const getMachineNumber = useMachineNumber();
const deviceMap = new Map<number, any>();
@ -23,27 +14,18 @@ export function DeviceGrid({
const totalRows = 5;
const renderRow = (rowIndex: number) => {
// Đảo ngược: 21-40 sang trái, 1-20 sang phải
const leftStart = 21 + (totalRows - 1 - rowIndex) * 4;
const rightStart = (totalRows - 1 - rowIndex) * 4 + 1;
// Trái: 120
const leftStart = rowIndex * 4 + 1;
// Phải: 2140
const rightStart = 21 + rowIndex * 4;
return (
<div key={rowIndex} className="flex items-center justify-center gap-3">
{/* Bên trái (2140) */}
{/* Bên trái (120) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = leftStart + (3 - i);
const device = deviceMap.get(pos);
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
const pos = leftStart + i;
return (
<ComputerCard
key={pos}
device={device}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
/>
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
);
})}
@ -52,21 +34,11 @@ export function DeviceGrid({
<div className="h-px w-full bg-border border-t-2 border-dashed" />
</div>
{/* Bên phải (120) */}
{/* Bên phải (2140) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = rightStart + (3 - i);
const device = deviceMap.get(pos);
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
const pos = rightStart + i;
return (
<ComputerCard
key={pos}
device={device}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
/>
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
);
})}
</div>
@ -75,18 +47,19 @@ export function DeviceGrid({
return (
<div className="px-0.5 py-8 space-y-6">
<div className="space-y-4">
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
</div>
<div className="flex items-center justify-between px-4 pb-6 border-b-2 border-dashed">
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
<DoorOpen className="h-6 w-6 text-muted-foreground" />
<span className="font-semibold text-lg">Cửa Ra Vào</span>
</div>
<div className="flex items-center gap-3 px-6 py-4 bg-primary/10 rounded-lg border-2 border-primary/20">
<Monitor className="h-6 w-6 text-primary" />
<span className="font-semibold text-lg">Bàn Giảng Viên</span>
</div>
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
<DoorOpen className="h-6 w-6 text-muted-foreground" />
<span className="font-semibold text-lg">Cửa Ra Vào</span>
</div>
</div>
<div className="space-y-4">
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
</div>
</div>
);

View File

@ -17,24 +17,16 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { FolderStatusPopover } from "../folder-status-popover";
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
import { useMachineNumber } from "../hooks/useMachineNumber";
interface DeviceTableProps {
devices: any[];
folderStatuses?: Map<string, ClientFolderStatus>;
isCheckingFolder?: boolean;
}
/**
* Component hiển thị danh sách thiết bị dạng bảng
*/
export function DeviceTable({
devices,
folderStatuses,
isCheckingFolder,
}: DeviceTableProps) {
export function DeviceTable({ devices }: DeviceTableProps) {
const getMachineNumber = useMachineNumber();
const columns: ColumnDef<any>[] = [
@ -145,27 +137,6 @@ export function DeviceTable({
);
},
},
{
header: "Thư mục Setup",
cell: ({ row }) => {
const device = row.original;
const isOffline = device.isOffline;
const macAddress = device.networkInfos?.[0]?.macAddress || device.id;
const folderStatus = folderStatuses?.get(macAddress);
if (isOffline) {
return <span className="text-muted-foreground text-sm">-</span>;
}
return (
<FolderStatusPopover
deviceId={macAddress}
status={folderStatus}
isLoading={isCheckingFolder}
/>
);
},
},
];
const table = useReactTable({

View File

@ -1,30 +0,0 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { type ReactNode, useState } from "react";
interface FormDialogProps {
triggerLabel: string;
title: string;
children: (closeDialog: () => void) => ReactNode;
}
export function FormDialog({ triggerLabel, title, children }: FormDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const closeDialog = () => setIsOpen(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>{triggerLabel}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{children(closeDialog)}
</DialogContent>
</Dialog>
);
}

View File

@ -1,98 +0,0 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { useState, useMemo } from "react";
export interface SelectItem {
label: string;
value: string;
}
interface SelectDialogProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
icon?: React.ReactNode;
items: SelectItem[];
onConfirm: (values: string[]) => Promise<void> | void;
}
export function SelectDialog({
open,
onClose,
title,
description,
icon,
items,
onConfirm,
}: SelectDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const [search, setSearch] = useState("");
const filteredItems = useMemo(() => {
return items.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())
);
}, [items, search]);
const toggleItem = (value: string) => {
setSelected((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
);
};
const handleConfirm = async () => {
await onConfirm(selected);
setSelected([]);
setSearch("");
onClose();
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{icon}
{title}
</DialogTitle>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</DialogHeader>
<Input
placeholder="Tìm kiếm..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="my-2"
/>
<div className="max-h-64 overflow-y-auto space-y-2 mt-2 border rounded p-2">
{filteredItems.map((item) => (
<div key={item.value} className="flex items-center gap-2">
<Checkbox
checked={selected.includes(item.value)}
onCheckedChange={() => toggleItem(item.value)}
/>
<span>{item.label}</span>
</div>
))}
{filteredItems.length === 0 && (
<p className="text-sm text-muted-foreground text-center">Không kết quả</p>
)}
</div>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" onClick={onClose}>
Hủy
</Button>
<Button onClick={handleConfirm} disabled={selected.length === 0}>
Xác nhận
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,145 +0,0 @@
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CheckCircle2, AlertCircle, Loader2, AlertTriangle } from "lucide-react";
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
interface FolderStatusPopoverProps {
deviceId: string;
status?: ClientFolderStatus;
isLoading?: boolean;
}
export function FolderStatusPopover({
deviceId,
status,
isLoading,
}: FolderStatusPopoverProps) {
const hasMissing = status && status.missingFiles.length > 0;
const hasExtra = status && status.extraFiles.length > 0;
const hasIssues = hasMissing || hasExtra;
// Xác định màu sắc và icon dựa trên trạng thái
let statusColor = "text-green-500";
let statusIcon = (
<CheckCircle2 className={`h-5 w-5 ${statusColor}`} />
);
if (isLoading) {
statusColor = "text-blue-500";
statusIcon = <Loader2 className={`h-5 w-5 animate-spin ${statusColor}`} />;
} else if (hasMissing && hasExtra) {
// Vừa thiếu vừa thừa -> Đỏ + Alert
statusColor = "text-red-600";
statusIcon = <AlertTriangle className={`h-5 w-5 ${statusColor}`} />;
} else if (hasMissing) {
// Chỉ thiếu -> Đỏ
statusColor = "text-red-500";
statusIcon = <AlertCircle className={`h-5 w-5 ${statusColor}`} />;
} else if (hasExtra) {
// Chỉ thừa -> Cam
statusColor = "text-orange-500";
statusIcon = <AlertCircle className={`h-5 w-5 ${statusColor}`} />;
}
return (
<Popover>
<PopoverTrigger asChild>
<button className="p-2 hover:bg-muted rounded-md transition-colors">
{statusIcon}
</button>
</PopoverTrigger>
<PopoverContent className="w-96 p-4" side="right">
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="text-sm font-semibold">Thư mục Setup: {deviceId}</div>
{hasIssues && (
<Badge variant="destructive" className="text-xs">
{hasMissing && hasExtra
? "Không đồng bộ"
: hasMissing
? "Thiếu file"
: "Thừa file"}
</Badge>
)}
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Đang kiểm tra...
</div>
) : !status ? (
<div className="text-sm text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<div className="space-y-3">
{/* File thiếu */}
{hasMissing && (
<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">
<AlertCircle className="h-4 w-4" />
File thiếu ({status.missingFiles.length})
</h4>
<ScrollArea className="h-32 rounded-md border bg-red-50/30 p-2">
<div className="space-y-2">
{status.missingFiles.map((file, idx) => (
<div
key={idx}
className="text-xs bg-white rounded p-2 border border-red-200"
>
<div className="font-mono font-semibold text-red-700">
{file.fileName}
</div>
<div className="text-xs text-muted-foreground mt-1">
{file.folderPath}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{/* File thừa */}
{hasExtra && (
<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">
<AlertCircle className="h-4 w-4" />
File thừa ({status.extraFiles.length})
</h4>
<ScrollArea className="h-32 rounded-md border bg-orange-50/30 p-2">
<div className="space-y-2">
{status.extraFiles.map((file, idx) => (
<div
key={idx}
className="text-xs bg-white rounded p-2 border border-orange-200"
>
<div className="font-mono font-semibold text-orange-700">
{file.fileName}
</div>
<div className="text-xs text-muted-foreground mt-1">
{file.folderPath}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{/* Trạng thái OK */}
{!hasIssues && (
<div className="flex items-center gap-2 text-sm text-green-600 bg-green-50/30 rounded p-3 border border-green-200">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<span className="font-medium">Thư mục đt yêu cầu</span>
</div>
)}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -1,67 +0,0 @@
import { FormBuilder, FormField } from "@/components/forms/dynamic-submit-form";
import { type BlacklistFormData } from "@/types/black-list";
import { toast } from "sonner";
interface BlacklistFormProps {
onSubmit: (data: BlacklistFormData) => Promise<void>;
closeDialog: () => void;
initialData?: Partial<BlacklistFormData>;
}
export function BlacklistForm({
onSubmit,
closeDialog,
initialData,
}: BlacklistFormProps) {
return (
<FormBuilder<BlacklistFormData>
defaultValues={{
appName: initialData?.appName || "",
processName: initialData?.processName || "",
}}
onSubmit={async (values: BlacklistFormData) => {
if (!values.appName.trim()) {
toast.error("Vui lòng nhập tên ứng dụng");
return;
}
if (!values.processName.trim()) {
toast.error("Vui lòng nhập tên tiến trình");
return;
}
try {
await onSubmit(values);
toast.success("Thêm phần mềm bị chặn thành công!");
closeDialog();
} catch (error) {
console.error("Error:", error);
toast.error("Có lỗi xảy ra!");
}
}}
submitLabel="Thêm"
cancelLabel="Hủy"
onCancel={closeDialog}
showCancel={true}
>
{(form: any) => (
<>
<FormField<BlacklistFormData, "appName">
form={form}
name="appName"
label="Tên ứng dụng"
placeholder="VD: Google Chrome"
required
/>
<FormField<BlacklistFormData, "processName">
form={form}
name="processName"
label="Tên tiến trình"
placeholder="VD: chrome.exe"
required
/>
</>
)}
</FormBuilder>
);
}

View File

@ -1,164 +0,0 @@
"use client";
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
import { useState } from "react";
export interface ShellCommandData {
command: string;
qos: 0 | 1 | 2;
isRetained: boolean;
}
interface ShellCommandFormProps {
command: string;
onCommandChange: (value: string) => void;
qos?: 0 | 1 | 2;
onQoSChange?: (value: 0 | 1 | 2) => void;
isRetained?: boolean;
onIsRetainedChange?: (value: boolean) => void;
disabled?: boolean;
}
const QoSDescriptions = {
0: {
name: "At Most Once (Fire and Forget)",
description:
"Gửi lệnh một lần mà không đảm bảo. Nhanh nhất, tiêu tốn ít tài nguyên.",
},
1: {
name: "At Least Once",
description:
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Cân bằng giữa tốc độ và độ tin cậy.",
},
2: {
name: "Exactly Once",
description:
"Đảm bảo lệnh được nhận chính xác một lần. Chậm nhất nhưng đáng tin cậy nhất.",
},
};
export function ShellCommandForm({
command,
onCommandChange,
qos = 0,
onQoSChange,
isRetained = false,
onIsRetainedChange,
disabled,
}: ShellCommandFormProps) {
const [selectedQoS, setSelectedQoS] = useState<0 | 1 | 2>(qos);
const form = useForm({
defaultValues: { command },
onSubmit: () => {},
});
const handleQoSChange = (value: string) => {
const newQoS = Number(value) as 0 | 1 | 2;
setSelectedQoS(newQoS);
onQoSChange?.(newQoS);
};
const handleRetainedChange = (checked: boolean) => {
onIsRetainedChange?.(checked);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-5"
>
{/* Command Input */}
<form.Field
name="command"
validators={{
onChange: ({ value }: { value: string }) => {
const schema = z
.string()
.min(1, "Nhập command để thực thi")
.max(500, "Command quá dài");
const result = schema.safeParse(value);
if (!result.success) {
return result.error.issues.map((i) => i.message);
}
return [];
},
}}
children={(field) => (
<div className="w-full space-y-2">
<Label>Nội Dung Lệnh *</Label>
<Textarea
className="w-full h-[20vh] font-mono"
placeholder="VD: shutdown /s /t 60 /c 'Máy sẽ tắt trong 60 giây'"
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
onCommandChange(e.target.value);
}}
disabled={disabled}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
/>
{/* QoS Selection */}
<div className="space-y-2">
<Label>QoS (Quality of Service) *</Label>
<select
value={selectedQoS}
onChange={(e) => handleQoSChange(e.target.value)}
disabled={disabled}
className="w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="0">QoS 0 - At Most Once (Tốc đ cao)</option>
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
<option value="2">QoS 2 - Exactly Once (Đ tin cậy cao)</option>
</select>
{/* QoS Description */}
<Alert className="border-l-4 border-l-blue-500 bg-blue-50 mt-2">
<Info className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-sm text-blue-800 mt-1">
<div className="font-semibold">
{QoSDescriptions[selectedQoS].name}
</div>
<div className="mt-1">{QoSDescriptions[selectedQoS].description}</div>
</AlertDescription>
</Alert>
</div>
{/* Retained Checkbox */}
<div className="flex items-center gap-3 rounded-lg border p-4">
<Checkbox
id="retained"
checked={isRetained}
onCheckedChange={handleRetainedChange}
disabled={disabled}
/>
<div className="flex-1">
<Label htmlFor="retained" className="text-base cursor-pointer">
Lưu giữ lệnh (Retained)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Broker MQTT sẽ lưu lệnh này gửi cho client mới khi kết nối. Hữu ích
cho các lệnh cấu hình cần duy trì trạng thái.
</p>
</div>
</div>
</form>
);
}

View File

@ -1,379 +0,0 @@
import { useForm } from "@tanstack/react-form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
interface CommandRegistryFormProps {
onSubmit: (data: CommandRegistryFormData) => Promise<void>;
closeDialog?: () => void;
initialData?: Partial<CommandRegistryFormData>;
title?: string;
}
export interface CommandRegistryFormData {
commandName: string;
description?: string;
commandContent: string;
qos: 0 | 1 | 2;
isRetained: boolean;
}
// Zod validation schema
const commandRegistrySchema = z.object({
commandName: z
.string()
.min(1, "Tên lệnh không được để trống")
.min(3, "Tên lệnh phải có ít nhất 3 ký tự")
.max(100, "Tên lệnh tối đa 100 ký tự")
.trim(),
description: z.string().max(500, "Mô tả tối đa 500 ký tự").optional(),
commandContent: z
.string()
.min(1, "Nội dung lệnh không được để trống")
.min(5, "Nội dung lệnh phải có ít nhất 5 ký tự")
.trim(),
qos: z.union([z.literal(0), z.literal(1), z.literal(2)]),
isRetained: z.boolean(),
});
const QoSLevels = [
{
level: 0,
name: "At Most Once (Fire and Forget)",
description:
"Gửi lệnh một lần mà không đảm bảo. Nếu broker hoặc client bị ngắt kết nối, lệnh có thể bị mất. Tốc độ nhanh nhất, tiêu tốn ít tài nguyên.",
useCase: "Các lệnh không quan trọng, có thể mất mà không ảnh hưởng",
},
{
level: 1,
name: "At Least Once",
description:
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Có thể gửi lại nếu chưa nhận được ACK. Lệnh có thể được nhận nhiều lần.",
useCase: "Hầu hết các lệnh bình thường cần đảm bảo gửi thành công",
},
{
level: 2,
name: "Exactly Once",
description:
"Đảm bảo lệnh được nhận chính xác một lần. Sử dụng bắt tay 4 chiều để đảm bảo độ tin cậy cao nhất. Tốc độ chậm hơn, tiêu tốn nhiều tài nguyên.",
useCase: "Các lệnh quan trọng như xóa dữ liệu, thay đổi cấu hình",
},
];
export function CommandRegistryForm({
onSubmit,
closeDialog,
initialData,
title = "Đăng ký Lệnh Mới",
}: CommandRegistryFormProps) {
const [selectedQoS, setSelectedQoS] = useState<number>(
initialData?.qos ?? 0
);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm({
defaultValues: {
commandName: initialData?.commandName || "",
description: initialData?.description || "",
commandContent: initialData?.commandContent || "",
qos: (initialData?.qos || 0) as 0 | 1 | 2,
isRetained: initialData?.isRetained || false,
},
onSubmit: async ({ value }) => {
try {
// Validate using Zod
const validatedData = commandRegistrySchema.parse(value);
setIsSubmitting(true);
await onSubmit(validatedData as CommandRegistryFormData);
toast.success("Lưu lệnh thành công!");
if (closeDialog) {
closeDialog();
}
} catch (error: any) {
if (error.errors?.length > 0) {
toast.error(error.errors[0].message);
} else {
console.error("Submit error:", error);
toast.error("Có lỗi xảy ra khi lưu lệnh!");
}
} finally {
setIsSubmitting(false);
}
},
});
return (
<div className="w-full space-y-6">
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>
Tạo cấu hình lệnh MQTT mới đ điều khiển thiết bị
</CardDescription>
</CardHeader>
<CardContent>
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{/* Tên lệnh */}
<form.Field name="commandName">
{(field: any) => (
<div className="space-y-2">
<Label>
Tên Lệnh <span className="text-red-500">*</span>
</Label>
<Input
placeholder="VD: RestartDevice, ShutdownPC, UpdateSoftware..."
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
Tên đnh danh duy nhất cho lệnh này
</p>
</div>
)}
</form.Field>
{/* Mô tả */}
<form.Field name="description">
{(field: any) => (
<div className="space-y-2">
<Label> Tả (Tùy chọn)</Label>
<Textarea
placeholder="Nhập mô tả chi tiết về lệnh này..."
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
rows={3}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
tả chi tiết về chức năng cách sử dụng lệnh
</p>
</div>
)}
</form.Field>
{/* Nội dung lệnh */}
<form.Field name="commandContent">
{(field: any) => (
<div className="space-y-2">
<Label>
Nội Dung Lệnh <span className="text-red-500">*</span>
</Label>
<Textarea
placeholder="VD: shutdown /s /t 30 /c 'Máy sẽ tắt trong 30 giây'"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
rows={5}
className="font-mono text-sm"
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
Nội dung lệnh sẽ đưc gửi tới thiết bị (PowerShell, CMD, bash...)
</p>
</div>
)}
</form.Field>
{/* QoS Level */}
<form.Field name="qos">
{(field: any) => (
<div className="space-y-2">
<Label>
QoS (Quality of Service) <span className="text-red-500">*</span>
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
value={field.state.value}
onChange={(e) => {
const value = Number(e.target.value) as 0 | 1 | 2;
field.handleChange(value);
setSelectedQoS(value);
}}
onBlur={field.handleBlur}
disabled={isSubmitting}
>
<option value="0">QoS 0 - At Most Once (Tốc đ cao)</option>
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
<option value="2">QoS 2 - Exactly Once (Đ tin cậy cao)</option>
</select>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</form.Field>
{/* Chú thích QoS */}
{selectedQoS !== null && (
<Alert className="border-l-4 border-l-blue-500 bg-blue-50">
<Info className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-sm space-y-3 mt-2">
<div>
<div className="font-semibold text-blue-900">
{QoSLevels[selectedQoS].name}
</div>
<div className="text-blue-800 mt-1">
{QoSLevels[selectedQoS].description}
</div>
<div className="text-blue-700 mt-2">
<span className="font-medium">Trường hợp sử dụng:</span>{" "}
{QoSLevels[selectedQoS].useCase}
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* Bảng so sánh QoS */}
<Card className="bg-muted/50">
<CardHeader className="pb-3">
<CardTitle className="text-base">
Bảng So Sánh Các Mức QoS
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-2 font-semibold">
Tiêu Chí
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 0
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 1
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 2
</th>
</tr>
</thead>
<tbody>
<tr className="border-b bg-white">
<td className="py-2 px-2">Đm bảo gửi</td>
<td className="text-center">Không</td>
<td className="text-center"></td>
<td className="text-center">Chính xác</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Tốc đ</td>
<td className="text-center">Nhanh nhất</td>
<td className="text-center">Trung bình</td>
<td className="text-center">Chậm nhất</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Tài nguyên</td>
<td className="text-center">Ít nhất</td>
<td className="text-center">Trung bình</td>
<td className="text-center">Nhiều nhất</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Đ tin cậy</td>
<td className="text-center">Thấp</td>
<td className="text-center">Cao</td>
<td className="text-center">Cao nhất</td>
</tr>
<tr className="bg-white">
<td className="py-2 px-2">Số lần nhận tối đa</td>
<td className="text-center">1 (hoặc 0)</td>
<td className="text-center"> 1</td>
<td className="text-center">1</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* IsRetained Checkbox */}
<form.Field name="isRetained">
{(field: any) => (
<div className="flex items-center gap-3 rounded-lg border p-4">
<Checkbox
checked={field.state.value}
onCheckedChange={field.handleChange}
disabled={isSubmitting}
/>
<div className="flex-1">
<Label className="text-base cursor-pointer">
Lưu giữ lệnh (Retained)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Broker MQTT sẽ lưu lệnh này gửi cho client mới khi
kết nối. Hữu ích cho các lệnh cấu hình cần duy trì trạng
thái.
</p>
</div>
</div>
)}
</form.Field>
{/* Submit Button */}
<div className="flex gap-3 pt-4">
<Button
type="submit"
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting ? "Đang lưu..." : "Lưu Lệnh"}
</Button>
{closeDialog && (
<Button
type="button"
variant="outline"
disabled={isSubmitting}
className="flex-1"
onClick={closeDialog}
>
Hủy
</Button>
)}
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,161 +0,0 @@
import { useForm } from "@tanstack/react-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { type ReactNode } from "react";
interface FormBuilderProps<T extends Record<string, any>> {
defaultValues: T;
onSubmit: (values: T) => Promise<void> | void;
submitLabel?: string;
cancelLabel?: string;
onCancel?: () => void;
showCancel?: boolean;
children: (form: any) => ReactNode;
}
export function FormBuilder<T extends Record<string, any>>({
defaultValues,
onSubmit,
submitLabel = "Submit",
cancelLabel = "Hủy",
onCancel,
showCancel = false,
children,
}: FormBuilderProps<T>) {
const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
try {
await onSubmit(value as T);
} catch (error) {
console.error("Submit error:", error);
toast.error("Có lỗi xảy ra!");
}
},
});
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{children(form)}
<div className="flex justify-end gap-2">
{showCancel && onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
{cancelLabel}
</Button>
)}
<Button type="submit">{submitLabel}</Button>
</div>
</form>
);
}
interface FormFieldProps<T, K extends keyof T> {
form: any;
name: K;
label: string;
type?: string;
placeholder?: string;
required?: boolean;
}
export function FormField<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
type = "text",
placeholder,
required,
}: FormFieldProps<T, K>) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<Input
type={type}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder={placeholder}
/>
</div>
)}
</form.Field>
);
}
export function FormTextarea<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
placeholder,
required,
}: Omit<FormFieldProps<T, K>, "type">) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder={placeholder}
/>
</div>
)}
</form.Field>
);
}
export function FormSelect<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
options,
required,
}: {
form: any;
name: K;
label: string;
options: { value: string; label: string }[];
required?: boolean;
}) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</form.Field>
);
}

View File

@ -1,112 +0,0 @@
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { LoginResquest } from "@/types/auth";
import { useMutation } from "@tanstack/react-query";
import { login } from "@/services/auth.service";
import { useState } from "react";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { Route } from "@/routes/(auth)/login";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/hooks/useAuth";
import { LoaderCircle } from "lucide-react";
export function LoginForm({ className }: React.ComponentProps<"form">) {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [formData, setFormData] = useState<LoginResquest>({
username: "",
password: "",
});
const auth = useAuth();
const router = useRouter();
const navigate = useNavigate();
const search = Route.useSearch() as { redirect?: string };
const mutation = useMutation({
mutationFn: login,
async onSuccess(data) {
localStorage.setItem("accesscontrol.auth.user", data.username!);
localStorage.setItem("token", data.token!);
localStorage.setItem("name", data.name!);
localStorage.setItem("acs", (data.access ?? "").toString());
localStorage.setItem("role", data.role.roleName ?? "");
localStorage.setItem("priority", (data.role.priority ?? 0).toString());
auth.setAuthenticated(true);
auth.login(data.username!);
await router.invalidate();
await navigate({ to: search.redirect || "/dashboard" });
},
onError(error) {
setErrorMessage(error.message || "Login failed");
}
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
mutation.mutate(formData);
};
return (
<div className={cn("flex flex-col gap-6", className)}>
<Card>
<CardHeader className="text-center flex flex-col items-center">
<CardTitle className="text-xl flex items-center gap-3">
<img src="/soict_logo.png" alt="logo" className="size-20" />
<p> Computer Management</p>
</CardTitle>
<CardDescription>Hệ thống quản phòng máy thực hành</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Tên đăng nhập</Label>
<Input
id="email"
type="text"
autoFocus
required
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
/>
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Mật khẩu</Label>
</div>
<Input
id="password"
type="password"
required
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
/>
</div>
{errorMessage && (
<div className="text-destructive text-sm font-medium">{errorMessage}</div>
)}
{mutation.isPending ? (
<Button className="w-full" disabled>
<LoaderCircle className="w-4 h-4 mr-1 animate-spin" />
Đang đăng nhập
</Button>
) : (
<Button type="submit" className="w-full">
Đăng nhập
</Button>
)}
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,139 +0,0 @@
import { useForm } from "@tanstack/react-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import type { AxiosProgressEvent } from "axios";
import { useState } from "react";
import { toast } from "sonner";
interface UploadVersionFormProps {
onSubmit: (fd: FormData, config?: { onUploadProgress: (e: AxiosProgressEvent) => void }) => Promise<void>;
closeDialog: () => void;
}
export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormProps) {
const [uploadPercent, setUploadPercent] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [isDone, setIsDone] = useState(false);
// Match server allowed extensions
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1"];
const isFileValid = (file: File) => {
const fileName = file.name.toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
};
const form = useForm({
defaultValues: { files: new DataTransfer().files, newVersion: "" },
onSubmit: async ({ value }) => {
if (!value.newVersion || value.files.length === 0) {
toast.error("Vui lòng điền đầy đủ thông tin");
return;
}
// Validate file types
const invalidFiles = Array.from(value.files).filter((f) => !isFileValid(f));
if (invalidFiles.length > 0) {
toast.error(
`File không hợp lệ: ${invalidFiles.map((f) => f.name).join(", ")}. Chỉ chấp nhận ${ALLOWED_EXTENSIONS.join(", ")}`
);
return;
}
try {
setIsUploading(true);
setUploadPercent(0);
setIsDone(false);
const fd = new FormData();
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
fd.append("Version", value.newVersion);
await onSubmit(fd, {
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.total) {
const progress = Math.round((e.loaded * 100) / e.total);
setUploadPercent(progress);
}
},
});
setIsDone(true);
} catch (error) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
} finally {
setIsUploading(false);
}
},
});
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.Field name="newVersion">
{(field) => (
<div>
<Label>Phiên bản</Label>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="1.0.0"
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
<form.Field name="files">
{(field) => (
<div>
<Label>File</Label>
<Input
type="file"
accept=".exe,.apk,.conf,.json,.xml,.setting,.lnk,.url,.seb"
onChange={(e) => e.target.files && field.handleChange(e.target.files)}
disabled={isUploading || isDone}
/>
<p className="text-xs text-muted-foreground mt-1">
Chỉ chấp nhận file: .exe, .apk, .conf, .json, .xml, .setting, .lnk, .url, .seb
</p>
</div>
)}
</form.Field>
{(uploadPercent > 0 || isUploading || isDone) && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
<span>{uploadPercent}%</span>
</div>
<Progress value={uploadPercent} className="w-full" />
</div>
)}
<div className="flex justify-end gap-2">
{!isDone ? (
<>
<Button type="button" variant="outline" onClick={closeDialog} disabled={isUploading}>
Hủy
</Button>
<Button type="submit" disabled={isUploading}>
{isUploading ? "Đang tải..." : "Upload"}
</Button>
</>
) : (
<Button type="button" onClick={closeDialog}>
Hoàn tất
</Button>
)}
</div>
</form>
);
}

View File

@ -1,140 +0,0 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Loader2, Trash2, ChevronDown, AlertTriangle } from "lucide-react";
import { useState } from "react";
interface DeleteMenuProps {
onDeleteFromServer: () => void;
onDeleteFromRequired: () => void;
loading?: boolean;
label?: string;
serverLabel?: string;
requiredLabel?: string;
}
export function DeleteMenu({
onDeleteFromServer,
onDeleteFromRequired,
loading,
label = "Xóa",
serverLabel = "Xóa khỏi server",
requiredLabel = "Xóa khỏi danh sách yêu cầu",
}: DeleteMenuProps) {
const [open, setOpen] = useState(false);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const handleDeleteFromServer = async () => {
try {
await onDeleteFromServer();
} finally {
setOpen(false);
setShowConfirmDelete(false);
}
};
const handleDeleteFromRequired = async () => {
try {
await onDeleteFromRequired();
} finally {
setOpen(false);
}
};
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="destructive"
disabled={loading}
className="group relative overflow-hidden font-medium px-6 py-2.5 rounded-lg transition-all duration-300 hover:shadow-lg hover:shadow-red-200/50 hover:-translate-y-0.5 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
>
<div className="flex items-center gap-2">
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
<span className="text-sm font-semibold">
{loading ? "Đang xóa..." : label}
</span>
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
<div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
onClick={handleDeleteFromRequired}
disabled={loading}
className="focus:bg-orange-50 focus:text-orange-900"
>
<Trash2 className="h-4 w-4 mr-2 text-orange-600" />
<span>{requiredLabel}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowConfirmDelete(true)}
disabled={loading}
className="focus:bg-red-50 focus:text-red-900"
>
<Trash2 className="h-4 w-4 mr-2 text-red-600" />
<span>{serverLabel}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Confirmation Dialog for Delete from Server */}
{showConfirmDelete && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-sm mx-4 shadow-lg">
<div className="flex items-center gap-3 mb-4">
<AlertTriangle className="h-6 w-6 text-red-600" />
<h3 className="font-semibold text-lg">Cảnh báo: Xóa khỏi server</h3>
</div>
<p className="text-muted-foreground mb-6">
Bạn đang chuẩn bị xóa các phần mềm này khỏi server. Hành đng này <strong>không thể hoàn tác</strong> sẽ xóa vĩnh viễn tất cả các tệp liên quan.
</p>
<p className="text-sm text-red-600 mb-6 font-medium">
Vui lòng chắc chắn trước khi tiếp tục.
</p>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setShowConfirmDelete(false)}
disabled={loading}
>
Hủy
</Button>
<Button
variant="destructive"
onClick={handleDeleteFromServer}
disabled={loading}
className="gap-2"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Đang xóa...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Xóa khỏi server
</>
)}
</Button>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -1,24 +0,0 @@
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Link } from "@tanstack/react-router";
export default function ErrorRoute({ error }: { error: string }) {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center">
<div className="bg-destructive/10 rounded-full p-6 mb-6">
<AlertTriangle className="h-12 w-12 text-destructive" />
</div>
<h1 className="text-4xl font-bold mb-4">Lỗi</h1>
<p className="text-muted-foreground mb-8 max-w-md">
Đã xảy ra lỗi: <strong>{error}</strong>
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button asChild variant="outline">
<Link to="/dashboard">Về trang chủ</Link>
</Button>
</div>
</div>
);
}

View File

@ -1,35 +0,0 @@
import { Button } from "@/components/ui/button";
import { Link } from "@tanstack/react-router";
import { ArrowLeft, Search } from "lucide-react";
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] px-4 text-center">
<div className="space-y-6 max-w-md mx-auto">
<div className="relative mx-auto w-40 h-40 md:w-52 md:h-52">
<div className="absolute inset-0 bg-primary/10 rounded-full animate-pulse" />
<div className="absolute inset-0 flex items-center justify-center">
<Search className="h-20 w-20 md:h-24 md:w-24 text-primary" strokeWidth={1.5} />
</div>
</div>
<div className="space-y-3">
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter">404</h1>
<h2 className="text-2xl md:text-3xl font-semibold">Không tìm thấy</h2>
<p className="text-muted-foreground">
Trang bạn yêu cầu truy cập không tồn tại hoặc đã bị xoá.
</p>
</div>
<div className="pt-6">
<Button asChild size="lg" className="gap-2">
<Link to="/dashboard" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Trở về trang chủ
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,151 @@
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Checkbox } from "@/components/ui/checkbox"
import { Play, PlayCircle } from "lucide-react"
import { useState } from "react"
interface PresetCommand {
id: string
label: string
command: string
description?: string
}
interface PresetCommandsProps {
onSelectCommand: (command: string) => void
onExecuteMultiple?: (commands: string[]) => void
disabled?: boolean
}
// Danh sách các command có sẵn
const PRESET_COMMANDS: PresetCommand[] = [
{
id: "check-disk",
label: "Kiểm tra dung lượng ổ đĩa",
command: "df -h",
description: "Hiển thị thông tin dung lượng các ổ đĩa",
},
{
id: "check-memory",
label: "Kiểm tra RAM",
command: "free -h",
description: "Hiển thị thông tin bộ nhớ RAM",
},
{
id: "check-cpu",
label: "Kiểm tra CPU",
command: "top -bn1 | head -20",
description: "Hiển thị thông tin CPU và tiến trình",
},
{
id: "list-processes",
label: "Danh sách tiến trình",
command: "ps aux",
description: "Liệt kê tất cả tiến trình đang chạy",
},
{
id: "network-info",
label: "Thông tin mạng",
command: "ifconfig",
description: "Hiển thị cấu hình mạng",
},
{
id: "system-info",
label: "Thông tin hệ thống",
command: "uname -a",
description: "Hiển thị thông tin hệ điều hành",
},
{
id: "uptime",
label: "Thời gian hoạt động",
command: "uptime",
description: "Hiển thị thời gian hệ thống đã chạy",
},
{
id: "reboot",
label: "Khởi động lại",
command: "reboot",
description: "Khởi động lại thiết bị",
},
]
export function PresetCommands({ onSelectCommand, onExecuteMultiple, disabled }: PresetCommandsProps) {
const [selectedCommands, setSelectedCommands] = useState<Set<string>>(new Set())
const handleToggleCommand = (commandId: string) => {
setSelectedCommands((prev) => {
const newSet = new Set(prev)
if (newSet.has(commandId)) {
newSet.delete(commandId)
} else {
newSet.add(commandId)
}
return newSet
})
}
const handleExecuteSelected = () => {
const commands = PRESET_COMMANDS.filter((cmd) => selectedCommands.has(cmd.id)).map((cmd) => cmd.command)
if (commands.length > 0 && onExecuteMultiple) {
onExecuteMultiple(commands)
setSelectedCommands(new Set()) // Clear selection after execution
}
}
const handleSelectAll = () => {
if (selectedCommands.size === PRESET_COMMANDS.length) {
setSelectedCommands(new Set())
} else {
setSelectedCommands(new Set(PRESET_COMMANDS.map((cmd) => cmd.id)))
}
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" onClick={handleSelectAll} disabled={disabled}>
<Checkbox checked={selectedCommands.size === PRESET_COMMANDS.length} className="mr-2" />
{selectedCommands.size === PRESET_COMMANDS.length ? "Bỏ chọn tất cả" : "Chọn tất cả"}
</Button>
{selectedCommands.size > 0 && (
<Button size="sm" onClick={handleExecuteSelected} disabled={disabled}>
<PlayCircle className="h-4 w-4 mr-2" />
Thực thi {selectedCommands.size} lệnh
</Button>
)}
</div>
<ScrollArea className="h-[25vh] w-full rounded-md border p-4">
<div className="space-y-2">
{PRESET_COMMANDS.map((preset) => (
<div
key={preset.id}
className="flex items-start gap-3 rounded-lg border p-3 hover:bg-accent transition-colors"
>
<Checkbox
checked={selectedCommands.has(preset.id)}
onCheckedChange={() => handleToggleCommand(preset.id)}
disabled={disabled}
className="mt-1"
/>
<div className="flex-1 space-y-1">
<div className="font-medium text-sm">{preset.label}</div>
{preset.description && <div className="text-xs text-muted-foreground">{preset.description}</div>}
<code className="text-xs bg-muted px-2 py-1 rounded block mt-1">{preset.command}</code>
</div>
<Button
size="sm"
variant="outline"
onClick={() => onSelectCommand(preset.command)}
disabled={disabled}
className="shrink-0"
>
<Play className="h-4 w-4" />
</Button>
</div>
))}
</div>
</ScrollArea>
</div>
)
}

View File

@ -7,18 +7,12 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Loader2, RefreshCw, ChevronDown } from "lucide-react";
import { useState } from "react";
interface RequestUpdateMenuProps {
onUpdateDevice: () => void;
onUpdateRoom: () => void;
onUpdateAll: () => void;
loading?: boolean;
label?: string;
deviceLabel?: string;
roomLabel?: string;
allLabel?: string;
icon?: React.ReactNode;
}
export function RequestUpdateMenu({
@ -26,39 +20,9 @@ export function RequestUpdateMenu({
onUpdateRoom,
onUpdateAll,
loading,
label = "Cập nhật",
deviceLabel = "Thiết bị cụ thể",
roomLabel = "Theo phòng",
allLabel = "Tất cả thiết bị",
icon,
}: RequestUpdateMenuProps) {
const [open, setOpen] = useState(false);
const handleUpdateDevice = async () => {
try {
await onUpdateDevice();
} finally {
setOpen(false);
}
};
const handleUpdateRoom = async () => {
try {
await onUpdateRoom();
} finally {
setOpen(false);
}
};
const handleUpdateAll = async () => {
try {
await onUpdateAll();
} finally {
setOpen(false);
}
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
@ -68,13 +32,11 @@ export function RequestUpdateMenu({
<div className="flex items-center gap-2">
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-600" />
) : icon ? (
<div className="h-4 w-4 text-gray-600">{icon}</div>
) : (
<RefreshCw className="h-4 w-4 text-gray-600 transition-transform duration-300 group-hover:rotate-180" />
)}
<span className="text-sm font-semibold">
{loading ? "Đang gửi..." : label}
{loading ? "Đang gửi..." : "Cập nhật"}
</span>
<ChevronDown className="h-4 w-4 text-gray-600 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
@ -83,19 +45,19 @@ export function RequestUpdateMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem onClick={handleUpdateDevice} disabled={loading}>
<DropdownMenuItem onClick={onUpdateDevice} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>{deviceLabel}</span>
<span>Cập nhật thiết bị cụ thể</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleUpdateRoom} disabled={loading}>
<DropdownMenuItem onClick={onUpdateRoom} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>{roomLabel}</span>
<span>Cập nhật theo phòng</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleUpdateAll} disabled={loading}>
<DropdownMenuItem onClick={onUpdateAll} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>{allLabel}</span>
<span>Cập nhật tất cả thiết bị</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -0,0 +1,135 @@
import { useEffect, useState, useMemo } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Check, Search } from "lucide-react"
import { Input } from "@/components/ui/input"
interface SelectDialogProps {
open: boolean
onClose: () => void
items: string[] // danh sách chung: có thể là devices hoặc rooms
title?: string // tiêu đề động
description?: string // mô tả ngắn
icon?: React.ReactNode // icon thay đổi tùy loại
onConfirm: (selected: string[]) => void
}
export function SelectDialog({
open,
onClose,
items,
title = "Chọn mục",
description = "Bạn có thể chọn nhiều mục để thao tác",
icon,
onConfirm,
}: SelectDialogProps) {
const [selectedItems, setSelectedItems] = useState<string[]>([])
const [search, setSearch] = useState("")
useEffect(() => {
if (!open) {
setSelectedItems([])
setSearch("")
}
}, [open])
const toggleItem = (item: string) => {
setSelectedItems((prev) =>
prev.includes(item)
? prev.filter((i) => i !== item)
: [...prev, item]
)
}
// Lọc danh sách theo từ khóa
const filteredItems = useMemo(() => {
return items.filter((item) =>
item.toLowerCase().includes(search.toLowerCase())
)
}, [items, search])
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="text-center pb-4">
<div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-3">
{icon ?? <Search className="w-6 h-6 text-primary" />}
</div>
<DialogTitle className="text-xl font-semibold">{title}</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
</DialogHeader>
{/* 🔍 Thanh tìm kiếm */}
<div className="relative mb-3">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Tìm kiếm..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Danh sách các item */}
<div className="py-3 space-y-3 max-h-64 overflow-y-auto">
{filteredItems.length > 0 ? (
filteredItems.map((item) => (
<div
key={item}
className="flex items-center justify-between p-3 rounded-lg border border-border hover:border-primary/60 hover:bg-accent/50 transition-all duration-200 cursor-pointer"
onClick={() => toggleItem(item)}
>
<div className="flex items-center gap-3">
<Checkbox
checked={selectedItems.includes(item)}
onCheckedChange={() => toggleItem(item)}
/>
<Label className="font-medium cursor-pointer hover:text-primary">
{item}
</Label>
</div>
{selectedItems.includes(item) && (
<div className="w-5 h-5 bg-primary rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
)}
</div>
))
) : (
<p className="text-center text-sm text-muted-foreground py-4">
Không tìm thấy kết quả
</p>
)}
</div>
<DialogFooter className="gap-2 pt-4">
<Button variant="outline" onClick={onClose} className="flex-1 sm:flex-none">
Hủy
</Button>
<Button
onClick={() => {
if (selectedItems.length > 0) {
onConfirm(selectedItems)
onClose()
}
}}
disabled={selectedItems.length === 0}
className="flex-1 sm:flex-none"
>
<Check className="w-4 h-4 mr-2" />
Xác nhận ({selectedItems.length})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -3,7 +3,7 @@ import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/useMobile"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

View File

@ -0,0 +1,164 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { useForm, formOptions } from "@tanstack/react-form";
import { toast } from "sonner";
import type { AxiosProgressEvent } from "axios";
interface UploadDialogProps {
onSubmit: (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => Promise<void>;
}
const formOpts = formOptions({
defaultValues: { files: new DataTransfer().files, newVersion: "" },
});
export function UploadDialog({ onSubmit }: UploadDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [isDone, setIsDone] = useState(false);
const form = useForm({
...formOpts,
onSubmit: async ({ value }) => {
if (!value.newVersion || value.files.length === 0) {
toast.error("Vui lòng điền đầy đủ thông tin");
return;
}
try {
setIsUploading(true);
setUploadPercent(0);
setIsDone(false);
const fd = new FormData();
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
fd.append("Version", value.newVersion);
await onSubmit(fd, {
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.total) {
const progress = Math.round((e.loaded * 100) / e.total);
setUploadPercent(progress);
}
},
});
setIsDone(true);
} catch (error) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
} finally {
setIsUploading(false);
}
},
});
const handleDialogClose = (open: boolean) => {
if (isUploading) return;
setIsOpen(open);
if (!open) {
setUploadPercent(0);
setIsDone(false);
form.reset();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleDialogClose}>
<DialogTrigger asChild>
<Button>Tải lên phiên bản mới</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Cập nhật phiên bản</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field name="newVersion">
{(field) => (
<div>
<Label>Phiên bản</Label>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="1.0.0"
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
<form.Field name="files">
{(field) => (
<div>
<Label>File</Label>
<Input
type="file"
accept=".exe,.msi,.apk"
onChange={(e) =>
e.target.files && field.handleChange(e.target.files)
}
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
{(uploadPercent > 0 || isUploading || isDone) && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
<span>{uploadPercent}%</span>
</div>
<Progress value={uploadPercent} className="w-full" />
</div>
)}
<DialogFooter>
{!isDone ? (
<>
<Button
type="button"
variant="outline"
onClick={() => handleDialogClose(false)}
disabled={isUploading}
>
Hủy
</Button>
<Button type="submit" disabled={isUploading}>
{isUploading ? "Đang tải..." : "Upload"}
</Button>
</>
) : (
<Button type="button" onClick={() => handleDialogClose(false)}>
Hoàn tất
</Button>
)}
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -18,7 +18,7 @@ interface VersionTableProps<TData> {
data: TData[];
columns: ColumnDef<TData, any>[];
isLoading: boolean;
onTableInit?: (table: any) => void;
onTableInit?: (table: any) => void; // <-- thêm
}
export function VersionTable<TData>({

View File

@ -5,59 +5,29 @@ export const BASE_URL = isDev
: "/api";
export const API_ENDPOINTS = {
AUTH: {
LOGIN: `${BASE_URL}/login`,
LOGOUT: `${BASE_URL}/logout`,
CHANGE_PASSWORD: `${BASE_URL}/auth/change-password`,
CHANGE_PASSWORD_ADMIN: `${BASE_URL}/auth/admin/change-password`,
PING: `${BASE_URL}/ping`,
CSRF_TOKEN: `${BASE_URL}/csrf-token`,
CREATE_ACCOUNT: `${BASE_URL}/auth/create-account`,
},
APP_VERSION: {
//agent and app api
GET_VERSION: `${BASE_URL}/AppVersion/version`,
UPLOAD: `${BASE_URL}/AppVersion/upload`,
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
//blacklist api
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
DELETE_BLACKLIST: (appId: number) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
DELETE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
UPDATE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/update/${appId}`,
REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
//require file api
GET_REQUIRED_FILES: `${BASE_URL}/AppVersion/requirefiles`,
ADD_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/add`,
DELETE_REQUIRED_FILE: (fileId: number) => `${BASE_URL}/AppVersion/requirefile/delete/${fileId}`,
DELETE_FILES: (fileId: number) => `${BASE_URL}/AppVersion/delete/${fileId}`,
},
DEVICE_COMM: {
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
INSTALL_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
GET_DEVICE_FROM_ROOM: (roomName: string) =>
`${BASE_URL}/DeviceComm/room/${roomName}`,
UPDATE_AGENT: (roomName: string) => `${BASE_URL}/DeviceComm/updateagent/${roomName}`,
UPDATE_BLACKLIST: (roomName: string) => `${BASE_URL}/DeviceComm/updateblacklist/${roomName}`,
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
REQUEST_GET_CLIENT_FOLDER_STATUS: (roomName: string) =>
`${BASE_URL}/DeviceComm/clientfolderstatus/${roomName}`,
},
COMMAND:
{
ADD_COMMAND: `${BASE_URL}/Command/add`,
GET_COMMANDS: `${BASE_URL}/Command/all`,
UPDATE_COMMAND: (commandId: number) => `${BASE_URL}/Command/update/${commandId}`,
DELETE_COMMAND: (commandId: number) => `${BASE_URL}/Command/delete/${commandId}`,
},
SSE_EVENTS: {
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
GET_CLIENT_FOLDER_STATUS: `${BASE_URL}/Sse/events/clientFolderStatuses`,
},
};

View File

@ -1,9 +0,0 @@
import type { Room } from "@/types/room";
import type { SelectItem } from "@/components/dialogs/select-dialog";
export function mapRoomsToSelectItems(rooms: Room[]): SelectItem[] {
return rooms.map((room) => ({
label: `${room.name} (${room.numberOfDevices} máy, ${room.numberOfOfflineDevices} offline)`,
value: room.name,
}));
}

View File

@ -1,11 +0,0 @@
// Auth Queries
export * from "./useAuthQueries";
// App Version Queries
export * from "./useAppVersionQueries";
// Device Communication Queries
export * from "./useDeviceCommQueries";
// Command Queries
export * from "./useCommandQueries";

View File

@ -1,186 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as appVersionService from "@/services/app-version.service";
import type { AxiosProgressEvent } from "axios";
import type { Version } from "@/types/file";
const APP_VERSION_QUERY_KEYS = {
all: ["app-version"] as const,
agentVersion: () => [...APP_VERSION_QUERY_KEYS.all, "agent"] as const,
softwareList: () => [...APP_VERSION_QUERY_KEYS.all, "software"] as const,
blacklist: () => [...APP_VERSION_QUERY_KEYS.all, "blacklist"] as const,
requiredFiles: () => [...APP_VERSION_QUERY_KEYS.all, "required-files"] as const,
};
/**
* Hook đ lấy danh sách phiên bản agent
*/
export function useGetAgentVersion(enabled = true) {
return useQuery<Version[]>({
queryKey: APP_VERSION_QUERY_KEYS.agentVersion(),
queryFn: () => appVersionService.getAgentVersion(),
enabled,
staleTime: 60 * 1000, // 1 minute
});
}
/**
* Hook đ lấy danh sách phần mềm
*/
export function useGetSoftwareList(enabled = true) {
return useQuery<Version[]>({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
queryFn: () => appVersionService.getSoftwareList(),
enabled,
staleTime: 60 * 1000,
});
}
/**
* Hook đ upload file
*/
export function useUploadSoftware() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: {
formData: FormData;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
}) => appVersionService.uploadSoftware(data.formData, data.onUploadProgress),
onSuccess: () => {
// Invalidate software list
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
});
},
});
}
/**
* Hook đ lấy danh sách blacklist
*/
export function useGetBlacklist(enabled = true) {
return useQuery({
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
queryFn: () => appVersionService.getBlacklist(),
enabled,
staleTime: 60 * 1000,
});
}
/**
* Hook đ thêm vào blacklist
*/
export function useAddBlacklist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => appVersionService.addBlacklist(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
});
},
});
}
/**
* Hook đ xóa khỏi blacklist
*/
export function useDeleteBlacklist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (appId: number) => appVersionService.deleteBlacklist(appId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
});
},
});
}
/**
* Hook đ cập nhật blacklist
*/
export function useUpdateBlacklist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ appId, data }: { appId: string; data: any }) =>
appVersionService.updateBlacklist(appId, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
});
},
});
}
/**
* Hook đ yêu cầu cập nhật blacklist
*/
export function useRequestUpdateBlacklist() {
return useMutation({
mutationFn: (data: any) => appVersionService.requestUpdateBlacklist(data),
});
}
/**
* Hook đ lấy danh sách file bắt buộc
*/
export function useGetRequiredFiles(enabled = true) {
return useQuery({
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
queryFn: () => appVersionService.getRequiredFiles(),
enabled,
staleTime: 60 * 1000,
});
}
/**
* Hook đ thêm file bắt buộc
*/
export function useAddRequiredFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => appVersionService.addRequiredFile(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
});
},
});
}
/**
* Hook đ xóa file bắt buộc
*/
export function useDeleteRequiredFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (fileId: number) => appVersionService.deleteRequiredFile(fileId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
});
},
});
}
/**
* Hook đ xóa file
*/
export function useDeleteFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (fileId: number) => appVersionService.deleteFile(fileId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
});
},
});
}

View File

@ -1,120 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as authService from "@/services/auth.service";
import type { LoginResquest, LoginResponse } from "@/types/auth";
const AUTH_QUERY_KEYS = {
all: ["auth"] as const,
ping: () => [...AUTH_QUERY_KEYS.all, "ping"] as const,
csrfToken: () => [...AUTH_QUERY_KEYS.all, "csrf-token"] as const,
};
/**
* Hook đ đăng nhập
*/
export function useLogin() {
const queryClient = useQueryClient();
return useMutation<LoginResponse, any, LoginResquest>({
mutationFn: (credentials) => authService.login(credentials),
onSuccess: (data) => {
// Lưu vào localStorage
if (data.token) {
localStorage.setItem("token", data.token);
localStorage.setItem("username", data.username || "");
localStorage.setItem("name", data.name || "");
localStorage.setItem("acs", JSON.stringify(data.access || []));
localStorage.setItem("role", data.role?.roleName || "");
localStorage.setItem("priority", String(data.role?.priority || "-1"));
}
// Invalidate ping query
queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.ping() });
},
});
}
/**
* Hook đ đăng xuất
*/
export function useLogout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => authService.logout(),
onSuccess: () => {
localStorage.removeItem("token");
localStorage.removeItem("username");
localStorage.removeItem("name");
localStorage.removeItem("acs");
localStorage.removeItem("role");
localStorage.removeItem("priority");
// Clear all queries
queryClient.clear();
},
});
}
/**
* Hook đ kiểm tra phiên đăng nhập
*/
export function usePing(token?: string, enabled = true) {
return useQuery({
queryKey: AUTH_QUERY_KEYS.ping(),
queryFn: () => authService.ping(token),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
}
/**
* Hook đ lấy CSRF token
*/
export function useGetCsrfToken(enabled = true) {
return useQuery({
queryKey: AUTH_QUERY_KEYS.csrfToken(),
queryFn: () => authService.getCsrfToken(),
enabled,
staleTime: Infinity,
});
}
/**
* Hook đ thay đi mật khẩu
*/
export function useChangePassword() {
return useMutation({
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
authService.changePassword(data),
});
}
/**
* Hook đ admin thay đi mật khẩu user khác
*/
export function useChangePasswordAdmin() {
return useMutation({
mutationFn: (data: { username: string; newPassword: string }) =>
authService.changePasswordAdmin(data),
});
}
/**
* Hook đ tạo tài khoản mới
*/
export function useCreateAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: {
userName: string;
password: string;
name: string;
roleId: number;
accessBuildings?: number[];
}) => authService.createAccount(data),
onSuccess: () => {
// Có thể invalidate user list query nếu có
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}

View File

@ -1,74 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as commandService from "@/services/command.service";
const COMMAND_QUERY_KEYS = {
all: ["commands"] as const,
list: () => [...COMMAND_QUERY_KEYS.all, "list"] as const,
detail: (id: number) => [...COMMAND_QUERY_KEYS.all, "detail", id] as const,
};
/**
* Hook đ lấy danh sách lệnh
*/
export function useGetCommandList(enabled = true) {
return useQuery({
queryKey: COMMAND_QUERY_KEYS.list(),
queryFn: () => commandService.getCommandList(),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ thêm lệnh mới
*/
export function useAddCommand() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => commandService.addCommand(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: COMMAND_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ cập nhật lệnh
*/
export function useUpdateCommand() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
commandId,
data,
}: {
commandId: number;
data: any;
}) => commandService.updateCommand(commandId, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: COMMAND_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ xóa lệnh
*/
export function useDeleteCommand() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (commandId: number) => commandService.deleteCommand(commandId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: COMMAND_QUERY_KEYS.list(),
});
},
});
}

View File

@ -1,172 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as deviceCommService from "@/services/device-comm.service";
import type { DeviceHealthCheck } from "@/types/device";
const DEVICE_COMM_QUERY_KEYS = {
all: ["device-comm"] as const,
allDevices: () => [...DEVICE_COMM_QUERY_KEYS.all, "all"] as const,
roomList: () => [...DEVICE_COMM_QUERY_KEYS.all, "rooms"] as const,
devicesInRoom: (roomName: string) =>
[...DEVICE_COMM_QUERY_KEYS.all, "room", roomName] as const,
clientFolderStatus: (roomName: string) =>
[...DEVICE_COMM_QUERY_KEYS.all, "folder-status", roomName] as const,
};
/**
* Hook đ lấy tất cả thiết bị
*/
export function useGetAllDevices(enabled = true) {
return useQuery({
queryKey: DEVICE_COMM_QUERY_KEYS.allDevices(),
queryFn: () => deviceCommService.getAllDevices(),
enabled,
staleTime: 60 * 1000,
});
}
/**
* Hook đ lấy danh sách phòng
*/
export function useGetRoomList(enabled = true) {
return useQuery({
queryKey: DEVICE_COMM_QUERY_KEYS.roomList(),
queryFn: () => deviceCommService.getRoomList(),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ lấy danh sách thiết bị trong phòng
*/
export function useGetDeviceFromRoom(roomName?: string, enabled = true) {
return useQuery<DeviceHealthCheck[]>({
queryKey: roomName ? DEVICE_COMM_QUERY_KEYS.devicesInRoom(roomName) : ["disabled"],
queryFn: () =>
roomName ? deviceCommService.getDeviceFromRoom(roomName) : Promise.reject("No room"),
enabled: enabled && !!roomName,
staleTime: 30 * 1000, // 30 seconds
});
}
/**
* Hook đ tải file
*/
export function useDownloadFiles() {
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.downloadFiles(roomName, data),
});
}
/**
* Hook đ cài đt MSI
*/
export function useInstallMsi() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.installMsi(roomName, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: DEVICE_COMM_QUERY_KEYS.all,
});
},
});
}
/**
* Hook đ cập nhật agent
*/
export function useUpdateAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.updateAgent(roomName, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: DEVICE_COMM_QUERY_KEYS.all,
});
},
});
}
/**
* Hook đ cập nhật blacklist
*/
export function useUpdateDeviceBlacklist() {
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.updateBlacklist(roomName, data),
});
}
/**
* Hook đ gửi lệnh shell
*/
export function useSendCommand() {
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.sendCommand(roomName, data),
});
}
/**
* Hook đ thay đi phòng của thiết bị
*/
export function useChangeDeviceRoom() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => deviceCommService.changeDeviceRoom(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: DEVICE_COMM_QUERY_KEYS.all,
});
},
});
}
/**
* Hook đ lấy trạng thái folder client
*/
export function useGetClientFolderStatus(roomName?: string, enabled = true) {
return useQuery({
queryKey: roomName
? DEVICE_COMM_QUERY_KEYS.clientFolderStatus(roomName)
: ["disabled"],
queryFn: () =>
roomName
? deviceCommService.getClientFolderStatus(roomName)
: Promise.reject("No room"),
enabled: enabled && !!roomName,
staleTime: 30 * 1000,
});
}

View File

@ -1,106 +0,0 @@
import { sleep } from "@/lib/utils";
import { PermissionEnum } from "@/types/permission";
import React, { useContext, useEffect, useMemo } from "react";
import { useCallback, useState } from "react";
export interface IAuthContext {
isAuthenticated: boolean;
setAuthenticated: (value: boolean) => void;
logout: () => Promise<void>;
login: (username: string) => void;
username: string;
token: string;
name: string;
acs: number[];
hasPermission: (permission: PermissionEnum) => boolean;
role: {
roleName: string;
priority: number;
};
}
const AuthContext = React.createContext<IAuthContext | null>(null);
const key = "accesscontrol.auth.user";
function getStoredUser() {
return localStorage.getItem(key);
}
function setStoredUser(user: string | null) {
if (user) {
localStorage.setItem(key, user);
} else {
localStorage.removeItem(key);
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<string>(getStoredUser() || "");
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(!!user);
const token = localStorage.getItem("token") || "";
const name = localStorage.getItem("name") || "";
const acsString = localStorage.getItem("acs");
const acs = useMemo(() => (acsString ? acsString.split(",").map(Number) : []), [acsString]);
const roleName = localStorage.getItem("role") || "";
const priority = localStorage.getItem("priority") || "-1";
const setAuthenticated = useCallback((value: boolean) => {
setIsAuthenticated(value);
}, []);
const login = useCallback((username: string) => {
setStoredUser(username);
setUser(username);
}, []);
const hasPermission = useCallback(
(permission: PermissionEnum) => {
return acs.some((a) => a === permission);
},
[acs]
);
const logout = useCallback(async () => {
await sleep(250);
setAuthenticated(false);
setStoredUser("");
setUser("");
localStorage.removeItem("token");
localStorage.removeItem("name");
localStorage.removeItem("acs");
localStorage.removeItem("role");
localStorage.removeItem("priority");
}, [setAuthenticated]);
useEffect(() => {
setUser(getStoredUser() || "");
}, []);
return (
<AuthContext.Provider
value={{
isAuthenticated,
setAuthenticated,
login,
logout,
username: user,
token,
name,
acs,
role: { roleName, priority: Number(priority) },
hasPermission
}}>
{children}
</AuthContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

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

@ -0,0 +1,53 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios, { type Method } from "axios";
type MutationDataOptions<TInput, TOutput> = {
url: string;
method?: Method;
onSuccess?: (data: TOutput) => void;
onError?: (error: any) => void;
invalidate?: string[][];
};
export function useMutationData<TInput = any, TOutput = any>({
url,
method = "POST",
onSuccess,
onError,
invalidate = [],
}: MutationDataOptions<TInput, TOutput>) {
const queryClient = useQueryClient();
return useMutation<
TOutput,
any,
{ data: TInput; url?: string; config?: any; method?: Method }
>({
mutationFn: async ({
data,
config,
url: customUrl,
method: customMethod,
}) => {
const isFormData = data instanceof FormData;
const response = await axios.request({
url: customUrl ?? url,
method: customMethod ?? method,
data,
headers: {
...(isFormData ? {} : { "Content-Type": "application/json" }),
},
...config,
});
return response.data;
},
onSuccess: (data) => {
invalidate.forEach((key) =>
queryClient.invalidateQueries({ queryKey: key })
);
onSuccess?.(data);
},
onError,
});
}

26
src/hooks/useQueryData.ts Normal file
View File

@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
type QueryDataOptions<T> = {
queryKey: string[];
url: string;
params?: Record<string, any>;
select?: (data: any) => T;
enabled?: boolean;
}
export function useQueryData<T = any>({
queryKey,
url,
params,
select,
enabled = true,
}: QueryDataOptions<T>) {
return useQuery<T>({
queryKey,
queryFn: () => axios.get(url, { params }).then((res) => res.data),
select,
enabled,
})
}

View File

@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { AppSidebar } from "@/components/sidebars/app-sidebar";
import { AppSidebar } from "@/components/app-sidebar";
import {
SidebarProvider,
SidebarInset,
@ -8,13 +8,8 @@ import {
import { Home, Building, AppWindow, Terminal, CircleX } from "lucide-react";
import { Toaster } from "@/components/ui/sonner";
import { useQueryClient } from "@tanstack/react-query";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { Separator } from "@/components/ui/separator";
import {
useGetAgentVersion,
useGetSoftwareList,
useGetRoomList,
useGetBlacklist,
} from "@/hooks/queries";
type AppLayoutProps = {
children: ReactNode;
@ -25,32 +20,43 @@ export default function AppLayout({ children }: AppLayoutProps) {
const handlePrefetchAgents = () => {
queryClient.prefetchQuery({
queryKey: ["app-version", "agent"],
queryFn: useGetAgentVersion as any,
queryKey: ["agent-version"],
queryFn: () =>
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION).then((res) =>
res.json()
),
staleTime: 60 * 1000,
});
};
const handlePrefetchSofware = () => {
queryClient.prefetchQuery({
queryKey: ["app-version", "software"],
queryFn: useGetSoftwareList as any,
queryKey: ["software-version"],
queryFn: () =>
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE).then((res) =>
res.json()
),
staleTime: 60 * 1000,
});
};
const handlePrefetchRooms = () => {
queryClient.prefetchQuery({
queryKey: ["device-comm", "rooms"],
queryFn: useGetRoomList as any,
staleTime: 5 * 60 * 1000,
queryKey: ["room-list"],
queryFn: () =>
fetch(BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST).then((res) =>
res.json()
),
staleTime: 60 * 1000,
});
};
const handlePrefetchBannedSoftware = () => {
queryClient.prefetchQuery({
queryKey: ["app-version", "blacklist"],
queryFn: useGetBlacklist as any,
queryKey: ["blacklist"],
queryFn: () =>
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION).then((res) =>
res.json()
),
staleTime: 60 * 1000,
});
};

View File

@ -4,7 +4,3 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@ -1,24 +1,25 @@
/* eslint-disable react-refresh/only-export-components */
import { StrictMode } from "react";
import "./index.css";
import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import useAuthToken from "./hooks/useAuthtoken";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Import the generated route tree
import { routeTree } from "./routeTree.gen";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import axios from "axios";
import { AuthProvider, useAuth } from "@/hooks/useAuth";
import { toast, Toaster } from "sonner";
import "./styles.css";
const auth = useAuthToken.getState();
const queryClient = new QueryClient();
// Create a new router instance
const router = createRouter({
routeTree,
context: { auth },
defaultPreload: "intent",
scrollRestoration: true,
context: {
auth: undefined!, // This will be set after we initialize the auth store
queryClient: undefined!
}
defaultStructuralSharing: true,
defaultPreloadStaleTime: 0,
});
// Register the router instance for type safety
@ -26,61 +27,18 @@ declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
interface HistoryState {
name?: string;
}
}
function InnerApp() {
const auth = useAuth();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: unknown) => {
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
if (error.response?.data.message != null) {
toast.error("Không có quyền truy cập!");
return false;
} else {
auth.logout();
queryClient.invalidateQueries();
queryClient.clear();
return false;
}
}
}
return failureCount < 3;
}
}
}
});
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} context={{ auth, queryClient }} />
</QueryClientProvider>
);
}
function App() {
return (
<AuthProvider>
<InnerApp />
</AuthProvider>
);
}
// Render the app
const rootElement = document.getElementById("app");
if (!rootElement) {
throw new Error("Failed to find the root element");
if (rootElement && !rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<QueryClientProvider client={queryClient}>
{" "}
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>
);
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<App />
<Toaster richColors />
</StrictMode>
);

View File

@ -9,18 +9,22 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthRoomIndexRouteImport } from './routes/_auth/room/index'
import { Route as AuthDeviceIndexRouteImport } from './routes/_auth/device/index'
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
import { Route as AuthCommandIndexRouteImport } from './routes/_auth/command/index'
import { Route as AuthBlacklistIndexRouteImport } from './routes/_auth/blacklist/index'
import { Route as AuthAppsIndexRouteImport } from './routes/_auth/apps/index'
import { Route as AuthAgentIndexRouteImport } from './routes/_auth/agent/index'
import { Route as authLoginIndexRouteImport } from './routes/(auth)/login/index'
import { Route as AuthRoomRoomNameIndexRouteImport } from './routes/_auth/room/$roomName/index'
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
import { Route as AuthenticatedDeviceIndexRouteImport } from './routes/_authenticated/device/index'
import { Route as AuthenticatedCommandIndexRouteImport } from './routes/_authenticated/command/index'
import { Route as AuthenticatedBlacklistIndexRouteImport } from './routes/_authenticated/blacklist/index'
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
import { Route as AuthenticatedAgentIndexRouteImport } from './routes/_authenticated/agent/index'
import { Route as AuthLoginIndexRouteImport } from './routes/_auth/login/index'
import { Route as AuthenticatedRoomRoomNameIndexRouteImport } from './routes/_authenticated/room/$roomName/index'
const AuthenticatedRoute = AuthenticatedRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
} as any)
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
@ -30,89 +34,86 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthRoomIndexRoute = AuthRoomIndexRouteImport.update({
const AuthenticatedRoomIndexRoute = AuthenticatedRoomIndexRouteImport.update({
id: '/room/',
path: '/room/',
getParentRoute: () => AuthRoute,
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthDeviceIndexRoute = AuthDeviceIndexRouteImport.update({
id: '/device/',
path: '/device/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
id: '/dashboard/',
path: '/dashboard/',
getParentRoute: () => AuthRoute,
} as any)
const AuthCommandIndexRoute = AuthCommandIndexRouteImport.update({
id: '/command/',
path: '/command/',
getParentRoute: () => AuthRoute,
} as any)
const AuthBlacklistIndexRoute = AuthBlacklistIndexRouteImport.update({
id: '/blacklist/',
path: '/blacklist/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAppsIndexRoute = AuthAppsIndexRouteImport.update({
const AuthenticatedDeviceIndexRoute =
AuthenticatedDeviceIndexRouteImport.update({
id: '/device/',
path: '/device/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedCommandIndexRoute =
AuthenticatedCommandIndexRouteImport.update({
id: '/command/',
path: '/command/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedBlacklistIndexRoute =
AuthenticatedBlacklistIndexRouteImport.update({
id: '/blacklist/',
path: '/blacklist/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAppsIndexRoute = AuthenticatedAppsIndexRouteImport.update({
id: '/apps/',
path: '/apps/',
getParentRoute: () => AuthRoute,
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthAgentIndexRoute = AuthAgentIndexRouteImport.update({
const AuthenticatedAgentIndexRoute = AuthenticatedAgentIndexRouteImport.update({
id: '/agent/',
path: '/agent/',
getParentRoute: () => AuthRoute,
getParentRoute: () => AuthenticatedRoute,
} as any)
const authLoginIndexRoute = authLoginIndexRouteImport.update({
id: '/(auth)/login/',
const AuthLoginIndexRoute = AuthLoginIndexRouteImport.update({
id: '/login/',
path: '/login/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthRoomRoomNameIndexRoute = AuthRoomRoomNameIndexRouteImport.update({
id: '/room/$roomName/',
path: '/room/$roomName/',
getParentRoute: () => AuthRoute,
} as any)
const AuthenticatedRoomRoomNameIndexRoute =
AuthenticatedRoomRoomNameIndexRouteImport.update({
id: '/room/$roomName/',
path: '/room/$roomName/',
getParentRoute: () => AuthenticatedRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof authLoginIndexRoute
'/agent': typeof AuthAgentIndexRoute
'/apps': typeof AuthAppsIndexRoute
'/blacklist': typeof AuthBlacklistIndexRoute
'/command': typeof AuthCommandIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/device': typeof AuthDeviceIndexRoute
'/room': typeof AuthRoomIndexRoute
'/room/$roomName': typeof AuthRoomRoomNameIndexRoute
'/login': typeof AuthLoginIndexRoute
'/agent': typeof AuthenticatedAgentIndexRoute
'/apps': typeof AuthenticatedAppsIndexRoute
'/blacklist': typeof AuthenticatedBlacklistIndexRoute
'/command': typeof AuthenticatedCommandIndexRoute
'/device': typeof AuthenticatedDeviceIndexRoute
'/room': typeof AuthenticatedRoomIndexRoute
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof authLoginIndexRoute
'/agent': typeof AuthAgentIndexRoute
'/apps': typeof AuthAppsIndexRoute
'/blacklist': typeof AuthBlacklistIndexRoute
'/command': typeof AuthCommandIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/device': typeof AuthDeviceIndexRoute
'/room': typeof AuthRoomIndexRoute
'/room/$roomName': typeof AuthRoomRoomNameIndexRoute
'/login': typeof AuthLoginIndexRoute
'/agent': typeof AuthenticatedAgentIndexRoute
'/apps': typeof AuthenticatedAppsIndexRoute
'/blacklist': typeof AuthenticatedBlacklistIndexRoute
'/command': typeof AuthenticatedCommandIndexRoute
'/device': typeof AuthenticatedDeviceIndexRoute
'/room': typeof AuthenticatedRoomIndexRoute
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/(auth)/login/': typeof authLoginIndexRoute
'/_auth/agent/': typeof AuthAgentIndexRoute
'/_auth/apps/': typeof AuthAppsIndexRoute
'/_auth/blacklist/': typeof AuthBlacklistIndexRoute
'/_auth/command/': typeof AuthCommandIndexRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
'/_auth/device/': typeof AuthDeviceIndexRoute
'/_auth/room/': typeof AuthRoomIndexRoute
'/_auth/room/$roomName/': typeof AuthRoomRoomNameIndexRoute
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/_auth/login/': typeof AuthLoginIndexRoute
'/_authenticated/agent/': typeof AuthenticatedAgentIndexRoute
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
'/_authenticated/blacklist/': typeof AuthenticatedBlacklistIndexRoute
'/_authenticated/command/': typeof AuthenticatedCommandIndexRoute
'/_authenticated/device/': typeof AuthenticatedDeviceIndexRoute
'/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
'/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -123,7 +124,6 @@ export interface FileRouteTypes {
| '/apps'
| '/blacklist'
| '/command'
| '/dashboard'
| '/device'
| '/room'
| '/room/$roomName'
@ -135,7 +135,6 @@ export interface FileRouteTypes {
| '/apps'
| '/blacklist'
| '/command'
| '/dashboard'
| '/device'
| '/room'
| '/room/$roomName'
@ -143,25 +142,32 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/_auth'
| '/(auth)/login/'
| '/_auth/agent/'
| '/_auth/apps/'
| '/_auth/blacklist/'
| '/_auth/command/'
| '/_auth/dashboard/'
| '/_auth/device/'
| '/_auth/room/'
| '/_auth/room/$roomName/'
| '/_authenticated'
| '/_auth/login/'
| '/_authenticated/agent/'
| '/_authenticated/apps/'
| '/_authenticated/blacklist/'
| '/_authenticated/command/'
| '/_authenticated/device/'
| '/_authenticated/room/'
| '/_authenticated/room/$roomName/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
authLoginIndexRoute: typeof authLoginIndexRoute
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_authenticated': {
id: '/_authenticated'
path: ''
fullPath: ''
preLoaderRoute: typeof AuthenticatedRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth': {
id: '/_auth'
path: ''
@ -176,100 +182,103 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/room/': {
id: '/_auth/room/'
'/_authenticated/room/': {
id: '/_authenticated/room/'
path: '/room'
fullPath: '/room'
preLoaderRoute: typeof AuthRoomIndexRouteImport
parentRoute: typeof AuthRoute
preLoaderRoute: typeof AuthenticatedRoomIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/device/': {
id: '/_auth/device/'
'/_authenticated/device/': {
id: '/_authenticated/device/'
path: '/device'
fullPath: '/device'
preLoaderRoute: typeof AuthDeviceIndexRouteImport
parentRoute: typeof AuthRoute
preLoaderRoute: typeof AuthenticatedDeviceIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/dashboard/': {
id: '/_auth/dashboard/'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof AuthDashboardIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/command/': {
id: '/_auth/command/'
'/_authenticated/command/': {
id: '/_authenticated/command/'
path: '/command'
fullPath: '/command'
preLoaderRoute: typeof AuthCommandIndexRouteImport
parentRoute: typeof AuthRoute
preLoaderRoute: typeof AuthenticatedCommandIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/blacklist/': {
id: '/_auth/blacklist/'
'/_authenticated/blacklist/': {
id: '/_authenticated/blacklist/'
path: '/blacklist'
fullPath: '/blacklist'
preLoaderRoute: typeof AuthBlacklistIndexRouteImport
parentRoute: typeof AuthRoute
preLoaderRoute: typeof AuthenticatedBlacklistIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/apps/': {
id: '/_auth/apps/'
'/_authenticated/apps/': {
id: '/_authenticated/apps/'
path: '/apps'
fullPath: '/apps'
preLoaderRoute: typeof AuthAppsIndexRouteImport
parentRoute: typeof AuthRoute
preLoaderRoute: typeof AuthenticatedAppsIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/agent/': {
id: '/_auth/agent/'
'/_authenticated/agent/': {
id: '/_authenticated/agent/'
path: '/agent'
fullPath: '/agent'
preLoaderRoute: typeof AuthAgentIndexRouteImport
parentRoute: typeof AuthRoute
preLoaderRoute: typeof AuthenticatedAgentIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/(auth)/login/': {
id: '/(auth)/login/'
'/_auth/login/': {
id: '/_auth/login/'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof authLoginIndexRouteImport
parentRoute: typeof rootRouteImport
preLoaderRoute: typeof AuthLoginIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/room/$roomName/': {
id: '/_auth/room/$roomName/'
'/_authenticated/room/$roomName/': {
id: '/_authenticated/room/$roomName/'
path: '/room/$roomName'
fullPath: '/room/$roomName'
preLoaderRoute: typeof AuthRoomRoomNameIndexRouteImport
parentRoute: typeof AuthRoute
preLoaderRoute: typeof AuthenticatedRoomRoomNameIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
}
}
interface AuthRouteChildren {
AuthAgentIndexRoute: typeof AuthAgentIndexRoute
AuthAppsIndexRoute: typeof AuthAppsIndexRoute
AuthBlacklistIndexRoute: typeof AuthBlacklistIndexRoute
AuthCommandIndexRoute: typeof AuthCommandIndexRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
AuthDeviceIndexRoute: typeof AuthDeviceIndexRoute
AuthRoomIndexRoute: typeof AuthRoomIndexRoute
AuthRoomRoomNameIndexRoute: typeof AuthRoomRoomNameIndexRoute
AuthLoginIndexRoute: typeof AuthLoginIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAgentIndexRoute: AuthAgentIndexRoute,
AuthAppsIndexRoute: AuthAppsIndexRoute,
AuthBlacklistIndexRoute: AuthBlacklistIndexRoute,
AuthCommandIndexRoute: AuthCommandIndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
AuthRoomIndexRoute: AuthRoomIndexRoute,
AuthRoomRoomNameIndexRoute: AuthRoomRoomNameIndexRoute,
AuthLoginIndexRoute: AuthLoginIndexRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface AuthenticatedRouteChildren {
AuthenticatedAgentIndexRoute: typeof AuthenticatedAgentIndexRoute
AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
AuthenticatedBlacklistIndexRoute: typeof AuthenticatedBlacklistIndexRoute
AuthenticatedCommandIndexRoute: typeof AuthenticatedCommandIndexRoute
AuthenticatedDeviceIndexRoute: typeof AuthenticatedDeviceIndexRoute
AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedAgentIndexRoute: AuthenticatedAgentIndexRoute,
AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
AuthenticatedBlacklistIndexRoute: AuthenticatedBlacklistIndexRoute,
AuthenticatedCommandIndexRoute: AuthenticatedCommandIndexRoute,
AuthenticatedDeviceIndexRoute: AuthenticatedDeviceIndexRoute,
AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
AuthenticatedRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
authLoginIndexRoute: authLoginIndexRoute,
AuthenticatedRoute: AuthenticatedRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@ -1,18 +0,0 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { LoginForm } from '@/components/forms/login-form'
export const Route = createFileRoute('/(auth)/login/')({
beforeLoad: async ({ context }) => {
const { token } = context.auth
if (token) throw redirect({ to: '/' })
},
component: LoginPage,
})
function LoginPage() {
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
<LoginForm className="w-full max-w-md" />
</div>
)
}

View File

@ -1,37 +1,22 @@
import ErrorRoute from "@/components/pages/error-route";
import NotFound from "@/components/pages/not-found";
import { type IAuthContext } from "@/types/auth";
import { QueryClient } from "@tanstack/react-query";
import {
createRootRouteWithContext,
HeadContent,
Outlet,
} from "@tanstack/react-router";
import { Outlet, createRootRouteWithContext, HeadContent } from '@tanstack/react-router'
import type { AuthTokenProps } from '@/hooks/useAuthtoken'
export interface BreadcrumbItem {
title: string;
path: string;
export interface RouterContext {
auth: AuthTokenProps
}
export interface MyRouterContext {
auth: IAuthContext;
queryClient: QueryClient;
breadcrumbs?: BreadcrumbItem[];
}
export const Route = createRootRouteWithContext<RouterContext>()({
head: () => ({
meta: [
{ title: "Quản lý phòng máy" },
{ name: "description", content: "Ứng dụng quản lý thiết bị và phần mềm" },
],
}),
component: () => (
<>
<HeadContent />
<Outlet />
</>
),
})
export const Route = createRootRouteWithContext<MyRouterContext>()({
component: () => {
return (
<>
<HeadContent />
<Outlet />
</>
);
},
notFoundComponent: () => {
return <NotFound />;
},
errorComponent: ({ error }) => {
return <ErrorRoute error={error.message} />;
},
});

View File

@ -1,21 +1,16 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import AppLayout from '@/layouts/app-layout'
import {createFileRoute, Outlet, redirect} from '@tanstack/react-router'
export const Route = createFileRoute('/_auth')({
beforeLoad: async ({context}) => {
const {token} = context.auth
if (!token) {
throw redirect({to: '/login'})
}
},
component: AuthenticatedLayout,
const {authToken} = context.auth
if (authToken) {
throw redirect({to: '/'})
}
},
component:AuthLayout ,
})
function AuthenticatedLayout() {
function AuthLayout() {
return (
<AppLayout>
<Outlet />
</AppLayout>
)
}

View File

@ -1,294 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { AppManagerTemplate } from "@/template/app-manager-template";
import {
useGetSoftwareList,
useGetRoomList,
useUploadSoftware,
useDeleteFile,
useAddRequiredFile,
useDeleteRequiredFile,
useInstallMsi,
useDownloadFiles,
} from "@/hooks/queries";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import type { AxiosProgressEvent } from "axios";
import type { Version } from "@/types/file";
import { Check, X } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/_auth/apps/")({
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
component: AppsComponent,
});
function AppsComponent() {
const { data, isLoading } = useGetSoftwareList();
const { data: roomData } = useGetRoomList();
const versionList: Version[] = Array.isArray(data)
? data
: data
? [data]
: [];
const [table, setTable] = useState<any>();
const uploadMutation = useUploadSoftware();
const installMutation = useInstallMsi();
const downloadMutation = useDownloadFiles();
const deleteMutation = useDeleteFile();
const addRequiredFileMutation = useAddRequiredFile();
const deleteRequiredFileMutation = useDeleteRequiredFile();
// Cột bảng
const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{ accessorKey: "folderPath", header: "Đường dẫn" },
{
accessorKey: "updatedAt",
header: () => <div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>,
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: () => <div className="whitespace-normal max-w-xs">Thời gian yêu cầu cài đt/tải xuống</div>,
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
id: "required",
header: () => <div className="whitespace-normal max-w-xs">Đã thêm vào danh sách</div>,
cell: ({ row }) => {
const isRequired = row.original.isRequired;
return isRequired ? (
<div className="flex items-center gap-1">
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</div>
) : (
<div className="flex items-center gap-1">
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</div>
);
},
enableSorting: false,
enableHiding: false,
},
{
id: "select",
header: () => <div className="whitespace-normal max-w-xs">Chọn</div>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
disabled={installMutation.isPending}
/>
),
enableSorting: false,
enableHiding: false,
},
];
// Upload file MSI
const handleUpload = async (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => {
try {
await uploadMutation.mutateAsync({
formData: fd,
onUploadProgress: config?.onUploadProgress,
});
toast.success("Upload thành công!");
} catch (error: any) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
}
};
// Callback khi chọn phòng
const handleInstall = async (roomNames: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const roomName of roomNames) {
await installMutation.mutateAsync({
roomName,
data: { MsiFileIds },
});
}
toast.success("Đã gửi yêu cầu cài đặt phần mềm cho các phòng đã chọn!");
} catch (e) {
toast.error("Có lỗi xảy ra khi cài đặt!");
}
};
const handleDonwload = async (roomNames: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để tải!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const roomName of roomNames) {
await downloadMutation.mutateAsync({
roomName,
data: { MsiFileIds },
});
}
toast.success("Đã gửi yêu cầu tải file cho các phòng đã chọn!");
} catch (e) {
toast.error("Có lỗi xảy ra khi tải!");
}
};
const handleDelete = async () => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để xóa!");
return;
}
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteMutation.mutateAsync(id);
}
toast.success("Xóa phần mềm thành công!");
} catch (e) {
toast.error("Xóa phần mềm thất bại!");
}
};
const handleDeleteFromRequiredList = async () => {
if (!table) return;
const selectedRows = table.getSelectedRowModel().rows;
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteRequiredFileMutation.mutateAsync(id);
}
toast.success("Xóa file khỏi danh sách thành công!");
if (table) {
table.setRowSelection({});
}
} catch (e) {
console.error("Delete from required list error:", e);
toast.error("Có lỗi xảy ra khi xóa!");
}
};
const handleDeleteFromServer = async () => {
if (!table) return;
const selectedRows = table.getSelectedRowModel().rows;
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteMutation.mutateAsync(id);
}
toast.success("Xóa phần mềm từ server thành công!");
if (table) {
table.setRowSelection({});
}
} catch (e) {
console.error("Delete error:", e);
toast.error("Có lỗi xảy ra khi xóa!");
}
};
const handleAddToRequired = async () => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file!");
return;
}
try {
for (const row of selectedRows) {
const { fileName, version } = row.original;
await addRequiredFileMutation.mutateAsync({
fileName,
version,
});
}
toast.success("Thêm file vào danh sách thành công!");
table.setRowSelection({});
} catch (e) {
console.error("Add required file error:", e);
toast.error("Có lỗi xảy ra!");
}
};
return (
<>
<AppManagerTemplate<Version>
title="Quản lý phần mềm"
uploadFormTitle="Tải lên || Cập nhật file phần mềm"
description="Quản lý và gửi yêu cầu cài đặt phần mềm hoặc file cấu hình"
data={versionList}
isLoading={isLoading}
columns={columns}
onUpload={handleUpload}
onUpdate={handleInstall}
onDownload={handleDonwload}
onDelete={handleDelete}
onDeleteFromServer={handleDeleteFromServer}
onDeleteFromRequired={handleDeleteFromRequiredList}
onAddToRequired={handleAddToRequired}
updateLoading={installMutation.isPending}
downloadLoading={downloadMutation.isPending}
deleteLoading={deleteMutation.isPending || deleteRequiredFileMutation.isPending}
addToRequiredLoading={addRequiredFileMutation.isPending}
onTableInit={setTable}
rooms={roomData}
/>
</>
);
}

View File

@ -1,159 +0,0 @@
import {
useGetBlacklist,
useGetRoomList,
useAddBlacklist,
useDeleteBlacklist,
useUpdateDeviceBlacklist,
} from "@/hooks/queries";
import { createFileRoute } from "@tanstack/react-router";
import type { ColumnDef } from "@tanstack/react-table";
import type { Blacklist } from "@/types/black-list";
import { BlackListManagerTemplate } from "@/template/table-manager-template";
import { toast } from "sonner";
import { useState } from "react";
export const Route = createFileRoute("/_auth/blacklist/")({
head: () => ({ meta: [{ title: "Danh sách các ứng dụng bị chặn" }] }),
component: BlacklistComponent,
});
function BlacklistComponent() {
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
// Lấy danh sách blacklist
const { data, isLoading } = useGetBlacklist();
// Lấy danh sách phòng
const { data: roomData = [] } = useGetRoomList();
const blacklist: Blacklist[] = Array.isArray(data)
? (data as Blacklist[])
: [];
const columns: ColumnDef<Blacklist>[] = [
{
accessorKey: "id",
header: "STT",
cell: (info) => info.getValue(),
},
{
accessorKey: "appName",
header: "Tên ứng dụng",
cell: (info) => info.getValue(),
},
{
accessorKey: "processName",
header: "Tên tiến trình",
cell: (info) => info.getValue(),
},
{
accessorKey: "createdAt",
header: "Ngày tạo",
cell: (info) => info.getValue(),
},
{
accessorKey: "updatedAt",
header: "Ngày cập nhật",
cell: (info) => info.getValue(),
},
{
accessorKey: "createdBy",
header: "Người tạo",
cell: (info) => info.getValue(),
},
{
id: "select",
header: () => (
<input
type="checkbox"
onChange={(e) => {
if (e.target.checked && data) {
const allIds = data.map((item: { id: number }) => item.id);
setSelectedRows(new Set(allIds));
} else {
setSelectedRows(new Set());
}
}}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={selectedRows.has(row.original.id)}
onChange={(e) => {
const newSelected = new Set(selectedRows);
if (e.target.checked) {
newSelected.add(row.original.id);
} else {
newSelected.delete(row.original.id);
}
setSelectedRows(newSelected);
}}
/>
),
},
];
// API mutations
const addNewBlacklistMutation = useAddBlacklist();
const deleteBlacklistMutation = useDeleteBlacklist();
const updateDeviceMutation = useUpdateDeviceBlacklist();
// Thêm blacklist
const handleAddNewBlacklist = async (blacklistData: {
appName: string;
processName: string;
}) => {
try {
await addNewBlacklistMutation.mutateAsync(blacklistData);
toast.success("Thêm mới thành công!");
} catch (error: any) {
console.error("Add blacklist error:", error);
toast.error("Thêm mới thất bại!");
}
};
// Xoá blacklist
const handleDeleteBlacklist = async () => {
try {
for (const blacklistId of selectedRows) {
await deleteBlacklistMutation.mutateAsync(blacklistId);
}
toast.success("Xóa thành công!");
setSelectedRows(new Set());
} catch (error: any) {
console.error("Delete blacklist error:", error);
toast.error("Xóa thất bại!");
}
};
const handleUpdateDevice = async (target: string | string[]) => {
const targets = Array.isArray(target) ? target : [target];
try {
for (const roomName of targets) {
await updateDeviceMutation.mutateAsync({
roomName,
data: {},
});
toast.success(`Đã gửi cập nhật cho ${roomName}`);
}
} catch (e: any) {
console.error("Update device error:", e);
toast.error("Có lỗi xảy ra khi cập nhật!");
}
};
return (
<BlackListManagerTemplate<Blacklist>
title="Danh sách các ứng dụng bị chặn"
description="Quản lý các ứng dụng và tiến trình bị chặn trên thiết bị"
data={blacklist}
columns={columns}
isLoading={isLoading}
rooms={roomData}
onAdd={handleAddNewBlacklist}
onDelete={handleDeleteBlacklist}
onUpdate={handleUpdateDevice}
/>
);
}

View File

@ -1,296 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { CommandSubmitTemplate } from "@/template/command-submit-template";
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
import {
useGetCommandList,
useGetRoomList,
useAddCommand,
useUpdateCommand,
useDeleteCommand,
useSendCommand,
} from "@/hooks/queries";
import { toast } from "sonner";
import { Check, X, Edit2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ColumnDef } from "@tanstack/react-table";
import type { ShellCommandData } from "@/components/forms/command-form";
interface CommandRegistry {
id: number;
commandName: string;
description?: string;
commandContent: string;
qoS: 0 | 1 | 2;
isRetained: boolean;
createdAt?: string;
updatedAt?: string;
}
export const Route = createFileRoute("/_auth/command/")({
head: () => ({ meta: [{ title: "Gửi lệnh từ xa" }] }),
component: CommandPage,
});
function CommandPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedCommand, setSelectedCommand] = useState<CommandRegistry | null>(null);
const [table, setTable] = useState<any>();
// Fetch commands
const { data: commands = [], isLoading } = useGetCommandList();
// Fetch rooms
const { data: roomData = [] } = useGetRoomList();
const commandList: CommandRegistry[] = Array.isArray(commands)
? commands.map((cmd: any) => ({
...cmd,
qoS: cmd.qoS ?? 0,
isRetained: cmd.isRetained ?? false,
}))
: [];
// Mutations
const addCommandMutation = useAddCommand();
const updateCommandMutation = useUpdateCommand();
const deleteCommandMutation = useDeleteCommand();
const sendCommandMutation = useSendCommand();
// Columns for command table
const columns: ColumnDef<CommandRegistry>[] = [
{
accessorKey: "commandName",
header: "Tên lệnh",
cell: ({ getValue }) => (
<span className="font-semibold break-words">{getValue() as string}</span>
),
},
{
accessorKey: "description",
header: "Mô tả",
cell: ({ getValue }) => (
<span className="text-sm text-muted-foreground break-words whitespace-normal">
{(getValue() as string) || "-"}
</span>
),
},
{
accessorKey: "commandContent",
header: "Nội dung lệnh",
cell: ({ getValue }) => (
<code className="text-xs bg-muted/50 p-1 rounded break-words whitespace-normal block">
{(getValue() as string).substring(0, 100)}...
</code>
),
},
{
accessorKey: "qoS",
header: "QoS",
cell: ({ getValue }) => {
const qos = getValue() as number | undefined;
const qosValue = qos !== undefined ? qos : 0;
const colors = {
0: "text-blue-600",
1: "text-amber-600",
2: "text-red-600",
};
return (
<span className={colors[qosValue as 0 | 1 | 2]}>{qosValue}</span>
);
},
},
{
accessorKey: "isRetained",
header: "Lưu trữ",
cell: ({ getValue }) => {
const retained = getValue() as boolean;
return retained ? (
<div className="flex items-center gap-1">
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</div>
) : (
<div className="flex items-center gap-1">
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</div>
);
},
},
{
id: "select",
header: () => <div className="text-center text-xs">Chọn đ thực thi</div>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
/>
),
enableSorting: false,
enableHiding: false,
},
{
id: "actions",
header: () => <div className="text-center text-xs">Hành đng</div>,
cell: ({ row }) => (
<div className="flex gap-2 justify-center">
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedCommand(row.original);
setIsDialogOpen(true);
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteCommand(row.original.id)}
disabled={deleteCommandMutation.isPending}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
),
enableSorting: false,
enableHiding: false,
},
];
// Handle form submit
const handleFormSubmit = async (data: CommandRegistryFormData) => {
try {
if (selectedCommand) {
// Update
await updateCommandMutation.mutateAsync({
commandId: selectedCommand.id,
data,
});
} else {
// Add
await addCommandMutation.mutateAsync(data);
}
setIsDialogOpen(false);
setSelectedCommand(null);
toast.success(selectedCommand ? "Cập nhật lệnh thành công!" : "Thêm lệnh thành công!");
} catch (error) {
console.error("Form submission error:", error);
toast.error(selectedCommand ? "Cập nhật lệnh thất bại!" : "Thêm lệnh thất bại!");
}
};
// Handle delete
const handleDeleteCommand = async (commandId: number) => {
if (!confirm("Bạn có chắc muốn xóa lệnh này?")) return;
try {
await deleteCommandMutation.mutateAsync(commandId);
toast.success("Xóa lệnh thành công!");
} catch (error) {
console.error("Delete error:", error);
toast.error("Xóa lệnh thất bại!");
}
};
// Handle execute commands from list
const handleExecuteSelected = async (targets: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một lệnh để thực thi!");
return;
}
try {
for (const target of targets) {
for (const row of selectedRows) {
// API expects PascalCase directly
const apiData = {
Command: row.original.commandContent,
QoS: row.original.qoS,
IsRetained: row.original.isRetained,
};
console.log("[DEBUG] Sending to:", target, "Data:", apiData);
await sendCommandMutation.mutateAsync({
roomName: target,
data: apiData as any,
});
}
}
toast.success("Đã gửi yêu cầu thực thi lệnh cho các mục đã chọn!");
if (table) {
table.setRowSelection({});
}
} catch (error) {
console.error("[DEBUG] Execute error:", error);
console.error("[DEBUG] Response:", (error as any)?.response?.data);
toast.error("Có lỗi xảy ra khi thực thi!");
}
};
// Handle execute custom command
const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => {
try {
for (const target of targets) {
// API expects PascalCase directly
const apiData = {
Command: commandData.command,
QoS: commandData.qos,
IsRetained: commandData.isRetained,
};
console.log("[DEBUG] Sending custom to:", target, "Data:", apiData);
await sendCommandMutation.mutateAsync({
roomName: target,
data: apiData as any,
});
}
toast.success("Đã gửi lệnh tùy chỉnh cho các mục đã chọn!");
} catch (error) {
console.error("[DEBUG] Execute custom error:", error);
console.error("[DEBUG] Response:", (error as any)?.response?.data);
toast.error("Gửi lệnh tùy chỉnh thất bại!");
}
};
return (
<CommandSubmitTemplate
title="Gửi lệnh từ xa"
description="Quản lý và thực thi các lệnh trên thiết bị"
data={commandList}
isLoading={isLoading}
columns={columns}
dialogOpen={isDialogOpen}
onDialogOpen={setIsDialogOpen}
dialogTitle={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
onAddNew={() => {
setSelectedCommand(null);
setIsDialogOpen(true);
}}
onTableInit={setTable}
formContent={
<CommandRegistryForm
onSubmit={handleFormSubmit}
closeDialog={() => setIsDialogOpen(false)}
initialData={selectedCommand || undefined}
title={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
/>
}
onExecuteSelected={handleExecuteSelected}
onExecuteCustom={handleExecuteCustom}
isExecuting={sendCommandMutation.isPending}
rooms={roomData}
/>
);
}

View File

@ -1,9 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/dashboard/')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/(auth)/dashboard/"!</div>
}

View File

@ -0,0 +1,125 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
formOptions,
useForm,
} from '@tanstack/react-form'
interface LoginFormProps {
username: string
password: string
}
const defaultInput: LoginFormProps = {
username: '',
password: '',
}
const formOpts = formOptions({
defaultValues: defaultInput,
})
export const Route = createFileRoute('/_auth/login/')({
beforeLoad: async ({ context }) => {
const { authToken } = context.auth
if (authToken) throw redirect({ to: '/' })
},
component: LoginForm,
})
function LoginForm() {
const form = useForm({
...formOpts,
onSubmit: async ({ value }) => {
console.log('Submitting login form with values:', value)
// Giả lập đăng nhập
if (value.username === 'admin' && value.password === '123456') {
alert('Đăng nhập thành công!')
// Thêm xử lý lưu token, redirect...
} else {
alert('Tài khoản hoặc mật khẩu không đúng.')
}
},
})
return (
<Card className="max-w-md mx-auto mt-20 p-6">
<CardHeader>
<CardTitle>Đăng nhập</CardTitle>
<CardDescription>
Vui lòng nhập thông tin đăng nhập của bạn.
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
className="space-y-4"
>
{/* Username */}
<form.Field name="username">
{(field) => (
<div>
<Label htmlFor="username">Tên đăng nhập</Label>
<Input
id="username"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Tên đăng nhập"
/>
{field.state.meta.isTouched && field.state.meta.errors && (
<p className="text-sm text-red-500 mt-1">
{field.state.meta.errors}
</p>
)}
</div>
)}
</form.Field>
{/* Password */}
<form.Field name="password">
{(field) => (
<div>
<Label htmlFor="password">Mật khẩu</Label>
<Input
id="password"
type="password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Mật khẩu"
/>
{field.state.meta.isTouched && field.state.meta.errors && (
<p className="text-sm text-red-500 mt-1">
{field.state.meta.errors}
</p>
)}
</div>
)}
</form.Field>
<Button type="submit" className="w-full">
Đăng nhập
</Button>
</form>
</CardContent>
<CardFooter>
<p className="text-sm text-muted-foreground">
Chưa tài khoản? <span className="underline cursor-pointer">Đăng </span>
</p>
</CardFooter>
</Card>
)
}

View File

@ -1,138 +0,0 @@
import { createFileRoute, useParams } from "@tanstack/react-router";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LayoutGrid, TableIcon, Monitor, FolderCheck, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useGetDeviceFromRoom } from "@/hooks/queries";
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
import { useClientFolderStatus } from "@/hooks/useClientFolderStatus";
import { DeviceGrid } from "@/components/grids/device-grid";
import { DeviceTable } from "@/components/tables/device-table";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { toast } from "sonner";
export const Route = createFileRoute("/_auth/room/$roomName/")({
head: ({ params }) => ({
meta: [{ title: `Danh sách thiết bị phòng ${params.roomName}` }],
}),
component: RoomDetailPage,
});
function RoomDetailPage() {
const { roomName } = useParams({ from: "/_auth/room/$roomName/" });
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const [isCheckingFolder, setIsCheckingFolder] = useState(false);
// SSE real-time updates
useDeviceEvents(roomName);
// Folder status from SSE
const folderStatuses = useClientFolderStatus(roomName);
const { data: devices = [] } = useGetDeviceFromRoom(roomName);
const parseMachineNumber = useMachineNumber();
const handleCheckFolderStatus = async () => {
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) => {
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
});
return (
<div className="w-full px-6 space-y-6">
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
Danh sách thiết bị phòng {roomName}
</CardTitle>
<div className="flex items-center gap-3">
<Button
onClick={handleCheckFolderStatus}
disabled={isCheckingFolder}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
{isCheckingFolder ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FolderCheck className="h-4 w-4" />
)}
{isCheckingFolder ? "Đang kiểm tra..." : "Kiểm tra thư mục Setup"}
</Button>
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="flex items-center gap-2"
>
<LayoutGrid className="h-4 w-4" />
đ
</Button>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="flex items-center gap-2"
>
<TableIcon className="h-4 w-4" />
Bảng
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{devices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Không thiết bị</h3>
<p className="text-muted-foreground text-center max-w-sm">
Phòng này chưa thiết bị nào đưc kết nối.
</p>
</div>
) : viewMode === "grid" ? (
<DeviceGrid
devices={sortedDevices}
folderStatuses={folderStatuses}
isCheckingFolder={isCheckingFolder}
/>
) : (
<DeviceTable
devices={sortedDevices}
folderStatuses={folderStatuses}
isCheckingFolder={isCheckingFolder}
/>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import AppLayout from '@/layouts/app-layout'
export const Route = createFileRoute('/_authenticated')({
// Kiểm tra auth trước khi render
// beforeLoad: async ({context}) => {
// const {authToken} = context.auth
// if (!authToken) {
// throw redirect({to: '/login'})
// }
// },
component: AuthenticatedLayout,
})
function AuthenticatedLayout() {
return (
<AppLayout>
<Outlet />
</AppLayout>
)
}

View File

@ -1,27 +1,44 @@
import { createFileRoute } from "@tanstack/react-router";
import { AppManagerTemplate } from "@/template/app-manager-template";
import {
useGetAgentVersion,
useGetRoomList,
useUploadSoftware,
useUpdateAgent,
} from "@/hooks/queries";
import { useQueryData } from "@/hooks/useQueryData";
import { useMutationData } from "@/hooks/useMutationData";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import type { AxiosProgressEvent } from "axios";
import type { Version } from "@/types/file";
import { type Room } from "@/types/room";
export const Route = createFileRoute("/_auth/agent/")({
type Version = {
id?: string;
version: string;
fileName: string;
folderPath: string;
updatedAt?: string;
requestUpdateAt?: string;
};
export const Route = createFileRoute("/_authenticated/agent/")({
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
component: AgentsPage,
});
function AgentsPage() {
// Lấy danh sách version
const { data, isLoading } = useGetAgentVersion();
const { data, isLoading } = useQueryData({
queryKey: ["agent-version"],
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
});
// Lấy danh sách phòng
const { data: roomData } = useGetRoomList();
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
const versionList: Version[] = Array.isArray(data)
? data
@ -29,38 +46,48 @@ function AgentsPage() {
? [data]
: [];
const uploadMutation = useUploadSoftware();
// Mutation upload
const uploadMutation = useMutationData<FormData>({
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
method: "POST",
invalidate: [["agent-version"]],
onSuccess: () => toast.success("Upload thành công!"),
onError: (error) => {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
},
});
const updateMutation = useUpdateAgent();
const updateMutation = useMutationData<void>({
url: "",
method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
onError: () => toast.error("Gửi yêu cầu thất bại!"),
});
const handleUpload = async (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => {
try {
await uploadMutation.mutateAsync({
formData: fd,
onUploadProgress: config?.onUploadProgress,
});
toast.success("Upload thành công!");
} catch (error: any) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
}
return uploadMutation.mutateAsync({
data: fd,
config,
});
};
const handleUpdate = async (roomNames: string[]) => {
try {
for (const roomName of roomNames) {
for (const roomName of roomNames) {
try {
await updateMutation.mutateAsync({
roomName,
data: {}
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT(roomName),
method: "POST",
data: undefined
});
} catch {
toast.error(`Gửi yêu cầu thất bại cho ${roomName}`);
}
toast.success("Đã gửi yêu cầu update cho các phòng đã chọn!");
} catch (e) {
toast.error("Có lỗi xảy ra khi cập nhật!");
}
toast.success("Đã gửi yêu cầu update cho các phòng đã chọn!");
};
// Cột bảng
@ -96,7 +123,7 @@ function AgentsPage() {
onUpload={handleUpload}
onUpdate={handleUpdate}
updateLoading={updateMutation.isPending}
rooms={roomData}
rooms={rooms}
/>
);
}

View File

@ -0,0 +1,154 @@
import { createFileRoute } from "@tanstack/react-router";
import { AppManagerTemplate } from "@/template/app-manager-template";
import { useQueryData } from "@/hooks/useQueryData";
import { useMutationData } from "@/hooks/useMutationData";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import { useState } from "react";
import type { AxiosProgressEvent } from "axios";
import type { Room } from "@/types/room";
export const Route = createFileRoute("/_authenticated/apps/")({
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
component: AppsComponent,
});
type Version = {
id: number;
version: string;
fileName: string;
folderPath: string;
updatedAt?: string;
requestUpdateAt?: string;
};
function AppsComponent() {
const { data, isLoading } = useQueryData({
queryKey: ["software-version"],
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE,
});
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
const versionList: Version[] = Array.isArray(data)
? data
: data
? [data]
: [];
const [table, setTable] = useState<any>();
const uploadMutation = useMutationData<FormData>({
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
method: "POST",
invalidate: [["software-version"]],
onSuccess: () => toast.success("Upload thành công!"),
onError: (error) => {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
},
});
const installMutation = useMutationData<{ MsiFileIds: number[] }>({
url: "",
method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
onError: (error) => {
console.error("Install error:", error);
toast.error("Gửi yêu cầu thất bại!");
},
});
// Cột bảng
const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{ accessorKey: "folderPath", header: "Đường dẫn" },
{
accessorKey: "updatedAt",
header: "Thời gian cập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: "Thời gian yêu cầu cài đặt",
},
{
id: "select",
header: () => <span>Thêm vào danh sách yêu cầu</span>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
disabled={installMutation.isPending}
/>
),
enableSorting: false,
enableHiding: false,
},
];
// Upload file MSI
const handleUpload = async (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => {
return uploadMutation.mutateAsync({
data: fd,
config,
});
};
// Callback khi chọn phòng
const handleInstall = async (roomNames: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
for (const roomName of roomNames) {
await installMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI(roomName),
data: { MsiFileIds },
});
}
toast.success("Đã gửi yêu cầu cài đặt phần mềm cho các phòng đã chọn!");
};
return (
<AppManagerTemplate<Version>
title="Quản lý phần mềm"
description="Quản lý và gửi yêu cầu cài đặt phần mềm MSI"
data={versionList}
isLoading={isLoading}
columns={columns}
onUpload={handleUpload}
onUpdate={handleInstall}
updateLoading={installMutation.isPending}
onTableInit={setTable}
rooms={rooms}
/>
);
}

View File

@ -0,0 +1,66 @@
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { useQueryData } from "@/hooks/useQueryData";
import { createFileRoute } from "@tanstack/react-router";
import type { ColumnDef } from "@tanstack/react-table";
import { useState } from "react";
type Blacklist = {
id: number;
appName: string;
processName: string;
createdAt?: string;
updatedAt?: string;
createdBy?: string;
};
export const Route = createFileRoute("/_authenticated/blacklist/")({
head: () => ({ meta: [{ title: "Danh sách các ứng dụng bị chặn" }] }),
component: BlacklistComponent,
});
function BlacklistComponent() {
const { data, isLoading } = useQueryData({
queryKey: ["blacklist"],
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
});
const blacklist: Blacklist[] = Array.isArray(data)
? (data as Blacklist[])
: [];
const columns : ColumnDef<Blacklist>[] =
[
{
accessorKey: "id",
header: "ID",
cell: info => info.getValue(),
},
{
accessorKey: "appName",
header: "Tên ứng dụng",
cell: info => info.getValue(),
},
{
accessorKey: "processName",
header: "Tên tiến trình",
cell: info => info.getValue(),
},
{
accessorKey: "createdAt",
header: "Ngày tạo",
cell: info => info.getValue(),
},
{
accessorKey: "updatedAt",
header: "Ngày cập nhật",
cell: info => info.getValue(),
},
{
accessorKey: "createdBy",
header: "Người tạo",
cell: info => info.getValue(),
},
]
return <div>Hello "/_authenticated/blacklist/"!</div>;
}

View File

@ -0,0 +1,73 @@
import { createFileRoute } from "@tanstack/react-router";
import { FormSubmitTemplate } from "@/template/form-submit-template";
import { ShellCommandForm } from "@/components/command-form";
import { useMutationData } from "@/hooks/useMutationData";
import { useQueryData } from "@/hooks/useQueryData";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
import type { Room } from "@/types/room";
type SendCommandRequest = { Command: string };
type SendCommandResponse = { status: string; message: string };
export const Route = createFileRoute("/_authenticated/command/")({
head: () => ({ meta: [{ title: "Gửi lệnh CMD" }] }),
component: CommandPage,
});
function CommandPage() {
// Lấy danh sách phòng từ API
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
// Mutation gửi lệnh
const sendCommandMutation = useMutationData<
SendCommandRequest,
SendCommandResponse
>({
url: "", // sẽ set động theo roomName khi gọi
method: "POST",
onSuccess: (data) => {
if (data.status === "OK") {
toast.success("Gửi lệnh thành công!");
} else {
toast.error("Gửi lệnh thất bại!");
}
},
onError: (error) => {
console.error("Send command error:", error);
toast.error("Gửi lệnh thất bại!");
},
});
return (
<FormSubmitTemplate
title="CMD Command"
description="Gửi lệnh shell xuống thiết bị để thực thi"
isLoading={sendCommandMutation.isPending}
rooms={rooms}
onSubmit={(roomName, command) => {
sendCommandMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(roomName),
data: { Command: command },
});
}}
submitLoading={sendCommandMutation.isPending}
>
{({ command, setCommand }) => (
<ShellCommandForm
command={command}
onCommandChange={setCommand}
disabled={sendCommandMutation.isPending}
/>
)}
</FormSubmitTemplate>
);
}

View File

@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/device/')({
export const Route = createFileRoute('/_authenticated/device/')({
head: () => ({ meta: [{ title: 'Danh sách tất cả thiết bị' }] }),
component: AllDevicesComponent,
})

View File

@ -0,0 +1,75 @@
import { createFileRoute, useParams } from "@tanstack/react-router";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LayoutGrid, TableIcon, Monitor } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useQueryData } from "@/hooks/useQueryData";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { DeviceGrid } from "@/components/device-grid";
import { DeviceTable } from "@/components/device-table";
export const Route = createFileRoute("/_authenticated/room/$roomName/")({
head: ({ params }) => ({
meta: [{ title: `Danh sách thiết bị phòng ${params.roomName}` }],
}),
component: RoomDetailPage,
});
function RoomDetailPage() {
const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const { data: devices = [] } = useQueryData({
queryKey: ["devices", roomName],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
});
return (
<div className="w-full px-6 space-y-6">
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
Danh sách thiết bị phòng {roomName}
</CardTitle>
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="flex items-center gap-2"
>
<LayoutGrid className="h-4 w-4" />
đ
</Button>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="flex items-center gap-2"
>
<TableIcon className="h-4 w-4" />
Bảng
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{devices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Không thiết bị</h3>
<p className="text-muted-foreground text-center max-w-sm">
Phòng này chưa thiết bị nào đưc kết nối.
</p>
</div>
) : viewMode === "grid" ? (
<DeviceGrid devices={devices} />
) : (
<DeviceTable devices={devices} />
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { useGetRoomList } from "@/hooks/queries";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { useQueryData } from "@/hooks/useQueryData";
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
@ -25,13 +26,14 @@ import {
ChevronRight,
Loader2,
Wifi,
WifiOff,
} from "lucide-react";
import React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
export const Route = createFileRoute("/_auth/room/")({
export const Route = createFileRoute("/_authenticated/room/")({
head: () => ({
meta: [{ title: "Danh sách phòng" }],
}),
@ -41,7 +43,10 @@ export const Route = createFileRoute("/_auth/room/")({
function RoomComponent() {
const navigate = useNavigate();
const { data: roomData = [], isLoading } = useGetRoomList();
const { data: roomData = [], isLoading } = useQueryData({
queryKey: ["rooms"],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
const [sorting, setSorting] = React.useState<SortingState>([]);
@ -72,16 +77,34 @@ function RoomComponent() {
),
},
{
header: "Số lượng thiết bị online",
header: "Số lượng thiết bị",
accessorKey: "numberOfDevices",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4 text-green-600" />
<Badge variant="secondary" className="font-medium">
{row.original.numberOfDevices} thiết bị
</Badge>
</div>
),
},
{
header: "Thiết bị offline",
accessorKey: "numberOfOfflineDevices",
cell: ({ row }) => {
const onlineCount = row.original.numberOfDevices - row.original.numberOfOfflineDevices;
const totalCount = row.original.numberOfDevices;
const offlineCount = row.original.numberOfOfflineDevices;
const isOffline = offlineCount > 0;
return (
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4 text-green-600" />
<Badge variant="secondary" className="font-medium">
{onlineCount} / {totalCount}
<WifiOff
className={`h-4 w-4 ${isOffline ? "text-red-500" : "text-muted-foreground"}`}
/>
<Badge
variant={isOffline ? "destructive" : "outline"}
className="font-medium"
>
{offlineCount} offline
</Badge>
</div>
);

View File

@ -1,127 +0,0 @@
import axios, { type AxiosProgressEvent } from "axios";
import { API_ENDPOINTS } from "@/config/api";
import type { Version } from "@/types/file";
/**
* Lấy danh sách phiên bản agent
*/
export async function getAgentVersion(): Promise<Version[]> {
const response = await axios.get<Version[]>(API_ENDPOINTS.APP_VERSION.GET_VERSION);
return response.data;
}
/**
* Lấy danh sách phần mềm
*/
export async function getSoftwareList(): Promise<Version[]> {
const response = await axios.get<Version[]>(API_ENDPOINTS.APP_VERSION.GET_SOFTWARE);
return response.data;
}
/**
* Upload file phần mềm/agent
* @param formData - FormData chứa file
* @param onUploadProgress - Callback theo dõi tiến đ upload
*/
export async function uploadSoftware(
formData: FormData,
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
): Promise<{ message: string }> {
const response = await axios.post(API_ENDPOINTS.APP_VERSION.UPLOAD, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress,
});
return response.data;
}
/**
* Lấy danh sách blacklist
*/
export async function getBlacklist(): Promise<any[]> {
const response = await axios.get<any[]>(API_ENDPOINTS.APP_VERSION.GET_BLACKLIST);
return response.data;
}
/**
* Thêm ng dụng vào blacklist
* @param data - Dữ liệu thêm blacklist
*/
export async function addBlacklist(data: any): Promise<{ message: string }> {
const response = await axios.post(API_ENDPOINTS.APP_VERSION.ADD_BLACKLIST, data);
return response.data;
}
/**
* Xóa ng dụng khỏi blacklist
* @param appId - ID ng dụng
*/
export async function deleteBlacklist(appId: number): Promise<{ message: string }> {
const response = await axios.delete(
API_ENDPOINTS.APP_VERSION.DELETE_BLACKLIST(appId)
);
return response.data;
}
/**
* Cập nhật blacklist
* @param appId - ID ng dụng
* @param data - Dữ liệu cập nhật
*/
export async function updateBlacklist(appId: string, data: any): Promise<{ message: string }> {
const response = await axios.put(
API_ENDPOINTS.APP_VERSION.UPDATE_BLACKLIST(appId),
data
);
return response.data;
}
/**
* Yêu cầu cập nhật blacklist
* @param data - Dữ liệu yêu cầu
*/
export async function requestUpdateBlacklist(data: any): Promise<{ message: string }> {
const response = await axios.post(
API_ENDPOINTS.APP_VERSION.REQUEST_UPDATE_BLACKLIST,
data
);
return response.data;
}
/**
* Lấy danh sách file bắt buộc
*/
export async function getRequiredFiles(): Promise<any[]> {
const response = await axios.get<any[]>(API_ENDPOINTS.APP_VERSION.GET_REQUIRED_FILES);
return response.data;
}
/**
* Thêm file bắt buộc
* @param data - Dữ liệu file
*/
export async function addRequiredFile(data: any): Promise<{ message: string }> {
const response = await axios.post(API_ENDPOINTS.APP_VERSION.ADD_REQUIRED_FILE, data);
return response.data;
}
/**
* Xóa file bắt buộc
* @param fileId - ID file
*/
export async function deleteRequiredFile(fileId: number): Promise<{ message: string }> {
const response = await axios.delete(
API_ENDPOINTS.APP_VERSION.DELETE_REQUIRED_FILE(fileId)
);
return response.data;
}
/**
* Xóa file từ server
* @param fileId - ID file
*/
export async function deleteFile(fileId: number): Promise<{ message: string }> {
const response = await axios.delete(API_ENDPOINTS.APP_VERSION.DELETE_FILES(fileId));
return response.data;
}

View File

@ -1,86 +0,0 @@
import axios from "axios";
import { API_ENDPOINTS } from "@/config/api";
import type { LoginResquest, LoginResponse } from "@/types/auth";
/**
* Đăng nhập
* @param credentials - Thông tin đăng nhập
* @returns Response chứa token, name, username, access, role
*/
export async function login(credentials: LoginResquest): Promise<LoginResponse> {
const response = await axios.post<LoginResponse>(
API_ENDPOINTS.AUTH.LOGIN,
credentials
);
return response.data;
}
/**
* Đăng xuất
*/
export async function logout(): Promise<void> {
await axios.delete(API_ENDPOINTS.AUTH.LOGOUT);
}
/**
* Kiểm tra phiên đăng nhập
* @param token - Access token
* @returns Response kiểm tra phiên
*/
export async function ping(token?: string): Promise<{ message: string; code: number }> {
const response = await axios.get(API_ENDPOINTS.AUTH.PING, {
params: token ? { token } : undefined,
});
return response.data;
}
/**
* Lấy CSRF token
* @returns CSRF token
*/
export async function getCsrfToken(): Promise<{ token: string }> {
const response = await axios.get(API_ENDPOINTS.AUTH.CSRF_TOKEN);
return response.data;
}
/**
* Thay đi mật khẩu của user hiện tại
* @param data - Dữ liệu thay đi mật khẩu {currentPassword, newPassword}
*/
export async function changePassword(data: {
currentPassword: string;
newPassword: string;
}): Promise<{ message: string }> {
const response = await axios.put(API_ENDPOINTS.AUTH.CHANGE_PASSWORD, data);
return response.data;
}
/**
* Admin thay đi mật khẩu của user khác
* @param data - Dữ liệu {username, newPassword}
*/
export async function changePasswordAdmin(data: {
username: string;
newPassword: string;
}): Promise<{ message: string }> {
const response = await axios.put(
API_ENDPOINTS.AUTH.CHANGE_PASSWORD_ADMIN,
data
);
return response.data;
}
/**
* Tạo tài khoản mới
* @param data - Dữ liệu tạo tài khoản
*/
export async function createAccount(data: {
userName: string;
password: string;
name: string;
roleId: number;
accessBuildings?: number[];
}): Promise<{ message: string }> {
const response = await axios.post(API_ENDPOINTS.AUTH.CREATE_ACCOUNT, data);
return response.data;
}

View File

@ -1,43 +0,0 @@
import axios from "axios";
import { API_ENDPOINTS } from "@/config/api";
/**
* Lấy danh sách lệnh
*/
export async function getCommandList(): Promise<any[]> {
const response = await axios.get<any[]>(API_ENDPOINTS.COMMAND.GET_COMMANDS);
return response.data;
}
/**
* Thêm lệnh mới
* @param data - Dữ liệu lệnh
*/
export async function addCommand(data: any): Promise<any> {
const response = await axios.post(API_ENDPOINTS.COMMAND.ADD_COMMAND, data);
return response.data;
}
/**
* Cập nhật lệnh
* @param commandId - ID lệnh
* @param data - Dữ liệu cập nhật
*/
export async function updateCommand(commandId: number, data: any): Promise<any> {
const response = await axios.put(
API_ENDPOINTS.COMMAND.UPDATE_COMMAND(commandId),
data
);
return response.data;
}
/**
* Xóa lệnh
* @param commandId - ID lệnh
*/
export async function deleteCommand(commandId: number): Promise<any> {
const response = await axios.delete(
API_ENDPOINTS.COMMAND.DELETE_COMMAND(commandId)
);
return response.data;
}

View File

@ -1,115 +0,0 @@
import axios from "axios";
import { API_ENDPOINTS } from "@/config/api";
import type { DeviceHealthCheck } from "@/types/device";
/**
* Lấy tất cả thiết bị trong hệ thống
*/
export async function getAllDevices(): Promise<any[]> {
const response = await axios.get<any[]>(API_ENDPOINTS.DEVICE_COMM.GET_ALL_DEVICES);
return response.data;
}
/**
* Lấy danh sách phòng
*/
export async function getRoomList(): Promise<any[]> {
const response = await axios.get<any[]>(API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST);
return response.data;
}
/**
* Lấy danh sách thiết bị trong phòng
* @param roomName - Tên phòng
*/
export async function getDeviceFromRoom(roomName: string): Promise<DeviceHealthCheck[]> {
const response = await axios.get<DeviceHealthCheck[]>(
API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName)
);
return response.data;
}
/**
* Tải file về từ phòng
* @param roomName - Tên phòng
* @param data - Dữ liệu yêu cầu
*/
export async function downloadFiles(roomName: string, data: any): Promise<any> {
const response = await axios.post(
API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_FILES(roomName),
data
);
return response.data;
}
/**
* Cài đt MSI file cho phòng
* @param roomName - Tên phòng
* @param data - Dữ liệu MSI
*/
export async function installMsi(roomName: string, data: any): Promise<any> {
const response = await axios.post(
API_ENDPOINTS.DEVICE_COMM.INSTALL_MSI(roomName),
data
);
return response.data;
}
/**
* Cập nhật agent cho phòng
* @param roomName - Tên phòng
* @param data - Dữ liệu cập nhật
*/
export async function updateAgent(roomName: string, data: any): Promise<any> {
const response = await axios.post(
API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT(roomName),
data
);
return response.data;
}
/**
* Cập nhật blacklist cho phòng
* @param roomName - Tên phòng
* @param data - Dữ liệu blacklist
*/
export async function updateBlacklist(roomName: string, data: any): Promise<any> {
const response = await axios.post(
API_ENDPOINTS.DEVICE_COMM.UPDATE_BLACKLIST(roomName),
data
);
return response.data;
}
/**
* Gửi lệnh shell cho phòng
* @param roomName - Tên phòng
* @param data - Dữ liệu lệnh
*/
export async function sendCommand(roomName: string, data: any): Promise<any> {
const response = await axios.post(
API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(roomName),
data
);
return response.data;
}
/**
* Thay đi phòng của thiết bị
* @param data - Dữ liệu chuyển phòng
*/
export async function changeDeviceRoom(data: any): Promise<any> {
const response = await axios.post(API_ENDPOINTS.DEVICE_COMM.CHANGE_DEVICE_ROOM, data);
return response.data;
}
/**
* Lấy trạng thái folder client
* @param roomName - Tên phòng
*/
export async function getClientFolderStatus(roomName: string): Promise<any> {
const response = await axios.get(
API_ENDPOINTS.DEVICE_COMM.REQUEST_GET_CLIENT_FOLDER_STATUS(roomName)
);
return response.data;
}

View File

@ -1,12 +0,0 @@
// Auth API Services
export * as authService from "./auth.service";
// App Version API Services
export * as appVersionService from "./app-version.service";
// Device Communication API Services
export * as deviceCommService from "./device-comm.service";
// Command API Services
export * as commandService from "./command.service";

View File

@ -1,93 +0,0 @@
import { create } from "zustand";
import { type IAuthContext } from "@/types/auth";
import { useEffect } from "react";
export interface AuthState
extends Omit<IAuthContext, "setAuthenticated" | "setUser"> {
setAuthenticated: (value: boolean) => void;
setAuth: (
username: string,
token: string,
name: string,
acs: number[],
role: { roleName: string; priority: number }
) => void;
initialize: () => void;
}
const getStoredUser = () => localStorage.getItem("username");
export const useAuthStore = create<AuthState>((set) => ({
isAuthenticated: false,
username: "",
token: "",
name: "",
acs: [],
role: {
roleName: "",
priority: -1,
},
initialize: () => {
const username = getStoredUser();
const token = localStorage.getItem("token") || "";
const name = localStorage.getItem("name") || "";
const acsString = localStorage.getItem("acs");
const acs = acsString ? acsString.split(",").map(Number) : [];
const roleName = localStorage.getItem("role") || "";
const priority = localStorage.getItem("priority") || "-1";
set({
username: username || "",
token,
name,
acs,
role: {
roleName,
priority: parseInt(priority),
},
isAuthenticated: !!username,
});
},
setAuth: (
username: string,
token: string,
name: string,
acs: number[],
role: { roleName: string; priority: number }
) => set({ username, token, name, acs, role }),
setAuthenticated: (value: boolean) => set({ isAuthenticated: value }),
logout: () => {
localStorage.removeItem("token");
localStorage.removeItem("name");
localStorage.removeItem("username");
localStorage.removeItem("acs");
localStorage.removeItem("role");
localStorage.removeItem("priority");
set({
isAuthenticated: false,
username: "",
token: "",
name: "",
acs: [],
role: {
roleName: "",
priority: -1,
},
});
},
}));
// Hook to initialize auth store
export function useAuthStoreInitializer() {
const initialize = useAuthStore((state) => state.initialize);
useEffect(() => {
const cleanup = initialize();
return cleanup;
}, [initialize]);
}

View File

@ -7,24 +7,16 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { FileText, Building2, Download } from "lucide-react";
import { FormDialog } from "@/components/dialogs/form-dialog";
import { VersionTable } from "@/components/tables/version-table";
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { DeleteMenu } from "@/components/menu/delete-menu";
import { Button } from "@/components/ui/button";
import { FileText, Building2, Monitor } from "lucide-react";
import { UploadDialog } from "@/components/upload-dialog";
import { VersionTable } from "@/components/version-table";
import { RequestUpdateMenu } from "@/components/request-update-menu";
import type { AxiosProgressEvent } from "axios";
import { useState } from "react";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import { UploadVersionForm } from "@/components/forms/upload-file-form";
import type { Room } from "@/types/room";
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
import { getDeviceFromRoom } from "@/services/device-comm.service";
import { SelectDialog } from "@/components/select-dialog"; // <-- dùng dialog chung
interface AppManagerTemplateProps<TData> {
title: string;
uploadFormTitle?: string;
description: string;
data: TData[];
isLoading: boolean;
@ -35,22 +27,13 @@ interface AppManagerTemplateProps<TData> {
) => Promise<void>;
onUpdate?: (targetNames: string[]) => Promise<void> | void;
updateLoading?: boolean;
onDownload?: (targetNames: string[]) => Promise<void> | void;
downloadLoading?: boolean;
onDelete?: () => Promise<void> | void;
onDeleteFromServer?: () => Promise<void> | void;
onDeleteFromRequired?: () => Promise<void> | void;
deleteLoading?: boolean;
onAddToRequired?: () => Promise<void> | void;
addToRequiredLoading?: boolean;
onTableInit?: (table: any) => void;
rooms?: Room[];
rooms?: string[];
devices?: string[];
}
export function AppManagerTemplate<TData>({
title,
uploadFormTitle,
description,
data,
isLoading,
@ -58,20 +41,12 @@ export function AppManagerTemplate<TData>({
onUpload,
onUpdate,
updateLoading,
onDownload,
downloadLoading,
onDelete,
onDeleteFromServer,
onDeleteFromRequired,
deleteLoading,
onAddToRequired,
addToRequiredLoading,
onTableInit,
rooms = [],
devices = [],
}: AppManagerTemplateProps<TData>) {
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"room" | "device" | "download-room" | "download-device" | null>(null);
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
const openRoomDialog = () => {
if (rooms.length > 0 && onUpdate) {
@ -81,39 +56,40 @@ export function AppManagerTemplate<TData>({
};
const openDeviceDialog = () => {
if (onUpdate) {
if (devices.length > 0 && onUpdate) {
setDialogType("device");
setDialogOpen(true);
}
};
const openDownloadRoomDialog = () => {
if (rooms.length > 0 && onDownload) {
setDialogType("download-room");
setDialogOpen(true);
}
};
const openDownloadDeviceDialog = () => {
if (onDownload) {
setDialogType("download-device");
setDialogOpen(true);
}
};
const handleUpdateAll = async () => {
if (!onUpdate) return;
try {
const roomIds = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomIds, ...devices];
await onUpdate(allTargets);
} catch (e) {
console.error("Update error:", e);
}
const allTargets = [...rooms, ...devices];
await onUpdate(allTargets);
};
const getDialogProps = () => {
if (dialogType === "room") {
return {
title: "Chọn phòng",
description: "Chọn các phòng cần cập nhật",
icon: <Building2 className="w-6 h-6 text-primary" />,
items: rooms,
};
}
if (dialogType === "device") {
return {
title: "Chọn thiết bị",
description: "Chọn các thiết bị cần cập nhật",
icon: <Monitor className="w-6 h-6 text-primary" />,
items: devices,
};
}
return null;
};
const dialogProps = getDialogProps();
return (
<div className="w-full px-6 space-y-4">
{/* Header */}
@ -122,16 +98,10 @@ export function AppManagerTemplate<TData>({
<h1 className="text-3xl font-bold">{title}</h1>
<p className="text-muted-foreground mt-2">{description}</p>
</div>
<FormDialog
triggerLabel={uploadFormTitle || "Tải phiên bản mới"}
title={uploadFormTitle || "Cập nhật phiên bản"}
>
{(closeDialog) => (
<UploadVersionForm onSubmit={onUpload} closeDialog={closeDialog} />
)}
</FormDialog>
<UploadDialog onSubmit={onUpload} />
</div>
{/* Table */}
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@ -149,172 +119,31 @@ export function AppManagerTemplate<TData>({
/>
</CardContent>
{(onUpdate || onDelete || onAddToRequired) && (
<CardFooter className="flex items-center justify-between gap-4">
<div className="flex gap-2">
<RequestUpdateMenu
onUpdateDevice={openDeviceDialog}
onUpdateRoom={openRoomDialog}
onUpdateAll={handleUpdateAll}
loading={updateLoading}
label="Cài đặt"
deviceLabel="Cài đặt thiết bị cụ thể"
roomLabel="Cài đặt theo phòng"
allLabel="Cài đặt tất cả thiết bị"
/>
{onDownload && (
<RequestUpdateMenu
onUpdateDevice={openDownloadDeviceDialog}
onUpdateRoom={openDownloadRoomDialog}
onUpdateAll={() => {
if (!onDownload) return;
const roomIds = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomIds, ...devices];
onDownload(allTargets);
}}
loading={downloadLoading}
label="Tải xuống"
deviceLabel="Tải xuống thiết bị cụ thể"
roomLabel="Tải xuống theo phòng"
allLabel="Tải xuống tất cả thiết bị"
icon={<Download className="h-4 w-4" />}
/>
)}
{onAddToRequired && (
<Button
onClick={onAddToRequired}
disabled={addToRequiredLoading}
className="gap-2"
>
{addToRequiredLoading ? "Đang thêm..." : "Thêm vào danh sách"}
</Button>
)}
</div>
{onDeleteFromServer && onDeleteFromRequired && (
<DeleteMenu
onDeleteFromServer={onDeleteFromServer}
onDeleteFromRequired={onDeleteFromRequired}
loading={deleteLoading}
/>
)}
{onUpdate && (
<CardFooter>
<RequestUpdateMenu
onUpdateDevice={openDeviceDialog}
onUpdateRoom={openRoomDialog}
onUpdateAll={handleUpdateAll}
loading={updateLoading}
/>
</CardFooter>
)}
</Card>
{/* Dialog chọn phòng */}
{dialogType === "room" && (
{/* 🧩 SelectDialog tái sử dụng */}
{dialogProps && (
<SelectDialog
open={dialogOpen}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng cần cập nhật"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onClose={() => setDialogOpen(false)}
title={dialogProps.title}
description={dialogProps.description}
icon={dialogProps.icon}
items={dialogProps.items}
onConfirm={async (selectedItems) => {
if (!onUpdate) return;
try {
await onUpdate(selectedItems);
} catch (e) {
console.error("Update error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tìm thiết bị */}
{dialogType === "device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "device"}
onClose={() => {
await onUpdate(selectedItems);
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
onSelect={async (deviceIds) => {
if (!onUpdate) {
setDialogOpen(false);
setDialogType(null);
return;
}
try {
await onUpdate(deviceIds);
} catch (e) {
console.error("Update error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tải file - chọn phòng */}
{dialogType === "download-room" && (
<SelectDialog
open={dialogOpen}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để tải file xuống"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => {
if (!onDownload) return;
try {
await onDownload(selectedItems);
} catch (e) {
console.error("Download error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tải file - tìm thiết bị */}
{dialogType === "download-device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "download-device"}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
onSelect={async (deviceIds) => {
if (!onDownload) {
setDialogOpen(false);
setDialogType(null);
return;
}
try {
await onDownload(deviceIds);
} catch (e) {
console.error("Download error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}

View File

@ -1,424 +0,0 @@
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Plus, CommandIcon, Zap, Building2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { ColumnDef } from "@tanstack/react-table";
import { VersionTable } from "@/components/tables/version-table";
import {
ShellCommandForm,
type ShellCommandData,
} from "@/components/forms/command-form";
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
import { getDeviceFromRoom } from "@/services/device-comm.service";
import type { Room } from "@/types/room";
import { toast } from "sonner";
interface CommandSubmitTemplateProps<T extends { id: number }> {
title: string;
description: string;
// Data & Loading
data: T[];
isLoading?: boolean;
// Table config
columns: ColumnDef<T>[];
// Dialog
dialogOpen: boolean;
onDialogOpen: (open: boolean) => void;
dialogTitle?: string;
formContent?: React.ReactNode;
dialogContentClassName?: string;
// Actions
onAddNew?: () => void;
onTableInit?: (table: any) => void;
// Execute
onExecuteSelected?: (targets: string[]) => void;
onExecuteCustom?: (targets: string[], commandData: ShellCommandData) => void;
isExecuting?: boolean;
// Execution scope
rooms?: Room[];
devices?: string[];
}
export function CommandSubmitTemplate<T extends { id: number }>({
title,
description,
data,
isLoading = false,
columns,
dialogOpen,
onDialogOpen,
dialogTitle = "Thêm Mục Mới",
formContent,
dialogContentClassName,
onAddNew,
onTableInit,
onExecuteSelected,
onExecuteCustom,
isExecuting = false,
rooms = [],
devices = [],
}: CommandSubmitTemplateProps<T>) {
const [activeTab, setActiveTab] = useState<"list" | "execute">("list");
const [customCommand, setCustomCommand] = useState("");
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
const [customRetained, setCustomRetained] = useState(false);
const [table, setTable] = useState<any>();
const [dialogOpen2, setDialogOpen2] = useState(false);
const [dialogType, setDialogType] = useState<"room" | "device" | "room-custom" | "device-custom" | null>(null);
const handleTableInit = (t: any) => {
setTable(t);
onTableInit?.(t);
};
const openRoomDialog = () => {
if (rooms.length > 0 && onExecuteSelected) {
setDialogType("room");
setDialogOpen2(true);
}
};
const openDeviceDialog = () => {
if (onExecuteSelected) {
setDialogType("device");
setDialogOpen2(true);
}
};
const handleExecuteSelected = () => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một mục để thực thi!");
return;
}
onExecuteSelected?.([]);
};
const handleExecuteAll = () => {
if (!onExecuteSelected) return;
try {
const roomNames = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomNames, ...devices];
onExecuteSelected(allTargets);
} catch (e) {
console.error("Execute error:", e);
}
};
const handleExecuteCustom = async (targets: string[]) => {
if (!customCommand.trim()) {
toast.error("Vui lòng nhập lệnh!");
return;
}
const shellCommandData: ShellCommandData = {
command: customCommand,
qos: customQoS,
isRetained: customRetained,
};
try {
await onExecuteCustom?.(targets, shellCommandData);
setCustomCommand("");
setCustomQoS(0);
setCustomRetained(false);
} catch (e) {
console.error("Execute custom command error:", e);
}
};
const handleExecuteCustomAll = () => {
if (!onExecuteCustom) return;
try {
const roomNames = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomNames, ...devices];
handleExecuteCustom(allTargets);
} catch (e) {
console.error("Execute error:", e);
}
};
const openRoomDialogCustom = () => {
if (rooms.length > 0 && onExecuteCustom) {
setDialogType("room-custom");
setDialogOpen2(true);
}
};
const openDeviceDialogCustom = () => {
if (onExecuteCustom) {
setDialogType("device-custom");
setDialogOpen2(true);
}
};
return (
<div className="w-full px-6 space-y-6">
<div>
<h1 className="text-3xl font-bold">{title}</h1>
<p className="text-muted-foreground mt-2">{description}</p>
</div>
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<CommandIcon className="h-5 w-5" />
{title}
</CardTitle>
{onAddNew && (
<Button onClick={onAddNew} className="gap-2">
<Plus className="h-4 w-4" />
Thêm Mới
</Button>
)}
</CardHeader>
<CardContent className="p-6">
{/* Tabs Navigation */}
<div className="flex gap-4 mb-6 border-b">
<button
onClick={() => setActiveTab("list")}
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
activeTab === "list"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
<CommandIcon className="h-4 w-4" />
Danh sách lệnh sẵn
</button>
<button
onClick={() => setActiveTab("execute")}
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
activeTab === "execute"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Zap className="h-4 w-4" />
Lệnh thủ công
</button>
</div>
{/* Tab 1: Danh sách */}
{activeTab === "list" && (
<div className="space-y-4">
<VersionTable<T>
data={data}
columns={columns}
isLoading={isLoading}
onTableInit={handleTableInit}
/>
<RequestUpdateMenu
onUpdateDevice={openDeviceDialog}
onUpdateRoom={openRoomDialog}
onUpdateAll={handleExecuteAll}
loading={isExecuting}
label="Thực Thi"
deviceLabel="Thực thi cho thiết bị cụ thể"
roomLabel="Thực thi cho phòng"
allLabel="Thực thi cho tất cả thiết bị"
icon={<Zap className="h-4 w-4" />}
/>
</div>
)}
{/* Tab 2: Thực thi */}
{activeTab === "execute" && (
<div className="space-y-4">
{/* Lệnh tùy chỉnh */}
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-base">
Thực Thi Lệnh Tùy Chỉnh
</CardTitle>
<CardDescription>
Nhập lệnh tuỳ chỉnh với QoS Retained settings
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<ShellCommandForm
command={customCommand}
onCommandChange={setCustomCommand}
qos={customQoS}
onQoSChange={setCustomQoS}
isRetained={customRetained}
onIsRetainedChange={setCustomRetained}
disabled={isExecuting}
/>
<RequestUpdateMenu
onUpdateDevice={openDeviceDialogCustom}
onUpdateRoom={openRoomDialogCustom}
onUpdateAll={handleExecuteCustomAll}
loading={isExecuting}
label="Thực Thi"
deviceLabel="Thực thi cho thiết bị cụ thể"
roomLabel="Thực thi cho phòng"
allLabel="Thực thi cho tất cả thiết bị"
icon={<Zap className="h-4 w-4" />}
/>
</div>
</CardContent>
</Card>
</div>
)}
</CardContent>
</Card>
{/* Dialog chọn phòng - Thực thi */}
{dialogType === "room" && (
<SelectDialog
open={dialogOpen2}
onClose={() => {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để thực thi lệnh"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => {
if (!onExecuteSelected) return;
try {
await onExecuteSelected(selectedItems);
} catch (e) {
console.error("Execute error:", e);
} finally {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tìm thiết bị - Thực thi */}
{dialogType === "device" && (
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device"}
onClose={() => {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
onSelect={async (deviceIds) => {
if (!onExecuteSelected) {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
return;
}
try {
await onExecuteSelected(deviceIds);
} catch (e) {
console.error("Execute error:", e);
} finally {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog chọn phòng - Thực thi lệnh tùy chỉnh */}
{dialogType === "room-custom" && (
<SelectDialog
open={dialogOpen2}
onClose={() => {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để thực thi lệnh tùy chỉnh"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => {
try {
await handleExecuteCustom(selectedItems);
} catch (e) {
console.error("Execute error:", e);
} finally {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tìm thiết bị - Thực thi lệnh tùy chỉnh */}
{dialogType === "device-custom" && (
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device-custom"}
onClose={() => {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
onSelect={async (deviceIds) => {
try {
await handleExecuteCustom(deviceIds);
} catch (e) {
console.error("Execute error:", e);
} finally {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog for add/edit */}
{formContent && (
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>
<DialogContent className={dialogContentClassName || "max-w-2xl max-h-[90vh] overflow-y-auto"}>
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
{formContent}
</DialogContent>
</Dialog>
)}
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState } from "react"
import {
Card,
CardContent,
@ -6,73 +6,82 @@ import {
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Terminal, Building2 } from "lucide-react";
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import type { Room } from "@/types/room";
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
import { getDeviceFromRoom } from "@/services/device-comm.service";
} from "@/components/ui/card"
import { Terminal, Building2, Monitor } from "lucide-react"
import { RequestUpdateMenu } from "@/components/request-update-menu"
import { SelectDialog } from "@/components/select-dialog"
interface FormSubmitTemplateProps {
title: string;
description: string;
isLoading?: boolean;
title: string
description: string
isLoading?: boolean
children: (props: {
command: string;
setCommand: (val: string) => void;
}) => React.ReactNode;
onSubmit?: (target: string, command: string) => void | Promise<void>;
submitLoading?: boolean;
rooms?: Room[];
devices?: string[];
command: string
setCommand: (val: string) => void
}) => React.ReactNode
onSubmit?: (target: string, command: string) => void | Promise<void>
submitLoading?: boolean
rooms?: string[]
devices?: string[]
}
export function FormSubmitTemplate({
title,
description,
isLoading,
children,
onSubmit,
submitLoading,
rooms = [],
devices = [],
}: FormSubmitTemplateProps) {
const [command, setCommand] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
const [command, setCommand] = useState("")
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null)
// Mở dialog chọn phòng
const openRoomDialog = () => {
if (rooms.length > 0 && onSubmit) {
setDialogType("room");
setDialogOpen(true);
setDialogType("room")
setDialogOpen(true)
}
};
}
// Mở dialog tìm thiết bị (search bar)
const openDeviceDialog = () => {
if (onSubmit) {
setDialogType("device");
setDialogOpen(true);
if (devices.length > 0 && onSubmit) {
setDialogType("device")
setDialogOpen(true)
}
};
}
// Gửi cho tất cả
const handleSubmitAll = async () => {
if (!onSubmit) return;
try {
const roomIds = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomIds, ...devices];
for (const target of allTargets) {
await onSubmit(target, command);
}
} catch (e) {
console.error("Submit error:", e);
const handleSubmitAll = () => {
if (!onSubmit) return
const allTargets = [...rooms, ...devices]
for (const target of allTargets) {
onSubmit(target, command)
}
};
}
const getDialogProps = () => {
if (dialogType === "room") {
return {
title: "Chọn phòng để gửi lệnh",
description: "Chọn các phòng muốn gửi lệnh CMD tới",
icon: <Building2 className="w-6 h-6 text-primary" />,
items: rooms,
}
}
if (dialogType === "device") {
return {
title: "Chọn thiết bị để gửi lệnh",
description: "Chọn các thiết bị muốn gửi lệnh CMD tới",
icon: <Monitor className="w-6 h-6 text-primary" />,
items: devices,
}
}
return null
}
const dialogProps = getDialogProps()
return (
<div className="w-full px-6 space-y-4">
@ -103,61 +112,24 @@ export function FormSubmitTemplate({
)}
</Card>
{/* Dialog chọn phòng */}
{dialogType === "room" && (
{/* 🧩 Dùng SelectDialog chung */}
{dialogProps && (
<SelectDialog
open={dialogOpen}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
title="Chọn phòng để gửi lệnh"
description="Chọn các phòng muốn gửi lệnh CMD tới"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onClose={() => setDialogOpen(false)}
title={dialogProps.title}
description={dialogProps.description}
icon={dialogProps.icon}
items={dialogProps.items}
onConfirm={async (selectedItems) => {
if (!onSubmit) return;
try {
for (const item of selectedItems) {
await onSubmit(item, command);
}
} catch (e) {
console.error("Submit error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tìm thiết bị */}
{dialogType === "device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "device"}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
onSelect={async (deviceIds) => {
if (!onSubmit) return;
try {
for (const deviceId of deviceIds) {
await onSubmit(deviceId, command);
}
} catch (e) {
console.error("Submit error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
if (!onSubmit) return
for (const item of selectedItems) {
await onSubmit(item, command)
}
setDialogOpen(false)
}}
/>
)}
</div>
);
)
}

View File

@ -1,6 +1,5 @@
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import { RequestUpdateMenu } from "@/components/request-update-menu";
import { SelectDialog } from "@/components/select-dialog";
import {
Card,
CardContent,
@ -9,16 +8,12 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { FormDialog } from "@/components/dialogs/form-dialog";
import { VersionTable } from "@/components/tables/version-table";
import { UploadDialog } from "@/components/upload-dialog";
import { VersionTable } from "@/components/version-table";
import type { ColumnDef } from "@tanstack/react-table";
import { FileText, Building2 } from "lucide-react";
import type { AxiosProgressEvent } from "axios";
import { FileText, Building2, Monitor } from "lucide-react";
import { useState } from "react";
import { BlacklistForm } from "@/components/forms/black-list-form";
import type { BlacklistFormData } from "@/types/black-list";
import type { Room } from "@/types/room";
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
import { getDeviceFromRoom } from "@/services/device-comm.service";
interface BlackListManagerTemplateProps<TData> {
title: string;
@ -26,12 +21,15 @@ interface BlackListManagerTemplateProps<TData> {
data: TData[];
isLoading: boolean;
columns: ColumnDef<TData, any>[];
onAdd: (data: BlacklistFormData) => Promise<void>;
onDelete?: (id: number) => Promise<void>;
onUpdate?: (target: string | string[]) => void | Promise<void>;
onUpload: (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => Promise<void>;
onUpdate?: (roomName: string) => void;
updateLoading?: boolean;
onTableInit?: (table: any) => void;
rooms: Room[];
rooms: string[];
devices?: string[];
}
export function BlackListManagerTemplate<TData>({
@ -40,17 +38,18 @@ export function BlackListManagerTemplate<TData>({
data,
isLoading,
columns,
onAdd,
onUpload,
onUpdate,
updateLoading,
onTableInit,
rooms = [],
devices = [],
}: BlackListManagerTemplateProps<TData>) {
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
const handleUpdateAll = async () => {
if (onUpdate) await onUpdate("All");
const handleUpdateAll = () => {
if (onUpdate) onUpdate("All");
};
const openRoomDialog = () => {
@ -61,12 +60,33 @@ export function BlackListManagerTemplate<TData>({
};
const openDeviceDialog = () => {
if (onUpdate) {
if (devices.length > 0 && onUpdate) {
setDialogType("device");
setDialogOpen(true);
}
};
const getDialogProps = () => {
if (dialogType === "room") {
return {
title: "Chọn phòng",
description: "Chọn các phòng cần cập nhật",
icon: <Building2 className="w-6 h-6 text-primary" />,
items: rooms,
};
}
if (dialogType === "device") {
return {
title: "Chọn thiết bị",
description: "Chọn các thiết bị cần cập nhật",
icon: <Monitor className="w-6 h-6 text-primary" />,
items: devices,
};
}
return null;
};
const dialogProps = getDialogProps();
return (
<div className="w-full px-6 space-y-4">
{/* Header */}
@ -75,14 +95,7 @@ export function BlackListManagerTemplate<TData>({
<h1 className="text-3xl font-bold">{title}</h1>
<p className="text-muted-foreground mt-2">{description}</p>
</div>
<FormDialog
triggerLabel="Thêm phần mềm bị chặn"
title="Thêm phần mềm bị chặn"
>
{(closeDialog) => (
<BlacklistForm onSubmit={onAdd} closeDialog={closeDialog} />
)}
</FormDialog>
<UploadDialog onSubmit={onUpload} />
</div>
{/* Table */}
@ -118,33 +131,23 @@ export function BlackListManagerTemplate<TData>({
)}
</Card>
{/* Dialog chọn phòng */}
{dialogType === "room" && (
{dialogProps && (
<SelectDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title="Chọn phòng"
description="Chọn các phòng cần cập nhật danh sách đen"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedRooms) => {
title={dialogProps.title}
description={dialogProps.description}
icon={dialogProps.icon}
items={dialogProps.items}
onConfirm={async (selectedItems) => {
if (!onUpdate) return;
await onUpdate(selectedRooms);
for (const item of selectedItems) {
onUpdate(item);
}
setDialogOpen(false);
}}
/>
)}
{/* Dialog tìm thiết bị */}
{dialogType === "device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "device"}
onClose={() => setDialogOpen(false)}
rooms={rooms}
fetchDevices={getDeviceFromRoom} // ⬅ thêm vào đây
onSelect={(deviceIds) => onUpdate && onUpdate(deviceIds)}
/>
)}
</div>
);
}

View File

@ -1,59 +0,0 @@
import { AppWindow, Building, CircleX, Home, Terminal } from "lucide-react";
import { PermissionEnum } from "./permission";
enum AppSidebarSectionCode {
DASHBOARD = 1,
DEVICES = 2,
DOOR = 4,
DOOR_LAYOUT = 5,
BUILDING_DASHBOARD = 6,
SETUP_DOOR = 7,
DOOR_STATUS = 8,
DEPARTMENTS = 9,
DEPARTMENT_PATHS = 10,
SCHEDULES = 11,
ACCESS_STATUS = 12,
ACCESS_HISTORY = 13,
CONFIG_MANAGER,
APP_VERSION_MANAGER,
DEVICES_APP_VERSION,
HEALTHCHEAK,
LIST_ROLES,
ACCOUNT_PERMISSION,
LIST_ACCOUNT,
DOOR_WARNING,
COMMAND_HISTORY,
ACCESS_ILLEGAL,
ZONES,
MANTRAP,
ROLES,
DEVICES_SYNC_BIO,
}
export const appSidebarSection = {
versions: ["1.0.1", "1.1.0-alpha", "2.0.0-beta1"],
navMain: [
{ title: "Dashboard", to: "/", icon: Home },
{
title: "Danh sách phòng",
to: "/room",
icon: Building,
},
{
title: "Quản lý Agent",
to: "/agent",
icon: AppWindow,
},
{
title: "Quản lý phần mềm",
to: "/apps",
icon: AppWindow,
},
{ title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
{
title: "Danh sách đen",
to: "/blacklist",
icon: CircleX,
},
],
};

View File

@ -1,30 +0,0 @@
export interface IAuthContext {
isAuthenticated: boolean;
logout: () => void;
username: string;
token: string;
name: string;
acs: number[];
role: {
roleName: string;
priority: number;
};
}
export type LoginResquest = {
username: string;
password: string;
};
export type LoginResponse = {
token: string | null;
name: string | null;
username: string | null;
access: number[] | null;
message: string | null;
code: number | null;
role: {
roleName: string;
priority: number;
};
};

View File

@ -1,10 +0,0 @@
export type Blacklist = {
id: number;
appName: string;
processName: string;
createdAt?: string;
updatedAt?: string;
createdBy?: string;
};
export type BlacklistFormData = Pick<Blacklist, "appName" | "processName">;

View File

@ -1,9 +0,0 @@
export type Version = {
id: number;
version: string;
fileName: string;
folderPath: string;
updatedAt?: string;
requestUpdateAt?: string;
isRequired: boolean;
};

View File

@ -1,3 +0,0 @@
export type InstallHistory = {
}

View File

@ -1,144 +0,0 @@
export type Permission = {
id: number;
name: string;
code: string;
parentId: number | null;
enum: PermissionEnum;
};
export type PermissionOnRole = {
permisionId: number;
roleId: string;
isChecked: number;
permissionName: string;
permissionCode: string;
permissionEnum: PermissionEnum;
parentId: number | null;
};
export enum PermissionEnum {
//ACCESS_OPERATION
ACCESS_OPERATION = 10,
VIEW_ACCESSES = 11,
VIEW_ACCESS_HISTORY = 12,
//APP_CONFIG_OPERATION
APP_CONFIG_OPERATION = 20,
CREATE_APP_CONFIG = 21,
VIEW_APP_CONFIG = 22,
EDIT_APP_CONFIG = 23,
DEL_APP_CONFIG = 24,
//BIOMETRIC_OPERATION
BIOMETRIC_OPERATION = 30,
VIEW_GUEST = 31,
GET_BIO = 32,
GET_SEND_BIO_STATUS = 33,
//BUILDING_OPERATION
BUILDING_OPERATION = 40,
VIEW_BUILDING = 41,
CREATE_BUILDING = 42,
EDIT_BUILDING = 43,
CREATE_LV = 45,
DEL_BUILDING = 44,
//COMMAND_OPERATION
COMMAND_OPERATION = 50,
VIEW_COMMAND = 51,
//DEPARTMENT_OPERATION
DEPARTMENT_OPERATION = 60,
VIEW_DEP = 61,
CREATE_DEP = 62,
EDIT_DEP = 63,
DEL_DEP = 64,
VIEW_PATH = 65,
//DEVICE_OPERATION
DEVICE_OPERATION = 70,
DEL_DEVICE = 71,
EDIT_DEVICE = 73,
VIEW_DEVICE = 74,
//DOOR_OPERATION
DOOR_OPERATION = 80,
SET_DOOR_POSITION = 85,
RESET_DOOR_POSITION = 86,
VIEW_DOOR = 81,
ADD_DOOR = 82,
EDIT_DOOR = 83,
DEL_DOOR = 84,
ADD_DEVICE_TO_DOOR = 87,
REMOVE_DEVICE_FROM_DOOR = 88,
SEND_COMMAND = 801,
SEND_EMERGENCY = 803,
CONTROL_DOOR = 805,
//LEVEL_OPERATION
LEVEL_OPERATION = 90,
UPLOAD_LAYOUT = 91,
VIEW_LEVEL_IN_BUILDING = 92,
EDIT_LV = 93,
DEL_LV = 94,
//PATH_OPERATION
PATH_OPERATION = 100,
CREATE_PATH = 102,
EDIT_PATH = 103,
DEL_PATH = 104,
//PERMISSION_OPERATION
PERMISSION_OPERATION = 110,
VIEW_ALL_PER = 111,
CRE_PER = 112,
DEL_PER = 114,
VIEW_ACCOUNT_BUILDING = 115,
EDIT_ACCOUNT_BUILDING = 116,
//ZONE_OPERATION
ZONE_OPERATION = 120,
CREATE_ZONE = 122,
EDIT_ZONE = 123,
DEL_ZONE = 124,
VIEW_ZONE = 121,
//SCHEDULE_OPERATION
SCHEDULE_OPERATION = 130,
DEL_SCHEDULE = 134,
CREATE_SCHEDULE = 132,
EDIT_SCHEDULE = 133,
VIEW_ALL_SCHEDULE = 131,
//WARNING_OPERATION
WARNING_OPERATION = 140,
VIEW_WARNING = 141,
//USER_OPERATION
USER_OPERATION = 150,
VIEW_USER_ROLE = 151,
VIEW_USER = 152,
EDIT_USER_ROLE = 153,
CRE_USER = 154,
//ROLE_OPERATION
ROLE_OPERATION = 160,
VIEW_ROLES = 161,
CRE_ROLE = 162,
VIEW_ROLE_PER = 165,
EDIT_ROLE_PER = 163,
DEL_ROLE = 164,
// APP VERSION
APP_VERSION_OPERATION = 170,
VIEW_APP_VERSION = 171,
UPLOAD_APK = 172,
CHANGE_PASSWORD = 2,
//Undefined
UNDEFINED = 9999,
ALLOW_ALL = 0
}