Compare commits

..

11 Commits

59 changed files with 3434 additions and 1681 deletions

View File

@ -20,6 +20,6 @@ COPY --from=development /app/dist /usr/share/nginx/html
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
EXPOSE 80 443
ENTRYPOINT [ "nginx", "-g", "daemon off;" ]

View File

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

File diff suppressed because it is too large Load Diff

74
Users-API.md Normal file
View File

@ -0,0 +1,74 @@
# User API
Tai lieu mo ta cac endpoint cap nhat role va thong tin nguoi dung.
----------------------------------------
## 1) Cap nhat thong tin nguoi dung
- PUT /api/User/{id}
- Permission: EDIT_USER_ROLE
### Request
```json
{
"name": "Nguyen Van A",
"userName": "nguyenvana",
"accessRooms": [1, 2, 3]
}
```
### Response (200)
```json
{
"success": true,
"message": "User updated successfully",
"data": {
"userId": 12,
"userName": "nguyenvana",
"name": "Nguyen Van A",
"roleId": 3,
"accessRooms": [1, 2, 3],
"updatedAt": "2026-04-03T10:20:30Z",
"updatedBy": "admin"
}
}
```
### Ghi chu
- Neu khong truyen `accessRooms` thi giu nguyen danh sach phong.
- Neu truyen `accessRooms` = [] thi xoa tat ca phong.
- Neu `userName` bi trung hoac khong hop le thi tra ve 400.
----------------------------------------
## 2) Cap nhat role nguoi dung
- PUT /api/User/{id}/role
- Permission: EDIT_USER_ROLE
### Request
```json
{
"roleId": 2
}
```
### Response (200)
```json
{
"success": true,
"message": "User role updated",
"data": {
"userId": 12,
"userName": "nguyenvana",
"roleId": 2,
"roleName": "Manager",
"updatedAt": "2026-04-03T10:20:30Z",
"updatedBy": "admin"
}
}
```
### Ghi chu
- Chi System Admin moi duoc phep cap nhat role System Admin.
----------------------------------------

View File

@ -3,7 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/public/computer-956.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="/computer-956.svg" />
<meta name="theme-color" content="#000000" />
<meta
name="description"

View File

@ -1,10 +1,21 @@
upstream backend {
server 100.66.170.15:8080;
server 127.0.0.1:8080;
server 172.18.10.8:8080;
}
# upstream backend {
# server 100.66.170.15:8080;
# server 127.0.0.1:8080;
# server 172.18.10.8:8080;
# }
server {
listen 80;
return 301 https://$host$request_uri; # Redirect HTTP sang HTTPS
}
server{
listen 443 ssl;
server_name comp.soict.io;
ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt;
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key;
set $backend_server 172.18.10.8:8080;
root /usr/share/nginx/html;
# Default file to serve for directory requests
@ -25,7 +36,7 @@ server {
}
location /api/ {
proxy_pass http://100.66.170.15:8080;
proxy_pass http://$backend_server;
# Cho phép upload file lớn (vd: 200MB)
client_max_body_size 200M;
@ -38,17 +49,17 @@ server {
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
# CORS headers
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
# CORS headers - Comment vi da xu ly o backend C#
# add_header 'Access-Control-Allow-Origin' '*' always;
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
# add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
if ($request_method = OPTIONS) {
return 204;
}
}
location /api/Sse/events {
proxy_pass http://backend/api/Sse/events;
proxy_pass http://$backend_server/api/Sse/events;
proxy_http_version 1.1;
# cần thiết cho SSE
@ -57,4 +68,14 @@ server {
proxy_cache off;
proxy_read_timeout 1h;
}
location /mesh-proxy/ {
proxy_pass https://202.191.59.59/;
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
# Cấu hình WebSocket cho commander.ashx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

432
package-lock.json generated
View File

@ -18,6 +18,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-form": "^1.23.0",
@ -36,6 +37,7 @@
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"shadcn": "^2.9.3",
"sidebar": "^1.0.0",
"sonner": "^2.0.7",
@ -50,6 +52,7 @@
"@types/node": "^24.1.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"tw-animate-css": "^1.3.6",
@ -2853,6 +2856,40 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -3160,6 +3197,16 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
},
"node_modules/@tailwindcss/node": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
@ -3916,6 +3963,60 @@
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@ -3968,6 +4069,23 @@
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
},
"node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
"dev": true,
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@ -4394,9 +4512,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dependencies": {
"balanced-match": "^1.0.0"
}
@ -4872,6 +4990,116 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@ -4926,6 +5154,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"node_modules/decode-formdata": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/decode-formdata/-/decode-formdata-0.9.0.tgz",
@ -5001,9 +5234,9 @@
"license": "MIT"
},
"node_modules/devalue": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg=="
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="
},
"node_modules/diff": {
"version": "8.0.3",
@ -5137,6 +5370,15 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
@ -5220,6 +5462,11 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@ -5314,11 +5561,11 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"dependencies": {
"ip-address": "10.0.1"
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
@ -5728,9 +5975,9 @@
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="
},
"node_modules/hono": {
"version": "4.12.4",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.4.tgz",
"integrity": "sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==",
"version": "4.12.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
"engines": {
"node": ">=16.9.0"
}
@ -5835,6 +6082,15 @@
}
]
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -5855,10 +6111,18 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"engines": {
"node": ">= 12"
}
@ -6914,9 +7178,9 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"engines": {
"node": ">=8.6"
},
@ -7234,9 +7498,30 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -7363,6 +7648,48 @@
"node": ">=0.10.0"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -7384,6 +7711,11 @@
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -7505,9 +7837,9 @@
}
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
@ -8058,9 +8390,9 @@
}
},
"node_modules/tar": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
@ -8135,10 +8467,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"engines": {
"node": ">=12"
},
@ -8370,9 +8701,9 @@
}
},
"node_modules/unplugin/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"engines": {
"node": ">=12"
},
@ -8494,6 +8825,27 @@
"node": ">= 0.8"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
@ -8605,10 +8957,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"engines": {
"node": ">=12"
},
@ -8690,11 +9041,10 @@
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},

View File

