Compare commits
No commits in common. "main" and "feature_update_button" have entirely different histories.
main
...
feature_up
|
|
@ -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!
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
# API Services Documentation
|
|
||||||
|
|
||||||
Tất cả logic gọi API đã được tách riêng vào folder `services`. Mỗi service tương ứng với một nhóm API.
|
|
||||||
|
|
||||||
## Cấu trúc Services
|
|
||||||
|
|
||||||
```
|
|
||||||
src/services/
|
|
||||||
├── index.ts # Export tất cả services
|
|
||||||
├── auth.service.ts # API xác thực
|
|
||||||
├── app-version.service.ts # API quản lý phần mềm
|
|
||||||
├── device-comm.service.ts # API thiết bị
|
|
||||||
├── command.service.ts # API lệnh
|
|
||||||
└── device.service.ts # Helper functions
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cách Sử Dụng
|
|
||||||
|
|
||||||
### 1. Auth Service (Xác thực)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { authService } from '@/services'
|
|
||||||
|
|
||||||
// Đăng nhập
|
|
||||||
const response = await authService.login({
|
|
||||||
username: 'user',
|
|
||||||
password: 'pass'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Đăng xuất
|
|
||||||
await authService.logout()
|
|
||||||
|
|
||||||
// Kiểm tra session
|
|
||||||
const pingResult = await authService.ping(token)
|
|
||||||
|
|
||||||
// Thay đổi mật khẩu
|
|
||||||
await authService.changePassword({
|
|
||||||
currentPassword: 'old',
|
|
||||||
newPassword: 'new'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Tạo tài khoản mới (admin)
|
|
||||||
await authService.createAccount({
|
|
||||||
userName: 'newuser',
|
|
||||||
password: 'pass',
|
|
||||||
name: 'John Doe',
|
|
||||||
roleId: 1,
|
|
||||||
accessBuildings: [1, 2, 3]
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. App Version Service (Quản lý phần mềm)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { appVersionService } from '@/services'
|
|
||||||
|
|
||||||
// Lấy danh sách agent
|
|
||||||
const agents = await appVersionService.getAgentVersion()
|
|
||||||
|
|
||||||
// Lấy danh sách phần mềm
|
|
||||||
const software = await appVersionService.getSoftwareList()
|
|
||||||
|
|
||||||
// Upload file
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', fileInput.files[0])
|
|
||||||
await appVersionService.uploadSoftware(formData, (progressEvent) => {
|
|
||||||
console.log(`Progress: ${progressEvent.loaded}/${progressEvent.total}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Lấy blacklist
|
|
||||||
const blacklist = await appVersionService.getBlacklist()
|
|
||||||
|
|
||||||
// Thêm vào blacklist
|
|
||||||
await appVersionService.addBlacklist({ appId: 1, reason: 'virus' })
|
|
||||||
|
|
||||||
// Xóa khỏi blacklist
|
|
||||||
await appVersionService.deleteBlacklist(1)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Device Comm Service (Thiết bị)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { deviceCommService } from '@/services'
|
|
||||||
|
|
||||||
// Lấy tất cả thiết bị
|
|
||||||
const allDevices = await deviceCommService.getAllDevices()
|
|
||||||
|
|
||||||
// Lấy danh sách phòng
|
|
||||||
const rooms = await deviceCommService.getRoomList()
|
|
||||||
|
|
||||||
// Lấy thiết bị trong phòng
|
|
||||||
const devices = await deviceCommService.getDeviceFromRoom('Room A')
|
|
||||||
|
|
||||||
// Gửi lệnh
|
|
||||||
await deviceCommService.sendCommand('Room A', {
|
|
||||||
command: 'dir'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cập nhật agent
|
|
||||||
await deviceCommService.updateAgent('Room A', { version: '1.0.0' })
|
|
||||||
|
|
||||||
// Cài đặt MSI
|
|
||||||
await deviceCommService.installMsi('Room A', { msiFileId: 1 })
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Command Service (Lệnh)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { commandService } from '@/services'
|
|
||||||
|
|
||||||
// Lấy danh sách lệnh
|
|
||||||
const commands = await commandService.getCommandList()
|
|
||||||
|
|
||||||
// Thêm lệnh
|
|
||||||
await commandService.addCommand({ name: 'cmd1', command: 'echo hello' })
|
|
||||||
|
|
||||||
// Cập nhật lệnh
|
|
||||||
await commandService.updateCommand(1, { name: 'cmd1 updated' })
|
|
||||||
|
|
||||||
// Xóa lệnh
|
|
||||||
await commandService.deleteCommand(1)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Sử dụng với React Query/Hooks
|
|
||||||
|
|
||||||
### Cách cũ (trực tiếp gọi từ component):
|
|
||||||
```tsx
|
|
||||||
const { data } = useQueryData({
|
|
||||||
queryKey: ["software-version"],
|
|
||||||
url: API_ENDPOINTS.APP_VERSION.GET_SOFTWARE,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cách mới (tách biệt logic):
|
|
||||||
|
|
||||||
Có thể tạo custom hooks bao quanh services:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// hooks/useGetSoftware.ts
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { appVersionService } from '@/services'
|
|
||||||
|
|
||||||
export function useGetSoftware() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['software-version'],
|
|
||||||
queryFn: () => appVersionService.getSoftwareList(),
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Sau đó sử dụng trong component:
|
|
||||||
```tsx
|
|
||||||
function AppsComponent() {
|
|
||||||
const { data, isLoading } = useGetSoftware()
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lợi ích của cách sử dụng mới
|
|
||||||
|
|
||||||
1. **Tách biệt logic** - Logic API nằm riêng, dễ bảo trì
|
|
||||||
2. **Tái sử dụng** - Có thể sử dụng service từ bất kỳ nơi
|
|
||||||
3. **Dễ test** - Dễ mock services khi viết unit tests
|
|
||||||
4. **Centralized error handling** - Có thể xử lý lỗi chung
|
|
||||||
5. **Type safety** - TypeScript types cho tất cả API requests/responses
|
|
||||||
|
|
||||||
## Cải tiến trong tương lai
|
|
||||||
|
|
||||||
Có thể thêm:
|
|
||||||
- Global error handling middleware trong axios
|
|
||||||
- Request/response interceptors cho authentication
|
|
||||||
- Retry logic cho failed requests
|
|
||||||
- Request cancellation
|
|
||||||
|
|
@ -1,328 +0,0 @@
|
||||||
# Khác biệt giữa Services và Query Hooks
|
|
||||||
|
|
||||||
## Tóm tắt nhanh
|
|
||||||
|
|
||||||
| Aspect | Services | Query Hooks |
|
|
||||||
|--------|----------|-------------|
|
|
||||||
| **Location** | `src/services/` | `src/hooks/queries/` |
|
|
||||||
| **Mục đích** | Gọi API trực tiếp | Wrapper TanStack Query |
|
|
||||||
| **Caching** | ❌ Không | ✅ Có |
|
|
||||||
| **Background Refetch** | ❌ Không | ✅ Có |
|
|
||||||
| **Auto Invalidation** | ❌ Không | ✅ Có |
|
|
||||||
| **Type** | Async functions | React Hooks |
|
|
||||||
| **Dùng trong** | Non-React code, utilities | React components |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chi tiết Từng Layer
|
|
||||||
|
|
||||||
### 1. Services Layer (`src/services/`)
|
|
||||||
|
|
||||||
**Mục đích:** Đơn thuần gọi API và trả về dữ liệu
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// app-version.service.ts
|
|
||||||
export async function getSoftwareList(): Promise<Version[]> {
|
|
||||||
const response = await axios.get<Version[]>(
|
|
||||||
API_ENDPOINTS.APP_VERSION.GET_SOFTWARE
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Đặc điểm:**
|
|
||||||
- ✅ Pure async functions
|
|
||||||
- ✅ Không phụ thuộc vào React
|
|
||||||
- ✅ Có thể sử dụng ở bất kỳ đâu (utils, servers, non-React code)
|
|
||||||
- ❌ Không có caching
|
|
||||||
- ❌ Phải tự quản lý state loading/error
|
|
||||||
- ❌ Phải tự gọi lại khi dữ liệu thay đổi
|
|
||||||
|
|
||||||
**Khi nào dùng:**
|
|
||||||
```typescript
|
|
||||||
// Dùng trong utility functions
|
|
||||||
export async function initializeApp() {
|
|
||||||
const software = await appVersionService.getSoftwareList();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hoặc trong services khác
|
|
||||||
export async function validateSoftware() {
|
|
||||||
const list = await appVersionService.getSoftwareList();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Query Hooks Layer (`src/hooks/queries/`)
|
|
||||||
|
|
||||||
**Mục đích:** Wrapper TanStack Query bên trên services
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// useAppVersionQueries.ts
|
|
||||||
export function useGetSoftwareList(enabled = true) {
|
|
||||||
return useQuery<Version[]>({
|
|
||||||
queryKey: ["app-version", "software"],
|
|
||||||
queryFn: () => appVersionService.getSoftwareList(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 60 * 1000, // 1 minute
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Đặc điểm:**
|
|
||||||
- ✅ React hooks
|
|
||||||
- ✅ Automatic caching
|
|
||||||
- ✅ Background refetching
|
|
||||||
- ✅ Automatic invalidation sau mutations
|
|
||||||
- ✅ Built-in loading/error states
|
|
||||||
- ✅ Deduplication (gộp requests giống nhau)
|
|
||||||
- ❌ Chỉ dùng được trong React components
|
|
||||||
- ❌ Phức tạp hơn services
|
|
||||||
|
|
||||||
**Khi nào dùng:**
|
|
||||||
```typescript
|
|
||||||
// Dùng trong React components
|
|
||||||
function SoftwareList() {
|
|
||||||
const { data: software, isLoading } = useGetSoftwareList()
|
|
||||||
|
|
||||||
if (isLoading) return <div>Loading...</div>
|
|
||||||
return software?.map(item => <div>{item.name}</div>)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## So sánh cụ thể
|
|
||||||
|
|
||||||
### Ví dụ 1: Lấy danh sách
|
|
||||||
|
|
||||||
**Service - Raw API call:**
|
|
||||||
```typescript
|
|
||||||
// services/app-version.service.ts
|
|
||||||
export async function getSoftwareList(): Promise<Version[]> {
|
|
||||||
const response = await axios.get(API_ENDPOINTS.APP_VERSION.GET_SOFTWARE);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hook - TanStack Query wrapper:**
|
|
||||||
```typescript
|
|
||||||
// hooks/queries/useAppVersionQueries.ts
|
|
||||||
export function useGetSoftwareList(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["app-version", "software"],
|
|
||||||
queryFn: () => appVersionService.getSoftwareList(),
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Sử dụng trong component:**
|
|
||||||
```typescript
|
|
||||||
function Component() {
|
|
||||||
// ❌ KHÔNG nên dùng service trực tiếp
|
|
||||||
const [data, setData] = useState<Version[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
useEffect(() => {
|
|
||||||
appVersionService.getSoftwareList().then(d => {
|
|
||||||
setData(d);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ✅ NÊN dùng hook thay vì
|
|
||||||
const { data, isLoading } = useGetSoftwareList();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Ví dụ 2: Upload file
|
|
||||||
|
|
||||||
**Service:**
|
|
||||||
```typescript
|
|
||||||
// services/app-version.service.ts
|
|
||||||
export async function uploadSoftware(
|
|
||||||
formData: FormData,
|
|
||||||
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
return axios.post(API_ENDPOINTS.APP_VERSION.UPLOAD, formData, {
|
|
||||||
onUploadProgress,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hook:**
|
|
||||||
```typescript
|
|
||||||
// hooks/queries/useAppVersionQueries.ts
|
|
||||||
export function useUploadSoftware() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: { formData: FormData; onUploadProgress?: ... }) =>
|
|
||||||
appVersionService.uploadSoftware(data.formData, data.onUploadProgress),
|
|
||||||
onSuccess: () => {
|
|
||||||
// Tự động invalidate software list
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["app-version", "software"],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Sử dụng:**
|
|
||||||
```typescript
|
|
||||||
function UploadForm() {
|
|
||||||
const uploadMutation = useUploadSoftware();
|
|
||||||
|
|
||||||
const handleUpload = async (file: File) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
await uploadMutation.mutateAsync({
|
|
||||||
formData,
|
|
||||||
onUploadProgress: (e) => console.log(`${e.loaded}/${e.total}`)
|
|
||||||
});
|
|
||||||
|
|
||||||
// ✅ Software list tự động update
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button disabled={uploadMutation.isPending}>
|
|
||||||
{uploadMutation.isPending ? "Uploading..." : "Upload"}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ React Component │
|
|
||||||
│ (SoftwareList, UploadForm, etc.) │
|
|
||||||
└──────────────┬──────────────────────────┘
|
|
||||||
│
|
|
||||||
│ uses
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Query Hooks (src/hooks/queries/) │
|
|
||||||
│ - useGetSoftwareList() │
|
|
||||||
│ - useUploadSoftware() │
|
|
||||||
│ - useDeleteBlacklist() │
|
|
||||||
│ - Features: │
|
|
||||||
│ - Caching │
|
|
||||||
│ - Auto invalidation │
|
|
||||||
│ - Loading states │
|
|
||||||
└──────────────┬──────────────────────────┘
|
|
||||||
│
|
|
||||||
│ wraps
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Service Functions (src/services/) │
|
|
||||||
│ - getSoftwareList() │
|
|
||||||
│ - uploadSoftware() │
|
|
||||||
│ - deleteBlacklist() │
|
|
||||||
│ - Features: │
|
|
||||||
│ - Pure async functions │
|
|
||||||
│ - Direct API calls │
|
|
||||||
└──────────────┬──────────────────────────┘
|
|
||||||
│
|
|
||||||
│ uses
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Axios (HTTP Client) │
|
|
||||||
└──────────────┬──────────────────────────┘
|
|
||||||
│
|
|
||||||
│ requests
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Backend API Server │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nguyên tắc sử dụng
|
|
||||||
|
|
||||||
### ✅ NÊN dùng Services khi:
|
|
||||||
- Gọi API từ non-React code (utilities, event handlers, etc.)
|
|
||||||
- Cần gọi API một lần rồi không cần tracking state
|
|
||||||
- Không cần caching hay background refetch
|
|
||||||
- Viết code không phụ thuộc React
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ OK - utility function
|
|
||||||
export async function syncData() {
|
|
||||||
const software = await appVersionService.getSoftwareList();
|
|
||||||
const commands = await commandService.getCommandList();
|
|
||||||
return { software, commands };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ NÊN dùng Hooks khi:
|
|
||||||
- Lấy/update dữ liệu trong React components
|
|
||||||
- Cần caching và background refetch
|
|
||||||
- Muốn dữ liệu tự động update
|
|
||||||
- Cần tracking loading/error states
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ OK - React component
|
|
||||||
function Dashboard() {
|
|
||||||
const { data: software, isLoading } = useGetSoftwareList();
|
|
||||||
const uploadMutation = useUploadSoftware();
|
|
||||||
|
|
||||||
return <div>{/* ... */}</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ❌ KHÔNG nên dùng Services khi:
|
|
||||||
- Đang trong React component và cần state management
|
|
||||||
- Cần automatic refetching
|
|
||||||
- Cần auto-invalidation sau mutations
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ WRONG
|
|
||||||
function Component() {
|
|
||||||
const [data, setData] = useState([]);
|
|
||||||
useEffect(() => {
|
|
||||||
appVersionService.getSoftwareList().then(setData);
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ RIGHT
|
|
||||||
function Component() {
|
|
||||||
const { data } = useGetSoftwareList();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ❌ KHÔNG nên dùng Hooks khi:
|
|
||||||
- Không phải trong React component
|
|
||||||
- Không có React context
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ WRONG - không thể gọi hooks ở đây
|
|
||||||
export function initApp() {
|
|
||||||
const { data } = useGetSoftwareList(); // ERROR!
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ RIGHT
|
|
||||||
export async function initApp() {
|
|
||||||
const data = await appVersionService.getSoftwareList();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
**Services** = Cơ sở API calls, có thể tái sử dụng ở bất kỳ đâu
|
|
||||||
**Hooks** = Lớp React trên services, tối ưu cho React components
|
|
||||||
|
|
||||||
**Dùng Services** khi bạn cần tính linh hoạt và độc lập với React
|
|
||||||
**Dùng Hooks** khi bạn muốn TanStack Query quản lý state và caching tự động
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
upstream backend {
|
upstream backend {
|
||||||
server 100.66.170.15:8080;
|
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 172.18.10.8:8080;
|
||||||
}
|
}
|
||||||
server {
|
server {
|
||||||
|
|
@ -25,7 +25,7 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://100.66.170.15:8080;
|
proxy_pass http://backend/;
|
||||||
|
|
||||||
# Cho phép upload file lớn (vd: 200MB)
|
# Cho phép upload file lớn (vd: 200MB)
|
||||||
client_max_body_size 200M;
|
client_max_body_size 200M;
|
||||||
|
|
|
||||||
60
package-lock.json
generated
60
package-lock.json
generated
|
|
@ -26,7 +26,6 @@
|
||||||
"@tanstack/react-router-devtools": "^1.121.2",
|
"@tanstack/react-router-devtools": "^1.121.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/router-plugin": "^1.121.2",
|
"@tanstack/router-plugin": "^1.121.2",
|
||||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -129,7 +128,6 @@
|
||||||
"version": "7.28.0",
|
"version": "7.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
|
||||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
|
|
@ -659,7 +657,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
|
|
@ -683,7 +680,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -3257,7 +3253,6 @@
|
||||||
"version": "1.129.8",
|
"version": "1.129.8",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.129.8.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.129.8.tgz",
|
||||||
"integrity": "sha512-d5mfM+67h3wq7aHkLjRKXD1ddbzx1YuxaEbNvW45jjZXMgaikZSVfJrZBiUWXE/nhV1sTdbMQ48JcPagvGPmYQ==",
|
"integrity": "sha512-d5mfM+67h3wq7aHkLjRKXD1ddbzx1YuxaEbNvW45jjZXMgaikZSVfJrZBiUWXE/nhV1sTdbMQ48JcPagvGPmYQ==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/history": "1.129.7",
|
"@tanstack/history": "1.129.7",
|
||||||
"@tanstack/react-store": "^0.7.0",
|
"@tanstack/react-store": "^0.7.0",
|
||||||
|
|
@ -3338,7 +3333,6 @@
|
||||||
"version": "1.129.8",
|
"version": "1.129.8",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
|
||||||
"integrity": "sha512-Izqf5q8TzJv0DJURynitJioPJT3dPAefrzHi2wlY/Q5+7nEG41SkjYMotTX2Q9i/Pjl91lW8gERCHpksszRdRw==",
|
"integrity": "sha512-Izqf5q8TzJv0DJURynitJioPJT3dPAefrzHi2wlY/Q5+7nEG41SkjYMotTX2Q9i/Pjl91lW8gERCHpksszRdRw==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/history": "1.129.7",
|
"@tanstack/history": "1.129.7",
|
||||||
"@tanstack/store": "^0.7.0",
|
"@tanstack/store": "^0.7.0",
|
||||||
|
|
@ -3511,40 +3505,12 @@
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"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": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
||||||
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
|
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.10.4",
|
"@babel/code-frame": "^7.10.4",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
|
|
@ -3697,7 +3663,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
|
||||||
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
|
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.8.0"
|
"undici-types": "~7.8.0"
|
||||||
}
|
}
|
||||||
|
|
@ -3708,7 +3673,6 @@
|
||||||
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
|
|
@ -3719,7 +3683,6 @@
|
||||||
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
|
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -4161,7 +4124,6 @@
|
||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
|
|
@ -4601,8 +4563,7 @@
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/data-uri-to-buffer": {
|
"node_modules/data-uri-to-buffer": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
|
|
@ -4998,7 +4959,6 @@
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
|
|
@ -5730,7 +5690,6 @@
|
||||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssstyle": "^4.2.1",
|
"cssstyle": "^4.2.1",
|
||||||
"data-urls": "^5.0.0",
|
"data-urls": "^5.0.0",
|
||||||
|
|
@ -6814,7 +6773,6 @@
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -6823,7 +6781,6 @@
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.26.0"
|
"scheduler": "^0.26.0"
|
||||||
},
|
},
|
||||||
|
|
@ -7206,7 +7163,6 @@
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
|
||||||
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
|
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
|
|
@ -7665,8 +7621,7 @@
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tiny-warning": {
|
"node_modules/tiny-warning": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
|
|
@ -7722,7 +7677,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -7910,7 +7864,6 @@
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -8088,10 +8041,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|
@ -8203,7 +8155,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -8537,7 +8488,6 @@
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@
|
||||||
"@tanstack/react-router-devtools": "^1.121.2",
|
"@tanstack/react-router-devtools": "^1.121.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/router-plugin": "^1.121.2",
|
"@tanstack/router-plugin": "^1.121.2",
|
||||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
38
src/App.css
Normal file
38
src/App.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
68
src/components/command-form.tsx
Normal file
68
src/components/command-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,19 +2,13 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Monitor, Wifi, WifiOff } from "lucide-react";
|
import { Monitor, Wifi, WifiOff } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FolderStatusPopover } from "../folder-status-popover";
|
|
||||||
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
|
||||||
|
|
||||||
export function ComputerCard({
|
export function ComputerCard({
|
||||||
device,
|
device,
|
||||||
position,
|
position,
|
||||||
folderStatus,
|
|
||||||
isCheckingFolder,
|
|
||||||
}: {
|
}: {
|
||||||
device: any | undefined;
|
device: any | undefined;
|
||||||
position: number;
|
position: number;
|
||||||
folderStatus?: ClientFolderStatus;
|
|
||||||
isCheckingFolder?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
if (!device) {
|
if (!device) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -104,17 +98,6 @@ export function ComputerCard({
|
||||||
{position}
|
{position}
|
||||||
</div>
|
</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")} />
|
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
|
||||||
{firstNetworkInfo?.ipAddress && (
|
{firstNetworkInfo?.ipAddress && (
|
||||||
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
||||||
|
|
@ -1,17 +1,8 @@
|
||||||
import { Monitor, DoorOpen } from "lucide-react";
|
import { Monitor, DoorOpen } from "lucide-react";
|
||||||
import { ComputerCard } from "../cards/computer-card";
|
import { ComputerCard } from "./computer-card";
|
||||||
import { useMachineNumber } from "../../hooks/useMachineNumber";
|
import { useMachineNumber } from "../hooks/useMachineNumber";
|
||||||
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
|
||||||
|
|
||||||
export function DeviceGrid({
|
export function DeviceGrid({ devices }: { devices: any[] }) {
|
||||||
devices,
|
|
||||||
folderStatuses,
|
|
||||||
isCheckingFolder,
|
|
||||||
}: {
|
|
||||||
devices: any[];
|
|
||||||
folderStatuses?: Map<string, ClientFolderStatus>;
|
|
||||||
isCheckingFolder?: boolean;
|
|
||||||
}) {
|
|
||||||
const getMachineNumber = useMachineNumber();
|
const getMachineNumber = useMachineNumber();
|
||||||
const deviceMap = new Map<number, any>();
|
const deviceMap = new Map<number, any>();
|
||||||
|
|
||||||
|
|
@ -23,27 +14,18 @@ export function DeviceGrid({
|
||||||
const totalRows = 5;
|
const totalRows = 5;
|
||||||
|
|
||||||
const renderRow = (rowIndex: number) => {
|
const renderRow = (rowIndex: number) => {
|
||||||
// Đảo ngược: 21-40 sang trái, 1-20 sang phải
|
// Trái: 1–20
|
||||||
const leftStart = 21 + (totalRows - 1 - rowIndex) * 4;
|
const leftStart = rowIndex * 4 + 1;
|
||||||
const rightStart = (totalRows - 1 - rowIndex) * 4 + 1;
|
// Phải: 21–40
|
||||||
|
const rightStart = 21 + rowIndex * 4;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={rowIndex} className="flex items-center justify-center gap-3">
|
<div key={rowIndex} className="flex items-center justify-center gap-3">
|
||||||
{/* Bên trái (21–40) */}
|
{/* Bên trái (1–20) */}
|
||||||
{Array.from({ length: 4 }).map((_, i) => {
|
{Array.from({ length: 4 }).map((_, i) => {
|
||||||
const pos = leftStart + (3 - i);
|
const pos = leftStart + i;
|
||||||
const device = deviceMap.get(pos);
|
|
||||||
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
|
||||||
const folderStatus = folderStatuses?.get(macAddress);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComputerCard
|
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
|
||||||
key={pos}
|
|
||||||
device={device}
|
|
||||||
position={pos}
|
|
||||||
folderStatus={folderStatus}
|
|
||||||
isCheckingFolder={isCheckingFolder}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|
@ -52,21 +34,11 @@ export function DeviceGrid({
|
||||||
<div className="h-px w-full bg-border border-t-2 border-dashed" />
|
<div className="h-px w-full bg-border border-t-2 border-dashed" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bên phải (1–20) */}
|
{/* Bên phải (21–40) */}
|
||||||
{Array.from({ length: 4 }).map((_, i) => {
|
{Array.from({ length: 4 }).map((_, i) => {
|
||||||
const pos = rightStart + (3 - i);
|
const pos = rightStart + i;
|
||||||
const device = deviceMap.get(pos);
|
|
||||||
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
|
||||||
const folderStatus = folderStatuses?.get(macAddress);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComputerCard
|
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
|
||||||
key={pos}
|
|
||||||
device={device}
|
|
||||||
position={pos}
|
|
||||||
folderStatus={folderStatus}
|
|
||||||
isCheckingFolder={isCheckingFolder}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -75,18 +47,19 @@ export function DeviceGrid({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-0.5 py-8 space-y-6">
|
<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 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">
|
<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" />
|
<Monitor className="h-6 w-6 text-primary" />
|
||||||
<span className="font-semibold text-lg">Bàn Giảng Viên</span>
|
<span className="font-semibold text-lg">Bàn Giảng Viên</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -17,24 +17,16 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
import { useMachineNumber } from "../hooks/useMachineNumber";
|
||||||
import { FolderStatusPopover } from "../folder-status-popover";
|
|
||||||
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
|
||||||
|
|
||||||
interface DeviceTableProps {
|
interface DeviceTableProps {
|
||||||
devices: any[];
|
devices: any[];
|
||||||
folderStatuses?: Map<string, ClientFolderStatus>;
|
|
||||||
isCheckingFolder?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component hiển thị danh sách thiết bị ở dạng bảng
|
* Component hiển thị danh sách thiết bị ở dạng bảng
|
||||||
*/
|
*/
|
||||||
export function DeviceTable({
|
export function DeviceTable({ devices }: DeviceTableProps) {
|
||||||
devices,
|
|
||||||
folderStatuses,
|
|
||||||
isCheckingFolder,
|
|
||||||
}: DeviceTableProps) {
|
|
||||||
const getMachineNumber = useMachineNumber();
|
const getMachineNumber = useMachineNumber();
|
||||||
|
|
||||||
const columns: ColumnDef<any>[] = [
|
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({
|
const table = useReactTable({
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 có 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 có 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 và 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 và 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>Mô 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">
|
|
||||||
Mô tả chi tiết về chức năng và 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">Có</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 và 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 lý 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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> và 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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
151
src/components/preset-command.tsx
Normal file
151
src/components/preset-command.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,18 +7,12 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Loader2, RefreshCw, ChevronDown } from "lucide-react";
|
import { Loader2, RefreshCw, ChevronDown } from "lucide-react";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface RequestUpdateMenuProps {
|
interface RequestUpdateMenuProps {
|
||||||
onUpdateDevice: () => void;
|
onUpdateDevice: () => void;
|
||||||
onUpdateRoom: () => void;
|
onUpdateRoom: () => void;
|
||||||
onUpdateAll: () => void;
|
onUpdateAll: () => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
label?: string;
|
|
||||||
deviceLabel?: string;
|
|
||||||
roomLabel?: string;
|
|
||||||
allLabel?: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RequestUpdateMenu({
|
export function RequestUpdateMenu({
|
||||||
|
|
@ -26,39 +20,9 @@ export function RequestUpdateMenu({
|
||||||
onUpdateRoom,
|
onUpdateRoom,
|
||||||
onUpdateAll,
|
onUpdateAll,
|
||||||
loading,
|
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) {
|
}: 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 (
|
return (
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -68,13 +32,11 @@ export function RequestUpdateMenu({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-gray-600" />
|
<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" />
|
<RefreshCw className="h-4 w-4 text-gray-600 transition-transform duration-300 group-hover:rotate-180" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-semibold">
|
<span className="text-sm font-semibold">
|
||||||
{loading ? "Đang gửi..." : label}
|
{loading ? "Đang gửi..." : "Cập nhật"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-4 w-4 text-gray-600 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
<ChevronDown className="h-4 w-4 text-gray-600 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -83,19 +45,19 @@ export function RequestUpdateMenu({
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-56">
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
<DropdownMenuItem onClick={handleUpdateDevice} disabled={loading}>
|
<DropdownMenuItem onClick={onUpdateDevice} disabled={loading}>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
<span>{deviceLabel}</span>
|
<span>Cập nhật thiết bị cụ thể</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleUpdateRoom} disabled={loading}>
|
<DropdownMenuItem onClick={onUpdateRoom} disabled={loading}>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
<span>{roomLabel}</span>
|
<span>Cập nhật theo phòng</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleUpdateAll} disabled={loading}>
|
<DropdownMenuItem onClick={onUpdateAll} disabled={loading}>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
<span>{allLabel}</span>
|
<span>Cập nhật tất cả thiết bị</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
135
src/components/select-dialog.tsx
Normal file
135
src/components/select-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { PanelLeftIcon } from "lucide-react"
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/useMobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
|
||||||
164
src/components/upload-dialog.tsx
Normal file
164
src/components/upload-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ interface VersionTableProps<TData> {
|
||||||
data: TData[];
|
data: TData[];
|
||||||
columns: ColumnDef<TData, any>[];
|
columns: ColumnDef<TData, any>[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onTableInit?: (table: any) => void;
|
onTableInit?: (table: any) => void; // <-- thêm
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VersionTable<TData>({
|
export function VersionTable<TData>({
|
||||||
|
|
@ -5,59 +5,29 @@ export const BASE_URL = isDev
|
||||||
: "/api";
|
: "/api";
|
||||||
|
|
||||||
export const API_ENDPOINTS = {
|
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: {
|
APP_VERSION: {
|
||||||
//agent and app api
|
|
||||||
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
||||||
UPLOAD: `${BASE_URL}/AppVersion/upload`,
|
UPLOAD: `${BASE_URL}/AppVersion/upload`,
|
||||||
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
||||||
|
|
||||||
//blacklist api
|
|
||||||
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
|
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
|
||||||
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
|
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}`,
|
UPDATE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/update/${appId}`,
|
||||||
REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
|
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: {
|
DEVICE_COMM: {
|
||||||
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
|
DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
|
||||||
INSTALL_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
|
|
||||||
GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
|
GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
|
||||||
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
|
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
|
||||||
GET_DEVICE_FROM_ROOM: (roomName: string) =>
|
GET_DEVICE_FROM_ROOM: (roomName: string) =>
|
||||||
`${BASE_URL}/DeviceComm/room/${roomName}`,
|
`${BASE_URL}/DeviceComm/room/${roomName}`,
|
||||||
UPDATE_AGENT: (roomName: string) => `${BASE_URL}/DeviceComm/updateagent/${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}`,
|
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
|
||||||
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
|
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
|
||||||
REQUEST_GET_CLIENT_FOLDER_STATUS: (roomName: string) =>
|
|
||||||
`${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: {
|
SSE_EVENTS: {
|
||||||
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
||||||
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
|
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
|
||||||
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
|
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
|
||||||
GET_CLIENT_FOLDER_STATUS: `${BASE_URL}/Sse/events/clientFolderStatuses`,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
@ -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(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
53
src/hooks/useMutationData.ts
Normal file
53
src/hooks/useMutationData.ts
Normal 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
26
src/hooks/useQueryData.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AppSidebar } from "@/components/sidebars/app-sidebar";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
import {
|
import {
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
|
|
@ -8,13 +8,8 @@ import {
|
||||||
import { Home, Building, AppWindow, Terminal, CircleX } from "lucide-react";
|
import { Home, Building, AppWindow, Terminal, CircleX } from "lucide-react";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
|
||||||
useGetAgentVersion,
|
|
||||||
useGetSoftwareList,
|
|
||||||
useGetRoomList,
|
|
||||||
useGetBlacklist,
|
|
||||||
} from "@/hooks/queries";
|
|
||||||
|
|
||||||
type AppLayoutProps = {
|
type AppLayoutProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|
@ -25,32 +20,43 @@ export default function AppLayout({ children }: AppLayoutProps) {
|
||||||
|
|
||||||
const handlePrefetchAgents = () => {
|
const handlePrefetchAgents = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["app-version", "agent"],
|
queryKey: ["agent-version"],
|
||||||
queryFn: useGetAgentVersion as any,
|
queryFn: () =>
|
||||||
|
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION).then((res) =>
|
||||||
|
res.json()
|
||||||
|
),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrefetchSofware = () => {
|
const handlePrefetchSofware = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["app-version", "software"],
|
queryKey: ["software-version"],
|
||||||
queryFn: useGetSoftwareList as any,
|
queryFn: () =>
|
||||||
|
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE).then((res) =>
|
||||||
|
res.json()
|
||||||
|
),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrefetchRooms = () => {
|
const handlePrefetchRooms = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["device-comm", "rooms"],
|
queryKey: ["room-list"],
|
||||||
queryFn: useGetRoomList as any,
|
queryFn: () =>
|
||||||
staleTime: 5 * 60 * 1000,
|
fetch(BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST).then((res) =>
|
||||||
|
res.json()
|
||||||
|
),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrefetchBannedSoftware = () => {
|
const handlePrefetchBannedSoftware = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["app-version", "blacklist"],
|
queryKey: ["blacklist"],
|
||||||
queryFn: useGetBlacklist as any,
|
queryFn: () =>
|
||||||
|
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION).then((res) =>
|
||||||
|
res.json()
|
||||||
|
),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,3 @@ import { twMerge } from "tailwind-merge"
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
76
src/main.tsx
76
src/main.tsx
|
|
@ -1,24 +1,25 @@
|
||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import "./index.css";
|
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
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 the generated route tree
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import axios from "axios";
|
import "./styles.css";
|
||||||
import { AuthProvider, useAuth } from "@/hooks/useAuth";
|
|
||||||
import { toast, Toaster } from "sonner";
|
const auth = useAuthToken.getState();
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
// Create a new router instance
|
// Create a new router instance
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
|
context: { auth },
|
||||||
defaultPreload: "intent",
|
defaultPreload: "intent",
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
context: {
|
defaultStructuralSharing: true,
|
||||||
auth: undefined!, // This will be set after we initialize the auth store
|
defaultPreloadStaleTime: 0,
|
||||||
queryClient: undefined!
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register the router instance for type safety
|
// Register the router instance for type safety
|
||||||
|
|
@ -26,61 +27,18 @@ declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: typeof router;
|
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
|
// Render the app
|
||||||
const rootElement = document.getElementById("app");
|
const rootElement = document.getElementById("app");
|
||||||
|
if (rootElement && !rootElement.innerHTML) {
|
||||||
if (!rootElement) {
|
|
||||||
throw new Error("Failed to find the root element");
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<QueryClientProvider client={queryClient}>
|
||||||
<Toaster richColors />
|
{" "}
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// 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 rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
||||||
import { Route as AuthRouteImport } from './routes/_auth'
|
import { Route as AuthRouteImport } from './routes/_auth'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as AuthRoomIndexRouteImport } from './routes/_auth/room/index'
|
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
|
||||||
import { Route as AuthDeviceIndexRouteImport } from './routes/_auth/device/index'
|
import { Route as AuthenticatedDeviceIndexRouteImport } from './routes/_authenticated/device/index'
|
||||||
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
|
import { Route as AuthenticatedCommandIndexRouteImport } from './routes/_authenticated/command/index'
|
||||||
import { Route as AuthCommandIndexRouteImport } from './routes/_auth/command/index'
|
import { Route as AuthenticatedBlacklistIndexRouteImport } from './routes/_authenticated/blacklist/index'
|
||||||
import { Route as AuthBlacklistIndexRouteImport } from './routes/_auth/blacklist/index'
|
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
|
||||||
import { Route as AuthAppsIndexRouteImport } from './routes/_auth/apps/index'
|
import { Route as AuthenticatedAgentIndexRouteImport } from './routes/_authenticated/agent/index'
|
||||||
import { Route as AuthAgentIndexRouteImport } from './routes/_auth/agent/index'
|
import { Route as AuthLoginIndexRouteImport } from './routes/_auth/login/index'
|
||||||
import { Route as authLoginIndexRouteImport } from './routes/(auth)/login/index'
|
import { Route as AuthenticatedRoomRoomNameIndexRouteImport } from './routes/_authenticated/room/$roomName/index'
|
||||||
import { Route as AuthRoomRoomNameIndexRouteImport } from './routes/_auth/room/$roomName/index'
|
|
||||||
|
|
||||||
|
const AuthenticatedRoute = AuthenticatedRouteImport.update({
|
||||||
|
id: '/_authenticated',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AuthRoute = AuthRouteImport.update({
|
const AuthRoute = AuthRouteImport.update({
|
||||||
id: '/_auth',
|
id: '/_auth',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
|
|
@ -30,89 +34,86 @@ const IndexRoute = IndexRouteImport.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthRoomIndexRoute = AuthRoomIndexRouteImport.update({
|
const AuthenticatedRoomIndexRoute = AuthenticatedRoomIndexRouteImport.update({
|
||||||
id: '/room/',
|
id: '/room/',
|
||||||
path: '/room/',
|
path: '/room/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthDeviceIndexRoute = AuthDeviceIndexRouteImport.update({
|
const AuthenticatedDeviceIndexRoute =
|
||||||
|
AuthenticatedDeviceIndexRouteImport.update({
|
||||||
id: '/device/',
|
id: '/device/',
|
||||||
path: '/device/',
|
path: '/device/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
|
const AuthenticatedCommandIndexRoute =
|
||||||
id: '/dashboard/',
|
AuthenticatedCommandIndexRouteImport.update({
|
||||||
path: '/dashboard/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthCommandIndexRoute = AuthCommandIndexRouteImport.update({
|
|
||||||
id: '/command/',
|
id: '/command/',
|
||||||
path: '/command/',
|
path: '/command/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthBlacklistIndexRoute = AuthBlacklistIndexRouteImport.update({
|
const AuthenticatedBlacklistIndexRoute =
|
||||||
|
AuthenticatedBlacklistIndexRouteImport.update({
|
||||||
id: '/blacklist/',
|
id: '/blacklist/',
|
||||||
path: '/blacklist/',
|
path: '/blacklist/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthAppsIndexRoute = AuthAppsIndexRouteImport.update({
|
const AuthenticatedAppsIndexRoute = AuthenticatedAppsIndexRouteImport.update({
|
||||||
id: '/apps/',
|
id: '/apps/',
|
||||||
path: '/apps/',
|
path: '/apps/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthAgentIndexRoute = AuthAgentIndexRouteImport.update({
|
const AuthenticatedAgentIndexRoute = AuthenticatedAgentIndexRouteImport.update({
|
||||||
id: '/agent/',
|
id: '/agent/',
|
||||||
path: '/agent/',
|
path: '/agent/',
|
||||||
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthLoginIndexRoute = AuthLoginIndexRouteImport.update({
|
||||||
|
id: '/login/',
|
||||||
|
path: '/login/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const authLoginIndexRoute = authLoginIndexRouteImport.update({
|
const AuthenticatedRoomRoomNameIndexRoute =
|
||||||
id: '/(auth)/login/',
|
AuthenticatedRoomRoomNameIndexRouteImport.update({
|
||||||
path: '/login/',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const AuthRoomRoomNameIndexRoute = AuthRoomRoomNameIndexRouteImport.update({
|
|
||||||
id: '/room/$roomName/',
|
id: '/room/$roomName/',
|
||||||
path: '/room/$roomName/',
|
path: '/room/$roomName/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/login': typeof authLoginIndexRoute
|
'/login': typeof AuthLoginIndexRoute
|
||||||
'/agent': typeof AuthAgentIndexRoute
|
'/agent': typeof AuthenticatedAgentIndexRoute
|
||||||
'/apps': typeof AuthAppsIndexRoute
|
'/apps': typeof AuthenticatedAppsIndexRoute
|
||||||
'/blacklist': typeof AuthBlacklistIndexRoute
|
'/blacklist': typeof AuthenticatedBlacklistIndexRoute
|
||||||
'/command': typeof AuthCommandIndexRoute
|
'/command': typeof AuthenticatedCommandIndexRoute
|
||||||
'/dashboard': typeof AuthDashboardIndexRoute
|
'/device': typeof AuthenticatedDeviceIndexRoute
|
||||||
'/device': typeof AuthDeviceIndexRoute
|
'/room': typeof AuthenticatedRoomIndexRoute
|
||||||
'/room': typeof AuthRoomIndexRoute
|
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||||
'/room/$roomName': typeof AuthRoomRoomNameIndexRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/login': typeof authLoginIndexRoute
|
'/login': typeof AuthLoginIndexRoute
|
||||||
'/agent': typeof AuthAgentIndexRoute
|
'/agent': typeof AuthenticatedAgentIndexRoute
|
||||||
'/apps': typeof AuthAppsIndexRoute
|
'/apps': typeof AuthenticatedAppsIndexRoute
|
||||||
'/blacklist': typeof AuthBlacklistIndexRoute
|
'/blacklist': typeof AuthenticatedBlacklistIndexRoute
|
||||||
'/command': typeof AuthCommandIndexRoute
|
'/command': typeof AuthenticatedCommandIndexRoute
|
||||||
'/dashboard': typeof AuthDashboardIndexRoute
|
'/device': typeof AuthenticatedDeviceIndexRoute
|
||||||
'/device': typeof AuthDeviceIndexRoute
|
'/room': typeof AuthenticatedRoomIndexRoute
|
||||||
'/room': typeof AuthRoomIndexRoute
|
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||||
'/room/$roomName': typeof AuthRoomRoomNameIndexRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/_auth': typeof AuthRouteWithChildren
|
'/_auth': typeof AuthRouteWithChildren
|
||||||
'/(auth)/login/': typeof authLoginIndexRoute
|
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||||
'/_auth/agent/': typeof AuthAgentIndexRoute
|
'/_auth/login/': typeof AuthLoginIndexRoute
|
||||||
'/_auth/apps/': typeof AuthAppsIndexRoute
|
'/_authenticated/agent/': typeof AuthenticatedAgentIndexRoute
|
||||||
'/_auth/blacklist/': typeof AuthBlacklistIndexRoute
|
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
|
||||||
'/_auth/command/': typeof AuthCommandIndexRoute
|
'/_authenticated/blacklist/': typeof AuthenticatedBlacklistIndexRoute
|
||||||
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
|
'/_authenticated/command/': typeof AuthenticatedCommandIndexRoute
|
||||||
'/_auth/device/': typeof AuthDeviceIndexRoute
|
'/_authenticated/device/': typeof AuthenticatedDeviceIndexRoute
|
||||||
'/_auth/room/': typeof AuthRoomIndexRoute
|
'/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
|
||||||
'/_auth/room/$roomName/': typeof AuthRoomRoomNameIndexRoute
|
'/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
|
@ -123,7 +124,6 @@ export interface FileRouteTypes {
|
||||||
| '/apps'
|
| '/apps'
|
||||||
| '/blacklist'
|
| '/blacklist'
|
||||||
| '/command'
|
| '/command'
|
||||||
| '/dashboard'
|
|
||||||
| '/device'
|
| '/device'
|
||||||
| '/room'
|
| '/room'
|
||||||
| '/room/$roomName'
|
| '/room/$roomName'
|
||||||
|
|
@ -135,7 +135,6 @@ export interface FileRouteTypes {
|
||||||
| '/apps'
|
| '/apps'
|
||||||
| '/blacklist'
|
| '/blacklist'
|
||||||
| '/command'
|
| '/command'
|
||||||
| '/dashboard'
|
|
||||||
| '/device'
|
| '/device'
|
||||||
| '/room'
|
| '/room'
|
||||||
| '/room/$roomName'
|
| '/room/$roomName'
|
||||||
|
|
@ -143,25 +142,32 @@ export interface FileRouteTypes {
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/_auth'
|
| '/_auth'
|
||||||
| '/(auth)/login/'
|
| '/_authenticated'
|
||||||
| '/_auth/agent/'
|
| '/_auth/login/'
|
||||||
| '/_auth/apps/'
|
| '/_authenticated/agent/'
|
||||||
| '/_auth/blacklist/'
|
| '/_authenticated/apps/'
|
||||||
| '/_auth/command/'
|
| '/_authenticated/blacklist/'
|
||||||
| '/_auth/dashboard/'
|
| '/_authenticated/command/'
|
||||||
| '/_auth/device/'
|
| '/_authenticated/device/'
|
||||||
| '/_auth/room/'
|
| '/_authenticated/room/'
|
||||||
| '/_auth/room/$roomName/'
|
| '/_authenticated/room/$roomName/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AuthRoute: typeof AuthRouteWithChildren
|
AuthRoute: typeof AuthRouteWithChildren
|
||||||
authLoginIndexRoute: typeof authLoginIndexRoute
|
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/_authenticated': {
|
||||||
|
id: '/_authenticated'
|
||||||
|
path: ''
|
||||||
|
fullPath: ''
|
||||||
|
preLoaderRoute: typeof AuthenticatedRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_auth': {
|
'/_auth': {
|
||||||
id: '/_auth'
|
id: '/_auth'
|
||||||
path: ''
|
path: ''
|
||||||
|
|
@ -176,100 +182,103 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_auth/room/': {
|
'/_authenticated/room/': {
|
||||||
id: '/_auth/room/'
|
id: '/_authenticated/room/'
|
||||||
path: '/room'
|
path: '/room'
|
||||||
fullPath: '/room'
|
fullPath: '/room'
|
||||||
preLoaderRoute: typeof AuthRoomIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedRoomIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_auth/device/': {
|
'/_authenticated/device/': {
|
||||||
id: '/_auth/device/'
|
id: '/_authenticated/device/'
|
||||||
path: '/device'
|
path: '/device'
|
||||||
fullPath: '/device'
|
fullPath: '/device'
|
||||||
preLoaderRoute: typeof AuthDeviceIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedDeviceIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_auth/dashboard/': {
|
'/_authenticated/command/': {
|
||||||
id: '/_auth/dashboard/'
|
id: '/_authenticated/command/'
|
||||||
path: '/dashboard'
|
|
||||||
fullPath: '/dashboard'
|
|
||||||
preLoaderRoute: typeof AuthDashboardIndexRouteImport
|
|
||||||
parentRoute: typeof AuthRoute
|
|
||||||
}
|
|
||||||
'/_auth/command/': {
|
|
||||||
id: '/_auth/command/'
|
|
||||||
path: '/command'
|
path: '/command'
|
||||||
fullPath: '/command'
|
fullPath: '/command'
|
||||||
preLoaderRoute: typeof AuthCommandIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedCommandIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_auth/blacklist/': {
|
'/_authenticated/blacklist/': {
|
||||||
id: '/_auth/blacklist/'
|
id: '/_authenticated/blacklist/'
|
||||||
path: '/blacklist'
|
path: '/blacklist'
|
||||||
fullPath: '/blacklist'
|
fullPath: '/blacklist'
|
||||||
preLoaderRoute: typeof AuthBlacklistIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedBlacklistIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_auth/apps/': {
|
'/_authenticated/apps/': {
|
||||||
id: '/_auth/apps/'
|
id: '/_authenticated/apps/'
|
||||||
path: '/apps'
|
path: '/apps'
|
||||||
fullPath: '/apps'
|
fullPath: '/apps'
|
||||||
preLoaderRoute: typeof AuthAppsIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedAppsIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_auth/agent/': {
|
'/_authenticated/agent/': {
|
||||||
id: '/_auth/agent/'
|
id: '/_authenticated/agent/'
|
||||||
path: '/agent'
|
path: '/agent'
|
||||||
fullPath: '/agent'
|
fullPath: '/agent'
|
||||||
preLoaderRoute: typeof AuthAgentIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedAgentIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/(auth)/login/': {
|
'/_auth/login/': {
|
||||||
id: '/(auth)/login/'
|
id: '/_auth/login/'
|
||||||
path: '/login'
|
path: '/login'
|
||||||
fullPath: '/login'
|
fullPath: '/login'
|
||||||
preLoaderRoute: typeof authLoginIndexRouteImport
|
preLoaderRoute: typeof AuthLoginIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
'/_auth/room/$roomName/': {
|
'/_authenticated/room/$roomName/': {
|
||||||
id: '/_auth/room/$roomName/'
|
id: '/_authenticated/room/$roomName/'
|
||||||
path: '/room/$roomName'
|
path: '/room/$roomName'
|
||||||
fullPath: '/room/$roomName'
|
fullPath: '/room/$roomName'
|
||||||
preLoaderRoute: typeof AuthRoomRoomNameIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedRoomRoomNameIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthRouteChildren {
|
interface AuthRouteChildren {
|
||||||
AuthAgentIndexRoute: typeof AuthAgentIndexRoute
|
AuthLoginIndexRoute: typeof AuthLoginIndexRoute
|
||||||
AuthAppsIndexRoute: typeof AuthAppsIndexRoute
|
|
||||||
AuthBlacklistIndexRoute: typeof AuthBlacklistIndexRoute
|
|
||||||
AuthCommandIndexRoute: typeof AuthCommandIndexRoute
|
|
||||||
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
|
|
||||||
AuthDeviceIndexRoute: typeof AuthDeviceIndexRoute
|
|
||||||
AuthRoomIndexRoute: typeof AuthRoomIndexRoute
|
|
||||||
AuthRoomRoomNameIndexRoute: typeof AuthRoomRoomNameIndexRoute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthRouteChildren: AuthRouteChildren = {
|
const AuthRouteChildren: AuthRouteChildren = {
|
||||||
AuthAgentIndexRoute: AuthAgentIndexRoute,
|
AuthLoginIndexRoute: AuthLoginIndexRoute,
|
||||||
AuthAppsIndexRoute: AuthAppsIndexRoute,
|
|
||||||
AuthBlacklistIndexRoute: AuthBlacklistIndexRoute,
|
|
||||||
AuthCommandIndexRoute: AuthCommandIndexRoute,
|
|
||||||
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
|
|
||||||
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
|
|
||||||
AuthRoomIndexRoute: AuthRoomIndexRoute,
|
|
||||||
AuthRoomRoomNameIndexRoute: AuthRoomRoomNameIndexRoute,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
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 = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AuthRoute: AuthRouteWithChildren,
|
AuthRoute: AuthRouteWithChildren,
|
||||||
authLoginIndexRoute: authLoginIndexRoute,
|
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +1,22 @@
|
||||||
import ErrorRoute from "@/components/pages/error-route";
|
import { Outlet, createRootRouteWithContext, HeadContent } from '@tanstack/react-router'
|
||||||
import NotFound from "@/components/pages/not-found";
|
import type { AuthTokenProps } from '@/hooks/useAuthtoken'
|
||||||
import { type IAuthContext } from "@/types/auth";
|
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
createRootRouteWithContext,
|
|
||||||
HeadContent,
|
|
||||||
Outlet,
|
|
||||||
} from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export interface BreadcrumbItem {
|
export interface RouterContext {
|
||||||
title: string;
|
auth: AuthTokenProps
|
||||||
path: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MyRouterContext {
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
auth: IAuthContext;
|
head: () => ({
|
||||||
queryClient: QueryClient;
|
meta: [
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
{ 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" },
|
||||||
|
],
|
||||||
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
}),
|
||||||
component: () => {
|
component: () => (
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</>
|
</>
|
||||||
);
|
),
|
||||||
},
|
})
|
||||||
notFoundComponent: () => {
|
|
||||||
return <NotFound />;
|
|
||||||
},
|
|
||||||
errorComponent: ({ error }) => {
|
|
||||||
return <ErrorRoute error={error.message} />;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,16 @@
|
||||||
import {createFileRoute, Outlet, redirect} from '@tanstack/react-router'
|
import {createFileRoute, Outlet, redirect} from '@tanstack/react-router'
|
||||||
import AppLayout from '@/layouts/app-layout'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth')({
|
export const Route = createFileRoute('/_auth')({
|
||||||
|
|
||||||
beforeLoad: async ({context}) => {
|
beforeLoad: async ({context}) => {
|
||||||
const {token} = context.auth
|
const {authToken} = context.auth
|
||||||
if (!token) {
|
if (authToken) {
|
||||||
throw redirect({to: '/login'})
|
throw redirect({to: '/'})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: AuthenticatedLayout,
|
component:AuthLayout ,
|
||||||
})
|
})
|
||||||
|
function AuthLayout() {
|
||||||
function AuthenticatedLayout() {
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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">Có</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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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">Có</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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
}
|
|
||||||
125
src/routes/_auth/login/index.tsx
Normal file
125
src/routes/_auth/login/index.tsx
Normal 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 có tài khoản? <span className="underline cursor-pointer">Đăng ký</span>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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" />
|
|
||||||
Sơ đồ
|
|
||||||
</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 có thiết bị</h3>
|
|
||||||
<p className="text-muted-foreground text-center max-w-sm">
|
|
||||||
Phòng này chưa có 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
21
src/routes/_authenticated.tsx
Normal file
21
src/routes/_authenticated.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,27 +1,44 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { AppManagerTemplate } from "@/template/app-manager-template";
|
import { AppManagerTemplate } from "@/template/app-manager-template";
|
||||||
import {
|
import { useQueryData } from "@/hooks/useQueryData";
|
||||||
useGetAgentVersion,
|
import { useMutationData } from "@/hooks/useMutationData";
|
||||||
useGetRoomList,
|
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
||||||
useUploadSoftware,
|
|
||||||
useUpdateAgent,
|
|
||||||
} from "@/hooks/queries";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
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" }] }),
|
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
|
||||||
component: AgentsPage,
|
component: AgentsPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function AgentsPage() {
|
function AgentsPage() {
|
||||||
// Lấy danh sách version
|
// 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
|
// 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)
|
const versionList: Version[] = Array.isArray(data)
|
||||||
? data
|
? data
|
||||||
|
|
@ -29,38 +46,48 @@ function AgentsPage() {
|
||||||
? [data]
|
? [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 (
|
const handleUpload = async (
|
||||||
fd: FormData,
|
fd: FormData,
|
||||||
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
|
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
|
||||||
) => {
|
) => {
|
||||||
try {
|
return uploadMutation.mutateAsync({
|
||||||
await uploadMutation.mutateAsync({
|
data: fd,
|
||||||
formData: fd,
|
config,
|
||||||
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!");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (roomNames: string[]) => {
|
const handleUpdate = async (roomNames: string[]) => {
|
||||||
try {
|
|
||||||
for (const roomName of roomNames) {
|
for (const roomName of roomNames) {
|
||||||
|
try {
|
||||||
await updateMutation.mutateAsync({
|
await updateMutation.mutateAsync({
|
||||||
roomName,
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT(roomName),
|
||||||
data: {}
|
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!");
|
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!");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cột bảng
|
// Cột bảng
|
||||||
|
|
@ -96,7 +123,7 @@ function AgentsPage() {
|
||||||
onUpload={handleUpload}
|
onUpload={handleUpload}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
updateLoading={updateMutation.isPending}
|
updateLoading={updateMutation.isPending}
|
||||||
rooms={roomData}
|
rooms={rooms}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
154
src/routes/_authenticated/apps/index.tsx
Normal file
154
src/routes/_authenticated/apps/index.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/routes/_authenticated/blacklist/index.tsx
Normal file
66
src/routes/_authenticated/blacklist/index.tsx
Normal 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>;
|
||||||
|
}
|
||||||
73
src/routes/_authenticated/command/index.tsx
Normal file
73
src/routes/_authenticated/command/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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ị' }] }),
|
head: () => ({ meta: [{ title: 'Danh sách tất cả thiết bị' }] }),
|
||||||
component: AllDevicesComponent,
|
component: AllDevicesComponent,
|
||||||
})
|
})
|
||||||
75
src/routes/_authenticated/room/$roomName/index.tsx
Normal file
75
src/routes/_authenticated/room/$roomName/index.tsx
Normal 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" />
|
||||||
|
Sơ đồ
|
||||||
|
</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 có thiết bị</h3>
|
||||||
|
<p className="text-muted-foreground text-center max-w-sm">
|
||||||
|
Phòng này chưa có thiết bị nào được kết nối.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : viewMode === "grid" ? (
|
||||||
|
<DeviceGrid devices={devices} />
|
||||||
|
) : (
|
||||||
|
<DeviceTable devices={devices} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
|
|
@ -25,13 +26,14 @@ import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Loader2,
|
Loader2,
|
||||||
Wifi,
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/room/")({
|
export const Route = createFileRoute("/_authenticated/room/")({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [{ title: "Danh sách phòng" }],
|
meta: [{ title: "Danh sách phòng" }],
|
||||||
}),
|
}),
|
||||||
|
|
@ -41,7 +43,10 @@ export const Route = createFileRoute("/_auth/room/")({
|
||||||
function RoomComponent() {
|
function RoomComponent() {
|
||||||
const navigate = useNavigate();
|
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>([]);
|
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ị",
|
||||||
cell: ({ row }) => {
|
accessorKey: "numberOfDevices",
|
||||||
const onlineCount = row.original.numberOfDevices - row.original.numberOfOfflineDevices;
|
cell: ({ row }) => (
|
||||||
const totalCount = row.original.numberOfDevices;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Wifi className="h-4 w-4 text-green-600" />
|
<Wifi className="h-4 w-4 text-green-600" />
|
||||||
<Badge variant="secondary" className="font-medium">
|
<Badge variant="secondary" className="font-medium">
|
||||||
{onlineCount} / {totalCount}
|
{row.original.numberOfDevices} thiết bị
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Thiết bị offline",
|
||||||
|
accessorKey: "numberOfOfflineDevices",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const offlineCount = row.original.numberOfOfflineDevices;
|
||||||
|
const isOffline = offlineCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WifiOff
|
||||||
|
className={`h-4 w-4 ${isOffline ? "text-red-500" : "text-muted-foreground"}`}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant={isOffline ? "destructive" : "outline"}
|
||||||
|
className="font-medium"
|
||||||
|
>
|
||||||
|
{offlineCount} offline
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
||||||
|
|
@ -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]);
|
|
||||||
}
|
|
||||||
|
|
@ -7,24 +7,16 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { FileText, Building2, Download } from "lucide-react";
|
import { FileText, Building2, Monitor } from "lucide-react";
|
||||||
import { FormDialog } from "@/components/dialogs/form-dialog";
|
import { UploadDialog } from "@/components/upload-dialog";
|
||||||
import { VersionTable } from "@/components/tables/version-table";
|
import { VersionTable } from "@/components/version-table";
|
||||||
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
|
import { RequestUpdateMenu } from "@/components/request-update-menu";
|
||||||
import { DeleteMenu } from "@/components/menu/delete-menu";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
import { SelectDialog } from "@/components/select-dialog"; // <-- dùng dialog chung
|
||||||
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";
|
|
||||||
|
|
||||||
interface AppManagerTemplateProps<TData> {
|
interface AppManagerTemplateProps<TData> {
|
||||||
title: string;
|
title: string;
|
||||||
uploadFormTitle?: string;
|
|
||||||
description: string;
|
description: string;
|
||||||
data: TData[];
|
data: TData[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
@ -35,22 +27,13 @@ interface AppManagerTemplateProps<TData> {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onUpdate?: (targetNames: string[]) => Promise<void> | void;
|
onUpdate?: (targetNames: string[]) => Promise<void> | void;
|
||||||
updateLoading?: boolean;
|
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;
|
onTableInit?: (table: any) => void;
|
||||||
rooms?: Room[];
|
rooms?: string[];
|
||||||
devices?: string[];
|
devices?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppManagerTemplate<TData>({
|
export function AppManagerTemplate<TData>({
|
||||||
title,
|
title,
|
||||||
uploadFormTitle,
|
|
||||||
description,
|
description,
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -58,20 +41,12 @@ export function AppManagerTemplate<TData>({
|
||||||
onUpload,
|
onUpload,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
updateLoading,
|
updateLoading,
|
||||||
onDownload,
|
|
||||||
downloadLoading,
|
|
||||||
onDelete,
|
|
||||||
onDeleteFromServer,
|
|
||||||
onDeleteFromRequired,
|
|
||||||
deleteLoading,
|
|
||||||
onAddToRequired,
|
|
||||||
addToRequiredLoading,
|
|
||||||
onTableInit,
|
onTableInit,
|
||||||
rooms = [],
|
rooms = [],
|
||||||
devices = [],
|
devices = [],
|
||||||
}: AppManagerTemplateProps<TData>) {
|
}: AppManagerTemplateProps<TData>) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
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 = () => {
|
const openRoomDialog = () => {
|
||||||
if (rooms.length > 0 && onUpdate) {
|
if (rooms.length > 0 && onUpdate) {
|
||||||
|
|
@ -81,39 +56,40 @@ export function AppManagerTemplate<TData>({
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDeviceDialog = () => {
|
const openDeviceDialog = () => {
|
||||||
if (onUpdate) {
|
if (devices.length > 0 && onUpdate) {
|
||||||
setDialogType("device");
|
setDialogType("device");
|
||||||
setDialogOpen(true);
|
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 () => {
|
const handleUpdateAll = async () => {
|
||||||
if (!onUpdate) return;
|
if (!onUpdate) return;
|
||||||
try {
|
const allTargets = [...rooms, ...devices];
|
||||||
const roomIds = rooms.map((room) =>
|
|
||||||
typeof room === "string" ? room : room.name
|
|
||||||
);
|
|
||||||
const allTargets = [...roomIds, ...devices];
|
|
||||||
await onUpdate(allTargets);
|
await onUpdate(allTargets);
|
||||||
} catch (e) {
|
|
||||||
console.error("Update error:", e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="w-full px-6 space-y-4">
|
<div className="w-full px-6 space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -122,16 +98,10 @@ export function AppManagerTemplate<TData>({
|
||||||
<h1 className="text-3xl font-bold">{title}</h1>
|
<h1 className="text-3xl font-bold">{title}</h1>
|
||||||
<p className="text-muted-foreground mt-2">{description}</p>
|
<p className="text-muted-foreground mt-2">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<FormDialog
|
<UploadDialog onSubmit={onUpload} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
|
@ -149,172 +119,31 @@ export function AppManagerTemplate<TData>({
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{(onUpdate || onDelete || onAddToRequired) && (
|
{onUpdate && (
|
||||||
<CardFooter className="flex items-center justify-between gap-4">
|
<CardFooter>
|
||||||
<div className="flex gap-2">
|
|
||||||
<RequestUpdateMenu
|
<RequestUpdateMenu
|
||||||
onUpdateDevice={openDeviceDialog}
|
onUpdateDevice={openDeviceDialog}
|
||||||
onUpdateRoom={openRoomDialog}
|
onUpdateRoom={openRoomDialog}
|
||||||
onUpdateAll={handleUpdateAll}
|
onUpdateAll={handleUpdateAll}
|
||||||
loading={updateLoading}
|
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Dialog chọn phòng */}
|
{/* 🧩 SelectDialog tái sử dụng */}
|
||||||
{dialogType === "room" && (
|
{dialogProps && (
|
||||||
<SelectDialog
|
<SelectDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onClose={() => {
|
onClose={() => setDialogOpen(false)}
|
||||||
setDialogOpen(false);
|
title={dialogProps.title}
|
||||||
setDialogType(null);
|
description={dialogProps.description}
|
||||||
setTimeout(() => window.location.reload(), 500);
|
icon={dialogProps.icon}
|
||||||
}}
|
items={dialogProps.items}
|
||||||
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)}
|
|
||||||
onConfirm={async (selectedItems) => {
|
onConfirm={async (selectedItems) => {
|
||||||
if (!onUpdate) return;
|
if (!onUpdate) return;
|
||||||
try {
|
|
||||||
await onUpdate(selectedItems);
|
await onUpdate(selectedItems);
|
||||||
} catch (e) {
|
|
||||||
console.error("Update error:", e);
|
|
||||||
} finally {
|
|
||||||
setDialogOpen(false);
|
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);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 có 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 và 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -6,73 +6,82 @@ import {
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card"
|
||||||
import { Terminal, Building2 } from "lucide-react";
|
import { Terminal, Building2, Monitor } from "lucide-react"
|
||||||
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
|
import { RequestUpdateMenu } from "@/components/request-update-menu"
|
||||||
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
import { SelectDialog } from "@/components/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";
|
|
||||||
|
|
||||||
interface FormSubmitTemplateProps {
|
interface FormSubmitTemplateProps {
|
||||||
title: string;
|
title: string
|
||||||
description: string;
|
description: string
|
||||||
isLoading?: boolean;
|
isLoading?: boolean
|
||||||
children: (props: {
|
children: (props: {
|
||||||
command: string;
|
command: string
|
||||||
setCommand: (val: string) => void;
|
setCommand: (val: string) => void
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode
|
||||||
onSubmit?: (target: string, command: string) => void | Promise<void>;
|
onSubmit?: (target: string, command: string) => void | Promise<void>
|
||||||
submitLoading?: boolean;
|
submitLoading?: boolean
|
||||||
rooms?: Room[];
|
rooms?: string[]
|
||||||
devices?: string[];
|
devices?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormSubmitTemplate({
|
export function FormSubmitTemplate({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
isLoading,
|
||||||
children,
|
children,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitLoading,
|
submitLoading,
|
||||||
rooms = [],
|
rooms = [],
|
||||||
devices = [],
|
devices = [],
|
||||||
}: FormSubmitTemplateProps) {
|
}: FormSubmitTemplateProps) {
|
||||||
const [command, setCommand] = useState("");
|
const [command, setCommand] = useState("")
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
|
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null)
|
||||||
|
|
||||||
// Mở dialog chọn phòng
|
|
||||||
const openRoomDialog = () => {
|
const openRoomDialog = () => {
|
||||||
if (rooms.length > 0 && onSubmit) {
|
if (rooms.length > 0 && onSubmit) {
|
||||||
setDialogType("room");
|
setDialogType("room")
|
||||||
setDialogOpen(true);
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Mở dialog tìm thiết bị (search bar)
|
|
||||||
const openDeviceDialog = () => {
|
const openDeviceDialog = () => {
|
||||||
if (onSubmit) {
|
if (devices.length > 0 && onSubmit) {
|
||||||
setDialogType("device");
|
setDialogType("device")
|
||||||
setDialogOpen(true);
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Gửi cho tất cả
|
const handleSubmitAll = () => {
|
||||||
const handleSubmitAll = async () => {
|
if (!onSubmit) return
|
||||||
if (!onSubmit) return;
|
const allTargets = [...rooms, ...devices]
|
||||||
try {
|
|
||||||
const roomIds = rooms.map((room) =>
|
|
||||||
typeof room === "string" ? room : room.name
|
|
||||||
);
|
|
||||||
const allTargets = [...roomIds, ...devices];
|
|
||||||
for (const target of allTargets) {
|
for (const target of allTargets) {
|
||||||
await onSubmit(target, command);
|
onSubmit(target, command)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error("Submit error:", e);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="w-full px-6 space-y-4">
|
<div className="w-full px-6 space-y-4">
|
||||||
|
|
@ -103,61 +112,24 @@ export function FormSubmitTemplate({
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Dialog chọn phòng */}
|
{/* 🧩 Dùng SelectDialog chung */}
|
||||||
{dialogType === "room" && (
|
{dialogProps && (
|
||||||
<SelectDialog
|
<SelectDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onClose={() => {
|
onClose={() => setDialogOpen(false)}
|
||||||
setDialogOpen(false);
|
title={dialogProps.title}
|
||||||
setDialogType(null);
|
description={dialogProps.description}
|
||||||
}}
|
icon={dialogProps.icon}
|
||||||
title="Chọn phòng để gửi lệnh"
|
items={dialogProps.items}
|
||||||
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)}
|
|
||||||
onConfirm={async (selectedItems) => {
|
onConfirm={async (selectedItems) => {
|
||||||
if (!onSubmit) return;
|
if (!onSubmit) return
|
||||||
try {
|
|
||||||
for (const item of selectedItems) {
|
for (const item of selectedItems) {
|
||||||
await onSubmit(item, command);
|
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);
|
|
||||||
}
|
}
|
||||||
|
setDialogOpen(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
|
import { RequestUpdateMenu } from "@/components/request-update-menu";
|
||||||
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
import { SelectDialog } from "@/components/select-dialog";
|
||||||
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -9,16 +8,12 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { FormDialog } from "@/components/dialogs/form-dialog";
|
import { UploadDialog } from "@/components/upload-dialog";
|
||||||
import { VersionTable } from "@/components/tables/version-table";
|
import { VersionTable } from "@/components/version-table";
|
||||||
import type { ColumnDef } from "@tanstack/react-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 { 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> {
|
interface BlackListManagerTemplateProps<TData> {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -26,12 +21,15 @@ interface BlackListManagerTemplateProps<TData> {
|
||||||
data: TData[];
|
data: TData[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
columns: ColumnDef<TData, any>[];
|
columns: ColumnDef<TData, any>[];
|
||||||
onAdd: (data: BlacklistFormData) => Promise<void>;
|
onUpload: (
|
||||||
onDelete?: (id: number) => Promise<void>;
|
fd: FormData,
|
||||||
onUpdate?: (target: string | string[]) => void | Promise<void>;
|
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
|
||||||
|
) => Promise<void>;
|
||||||
|
onUpdate?: (roomName: string) => void;
|
||||||
updateLoading?: boolean;
|
updateLoading?: boolean;
|
||||||
onTableInit?: (table: any) => void;
|
onTableInit?: (table: any) => void;
|
||||||
rooms: Room[];
|
rooms: string[];
|
||||||
|
devices?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BlackListManagerTemplate<TData>({
|
export function BlackListManagerTemplate<TData>({
|
||||||
|
|
@ -40,17 +38,18 @@ export function BlackListManagerTemplate<TData>({
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
columns,
|
columns,
|
||||||
onAdd,
|
onUpload,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
updateLoading,
|
updateLoading,
|
||||||
onTableInit,
|
onTableInit,
|
||||||
rooms = [],
|
rooms = [],
|
||||||
|
devices = [],
|
||||||
}: BlackListManagerTemplateProps<TData>) {
|
}: BlackListManagerTemplateProps<TData>) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
|
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
|
||||||
|
|
||||||
const handleUpdateAll = async () => {
|
const handleUpdateAll = () => {
|
||||||
if (onUpdate) await onUpdate("All");
|
if (onUpdate) onUpdate("All");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openRoomDialog = () => {
|
const openRoomDialog = () => {
|
||||||
|
|
@ -61,12 +60,33 @@ export function BlackListManagerTemplate<TData>({
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDeviceDialog = () => {
|
const openDeviceDialog = () => {
|
||||||
if (onUpdate) {
|
if (devices.length > 0 && onUpdate) {
|
||||||
setDialogType("device");
|
setDialogType("device");
|
||||||
setDialogOpen(true);
|
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 (
|
return (
|
||||||
<div className="w-full px-6 space-y-4">
|
<div className="w-full px-6 space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -75,14 +95,7 @@ export function BlackListManagerTemplate<TData>({
|
||||||
<h1 className="text-3xl font-bold">{title}</h1>
|
<h1 className="text-3xl font-bold">{title}</h1>
|
||||||
<p className="text-muted-foreground mt-2">{description}</p>
|
<p className="text-muted-foreground mt-2">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<FormDialog
|
<UploadDialog onSubmit={onUpload} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
|
|
@ -118,33 +131,23 @@ export function BlackListManagerTemplate<TData>({
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Dialog chọn phòng */}
|
{dialogProps && (
|
||||||
{dialogType === "room" && (
|
|
||||||
<SelectDialog
|
<SelectDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onClose={() => setDialogOpen(false)}
|
onClose={() => setDialogOpen(false)}
|
||||||
title="Chọn phòng"
|
title={dialogProps.title}
|
||||||
description="Chọn các phòng cần cập nhật danh sách đen"
|
description={dialogProps.description}
|
||||||
icon={<Building2 className="w-6 h-6 text-primary" />}
|
icon={dialogProps.icon}
|
||||||
items={mapRoomsToSelectItems(rooms)}
|
items={dialogProps.items}
|
||||||
onConfirm={async (selectedRooms) => {
|
onConfirm={async (selectedItems) => {
|
||||||
if (!onUpdate) return;
|
if (!onUpdate) return;
|
||||||
await onUpdate(selectedRooms);
|
for (const item of selectedItems) {
|
||||||
|
onUpdate(item);
|
||||||
|
}
|
||||||
setDialogOpen(false);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -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">;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export type Version = {
|
|
||||||
id: number;
|
|
||||||
version: string;
|
|
||||||
fileName: string;
|
|
||||||
folderPath: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
requestUpdateAt?: string;
|
|
||||||
isRequired: boolean;
|
|
||||||
};
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export type InstallHistory = {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user