@ -22,6 +22,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-form": "^1.23.0",
@ -40,6 +41,7 @@
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"shadcn": "^2.9.3",
"sidebar": "^1.0.0",
"sonner": "^2.0.7",
@ -54,6 +56,7 @@
"@types/node": "^24.1.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"tw-animate-css": "^1.3.6",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -125,6 +125,15 @@ export function DeviceSearchDialog({
onClose();
};
const parseDeviceId = (id: string) => {
const match = /^P(.+?)M(\d+)$/i.exec(id.trim());
if (!match) return null;
return {
room: match[1].trim(),
index: Number(match[2]),
};
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-none w-[95vw] max-h-[90vh]">
@ -156,11 +165,31 @@ export function DeviceSearchDialog({
const isExpanded = expandedRoom === room.name;
const isLoading = loadingRoom === room.name;
const devices = roomDevices[room.name] || [];
const sortedDevices = [...devices].sort((a, b) => {
const aId = String(a.id);
const bId = String(b.id);
const parsedA = parseDeviceId(aId);
const parsedB = parseDeviceId(bId);
if (parsedA && parsedB) {
const roomCompare = parsedA.room.localeCompare(parsedB.room, undefined, {
numeric: true,
sensitivity: "base",
});
if (roomCompare !== 0) return roomCompare;
return parsedA.index - parsedB.index;
}
return aId.localeCompare(bId, undefined, {
numeric: true,
sensitivity: "base",
});
});
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) =>
sortedDevices.length > 0 &&
sortedDevices.every((d) => selected.includes(d.id));
const someSelected = sortedDevices.some((d) => selected.includes(d.id));
const selectedCount = sortedDevices.filter((d) =>
selected.includes(d.id)
).length;
@ -219,7 +248,7 @@ export function DeviceSearchDialog({
</div>
{/* Device table - collapsible */}
{isExpanded && devices.length > 0 && (
{isExpanded && sortedDevices.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">
@ -243,7 +272,7 @@ export function DeviceSearchDialog({
</tr>
</thead>
<tbody>
{devices.map((device) => (
{sortedDevices.map((device) => (
<tr
key={device.id}
className="border-b last:border-b-0 hover:bg-muted/50"

View File

@ -109,7 +109,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
// All rendered commands are sourced from sensitiveCommands — send via sensitive mutation
await executeSensitiveMutation.mutateAsync({
roomName,
command: confirmDialog.command.commandContent,
command: confirmDialog.command.commandName,
password: sensitivePassword,
});

View File

@ -0,0 +1,98 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import type { DeviceOverviewResponse } from "@/types/dashboard";
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from "recharts";
export function DeviceOverviewCard({
data,
isLoading = false,
}: {
data?: DeviceOverviewResponse | null;
isLoading?: boolean;
}) {
const pieData = [
{ name: "Online", value: data?.onlineDevices ?? 0 },
{ name: "Offline", value: data?.offlineDevices ?? 0 },
];
const COLORS = ["#22c55e", "#ef4444"];
return (
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Tổng quan thiết bị</CardTitle>
<CardDescription>Trạng thái chung các thiết bị offline gần đây</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Tổng thiết bị</div>
<div className="text-2xl font-bold">{data?.totalDevices ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Agent chưa đưc cập nhật</div>
<div className="text-2xl font-bold">{data?.devicesWithOutdatedVersion ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Online</div>
<div className="text-2xl font-bold">{data?.onlineDevices ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Offline</div>
<div className="text-2xl font-bold">{data?.offlineDevices ?? 0}</div>
</div>
</div>
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium mb-2">Tỉ lệ Online / Offline</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
innerRadius={30}
outerRadius={60}
label
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
<div>
<div className="text-sm font-medium">Thiết bị offline gần đây</div>
<div className="mt-2 max-h-40 overflow-auto divide-y divide-muted/40">
{data?.recentOfflineDevices && data.recentOfflineDevices.length > 0 ? (
data.recentOfflineDevices.map((d) => (
<div key={d.deviceId} className="flex items-center justify-between py-2">
<div>
<div className="font-medium">{d.deviceId}</div>
<div className="text-xs text-muted-foreground">{d.room ?? "-"}</div>
</div>
<div className="text-xs text-muted-foreground">
{d.lastSeen ? new Date(d.lastSeen).toLocaleString() : "-"}
</div>
</div>
))
) : (
<div className="text-sm text-muted-foreground">Không thiết bị offline gần đây</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,74 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { RoomManagementResponse, RoomHealthStatus } from "@/types/dashboard";
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from "recharts";
function statusBadge(status?: string) {
if (!status) return <Badge>Unknown</Badge>;
if (status === "InSession") return <Badge className="bg-green-100 text-green-700">Đang sử dụng</Badge>;
if (status === "NotInUse") return <Badge className="bg-red-100 text-red-700">Không sử dụng</Badge>;
return <Badge className="bg-yellow-100 text-yellow-700"> thể lớp học</Badge>;
}
export function RoomManagementCard({
data,
isLoading = false,
}: {
data?: RoomManagementResponse | null;
isLoading?: boolean;
}) {
const chartData = (data?.rooms ?? []).map((r) => ({ room: r.roomName, health: r.healthPercentage ?? 0 }));
return (
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Quản phòng</CardTitle>
<CardDescription>Thông tin tổng quan các phòng đang không sử dụng</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-muted-foreground">Tổng phòng</div>
<div className="text-2xl font-bold">{data?.totalRooms ?? 0}</div>
</div>
</div>
<div className="mt-4">
<div className="text-sm font-medium mb-2">Tỉ lệ thiết bị online</div>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" domain={[0, 100]} />
<YAxis dataKey="room" type="category" width={110} />
<Tooltip />
<Bar dataKey="health" fill="#0ea5e9" radius={[4, 4, 4, 4]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-4">
<div className="text-sm font-medium">Phòng không dùng</div>
<div className="mt-2 space-y-2">
{data?.roomsNeedAttention && data.roomsNeedAttention.length > 0 ? (
data.roomsNeedAttention.map((r: RoomHealthStatus) => (
<div key={r.roomName} className="flex items-center justify-between">
<div>
<div className="font-medium">{r.roomName}</div>
<div className="text-xs text-muted-foreground">{r.totalDevices} thiết bị</div>
</div>
<div className="flex items-center gap-3">
<div className="text-sm font-medium">{r.healthPercentage?.toFixed(1) ?? "-"}%</div>
{statusBadge(r.healthStatus)}
</div>
</div>
))
) : (
<div className="text-sm text-muted-foreground">Không phòng cần chú ý</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,88 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { SoftwareDistributionResponse } from "@/types/dashboard";
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from "recharts";
export function SoftwareDistributionCard({
data,
isLoading = false,
}: {
data?: SoftwareDistributionResponse | null;
isLoading?: boolean;
}) {
void isLoading;
const distData = [
{ name: "Success", value: data?.successfulInstallations ?? 0 },
{ name: "Failed", value: data?.failedInstallations ?? 0 },
{ name: "Pending", value: data?.pendingInstallations ?? 0 },
];
const COLORS = ["#10b981", "#ef4444", "#f59e0b"];
return (
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Phân phối phần mềm</CardTitle>
<CardDescription>Thống cài đt lỗi phổ biến</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-3">
<div>
<div className="text-xs text-muted-foreground">Tổng log</div>
<div className="text-2xl font-bold">{data?.totalInstallations ?? 0}</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40 text-center">
<div className="text-xs text-muted-foreground">Thành công</div>
<div className="text-2xl font-bold">{data?.successfulInstallations ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40 text-center">
<div className="text-xs text-muted-foreground">Thất bại</div>
<div className="text-2xl font-bold">{data?.failedInstallations ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40 text-center">
<div className="text-xs text-muted-foreground">Đang chờ</div>
<div className="text-2xl font-bold">{data?.pendingInstallations ?? 0}</div>
</div>
</div>
<div className="mt-4">
<div className="text-sm font-medium">Top lỗi</div>
<div className="mt-2 space-y-2">
{data?.topFailedSoftware && data.topFailedSoftware.length > 0 ? (
data.topFailedSoftware.map((t) => (
<div key={t.fileName} className="flex items-center justify-between">
<div className="truncate max-w-[180px]">{t.fileName}</div>
<Badge className="bg-red-100 text-red-700">{t.failCount}</Badge>
</div>
))
) : (
<div className="text-sm text-muted-foreground">Không lỗi phổ biến</div>
)}
</div>
</div>
</div>
<div>
<div className="text-sm font-medium mb-2">Tỉ lệ trạng thái cài đt</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={distData} dataKey="value" nameKey="name" innerRadius={30} outerRadius={60} label>
{distData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,23 @@
import type { Version } from "@/types/file";
import type { ColumnDef } from "@tanstack/react-table";
export const agentColumns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
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ập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
];

View File

@ -0,0 +1,70 @@
// components/columns/apps-column.tsx
import type { Version } from "@/types/file";
import type { ColumnDef } from "@tanstack/react-table";
import { Check, X } from "lucide-react";
// Không gọi hook ở đây — nhận isPending từ ngoài truyền vào
export function createAppsColumns(isPending: boolean): ColumnDef<Version>[] {
return [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
accessorKey: "updatedAt",
header: () => (
<div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>
),
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: () => (
<div className="whitespace-normal max-w-xs">
Thời gian yêu cầu cài đt/tải xuống
</div>
),
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
id: "required",
header: () => (
<div className="whitespace-normal max-w-xs">Đã thêm vào danh sách</div>
),
cell: ({ row }) => {
const isRequired = row.original.isRequired;
return isRequired ? (
<div className="flex items-center gap-1">
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</div>
) : (
<div className="flex items-center gap-1">
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</div>
);
},
enableSorting: false,
enableHiding: false,
},
{
id: "select",
header: () => <div className="whitespace-normal max-w-xs">Chọn</div>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
disabled={isPending} // ← nhận từ tham số, không gọi hook
/>
),
enableSorting: false,
enableHiding: false,
},
];
}

View File

@ -0,0 +1,98 @@
import { type ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import type { Audits } from "@/types/audit";
export const auditColumns: ColumnDef<Audits>[] = [
{
header: "Thời gian",
accessorKey: "dateTime",
cell: ({ getValue }) => {
const v = getValue() as string;
const d = v ? new Date(v) : null;
return d ? (
<div className="text-sm whitespace-nowrap">
<div className="font-medium">{d.toLocaleDateString("vi-VN")}</div>
<div className="text-muted-foreground text-xs">
{d.toLocaleTimeString("vi-VN")}
</div>
</div>
) : (
<span className="text-muted-foreground"></span>
);
},
},
{
header: "User",
accessorKey: "username",
cell: ({ getValue }) => (
<span className="font-medium text-sm whitespace-nowrap">
{getValue() as string}
</span>
),
},
{
header: "Loại",
accessorKey: "apiCall",
cell: ({ getValue }) => {
const v = (getValue() as string) ?? "";
if (!v) return <span className="text-muted-foreground"></span>;
return (
<code className="text-xs bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
{v}
</code>
);
},
},
{
header: "Hành động",
accessorKey: "action",
cell: ({ getValue }) => (
<code className="text-xs bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
{getValue() as string}
</code>
),
},
{
header: "URL",
accessorKey: "url",
cell: ({ getValue }) => (
<code className="text-xs text-muted-foreground max-w-[180px] truncate block">
{(getValue() as string) ?? "—"}
</code>
),
},
{
header: "Kết quả",
accessorKey: "isSuccess",
cell: ({ getValue }) => {
const v = getValue();
if (v == null) return <span className="text-muted-foreground"></span>;
return v ? (
<Badge variant="outline" className="text-green-600 border-green-600 whitespace-nowrap">
Thành công
</Badge>
) : (
<Badge variant="outline" className="text-red-600 border-red-600 whitespace-nowrap">
Thất bại
</Badge>
);
},
},
{
header: "Nội dung request",
accessorKey: "requestPayload",
cell: ({ getValue }) => {
const v = getValue() as string;
if (!v) return <span className="text-muted-foreground"></span>;
let preview = v;
try {
preview = JSON.stringify(JSON.parse(v));
} catch {}
return (
<span className="text-xs text-muted-foreground max-w-[200px] truncate block">
{preview}
</span>
);
},
},
];

View File

@ -0,0 +1,180 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import type { Audits } from "@/types/audit";
function JsonDisplay({ value }: { value: string | null | undefined }) {
if (!value) return <span className="text-muted-foreground"></span>;
try {
return (
<pre className="text-xs bg-muted/60 p-2.5 rounded-md overflow-auto whitespace-pre-wrap break-all leading-relaxed max-h-48 font-mono">
{JSON.stringify(JSON.parse(value), null, 2)}
</pre>
);
} catch {
return <span className="text-xs break-all font-mono">{value}</span>;
}
}
interface AuditDetailDialogProps {
audit: Audits | null;
open: boolean;
onClose: () => void;
}
export function AuditDetailDialog({
audit,
open,
onClose,
}: AuditDetailDialogProps) {
if (!audit) return null;
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl w-full max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Chi tiết audit
<span className="text-muted-foreground font-normal text-sm">
#{audit.id}
</span>
</DialogTitle>
</DialogHeader>
<Separator />
<div className="grid grid-cols-2 gap-x-6 gap-y-3 pt-1">
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Thời gian
</p>
<p className="text-sm font-medium">
{audit.dateTime
? new Date(audit.dateTime).toLocaleString("vi-VN")
: "—"}
</p>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
User
</p>
<p className="text-sm font-medium">{audit.username}</p>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
API Call
</p>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{audit.apiCall ?? "—"}
</code>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Kết quả
</p>
<div>
{audit.isSuccess == null ? (
<span className="text-muted-foreground text-sm"></span>
) : audit.isSuccess ? (
<Badge
variant="outline"
className="text-green-600 border-green-600"
>
Thành công
</Badge>
) : (
<Badge
variant="outline"
className="text-red-600 border-red-600"
>
Thất bại
</Badge>
)}
</div>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Hành đng
</p>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{audit.action}
</code>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
URL
</p>
<code className="text-xs text-muted-foreground break-all">
{audit.url ?? "—"}
</code>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Bảng
</p>
<p className="text-sm">{audit.tableName ?? "—"}</p>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Entity ID
</p>
<p className="text-sm">{audit.entityId ?? "—"}</p>
</div>
<div className="col-span-2 space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Lỗi
</p>
<p className="text-sm text-red-600">{audit.errorMessage ?? "—"}</p>
</div>
</div>
<Separator />
<div className="space-y-4">
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Nội dung request
</p>
<JsonDisplay value={audit.requestPayload} />
</div>
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Giá trị
</p>
<JsonDisplay value={audit.oldValues} />
</div>
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Giá trị mới
</p>
<JsonDisplay value={audit.newValues} />
</div>
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Kết quả
</p>
<p className="text-sm">{audit.isSuccess == null ? "—" : audit.isSuccess ? "Thành công" : "Thất bại"}</p>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -2,7 +2,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { useState, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
export interface SelectItem {
label: string;
@ -16,6 +16,7 @@ interface SelectDialogProps {
description?: string;
icon?: React.ReactNode;
items: SelectItem[];
selectedValues?: string[];
onConfirm: (values: string[]) => Promise<void> | void;
}
@ -26,11 +27,18 @@ export function SelectDialog({
description,
icon,
items,
selectedValues,
onConfirm,
}: SelectDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const [search, setSearch] = useState("");
useEffect(() => {
if (!open) return;
if (!selectedValues) return;
setSelected(selectedValues);
}, [open, selectedValues]);
const filteredItems = useMemo(() => {
return items.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())

View File

@ -0,0 +1,73 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface AuditFilterBarProps {
username: string | null;
action: string | null;
from: string | null;
to: string | null;
isLoading: boolean;
isFetching: boolean;
onUsernameChange: (v: string | null) => void;
onActionChange: (v: string | null) => void;
onFromChange: (v: string | null) => void;
onToChange: (v: string | null) => void;
onSearch: () => void;
onReset: () => void;
}
export function AuditFilterBar({
username,
action,
from,
to,
isLoading,
isFetching,
onUsernameChange,
onActionChange,
onFromChange,
onToChange,
onSearch,
onReset,
}: AuditFilterBarProps) {
return (
<div className="flex gap-2 mb-4 flex-wrap items-end">
<Input
className="w-36"
placeholder="Username"
value={username ?? ""}
onChange={(e) => onUsernameChange(e.target.value || null)}
/>
<Input
className="w-44"
placeholder="Hành động..."
value={action ?? ""}
onChange={(e) => onActionChange(e.target.value || null)}
/>
<Input
className="w-36"
type="date"
value={from ?? ""}
onChange={(e) => onFromChange(e.target.value || null)}
/>
<Input
className="w-36"
type="date"
value={to ?? ""}
onChange={(e) => onToChange(e.target.value || null)}
/>
<div className="flex gap-2">
<Button onClick={onSearch} disabled={isFetching || isLoading} size="sm">
Tìm
</Button>
<Button variant="outline" onClick={onReset} size="sm">
Reset
</Button>
</div>
</div>
);
}

View File

@ -4,7 +4,7 @@ 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 { buildSsoLoginUrl, login } from "@/services/auth.service";
import { useState } from "react";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { Route } from "@/routes/(auth)/login";
@ -44,6 +44,14 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
}
});
const handleSsoLogin = () => {
const returnUrl = new URL("/sso/callback", window.location.origin);
if (search.redirect) {
returnUrl.searchParams.set("redirect", search.redirect);
}
window.location.assign(buildSsoLoginUrl(returnUrl.toString()));
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
@ -53,10 +61,10 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
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>
<CardHeader className="text-center">
<CardTitle className="text-2xl font-semibold tracking-tight flex items-center justify-center gap-3">
<img src="/soict_logo.png" alt="SOICT logo" className="h-7 w-auto object-contain" />
<span>Computer Management</span>
</CardTitle>
<CardDescription>Hệ thống quản phòng máy thực hành</CardDescription>
</CardHeader>
@ -103,6 +111,16 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
Đăng nhập
</Button>
)}
<div className="text-center text-sm text-muted-foreground">Hoặc</div>
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleSsoLogin}>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<rect x="1" y="1" width="10" height="10" fill="#F25022" />
<rect x="13" y="1" width="10" height="10" fill="#7FBA00" />
<rect x="1" y="13" width="10" height="10" fill="#00A4EF" />
<rect x="13" y="13" width="10" height="10" fill="#FFB900" />
</svg>
Đăng nhập với Microsoft
</Button>
</div>
</form>
</CardContent>

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -4,9 +4,15 @@ export const BASE_URL = isDev
? import.meta.env.VITE_API_URL_DEV
: "/api";
export const BASE_MESH_URL = isDev
? import.meta.env.VITE_API_MESH_DEV
: "/meshapi";
export const API_ENDPOINTS = {
AUTH: {
LOGIN: `${BASE_URL}/login`,
SSO_LOGIN: `${BASE_URL}/auth/sso/login`,
SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`,
LOGOUT: `${BASE_URL}/logout`,
CHANGE_PASSWORD: `${BASE_URL}/auth/change-password`,
CHANGE_PASSWORD_ADMIN: `${BASE_URL}/auth/admin/change-password`,
@ -15,6 +21,10 @@ export const API_ENDPOINTS = {
CREATE_ACCOUNT: `${BASE_URL}/auth/create-account`,
GET_USERS_LIST: `${BASE_URL}/users-info`,
},
USER: {
UPDATE_INFO: (id: number) => `${BASE_URL}/User/${id}`,
UPDATE_ROLE: (id: number) => `${BASE_URL}/User/${id}/role`,
},
APP_VERSION: {
//agent and app api
GET_VERSION: `${BASE_URL}/AppVersion/version`,
@ -31,8 +41,8 @@ export const API_ENDPOINTS = {
//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}`,
DELETE_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/delete`,
DELETE_FILES: `${BASE_URL}/AppVersion/delete`,
},
DEVICE_COMM: {
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
@ -82,4 +92,20 @@ export const API_ENDPOINTS = {
TOGGLE_PERMISSION: (roleId: number, permissionId: number) =>
`${BASE_URL}/Role/${roleId}/permissions/${permissionId}/toggle`,
},
MESH_CENTRAL: {
GET_REMOTE_DESKTOP: (deviceId: string) =>
`${BASE_URL}/MeshCentral/devices/${encodeURIComponent(deviceId)}/remote-desktop`,
},
DASHBOARD: {
GET_SUMMARY: `${BASE_URL}/dashboard/summary`,
GET_GENERAL: `${BASE_URL}/dashboard/general`,
GET_ROOM_USAGE: `${BASE_URL}/dashboard/usage/rooms`,
GET_DEVICE_OVERVIEW: `${BASE_URL}/dashboard/devices/overview`,
GET_DEVICES_BY_ROOM: `${BASE_URL}/dashboard/devices/by-room`,
GET_ROOMS: `${BASE_URL}/dashboard/rooms`,
GET_SOFTWARE: `${BASE_URL}/dashboard/software`,
},
AUDIT: {
GET_AUDITS: `${BASE_URL}/Audit/audits`,
}
};

View File

@ -7,9 +7,15 @@ export * from "./useAppVersionQueries";
// Device Communication Queries
export * from "./useDeviceCommQueries";
// Dashboard Queries
export * from "./useDashboardQueries";
// Command Queries
export * from "./useCommandQueries";
// Audit Queries
export * from "./useAuditQueries";
// Permission Queries
export * from "./usePermissionQueries";

View File

@ -160,7 +160,7 @@ export function useDeleteRequiredFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (fileId: number) => appVersionService.deleteRequiredFile(fileId),
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteRequiredFile(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
@ -176,7 +176,7 @@ export function useDeleteFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (fileId: number) => appVersionService.deleteFile(fileId),
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteFile(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),

View File

@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query";
import * as auditService from "@/services/audit.service";
import type { PageResult, Audits } from "@/types/audit";
const AUDIT_QUERY_KEYS = {
all: ["audit"] as const,
list: () => [...AUDIT_QUERY_KEYS.all, "list"] as const,
audits: (params: any) => [...AUDIT_QUERY_KEYS.all, "audits", params] as const,
};
export function useGetAudits(
params: {
pageNumber?: number;
pageSize?: number;
username?: string | null;
action?: string | null;
from?: string | null;
to?: string | null;
} = { pageNumber: 1, pageSize: 20 },
enabled = true
) {
const { pageNumber = 1, pageSize = 20, username, action, from, to } = params;
return useQuery<PageResult<Audits>>({
queryKey: AUDIT_QUERY_KEYS.audits({ pageNumber, pageSize, username, action, from, to }),
queryFn: () =>
auditService.getAudits(
pageNumber,
pageSize,
username ?? null,
action ?? null,
from ?? null,
to ?? null
),
enabled,
});
}

View File

@ -113,3 +113,12 @@ export function useCreateAccount() {
},
});
}
/**
* Hook đ đi one-time code SSO lấy payload đăng nhập
*/
export function useExchangeSsoCode() {
return useMutation<LoginResponse, any, string>({
mutationFn: (code) => authService.exchangeSsoCode(code),
});
}

View File

@ -0,0 +1,85 @@
import { useQuery } from "@tanstack/react-query";
import * as dashboardService from "@/services/dashboard.service";
import type {
DashboardSummaryResponse,
DashboardGeneralInfo,
DeviceOverviewResponse,
DeviceStatusByRoom,
RoomManagementResponse,
RoomUsageResponse,
SoftwareDistributionResponse,
} from "@/types/dashboard";
const DASHBOARD_QUERY_KEYS = {
all: ["dashboard"] as const,
summary: () => [...DASHBOARD_QUERY_KEYS.all, "summary"] as const,
general: () => [...DASHBOARD_QUERY_KEYS.all, "general"] as const,
roomUsage: () => [...DASHBOARD_QUERY_KEYS.all, "usage", "rooms"] as const,
deviceOverview: () => [...DASHBOARD_QUERY_KEYS.all, "devices", "overview"] as const,
devicesByRoom: () => [...DASHBOARD_QUERY_KEYS.all, "devices", "by-room"] as const,
rooms: () => [...DASHBOARD_QUERY_KEYS.all, "rooms"] as const,
software: () => [...DASHBOARD_QUERY_KEYS.all, "software"] as const,
};
export function useGetDashboardSummary(enabled = true) {
return useQuery<DashboardSummaryResponse>({
queryKey: DASHBOARD_QUERY_KEYS.summary(),
queryFn: () => dashboardService.getDashboardSummary(),
enabled,
staleTime: 60 * 1000,
});
}
export function useGetDashboardGeneralInfo(enabled = true) {
return useQuery<DashboardGeneralInfo>({
queryKey: DASHBOARD_QUERY_KEYS.general(),
queryFn: () => dashboardService.getDashboardGeneralInfo(),
enabled,
staleTime: 60 * 1000,
});
}
export function useGetRoomUsage(enabled = true) {
return useQuery<RoomUsageResponse>({
queryKey: DASHBOARD_QUERY_KEYS.roomUsage(),
queryFn: () => dashboardService.getRoomUsage(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useGetDeviceOverview(enabled = true) {
return useQuery<DeviceOverviewResponse>({
queryKey: DASHBOARD_QUERY_KEYS.deviceOverview(),
queryFn: () => dashboardService.getDeviceOverview(),
enabled,
staleTime: 30 * 1000,
});
}
export function useGetDeviceStatusByRoom(enabled = true) {
return useQuery<DeviceStatusByRoom[]>({
queryKey: DASHBOARD_QUERY_KEYS.devicesByRoom(),
queryFn: () => dashboardService.getDeviceStatusByRoom(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useGetRoomManagement(enabled = true) {
return useQuery<RoomManagementResponse>({
queryKey: DASHBOARD_QUERY_KEYS.rooms(),
queryFn: () => dashboardService.getRoomManagement(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useGetSoftwareDistribution(enabled = true) {
return useQuery<SoftwareDistributionResponse>({
queryKey: DASHBOARD_QUERY_KEYS.software(),
queryFn: () => dashboardService.getSoftwareDistribution(),
enabled,
staleTime: 60 * 1000,
});
}

View File

@ -1,6 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as userService from "@/services/user.service";
import type { UserProfile } from "@/types/user-profile";
import type {
UserProfile,
UpdateUserInfoRequest,
UpdateUserRoleRequest,
} from "@/types/user-profile";
const USER_QUERY_KEYS = {
all: ["users"] as const,
@ -18,3 +22,47 @@ export function useGetUsersInfo(enabled = true) {
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ cập nhật thông tin người dùng
*/
export function useUpdateUserInfo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: UpdateUserInfoRequest;
}) => userService.updateUserInfo(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: USER_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ cập nhật role người dùng
*/
export function useUpdateUserRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: UpdateUserRoleRequest;
}) => userService.updateUserRole(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: USER_QUERY_KEYS.list(),
});
},
});
}

View File

@ -116,5 +116,10 @@
}
body {
@apply bg-background text-foreground;
font-family: "Be Vietnam Pro", "Segoe UI", sans-serif;
}
}
.dashboard-scope {
font-family: "Be Vietnam Pro", "Segoe UI", sans-serif;
}

View File

@ -14,10 +14,12 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthUserIndexRouteImport } from './routes/_auth/user/index'
import { Route as AuthRoomsIndexRouteImport } from './routes/_auth/rooms/index'
import { Route as AuthRoleIndexRouteImport } from './routes/_auth/role/index'
import { Route as AuthRemoteControlIndexRouteImport } from './routes/_auth/remote-control/index'
import { Route as AuthDeviceIndexRouteImport } from './routes/_auth/device/index'
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
import { Route as AuthCommandsIndexRouteImport } from './routes/_auth/commands/index'
import { Route as AuthBlacklistsIndexRouteImport } from './routes/_auth/blacklists/index'
import { Route as AuthAuditsIndexRouteImport } from './routes/_auth/audits/index'
import { Route as AuthAppsIndexRouteImport } from './routes/_auth/apps/index'
import { Route as AuthAgentIndexRouteImport } from './routes/_auth/agent/index'
import { Route as authLoginIndexRouteImport } from './routes/(auth)/login/index'
@ -26,9 +28,12 @@ import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/index'
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
import { Route as authSsoCallbackIndexRouteImport } from './routes/(auth)/sso/callback/index'
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
import { Route as AuthUserEditUserNameIndexRouteImport } from './routes/_auth/user/edit/$userName/index'
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
import { Route as AuthRoomsRoomNameFolderStatusIndexRouteImport } from './routes/_auth/rooms/$roomName/folder-status/index'
import { Route as AuthRoomsRoomNameConnectIndexRouteImport } from './routes/_auth/rooms/$roomName/connect/index'
import { Route as AuthRoleIdEditIndexRouteImport } from './routes/_auth/role/$id/edit/index'
const AuthRoute = AuthRouteImport.update({
@ -55,6 +60,11 @@ const AuthRoleIndexRoute = AuthRoleIndexRouteImport.update({
path: '/role/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRemoteControlIndexRoute = AuthRemoteControlIndexRouteImport.update({
id: '/remote-control/',
path: '/remote-control/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDeviceIndexRoute = AuthDeviceIndexRouteImport.update({
id: '/device/',
path: '/device/',
@ -75,6 +85,11 @@ const AuthBlacklistsIndexRoute = AuthBlacklistsIndexRouteImport.update({
path: '/blacklists/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAuditsIndexRoute = AuthAuditsIndexRouteImport.update({
id: '/audits/',
path: '/audits/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAppsIndexRoute = AuthAppsIndexRouteImport.update({
id: '/apps/',
path: '/apps/',
@ -117,11 +132,22 @@ const AuthProfileUserNameIndexRoute =
path: '/profile/$userName/',
getParentRoute: () => AuthRoute,
} as any)
const authSsoCallbackIndexRoute = authSsoCallbackIndexRouteImport.update({
id: '/(auth)/sso/callback/',
path: '/sso/callback/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
id: '/user/role/$roleId/',
path: '/user/role/$roleId/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserEditUserNameIndexRoute =
AuthUserEditUserNameIndexRouteImport.update({
id: '/user/edit/$userName/',
path: '/user/edit/$userName/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserChangePasswordUserNameIndexRoute =
AuthUserChangePasswordUserNameIndexRouteImport.update({
id: '/user/change-password/$userName/',
@ -134,6 +160,12 @@ const AuthRoomsRoomNameFolderStatusIndexRoute =
path: '/rooms/$roomName/folder-status/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRoomsRoomNameConnectIndexRoute =
AuthRoomsRoomNameConnectIndexRouteImport.update({
id: '/rooms/$roomName/connect/',
path: '/rooms/$roomName/connect/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRoleIdEditIndexRoute = AuthRoleIdEditIndexRouteImport.update({
id: '/role/$id/edit/',
path: '/role/$id/edit/',
@ -145,21 +177,26 @@ export interface FileRoutesByFullPath {
'/login': typeof authLoginIndexRoute
'/agent': typeof AuthAgentIndexRoute
'/apps': typeof AuthAppsIndexRoute
'/audits': typeof AuthAuditsIndexRoute
'/blacklists': typeof AuthBlacklistsIndexRoute
'/commands': typeof AuthCommandsIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/device': typeof AuthDeviceIndexRoute
'/remote-control': typeof AuthRemoteControlIndexRoute
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute
'/sso/callback': typeof authSsoCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
'/user/create': typeof AuthUserCreateIndexRoute
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRoutesByTo {
@ -167,21 +204,26 @@ export interface FileRoutesByTo {
'/login': typeof authLoginIndexRoute
'/agent': typeof AuthAgentIndexRoute
'/apps': typeof AuthAppsIndexRoute
'/audits': typeof AuthAuditsIndexRoute
'/blacklists': typeof AuthBlacklistsIndexRoute
'/commands': typeof AuthCommandsIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/device': typeof AuthDeviceIndexRoute
'/remote-control': typeof AuthRemoteControlIndexRoute
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute
'/sso/callback': typeof authSsoCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
'/user/create': typeof AuthUserCreateIndexRoute
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRoutesById {
@ -191,21 +233,26 @@ export interface FileRoutesById {
'/(auth)/login/': typeof authLoginIndexRoute
'/_auth/agent/': typeof AuthAgentIndexRoute
'/_auth/apps/': typeof AuthAppsIndexRoute
'/_auth/audits/': typeof AuthAuditsIndexRoute
'/_auth/blacklists/': typeof AuthBlacklistsIndexRoute
'/_auth/commands/': typeof AuthCommandsIndexRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
'/_auth/device/': typeof AuthDeviceIndexRoute
'/_auth/remote-control/': typeof AuthRemoteControlIndexRoute
'/_auth/role/': typeof AuthRoleIndexRoute
'/_auth/rooms/': typeof AuthRoomsIndexRoute
'/_auth/user/': typeof AuthUserIndexRoute
'/(auth)/sso/callback/': typeof authSsoCallbackIndexRoute
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
'/_auth/rooms/$roomName/': typeof AuthRoomsRoomNameIndexRoute
'/_auth/user/create/': typeof AuthUserCreateIndexRoute
'/_auth/role/$id/edit/': typeof AuthRoleIdEditIndexRoute
'/_auth/rooms/$roomName/connect/': typeof AuthRoomsRoomNameConnectIndexRoute
'/_auth/rooms/$roomName/folder-status/': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/_auth/user/change-password/$userName/': typeof AuthUserChangePasswordUserNameIndexRoute
'/_auth/user/edit/$userName/': typeof AuthUserEditUserNameIndexRoute
'/_auth/user/role/$roleId/': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRouteTypes {
@ -215,21 +262,26 @@ export interface FileRouteTypes {
| '/login'
| '/agent'
| '/apps'
| '/audits'
| '/blacklists'
| '/commands'
| '/dashboard'
| '/device'
| '/remote-control'
| '/role'
| '/rooms'
| '/user'
| '/sso/callback'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
| '/rooms/$roomName'
| '/user/create'
| '/role/$id/edit'
| '/rooms/$roomName/connect'
| '/rooms/$roomName/folder-status'
| '/user/change-password/$userName'
| '/user/edit/$userName'
| '/user/role/$roleId'
fileRoutesByTo: FileRoutesByTo
to:
@ -237,21 +289,26 @@ export interface FileRouteTypes {
| '/login'
| '/agent'
| '/apps'
| '/audits'
| '/blacklists'
| '/commands'
| '/dashboard'
| '/device'
| '/remote-control'
| '/role'
| '/rooms'
| '/user'
| '/sso/callback'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
| '/rooms/$roomName'
| '/user/create'
| '/role/$id/edit'
| '/rooms/$roomName/connect'
| '/rooms/$roomName/folder-status'
| '/user/change-password/$userName'
| '/user/edit/$userName'
| '/user/role/$roleId'
id:
| '__root__'
@ -260,21 +317,26 @@ export interface FileRouteTypes {
| '/(auth)/login/'
| '/_auth/agent/'
| '/_auth/apps/'
| '/_auth/audits/'
| '/_auth/blacklists/'
| '/_auth/commands/'
| '/_auth/dashboard/'
| '/_auth/device/'
| '/_auth/remote-control/'
| '/_auth/role/'
| '/_auth/rooms/'
| '/_auth/user/'
| '/(auth)/sso/callback/'
| '/_auth/profile/$userName/'
| '/_auth/profile/change-password/'
| '/_auth/role/create/'
| '/_auth/rooms/$roomName/'
| '/_auth/user/create/'
| '/_auth/role/$id/edit/'
| '/_auth/rooms/$roomName/connect/'
| '/_auth/rooms/$roomName/folder-status/'
| '/_auth/user/change-password/$userName/'
| '/_auth/user/edit/$userName/'
| '/_auth/user/role/$roleId/'
fileRoutesById: FileRoutesById
}
@ -282,6 +344,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
authLoginIndexRoute: typeof authLoginIndexRoute
authSsoCallbackIndexRoute: typeof authSsoCallbackIndexRoute
}
declare module '@tanstack/react-router' {
@ -321,6 +384,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthRoleIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/remote-control/': {
id: '/_auth/remote-control/'
path: '/remote-control'
fullPath: '/remote-control'
preLoaderRoute: typeof AuthRemoteControlIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/device/': {
id: '/_auth/device/'
path: '/device'
@ -349,6 +419,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthBlacklistsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/audits/': {
id: '/_auth/audits/'
path: '/audits'
fullPath: '/audits'
preLoaderRoute: typeof AuthAuditsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/apps/': {
id: '/_auth/apps/'
path: '/apps'
@ -405,6 +482,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/(auth)/sso/callback/': {
id: '/(auth)/sso/callback/'
path: '/sso/callback'
fullPath: '/sso/callback'
preLoaderRoute: typeof authSsoCallbackIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/user/role/$roleId/': {
id: '/_auth/user/role/$roleId/'
path: '/user/role/$roleId'
@ -412,6 +496,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthUserRoleRoleIdIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/edit/$userName/': {
id: '/_auth/user/edit/$userName/'
path: '/user/edit/$userName'
fullPath: '/user/edit/$userName'
preLoaderRoute: typeof AuthUserEditUserNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/change-password/$userName/': {
id: '/_auth/user/change-password/$userName/'
path: '/user/change-password/$userName'
@ -426,6 +517,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthRoomsRoomNameFolderStatusIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/rooms/$roomName/connect/': {
id: '/_auth/rooms/$roomName/connect/'
path: '/rooms/$roomName/connect'
fullPath: '/rooms/$roomName/connect'
preLoaderRoute: typeof AuthRoomsRoomNameConnectIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/role/$id/edit/': {
id: '/_auth/role/$id/edit/'
path: '/role/$id/edit'
@ -439,10 +537,12 @@ declare module '@tanstack/react-router' {
interface AuthRouteChildren {
AuthAgentIndexRoute: typeof AuthAgentIndexRoute
AuthAppsIndexRoute: typeof AuthAppsIndexRoute
AuthAuditsIndexRoute: typeof AuthAuditsIndexRoute
AuthBlacklistsIndexRoute: typeof AuthBlacklistsIndexRoute
AuthCommandsIndexRoute: typeof AuthCommandsIndexRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
AuthDeviceIndexRoute: typeof AuthDeviceIndexRoute
AuthRemoteControlIndexRoute: typeof AuthRemoteControlIndexRoute
AuthRoleIndexRoute: typeof AuthRoleIndexRoute
AuthRoomsIndexRoute: typeof AuthRoomsIndexRoute
AuthUserIndexRoute: typeof AuthUserIndexRoute
@ -452,18 +552,22 @@ interface AuthRouteChildren {
AuthRoomsRoomNameIndexRoute: typeof AuthRoomsRoomNameIndexRoute
AuthUserCreateIndexRoute: typeof AuthUserCreateIndexRoute
AuthRoleIdEditIndexRoute: typeof AuthRoleIdEditIndexRoute
AuthRoomsRoomNameConnectIndexRoute: typeof AuthRoomsRoomNameConnectIndexRoute
AuthRoomsRoomNameFolderStatusIndexRoute: typeof AuthRoomsRoomNameFolderStatusIndexRoute
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
AuthUserEditUserNameIndexRoute: typeof AuthUserEditUserNameIndexRoute
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAgentIndexRoute: AuthAgentIndexRoute,
AuthAppsIndexRoute: AuthAppsIndexRoute,
AuthAuditsIndexRoute: AuthAuditsIndexRoute,
AuthBlacklistsIndexRoute: AuthBlacklistsIndexRoute,
AuthCommandsIndexRoute: AuthCommandsIndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
AuthRemoteControlIndexRoute: AuthRemoteControlIndexRoute,
AuthRoleIndexRoute: AuthRoleIndexRoute,
AuthRoomsIndexRoute: AuthRoomsIndexRoute,
AuthUserIndexRoute: AuthUserIndexRoute,
@ -473,10 +577,12 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthRoomsRoomNameIndexRoute: AuthRoomsRoomNameIndexRoute,
AuthUserCreateIndexRoute: AuthUserCreateIndexRoute,
AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute,
AuthRoomsRoomNameConnectIndexRoute: AuthRoomsRoomNameConnectIndexRoute,
AuthRoomsRoomNameFolderStatusIndexRoute:
AuthRoomsRoomNameFolderStatusIndexRoute,
AuthUserChangePasswordUserNameIndexRoute:
AuthUserChangePasswordUserNameIndexRoute,
AuthUserEditUserNameIndexRoute: AuthUserEditUserNameIndexRoute,
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
}
@ -486,6 +592,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
authLoginIndexRoute: authLoginIndexRoute,
authSsoCallbackIndexRoute: authSsoCallbackIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@ -0,0 +1,81 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useExchangeSsoCode } from "@/hooks/queries";
import { useAuth } from "@/hooks/useAuth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { LoaderCircle } from "lucide-react";
export const Route = createFileRoute("/(auth)/sso/callback/")({
component: SsoCallbackPage,
});
function SsoCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
const exchangeMutation = useExchangeSsoCode();
const search = Route.useSearch() as { code?: string; redirect?: string };
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!search.code) {
setErrorMessage("SSO code is missing.");
return;
}
setErrorMessage(null);
exchangeMutation.mutate(search.code, {
onSuccess: async (data) => {
if (!data.token) {
setErrorMessage("SSO response missing token.");
return;
}
localStorage.setItem("token", data.token);
localStorage.setItem("username", data.username || "");
localStorage.setItem("name", data.name || "");
localStorage.setItem("acs", (data.access ?? "").toString());
localStorage.setItem("role", data.role?.roleName || "");
localStorage.setItem("priority", String(data.role?.priority ?? "-1"));
localStorage.setItem("computersmanagement.auth.user", data.username || "");
localStorage.setItem("accesscontrol.auth.user", data.username || "");
auth.setAuthenticated(true);
auth.login(data.username || "");
await navigate({ to: search.redirect || "/dashboard" });
},
onError: () => {
setErrorMessage("SSO exchange failed.");
},
});
}, [auth, exchangeMutation, navigate, search.code, search.redirect]);
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-xl">Đang xác thực SSO</CardTitle>
<CardDescription>Vui lòng đi trong giây lát.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
{exchangeMutation.isPending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="w-4 h-4 animate-spin" />
Đang trao đi đăng nhập
</div>
)}
{errorMessage && (
<div className="text-destructive text-sm text-center">{errorMessage}</div>
)}
{errorMessage && (
<Link to="/login" className="w-full">
<Button className="w-full">Quay lại đăng nhập</Button>
</Link>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -7,11 +7,10 @@ import {
useUpdateAgent,
} 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 { ErrorFetchingPage } from "@/components/pages/error-fetching-page";
import { agentColumns } from "@/components/columns/agent-column";
export const Route = createFileRoute("/_auth/agent/")({
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
component: AgentsPage,
@ -71,26 +70,7 @@ function AgentsPage() {
};
// Cột bảng
const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
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ập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
];
return (
<AppManagerTemplate<Version>
@ -98,7 +78,7 @@ function AgentsPage() {
description="Quản lý và theo dõi các phiên bản Agent"
data={versionList}
isLoading={isLoading}
columns={columns}
columns={agentColumns}
onUpload={handleUpload}
onUpdate={handleUpdate}
updateLoading={updateMutation.isPending}

View File

@ -11,12 +11,10 @@ import {
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";
import { useMemo, useState } from "react";
import { createAppsColumns } from "@/components/columns/apps-column";
export const Route = createFileRoute("/_auth/apps/")({
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
component: AppsComponent,
@ -51,62 +49,10 @@ function AppsComponent() {
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: "updatedAt",
header: () => <div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>,
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: () => <div className="whitespace-normal max-w-xs">Thời gian yêu cầu cài đt/tải xuống</div>,
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
id: "required",
header: () => <div className="whitespace-normal max-w-xs">Đã thêm vào danh sách</div>,
cell: ({ row }) => {
const isRequired = row.original.isRequired;
return isRequired ? (
<div className="flex items-center gap-1">
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</div>
) : (
<div className="flex items-center gap-1">
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</div>
const columns = useMemo(
() => createAppsColumns(installMutation.isPending),
[installMutation.isPending]
);
},
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,
@ -191,11 +137,10 @@ function AppsComponent() {
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteMutation.mutateAsync(id);
}
await deleteMutation.mutateAsync({ MsiFileIds });
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!");
@ -206,12 +151,15 @@ function AppsComponent() {
if (!table) 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;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteRequiredFileMutation.mutateAsync(id);
}
await deleteRequiredFileMutation.mutateAsync({ MsiFileIds });
toast.success("Xóa file khỏi danh sách thành công!");
if (table) {
table.setRowSelection({});
@ -226,12 +174,10 @@ function AppsComponent() {
if (!table) return;
const selectedRows = table.getSelectedRowModel().rows;
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteMutation.mutateAsync(id);
}
await deleteMutation.mutateAsync({ MsiFileIds });
toast.success("Xóa phần mềm từ server thành công!");
if (table) {
table.setRowSelection({});

View File

@ -0,0 +1,92 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect } from "react";
import { useGetAudits } from "@/hooks/queries";
import type { Audits } from "@/types/audit";
import { AuditListTemplate } from "@/template/audit-list-template";
import { auditColumns } from "@/components/columns/audit-column";
export const Route = createFileRoute("/_auth/audits/")({
head: () => ({ meta: [{ title: "Audit Logs" }] }),
loader: async ({ context }) => {
context.breadcrumbs = [{ title: "Audit logs", path: "#" }];
},
component: AuditsPage,
});
function AuditsPage() {
const [pageNumber, setPageNumber] = useState(1);
const [pageSize] = useState(20);
const [username, setUsername] = useState<string | null>(null);
const [action, setAction] = useState<string | null>(null);
const [from, setFrom] = useState<string | null>(null);
const [to, setTo] = useState<string | null>(null);
const [selectedAudit, setSelectedAudit] = useState<Audits | null>(null);
const { data, isLoading, refetch, isFetching } = useGetAudits(
{
pageNumber,
pageSize,
username,
action,
from,
to,
},
true
) as any;
const items: Audits[] = data?.items ?? [];
const total: number = data?.totalCount ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
useEffect(() => {
refetch();
}, [pageNumber, pageSize]);
const handleSearch = () => {
setPageNumber(1);
refetch();
};
const handleReset = () => {
setUsername(null);
setAction(null);
setFrom(null);
setTo(null);
setPageNumber(1);
refetch();
};
return (
<AuditListTemplate
// data
items={items}
total={total}
columns={auditColumns}
isLoading={isLoading}
isFetching={isFetching}
// pagination
pageNumber={pageNumber}
pageSize={pageSize}
pageCount={pageCount}
canPreviousPage={pageNumber > 1}
canNextPage={pageNumber < pageCount}
onPreviousPage={() => setPageNumber((p) => Math.max(1, p - 1))}
onNextPage={() => setPageNumber((p) => Math.min(pageCount, p + 1))}
// filter
username={username}
action={action}
from={from}
to={to}
onUsernameChange={setUsername}
onActionChange={setAction}
onFromChange={setFrom}
onToChange={setTo}
onSearch={handleSearch}
onReset={handleReset}
// detail dialog
selectedAudit={selectedAudit}
onRowClick={setSelectedAudit}
onDialogClose={() => setSelectedAudit(null)}
/>
);
}

View File

@ -11,7 +11,6 @@ import {
useSendCommand,
} from "@/hooks/queries";
import { toast } from "sonner";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Check, X, Edit2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ColumnDef } from "@tanstack/react-table";

View File

@ -1,15 +1,75 @@
import { createFileRoute } from '@tanstack/react-router'
import { DashboardTemplate } from '@/template/dashboard-template'
import {
useGetDashboardSummary,
useGetDashboardGeneralInfo,
useGetDeviceOverview,
useGetDeviceStatusByRoom,
useGetRoomUsage,
useGetRoomManagement,
useGetSoftwareDistribution,
} from '@/hooks/queries/useDashboardQueries'
export const Route = createFileRoute('/_auth/dashboard/')({
component: RouteComponent,
head: () => ({ meta: [{ title: 'Dashboard' }] }),
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Dashboard", path: "/_auth/dashboard/" },
{ title: "Dashboard", path: "#" },
];
},
})
function RouteComponent() {
return <div>Hello "/(auth)/dashboard/"!</div>
const summaryQuery = useGetDashboardSummary();
const generalQuery = useGetDashboardGeneralInfo();
const deviceOverviewQuery = useGetDeviceOverview();
const devicesByRoomQuery = useGetDeviceStatusByRoom();
const roomUsageQuery = useGetRoomUsage();
const roomsQuery = useGetRoomManagement();
const softwareQuery = useGetSoftwareDistribution();
const isLoading =
summaryQuery.isLoading ||
generalQuery.isLoading ||
deviceOverviewQuery.isLoading ||
devicesByRoomQuery.isLoading ||
roomUsageQuery.isLoading ||
roomsQuery.isLoading ||
softwareQuery.isLoading;
const isFetching =
summaryQuery.isFetching ||
generalQuery.isFetching ||
deviceOverviewQuery.isFetching ||
devicesByRoomQuery.isFetching ||
roomUsageQuery.isFetching ||
roomsQuery.isFetching ||
softwareQuery.isFetching;
const handleRefresh = async () => {
await Promise.allSettled([
summaryQuery.refetch(),
generalQuery.refetch(),
deviceOverviewQuery.refetch(),
devicesByRoomQuery.refetch(),
roomUsageQuery.refetch(),
roomsQuery.refetch(),
softwareQuery.refetch(),
]);
};
return (
<DashboardTemplate
generalInfo={generalQuery.data ?? summaryQuery.data?.generalInfo}
deviceOverview={deviceOverviewQuery.data ?? summaryQuery.data?.deviceOverview}
roomManagement={roomsQuery.data ?? summaryQuery.data?.roomManagement}
roomUsage={roomUsageQuery.data ?? summaryQuery.data?.roomUsage}
softwareDistribution={softwareQuery.data ?? summaryQuery.data?.softwareDistribution}
devicesByRoom={devicesByRoomQuery.data}
isLoading={isLoading}
isFetching={isFetching}
onRefresh={handleRefresh}
/>
);
}

View File

@ -0,0 +1,157 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { LoaderCircle, Monitor, X, Maximize2 } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
import { BASE_URL } from "@/config/api";
export const Route = createFileRoute("/_auth/remote-control/")({
head: () => ({ meta: [{ title: "Điều khiển trực tiếp" }] }),
component: RemoteControlPage,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Điều khiển từ xa", path: "/_auth/remote-control/" },
{ title: "Điều khiển trực tiếp", path: "/_auth/remote-control/" },
];
},
});
function RemoteControlPage() {
const [nodeId, setNodeId] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showRemote, setShowRemote] = useState(false);
const [proxyUrl, setProxyUrl] = useState<string | null>(null);
const connectMutation = useMutation({
mutationFn: async (nodeIdValue: string) => {
// Gọi API để lấy URL remote desktop
const response = await getRemoteDesktopUrl(nodeIdValue);
return response;
},
onSuccess: (data) => {
setErrorMessage(null);
// Chuyển URL MeshCentral thành proxy URL
const originalUrl = new URL(data.url);
const pathAndQuery = originalUrl.pathname + originalUrl.search;
const cleanPath = pathAndQuery.startsWith('/') ? pathAndQuery.substring(1) : pathAndQuery;
const baseWithoutApi = BASE_URL.replace('/api', '');
const proxyUrlFull = `${baseWithoutApi}/api/meshcentral/proxy/${cleanPath}`;
console.log("[RemoteControl] Proxy URL:", proxyUrlFull);
setProxyUrl(proxyUrlFull);
setShowRemote(true);
},
onError: (error: any) => {
console.error("[RemoteControl] Error:", error);
setErrorMessage(error?.response?.data?.message || "Lỗi không xác định khi kết nối remote.");
},
});
const handleConnect = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedNodeId = nodeId.trim();
if (!trimmedNodeId) {
setErrorMessage("Vui lòng nhập nodeID.");
return;
}
setErrorMessage(null);
connectMutation.mutate(trimmedNodeId);
};
const handleClose = () => {
setShowRemote(false);
setProxyUrl(null);
};
const handleFullscreen = () => {
const iframe = document.getElementById("mesh-iframe") as HTMLIFrameElement;
if (iframe?.requestFullscreen) {
iframe.requestFullscreen();
}
};
return (
<div className="w-full max-w-4xl space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Monitor className="h-5 w-5" />
Điều khiển trực tiếp
</CardTitle>
<CardDescription>
Nhập nodeID thiết bị nhấn Connect đ mở phiên remote desktop.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleConnect} className="space-y-3">
<Input
placeholder="Nhập nodeID (ví dụ: node//xxxxxx)"
value={nodeId}
onChange={(event) => setNodeId(event.target.value)}
disabled={connectMutation.isPending}
/>
<Button type="submit" disabled={connectMutation.isPending}>
{connectMutation.isPending ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin" />
Đang kết nối...
</>
) : (
<>
<Monitor className="h-4 w-4 mr-2" />
Connect
</>
)}
</Button>
</form>
{errorMessage && (
<p className="mt-3 text-sm font-medium text-destructive">{errorMessage}</p>
)}
</CardContent>
</Card>
{showRemote && proxyUrl && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 p-4">
<div className="relative h-[90vh] w-[90vw] overflow-hidden rounded-lg border bg-background shadow-2xl">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<p className="text-sm font-medium">Remote Session</p>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
type="button"
onClick={handleFullscreen}
title="Fullscreen"
>
<Maximize2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
type="button"
onClick={handleClose}
aria-label="Đóng"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<iframe
id="mesh-iframe"
title="Remote Desktop"
src={proxyUrl}
className="h-[calc(90vh-44px)] w-full border-0"
allowFullScreen
allow="clipboard-read; clipboard-write; camera; microphone"
/>
</div>
</div>
)}
</div>
);
}

View File

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

View File

@ -9,6 +9,9 @@ import { LoaderCircle } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/_auth/user/change-password/$userName/")({
head: () => ({
meta: [{ title: "Thay đổi mật khẩu" }],
}),
component: AdminChangePasswordComponent,
loader: async ({ context, params }) => {
context.breadcrumbs = [

View File

@ -22,6 +22,9 @@ import { UserPlus, ArrowLeft, Save, Loader2 } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/_auth/user/create/")({
head: () => ({
meta: [{ title: "Tạo người dùng mới" }],
}),
component: CreateUserComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
@ -59,7 +62,8 @@ function CreateUserComponent() {
if (!formData.userName) {
newErrors.userName = "Tên đăng nhập không được để trống";
} else if (!validateUserName(formData.userName)) {
newErrors.userName = "Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)";
newErrors.userName =
"Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)";
}
// Validate name
@ -106,7 +110,8 @@ function CreateUserComponent() {
toast.success("Tạo tài khoản thành công!");
navigate({ to: "/dashboard" }); // TODO: Navigate to user list page when it exists
} catch (error: any) {
const errorMessage = error.response?.data?.message || "Tạo tài khoản thất bại!";
const errorMessage =
error.response?.data?.message || "Tạo tài khoản thất bại!";
toast.error(errorMessage);
}
};
@ -128,15 +133,14 @@ function CreateUserComponent() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Tạo người dùng mới</h1>
<h1 className="text-3xl font-bold tracking-tight">
Tạo người dùng mới
</h1>
<p className="text-muted-foreground mt-1">
Thêm tài khoản người dùng mới vào hệ thống
</p>
</div>
<Button
variant="outline"
onClick={() => navigate({ to: "/user" })}
>
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
@ -164,7 +168,9 @@ function CreateUserComponent() {
<Input
id="userName"
value={formData.userName}
onChange={(e) => handleInputChange("userName", e.target.value)}
onChange={(e) =>
handleInputChange("userName", e.target.value)
}
placeholder="Nhập tên đăng nhập (3-20 ký tự, chỉ chữ, số, . và _)"
disabled={createMutation.isPending}
className="h-10"
@ -202,7 +208,9 @@ function CreateUserComponent() {
id="password"
type="password"
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
onChange={(e) =>
handleInputChange("password", e.target.value)
}
placeholder="Nhập mật khẩu (tối thiểu 6 ký tự)"
disabled={createMutation.isPending}
className="h-10"
@ -220,13 +228,17 @@ function CreateUserComponent() {
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
onChange={(e) =>
handleInputChange("confirmPassword", e.target.value)
}
placeholder="Nhập lại mật khẩu"
disabled={createMutation.isPending}
className="h-10"
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive">{errors.confirmPassword}</p>
<p className="text-sm text-destructive">
{errors.confirmPassword}
</p>
)}
</div>
</div>

View File

@ -0,0 +1,361 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import {
useGetRoleList,
useGetRoomList,
useGetUsersInfo,
useUpdateUserInfo,
useUpdateUserRole,
} from "@/hooks/queries";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { ArrowLeft, Save } from "lucide-react";
import { toast } from "sonner";
import type { UserProfile } from "@/types/user-profile";
export const Route = createFileRoute("/_auth/user/edit/$userName/")({
head: () => ({
meta: [{ title: "Chỉnh sửa người dùng" }],
}),
component: EditUserComponent,
loader: async ({ context, params }) => {
context.breadcrumbs = [
{ title: "Quản lý người dùng", path: "/user" },
{
title: `Chỉnh sửa thông tin người dùng ${params.userName}`,
path: `/user/edit/${params.userName}`,
},
];
},
});
function EditUserComponent() {
const { userName } = Route.useParams();
const navigate = useNavigate();
const { data: users = [], isLoading } = useGetUsersInfo();
const { data: roomData = [], isLoading: roomsLoading } = useGetRoomList();
const { data: roles = [], isLoading: rolesLoading } = useGetRoleList();
const updateUserInfoMutation = useUpdateUserInfo();
const updateUserRoleMutation = useUpdateUserRole();
const user = useMemo(() => {
return users.find((u) => u.userName === userName) as
| UserProfile
| undefined;
}, [users, userName]);
const [editForm, setEditForm] = useState({
userName: "",
name: "",
});
const [selectedRoleId, setSelectedRoleId] = useState<string>("");
const [selectedRoomValues, setSelectedRoomValues] = useState<string[]>([]);
const [isRoomDialogOpen, setIsRoomDialogOpen] = useState(false);
const roomOptions = useMemo(() => {
const list = Array.isArray(roomData) ? roomData : [];
return list
.map((room: any) => {
const rawValue =
room.id ??
room.roomId ??
room.roomID ??
room.Id ??
room.ID ??
room.RoomId ??
room.RoomID ??
room.name ??
room.roomName ??
room.RoomName ??
"";
const label =
room.name ?? room.roomName ?? room.RoomName ?? (rawValue ? String(rawValue) : "");
if (!rawValue || !label) return null;
return { label: String(label), value: String(rawValue) };
})
.filter((item): item is { label: string; value: string } => !!item);
}, [roomData]);
const roomLabelMap = useMemo(() => {
return new Map(roomOptions.map((room) => [room.value, room.label]));
}, [roomOptions]);
useEffect(() => {
if (!user) return;
setEditForm({
userName: user.userName ?? "",
name: user.name ?? "",
});
setSelectedRoleId(user.roleId ? String(user.roleId) : "");
setSelectedRoomValues(
Array.isArray(user.accessRooms)
? user.accessRooms.map((roomId) => String(roomId))
: []
);
}, [user]);
const handleUpdateUserInfo = async () => {
if (!user?.userId) {
toast.error("Không tìm thấy userId để cập nhật.");
return;
}
const nextUserName = editForm.userName.trim();
const nextName = editForm.name.trim();
if (!nextUserName || !nextName) {
toast.error("Vui lòng nhập đầy đủ tên đăng nhập và họ tên.");
return;
}
try {
const accessRooms = selectedRoomValues
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
if (
selectedRoomValues.length > 0 &&
accessRooms.length !== selectedRoomValues.length
) {
toast.error("Danh sách phòng không hợp lệ, vui lòng chọn lại.");
return;
}
await updateUserInfoMutation.mutateAsync({
id: user.userId,
data: {
userName: nextUserName,
name: nextName,
accessRooms,
},
});
toast.success("Cập nhật thông tin người dùng thành công!");
} catch (error: any) {
const message = error?.response?.data?.message || "Cập nhật thất bại!";
toast.error(message);
}
};
const handleUpdateUserRole = async () => {
if (!user?.userId) {
toast.error("Không tìm thấy userId để cập nhật role.");
return;
}
if (!selectedRoleId) {
toast.error("Vui lòng chọn vai trò.");
return;
}
try {
await updateUserRoleMutation.mutateAsync({
id: user.userId,
data: { roleId: Number(selectedRoleId) },
});
toast.success("Cập nhật vai trò thành công!");
} catch (error: any) {
const message =
error?.response?.data?.message || "Cập nhật vai trò thất bại!";
toast.error(message);
}
};
if (isLoading) {
return (
<div className="w-full px-6 py-8">
<div className="flex items-center justify-center min-h-[320px]">
<div className="text-muted-foreground">
Đang tải thông tin người dùng...
</div>
</div>
</div>
);
}
if (!user) {
return (
<div className="w-full px-6 py-8 space-y-4">
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
<div className="text-muted-foreground">
Không tìm thấy người dùng cần chỉnh sửa.
</div>
</div>
);
}
return (
<div className="w-full px-6 py-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Chỉnh sửa người dùng
</h1>
<p className="text-muted-foreground mt-1">
Tài khoản: {user.userName}
</p>
</div>
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
</div>
<Card className="shadow-sm">
<CardHeader>
<CardTitle>Thông tin người dùng</CardTitle>
<CardDescription>
Cập nhật họ tên, username danh sách phòng truy cập.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-userName">Tên đăng nhập</Label>
<Input
id="edit-userName"
value={editForm.userName}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, userName: e.target.value }))
}
disabled={updateUserInfoMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-name">Họ tên</Label>
<Input
id="edit-name"
value={editForm.name}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, name: e.target.value }))
}
disabled={updateUserInfoMutation.isPending}
/>
</div>
</div>
<div className="space-y-2">
<Label>Các phòng phụ trách</Label>
<div className="flex flex-wrap gap-2">
{selectedRoomValues.length > 0 ? (
selectedRoomValues.map((value) => (
<Badge key={value} variant="secondary">
{roomLabelMap.get(value) ?? value}
</Badge>
))
) : (
<span className="text-xs text-muted-foreground">
Chưa chọn phòng.
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsRoomDialogOpen(true)}
disabled={roomsLoading || updateUserInfoMutation.isPending}
>
Chọn phòng
</Button>
{roomsLoading && (
<span className="text-xs text-muted-foreground">
Đang tải danh sách phòng...
</span>
)}
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={handleUpdateUserInfo}
disabled={updateUserInfoMutation.isPending}
className="gap-2"
>
<Save className="h-4 w-4" />
{updateUserInfoMutation.isPending
? "Đang lưu..."
: "Lưu thông tin"}
</Button>
</div>
</CardContent>
</Card>
<Card className="shadow-sm">
<CardHeader>
<CardTitle>Vai trò</CardTitle>
<CardDescription>Cập nhật vai trò của người dùng.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2 max-w-md">
<Label>Vai trò</Label>
<Select
value={selectedRoleId}
onValueChange={setSelectedRoleId}
disabled={rolesLoading || updateUserRoleMutation.isPending}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
rolesLoading ? "Đang tải vai trò..." : "Chọn vai trò"
}
/>
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={String(role.id)}>
{role.roleName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={handleUpdateUserRole}
disabled={updateUserRoleMutation.isPending || rolesLoading}
className="gap-2"
>
<Save className="h-4 w-4" />
{updateUserRoleMutation.isPending ? "Đang lưu..." : "Lưu vai trò"}
</Button>
</div>
</CardContent>
</Card>
<SelectDialog
open={isRoomDialogOpen}
onClose={() => setIsRoomDialogOpen(false)}
title="Chọn phòng phụ trách"
description="Chọn một hoặc nhiều phòng để gán quyền truy cập."
items={roomOptions}
selectedValues={selectedRoomValues}
onConfirm={(values) => setSelectedRoomValues(values)}
/>
</div>
);
}

View File

@ -10,10 +10,13 @@ import {
} from "@/components/ui/tooltip";
import type { ColumnDef } from "@tanstack/react-table";
import { VersionTable } from "@/components/tables/version-table";
import { Edit2, Trash2, Shield } from "lucide-react";
import { Edit2, Settings, Shield, Trash2 } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/_auth/user/")({
head: () => ({
meta: [{ title: "Danh sách người dùng" }],
}),
component: RouteComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
@ -65,21 +68,6 @@ function RouteComponent() {
<div className="flex justify-center">{Array.isArray(getValue()) ? (getValue() as number[]).join(", ") : "-"}</div>
),
},
{
id: "select",
header: () => <div className="text-center whitespace-normal max-w-xs">Chọn</div>,
cell: ({ row }) => (
<div className="flex justify-center">
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
id: "actions",
header: () => (
@ -87,6 +75,26 @@ function RouteComponent() {
),
cell: ({ row }) => (
<div className="flex gap-2 justify-center items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({
to: "/user/edit/$userName",
params: { userName: row.original.userName },
} as any);
}}
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Đi thông tin</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
@ -100,16 +108,29 @@ function RouteComponent() {
>
<Edit2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Đi mật khẩu</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({ to: "/user/role/$roleId", params: { roleId: String(row.original.roleId) } } as any);
navigate({
to: "/user/role/$roleId",
params: { roleId: String(row.original.roleId) },
} as any);
}}
>
<Shield className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Xem quyền</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
@ -123,6 +144,9 @@ function RouteComponent() {
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Xóa người dùng</TooltipContent>
</Tooltip>
</div>
),
enableSorting: false,

View File

@ -14,7 +14,7 @@ import type { PermissionOnRole } from "@/types/permission";
export const Route = createFileRoute("/_auth/user/role/$roleId/")({
head: () => ({
meta: [{ title: "Quyền của người dùng | AccessControl" }]
meta: [{ title: "Quyền của người dùng" }]
}),
component: ViewRolePermissionsComponent,
loader: async ({ context, params }) => {

View File

@ -108,20 +108,24 @@ export async function addRequiredFile(data: any): Promise<{ message: string }> {
/**
* Xóa file bắt buộc
* @param fileId - ID file
* @param data - DownloadMsiRequest { MsiFileIds: number[] }
*/
export async function deleteRequiredFile(fileId: number): Promise<{ message: string }> {
export async function deleteRequiredFile(data: { MsiFileIds: number[] }): Promise<{ message: string }> {
const response = await axios.post(
API_ENDPOINTS.APP_VERSION.DELETE_REQUIRED_FILE(fileId)
API_ENDPOINTS.APP_VERSION.DELETE_REQUIRED_FILE,
data
);
return response.data;
}
/**
* Xóa file từ server
* @param fileId - ID file
* @param data - DownloadMsiRequest { MsiFileIds: number[] }
*/
export async function deleteFile(fileId: number): Promise<{ message: string }> {
const response = await axios.delete(API_ENDPOINTS.APP_VERSION.DELETE_FILES(fileId));
export async function deleteFile(data: { MsiFileIds: number[] }): Promise<{ message: string }> {
const response = await axios.delete(
API_ENDPOINTS.APP_VERSION.DELETE_FILES,
{ data }
);
return response.data;
}

View File

@ -0,0 +1,19 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { PageResult, Audits } from "@/types/audit";
export async function getAudits(
pageNumber = 1,
pageSize = 20,
username?: string | null,
action?: string | null,
from?: string | null,
to?: string | null
): Promise<PageResult<Audits>> {
const response = await axios.get<PageResult<Audits>>(API_ENDPOINTS.AUDIT.GET_AUDITS, {
params: { pageNumber, pageSize, username, action, from, to },
});
// API trả về camelCase khớp với PageResult<Audits> — dùng trực tiếp, không cần map
return response.data;
}

View File

@ -15,6 +15,28 @@ export async function login(credentials: LoginResquest): Promise<LoginResponse>
return response.data;
}
/**
* Build SSO login URL
* @param returnUrl - FE callback url
*/
export function buildSsoLoginUrl(returnUrl: string): string {
const base = API_ENDPOINTS.AUTH.SSO_LOGIN;
const encoded = encodeURIComponent(returnUrl);
return `${base}?returnUrl=${encoded}`;
}
/**
* Exchange one-time code for login payload
* @param code - one-time code
*/
export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
const response = await axios.post<LoginResponse>(
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
{ code }
);
return response.data;
}
/**
* Đăng xuất
*/

View File

@ -0,0 +1,46 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type {
DashboardSummaryResponse,
DashboardGeneralInfo,
DeviceOverviewResponse,
DeviceStatusByRoom,
RoomManagementResponse,
RoomUsageResponse,
SoftwareDistributionResponse,
} from "@/types/dashboard";
export async function getDashboardSummary(): Promise<DashboardSummaryResponse> {
const response = await axios.get<DashboardSummaryResponse>(API_ENDPOINTS.DASHBOARD.GET_SUMMARY);
return response.data;
}
export async function getDashboardGeneralInfo(): Promise<DashboardGeneralInfo> {
const response = await axios.get<DashboardGeneralInfo>(API_ENDPOINTS.DASHBOARD.GET_GENERAL);
return response.data;
}
export async function getRoomUsage(): Promise<RoomUsageResponse> {
const response = await axios.get<RoomUsageResponse>(API_ENDPOINTS.DASHBOARD.GET_ROOM_USAGE);
return response.data;
}
export async function getDeviceOverview(): Promise<DeviceOverviewResponse> {
const response = await axios.get<DeviceOverviewResponse>(API_ENDPOINTS.DASHBOARD.GET_DEVICE_OVERVIEW);
return response.data;
}
export async function getDeviceStatusByRoom(): Promise<DeviceStatusByRoom[]> {
const response = await axios.get<DeviceStatusByRoom[]>(API_ENDPOINTS.DASHBOARD.GET_DEVICES_BY_ROOM);
return response.data;
}
export async function getRoomManagement(): Promise<RoomManagementResponse> {
const response = await axios.get<RoomManagementResponse>(API_ENDPOINTS.DASHBOARD.GET_ROOMS);
return response.data;
}
export async function getSoftwareDistribution(): Promise<SoftwareDistributionResponse> {
const response = await axios.get<SoftwareDistributionResponse>(API_ENDPOINTS.DASHBOARD.GET_SOFTWARE);
return response.data;
}

View File

@ -18,3 +18,9 @@ export * as roleService from "./role.service";
// Mesh Central API Services
export * as meshCentralService from "./meshcentral.service";
// Dashboard API Services
export * as dashboardService from "./dashboard.service";
// Remote Control API Services
export * as remoteControlService from "./remote-control.service";

View File

@ -0,0 +1,13 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
export type RemoteDesktopResponse = {
url: string;
};
export async function getRemoteDesktopUrl(nodeId: string): Promise<RemoteDesktopResponse> {
const response = await axios.get<RemoteDesktopResponse>(
API_ENDPOINTS.MESH_CENTRAL.GET_REMOTE_DESKTOP(nodeId.trim())
);
return response.data;
}

View File

@ -1,6 +1,20 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { UserProfile } from "@/types/user-profile";
import type {
UserProfile,
UpdateUserInfoRequest,
UpdateUserRoleRequest,
UpdateUserInfoResponse,
UpdateUserRoleResponse,
} from "@/types/user-profile";
// Helper to extract data from wrapped or unwrapped response
function extractData<T>(responseData: any): T {
if (responseData && typeof responseData === "object" && "success" in responseData && "data" in responseData) {
return responseData.data as T;
}
return responseData as T;
}
/**
* Lấy danh sách thông tin người dùng chuyển sang camelCase keys
@ -11,6 +25,7 @@ export async function getUsersInfo(): Promise<UserProfile[]> {
const list = Array.isArray(response.data) ? response.data : [];
return list.map((u: any) => ({
userId: u.id ?? u.Id ?? u.userId ?? u.UserId ?? undefined,
userName: u.userName ?? u.UserName ?? "",
name: u.name ?? u.Name ?? "",
role: u.role ?? u.Role ?? "",
@ -31,4 +46,32 @@ export async function getUsersInfo(): Promise<UserProfile[]> {
}
}
export default { getUsersInfo };
/**
* Cập nhật thông tin người dùng
*/
export async function updateUserInfo(
userId: number,
data: UpdateUserInfoRequest
): Promise<UpdateUserInfoResponse> {
const response = await axios.put(
API_ENDPOINTS.USER.UPDATE_INFO(userId),
data
);
return extractData<UpdateUserInfoResponse>(response.data);
}
/**
* Cập nhật role người dùng
*/
export async function updateUserRole(
userId: number,
data: UpdateUserRoleRequest
): Promise<UpdateUserRoleResponse> {
const response = await axios.put(
API_ENDPOINTS.USER.UPDATE_ROLE(userId),
data
);
return extractData<UpdateUserRoleResponse>(response.data);
}
export default { getUsersInfo, updateUserInfo, updateUserRole };

View File

@ -0,0 +1,242 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { type Audits } from "@/types/audit";
import { AuditFilterBar } from "@/components/filters/audit-filter-bar";
import { AuditDetailDialog } from "@/components/dialogs/audit-detail-dialog";
interface AuditListTemplateProps {
// data
items: Audits[];
total: number;
columns: ColumnDef<Audits>[];
isLoading: boolean;
isFetching: boolean;
// pagination
pageNumber: number;
pageSize: number;
pageCount: number;
onPreviousPage: () => void;
onNextPage: () => void;
canPreviousPage: boolean;
canNextPage: boolean;
// filter
username: string | null;
action: string | null;
from: string | null;
to: string | null;
onUsernameChange: (v: string | null) => void;
onActionChange: (v: string | null) => void;
onFromChange: (v: string | null) => void;
onToChange: (v: string | null) => void;
onSearch: () => void;
onReset: () => void;
// detail dialog
selectedAudit: Audits | null;
onRowClick: (audit: Audits) => void;
onDialogClose: () => void;
}
export function AuditListTemplate({
items,
total,
columns,
isLoading,
isFetching,
pageNumber,
pageSize,
pageCount,
onPreviousPage,
onNextPage,
canPreviousPage,
canNextPage,
username,
action,
from,
to,
onUsernameChange,
onActionChange,
onFromChange,
onToChange,
onSearch,
onReset,
selectedAudit,
onRowClick,
onDialogClose,
}: AuditListTemplateProps) {
const table = useReactTable({
data: items,
columns,
state: {
pagination: { pageIndex: Math.max(0, pageNumber - 1), pageSize },
},
pageCount,
manualPagination: true,
onPaginationChange: (updater) => {
const next =
typeof updater === "function"
? updater({ pageIndex: Math.max(0, pageNumber - 1), pageSize })
: updater;
const newPage = (next.pageIndex ?? 0) + 1;
if (newPage > pageNumber) onNextPage();
else if (newPage < pageNumber) onPreviousPage();
},
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="w-full px-4 md:px-6 space-y-4">
<div>
<h1 className="text-2xl md:text-3xl font-bold">Nhật hoạt đng</h1>
<p className="text-muted-foreground mt-1 text-sm">
Xem nhật audit hệ thống
</p>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Danh sách audit</CardTitle>
<CardDescription className="text-xs">
Lọc theo người dùng, loại, hành đng khoảng thời gian. Nhấn vào
dòng đ xem chi tiết.
</CardDescription>
</CardHeader>
<CardContent>
<AuditFilterBar
username={username}
action={action}
from={from}
to={to}
isLoading={isLoading}
isFetching={isFetching}
onUsernameChange={onUsernameChange}
onActionChange={onActionChange}
onFromChange={onFromChange}
onToChange={onToChange}
onSearch={onSearch}
onReset={onReset}
/>
<div className="rounded-md border overflow-x-auto">
<Table className="min-w-[640px] w-full">
<TableHeader className="sticky top-0 bg-background z-10">
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((header) => (
<TableHead
key={header.id}
className="text-xs font-semibold whitespace-nowrap"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading || isFetching ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-10 text-muted-foreground text-sm"
>
Đang tải...
</TableCell>
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-10 text-muted-foreground text-sm"
>
Không dữ liệu
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="hover:bg-muted/40 cursor-pointer"
onClick={() => onRowClick(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-2.5 align-middle">
{cell.column.columnDef.cell
? flexRender(
cell.column.columnDef.cell,
cell.getContext()
)
: String(cell.getValue() ?? "")}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between mt-4">
<span className="text-xs text-muted-foreground">
Hiển thị {items.length} / {total} mục
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!canPreviousPage || isFetching}
onClick={onPreviousPage}
>
Trước
</Button>
<span className="text-sm tabular-nums">
{pageNumber} / {pageCount}
</span>
<Button
variant="outline"
size="sm"
disabled={!canNextPage || isFetching}
onClick={onNextPage}
>
Sau
</Button>
</div>
</div>
</CardContent>
</Card>
<AuditDetailDialog
audit={selectedAudit}
open={!!selectedAudit}
onClose={onDialogClose}
/>
</div>
);
}

View File

@ -0,0 +1,277 @@
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { DeviceOverviewCard } from "@/components/cards/device-overview-card";
import { RoomManagementCard } from "@/components/cards/room-management-card";
import { SoftwareDistributionCard } from "@/components/cards/software-distribution-card";
import { RefreshCw } from "lucide-react";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type {
DashboardGeneralInfo,
DeviceOverviewResponse,
RoomManagementResponse,
RoomUsageResponse,
SoftwareDistributionResponse,
DeviceStatusByRoom,
} from "@/types/dashboard";
interface DashboardTemplateProps {
generalInfo?: DashboardGeneralInfo | null;
deviceOverview?: DeviceOverviewResponse | null;
roomManagement?: RoomManagementResponse | null;
roomUsage?: RoomUsageResponse | null;
softwareDistribution?: SoftwareDistributionResponse | null;
devicesByRoom?: DeviceStatusByRoom[] | null;
isLoading?: boolean;
isFetching?: boolean;
onRefresh?: () => void | Promise<void>;
}
export function DashboardTemplate({
generalInfo,
deviceOverview,
roomManagement,
roomUsage,
softwareDistribution,
devicesByRoom,
isLoading = false,
isFetching = false,
onRefresh,
}: DashboardTemplateProps) {
const [showAllRooms, setShowAllRooms] = useState(false);
const [usageRange, setUsageRange] = useState<"weekly" | "monthly">("weekly");
const totalDevices = generalInfo?.totalDevices ?? deviceOverview?.totalDevices ?? 0;
const onlineDevices = generalInfo?.onlineDevices ?? deviceOverview?.onlineDevices ?? 0;
const offlineDevices = generalInfo?.offlineDevices ?? deviceOverview?.offlineDevices ?? 0;
const totalRooms = generalInfo?.totalRooms ?? roomManagement?.totalRooms ?? 0;
const roomsNeedAttentionCount = roomManagement?.roomsNeedAttention?.length ?? 0;
const onlineRate = totalDevices > 0 ? Math.round((onlineDevices / totalDevices) * 100) : 0;
const stats = [
{
label: "Tổng thiết bị",
value: totalDevices,
note: `Online ${onlineRate}%`,
},
{
label: "Đang online",
value: onlineDevices,
note: "Thiết bị kết nối",
},
{
label: "Đang offline",
value: offlineDevices,
note: "Cần kiểm tra",
},
{
label: "Tổng phòng",
value: totalRooms,
note: `${roomsNeedAttentionCount} phòng cần chú ý`,
},
];
const getRoomPercent = (room: DeviceStatusByRoom) => {
if (typeof room.onlinePercentage === "number") return room.onlinePercentage;
if (!room.totalDevices) return 0;
return (room.onlineDevices / room.totalDevices) * 100;
};
const sortedRooms = useMemo(() => {
return (devicesByRoom ?? [])
.slice()
.sort((a, b) => getRoomPercent(a) - getRoomPercent(b));
}, [devicesByRoom]);
const roomSnapshot = showAllRooms ? sortedRooms : sortedRooms.slice(0, 6);
const usageSeries = useMemo(() => {
const usageRooms = roomUsage?.rooms ?? [];
const usageKey = usageRange === "weekly" ? "weekly" : "monthly";
const aggregated = new Map<string, number>();
for (const room of usageRooms) {
for (const point of room[usageKey]) {
aggregated.set(
point.date,
(aggregated.get(point.date) ?? 0) + point.onlineDevices
);
}
}
return Array.from(aggregated.entries())
.map(([date, value]) => ({
date,
value,
label: new Date(date).toLocaleDateString("vi-VN", {
day: "2-digit",
month: "2-digit",
}),
}))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}, [roomUsage, usageRange]);
return (
<div className="dashboard-scope relative w-full px-6 py-8">
<div className="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-br from-slate-50 via-white to-emerald-50" />
<div className="pointer-events-none absolute -top-20 right-10 -z-10 h-72 w-72 rounded-full bg-emerald-200/40 blur-3xl" />
<div className="pointer-events-none absolute top-32 -left-24 -z-10 h-72 w-72 rounded-full bg-sky-200/40 blur-3xl" />
<div className="pointer-events-none absolute bottom-0 right-1/3 -z-10 h-64 w-64 rounded-full bg-amber-100/40 blur-3xl" />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight">Bảng điều khiển</h1>
<p className="text-sm text-muted-foreground">
Tổng hợp sức khỏe thiết bị, phòng phân phối phần mềm trong thời gian thực.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="border-amber-200 bg-amber-50 text-amber-700">
Cần chú ý: {roomsNeedAttentionCount} phòng
</Badge>
<Button onClick={() => onRefresh?.()} disabled={isFetching} className="gap-2">
<RefreshCw className={isFetching ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
{isFetching ? "Đang làm mới" : "Làm mới"}
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
{stats.map((stat) => (
<div
key={stat.label}
className="rounded-2xl border border-muted/60 bg-white/80 p-4 shadow-sm backdrop-blur"
>
<div className="text-xs uppercase tracking-wide text-muted-foreground">{stat.label}</div>
<div className="mt-2 flex items-end justify-between">
<div className="text-3xl font-semibold">{stat.value}</div>
</div>
<div className="mt-1 text-xs text-muted-foreground">{stat.note}</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3 animate-in fade-in slide-in-from-bottom-6 duration-700">
<div className="flex flex-col gap-6 xl:col-span-2">
<DeviceOverviewCard data={deviceOverview ?? null} isLoading={!!isLoading} />
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Tần suất sử dụng</CardTitle>
<CardDescription>Tổng thiết bị online theo ngày</CardDescription>
<CardAction>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant={usageRange === "weekly" ? "default" : "outline"}
onClick={() => setUsageRange("weekly")}
>
7 ngày
</Button>
<Button
type="button"
size="sm"
variant={usageRange === "monthly" ? "default" : "outline"}
onClick={() => setUsageRange("monthly")}
>
30 ngày
</Button>
</div>
</CardAction>
</CardHeader>
<CardContent>
{usageSeries.length > 0 ? (
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={usageSeries} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="usageGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#0ea5e9" stopOpacity={0.4} />
<stop offset="95%" stopColor="#0ea5e9" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
formatter={(value) => [value ?? 0, "Online"]}
labelFormatter={(label) => `Ngày ${label}`}
/>
<Area
type="monotone"
dataKey="value"
stroke="#0ea5e9"
fill="url(#usageGradient)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-sm text-muted-foreground">Chưa dữ liệu sử dụng</div>
)}
</CardContent>
</Card>
<RoomManagementCard data={roomManagement ?? null} isLoading={!!isLoading} />
</div>
<div className="flex flex-col gap-6">
<SoftwareDistributionCard data={softwareDistribution ?? null} isLoading={!!isLoading} />
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Tình trạng theo phòng</CardTitle>
<CardDescription>
Online / Tổng thiết bị từng phòng ({sortedRooms.length} phòng)
</CardDescription>
{sortedRooms.length > 6 && (
<CardAction>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowAllRooms((value) => !value)}
>
{showAllRooms ? "Thu gọn" : "Xem tất cả"}
</Button>
</CardAction>
)}
</CardHeader>
<CardContent className={showAllRooms ? "space-y-4 max-h-[480px] overflow-auto pr-2" : "space-y-4"}>
{roomSnapshot.length > 0 ? (
roomSnapshot.map((room) => {
const percentRaw = getRoomPercent(room);
const percent = Math.max(0, Math.min(100, Math.round(percentRaw)));
return (
<div key={room.roomName} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="font-medium">{room.roomName}</div>
<div className="text-xs text-muted-foreground">
{room.onlineDevices}/{room.totalDevices}
</div>
</div>
<div className="flex items-center gap-3">
<div className="h-2 flex-1 rounded-full bg-muted/40">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: `${percent}%` }}
/>
</div>
<div className="w-10 text-right text-xs font-medium">{percent}%</div>
</div>
</div>
);
})
) : (
<div className="text-sm text-muted-foreground">Chưa dữ liệu phòng</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { AppWindow, Building, CircleX, Folder, Home, ShieldCheck, Terminal, UserPlus} from "lucide-react";
import { AppWindow, Building, CircleX, ClipboardList, Folder, Home, Monitor, ShieldCheck, Terminal, UserPlus} from "lucide-react";
import { PermissionEnum } from "./permission";
enum AppSidebarSectionCode {
@ -12,6 +12,7 @@ enum AppSidebarSectionCode {
LIST_ROLES = 8,
LIST_PERMISSIONS = 9,
LIST_USERS = 10,
REMOTE_LIVE_CONTROL = 11,
}
export const appSidebarSection = {
@ -26,7 +27,7 @@ export const appSidebarSection = {
code: AppSidebarSectionCode.DASHBOARD,
icon: Home,
permissions: [PermissionEnum.ALLOW_ALL],
},
}
],
},
{
@ -39,6 +40,13 @@ export const appSidebarSection = {
icon: Building,
permissions: [PermissionEnum.VIEW_ROOM],
},
{
title: "Điều khiển trực tiếp",
url: "/remote-control",
code: AppSidebarSectionCode.REMOTE_LIVE_CONTROL,
icon: Monitor,
permissions: [PermissionEnum.VIEW_REMOTE_CONTROL],
}
],
},
{
@ -93,6 +101,17 @@ export const appSidebarSection = {
permissions: [PermissionEnum.VIEW_USER],
}
]
},
{
title: "Audits",
items: [
{
title: "Lịch sử hoạt động",
url: "/audits",
icon: ClipboardList,
permissions: [PermissionEnum.VIEW_AUDIT_LOGS],
}
]
}
],
};

29
src/types/audit.ts Normal file
View File

@ -0,0 +1,29 @@
export interface Audits {
id: number;
username: string;
dateTime: string; // ISO string
// request identity
apiCall?: string; // Controller.ActionName
url?: string;
requestPayload?: string; // request body (redacted)
// DB fields — null if request didn't touch DB
action?: string;
tableName?: string;
entityId?: string;
oldValues?: string;
newValues?: string;
// result
isSuccess?: boolean;
errorMessage?: string;
}
export interface PageResult<T> {
items: T[];
totalCount: number;
pageNumber: number;
pageSize: number;
totalPages: number;
}

105
src/types/dashboard.ts Normal file
View File

@ -0,0 +1,105 @@
export interface RecentOfflineDevice {
deviceId: string;
room?: string | null;
lastSeen?: string | null;
}
export interface DeviceOverviewResponse {
totalDevices: number;
onlineDevices: number;
offlineDevices: number;
onlinePercentage?: number;
devicesWithOutdatedVersion: number;
recentOfflineDevices: RecentOfflineDevice[];
}
export interface DeviceStatusByRoom {
roomName: string;
totalDevices: number;
onlineDevices: number;
offlineDevices: number;
onlinePercentage?: number;
}
export interface DashboardGeneralInfo {
totalDevices: number;
totalRooms: number;
onlineDevices: number;
offlineDevices: number;
}
export interface RoomUsagePoint {
date: string;
onlineDevices: number;
}
export interface RoomUsageItem {
roomName: string;
weekly: RoomUsagePoint[];
monthly: RoomUsagePoint[];
weeklyTotalOnlineDevices: number;
monthlyTotalOnlineDevices: number;
}
export interface RoomUsageResponse {
weekFrom: string;
weekTo: string;
monthFrom: string;
monthTo: string;
rooms: RoomUsageItem[];
}
export interface RoomHealthStatus {
roomName: string;
totalDevices: number;
onlineDevices: number;
offlineDevices: number;
healthPercentage?: number;
healthStatus?: string;
}
export interface RoomManagementResponse {
totalRooms: number;
rooms: RoomHealthStatus[];
roomsNeedAttention: RoomHealthStatus[];
}
export interface AgentVersionStats {
latestVersion: string;
devicesWithLatestVersion: number;
devicesWithOldVersion: number;
updateCoverage?: number;
}
export interface TopFailedSoftware {
fileName: string;
failCount: number;
}
export interface RecentInstallActivity {
deviceId: string;
fileName: string;
status: string;
timestamp: string;
message?: string | null;
}
export interface SoftwareDistributionResponse {
totalInstallations: number;
successfulInstallations: number;
failedInstallations: number;
pendingInstallations: number;
successRate?: number;
agentVersionStats: AgentVersionStats;
topFailedSoftware: TopFailedSoftware[];
recentActivities: RecentInstallActivity[];
}
export interface DashboardSummaryResponse {
generalInfo: DashboardGeneralInfo;
roomUsage: RoomUsageResponse;
deviceOverview: DeviceOverviewResponse;
roomManagement: RoomManagementResponse;
softwareDistribution: SoftwareDistributionResponse;
generatedAt: string;
}

View File

@ -44,6 +44,7 @@ export enum PermissionEnum {
EDIT_COMMAND = 53,
DEL_COMMAND = 54,
SEND_COMMAND = 55,
SEND_SENSITIVE_COMMAND = 56,
//DEVICE_OPERATION
DEVICE_OPERATION = 70,
@ -59,10 +60,12 @@ export enum PermissionEnum {
VIEW_ACCOUNT_ROOM = 115,
EDIT_ACCOUNT_ROOM = 116,
//WARNING_OPERATION
WARNING_OPERATION = 140,
VIEW_WARNING = 141,
//USER_OPERATION
USER_OPERATION = 150,
VIEW_USER_ROLE = 151,
@ -80,7 +83,7 @@ export enum PermissionEnum {
DEL_ROLE = 164,
// AGENT
APP_OPERATION = 170,
AGENT_OPERATION = 170,
VIEW_AGENT = 171,
UPDATE_AGENT = 173,
SEND_UPDATE_COMMAND = 174,
@ -94,9 +97,18 @@ export enum PermissionEnum {
ADD_APP_TO_SELECTED = 185,
DEL_APP_FROM_SELECTED = 186,
// AUDIT
AUDIT_OPERATION = 190,
VIEW_AUDIT_LOGS = 191,
//REMOTE CONTROL
REMOTE_CONTROL_OPERATION = 200,
VIEW_REMOTE_CONTROL = 201,
CONTROL_REMOTE = 202,
//Undefined
UNDEFINED = 9999,
//Allow All
ALLOW_ALL = 0,
ALLOW_ALL = 0
}

View File

@ -1,4 +1,5 @@
export type UserProfile = {
userId?: number;
userName: string;
name: string;
role: string;
@ -9,3 +10,32 @@ export type UserProfile = {
updatedAt?: string | null;
updatedBy?: string | null;
};
export type UpdateUserInfoRequest = {
name: string;
userName: string;
accessRooms?: number[];
};
export type UpdateUserRoleRequest = {
roleId: number;
};
export type UpdateUserInfoResponse = {
userId: number;
userName: string;
name: string;
roleId: number;
accessRooms: number[];
updatedAt?: string | null;
updatedBy?: string | null;
};
export type UpdateUserRoleResponse = {
userId: number;
userName: string;
roleId: number;
roleName?: string | null;
updatedAt?: string | null;
updatedBy?: string | null;
};

View File

@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
import tailwindcss from "@tailwindcss/vite"
import path from 'path'
import basicSsl from '@vitejs/plugin-basic-ssl'
// https://vitejs.dev/config/
export default defineConfig({
@ -15,7 +16,8 @@ export default defineConfig({
}),
react(),
tailwindcss()
tailwindcss(),
basicSsl()
// ...,
],
resolve: {
@ -23,4 +25,28 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
'/mesh-api': {
target: 'https://my-mesh-test.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/mesh-api/, ''),
secure: false, // Bỏ qua lỗi SSL của MeshCentral
configure: (proxy, options) => {
proxy.on('proxyRes', (proxyRes) => {
const setCookie = proxyRes.headers['set-cookie'];
if (setCookie) {
// Sửa toàn bộ Cookie trả về: Đổi Lax -> None, thêm Secure
proxyRes.headers['set-cookie'] = setCookie.map(cookie => {
// Nếu gặp cookie trống (e30=), ta có thể bỏ qua hoặc giữ nhưng phải ép None
return cookie
.replace(/SameSite=Lax/gi, 'SameSite=None')
.replace(/SameSite=Strict/gi, 'SameSite=None') + '; Secure';
});
}
});
},
},
},
},
})