Compare commits
No commits in common. "main" and "newRemoteUI" have entirely different histories.
main
...
newRemoteU
|
|
@ -1,28 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.14.0
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Test
|
||||
run: npm run test -- --passWithNoTests
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
name: Deploy Staging (Docker)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Generate build env
|
||||
env:
|
||||
VITE_API_MESH: ${{ secrets.VITE_API_MESH }}
|
||||
VITE_ENABLE_MESH_DIRECT_AFTER_PROXY: ${{ secrets.VITE_ENABLE_MESH_DIRECT_AFTER_PROXY }}
|
||||
run: |
|
||||
cat > .env.production <<EOF
|
||||
VITE_API_MESH=$VITE_API_MESH
|
||||
VITE_ENABLE_MESH_DIRECT_AFTER_PROXY=$VITE_ENABLE_MESH_DIRECT_AFTER_PROXY
|
||||
EOF
|
||||
|
||||
- name: Build Image
|
||||
run: |
|
||||
IMAGE="${{ secrets.IMAGE_NAME }}"
|
||||
if [ -z "$IMAGE" ]; then IMAGE="ttmt-frontend"; fi
|
||||
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
|
||||
docker build --build-arg NODE_VERSION=22.14.0 -t $IMAGE:staging-${{ github.sha }} .
|
||||
|
||||
- name: Deploy local (docker compose)
|
||||
run: |
|
||||
IMAGE="${{ secrets.IMAGE_NAME }}"
|
||||
if [ -z "$IMAGE" ]; then IMAGE="ttmt-frontend"; fi
|
||||
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
|
||||
COMPOSE_FILE=/home/compmanage/docker-compose.yml
|
||||
OVERRIDE_FILE=/tmp/ttmt-frontend.override.yml
|
||||
cat > $OVERRIDE_FILE <<EOF
|
||||
services:
|
||||
frontend:
|
||||
image: $IMAGE:staging-${{ github.sha }}
|
||||
EOF
|
||||
docker rm -f ttmt-frontend || true
|
||||
docker compose -f $COMPOSE_FILE -f $OVERRIDE_FILE up -d --no-deps --force-recreate frontend
|
||||
rm -f $OVERRIDE_FILE
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,4 +8,3 @@ count.txt
|
|||
.nitro
|
||||
.tanstack
|
||||
.vscode/
|
||||
plans/
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
# Frontend Guide - Microsoft SSO (Entra ID)
|
||||
|
||||
## 1) Muc tieu
|
||||
Tai lieu nay danh cho frontend de tich hop dang nhap Microsoft SSO voi backend TTMT.CompManageWeb.
|
||||
|
||||
## 2) Redirect URI da dang ky
|
||||
Redirect URI cho Microsoft app:
|
||||
|
||||
https://comp.soict.io/api/auth/sso/callback
|
||||
|
||||
Luu y:
|
||||
- Redirect URI trong Azure App Registration phai giong 100% (scheme, domain, path).
|
||||
- Sai ky tu hoac sai slash cuoi co the gay loi redirect_uri mismatch.
|
||||
|
||||
## 3) Endpoint frontend can goi
|
||||
Backend route SSO Microsoft (legacy route):
|
||||
|
||||
- GET /api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||
- POST /api/auth/sso/exchange
|
||||
|
||||
Route alias cung ho tro:
|
||||
- GET /api/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||
- POST /api/sso/exchange
|
||||
|
||||
## 4) Login flow cho frontend
|
||||
1. User bam nut "Login with Microsoft".
|
||||
2. Frontend redirect browser den:
|
||||
/api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||
3. Backend redirect sang Microsoft login page.
|
||||
4. Sau khi user xac thuc thanh cong, Microsoft goi ve redirect URI:
|
||||
https://comp.soict.io/api/auth/sso/callback
|
||||
5. Backend tao one-time code va redirect user ve FRONTEND_RETURN_URL kem query:
|
||||
?code={ONE_TIME_CODE}
|
||||
6. Frontend doc code trong URL, goi API exchange de doi code lay JWT noi bo.
|
||||
7. Frontend luu token va thong tin user, dieu huong vao app.
|
||||
|
||||
## 5) API exchange chi tiet
|
||||
Method: POST
|
||||
Path: /api/auth/sso/exchange
|
||||
Content-Type: application/json
|
||||
|
||||
Request body:
|
||||
{
|
||||
"code": "<one-time-code>"
|
||||
}
|
||||
|
||||
Success response (HTTP 200):
|
||||
{
|
||||
"token": "<jwt>",
|
||||
"name": "Nguyen Van A",
|
||||
"username": "user@hust.edu.vn",
|
||||
"access": [1, 2, 3],
|
||||
"role": {
|
||||
"roleName": "Pending",
|
||||
"priority": 99
|
||||
}
|
||||
}
|
||||
|
||||
## 6) Error handling goi y cho frontend
|
||||
- 400 BadRequest: code thieu hoac token khong hop le.
|
||||
- 401 Unauthorized: code het han / da dung / khong hop le.
|
||||
- 404 NotFound: user khong ton tai sau khi exchange.
|
||||
|
||||
One-time code co han su dung ngan (khoang 2 phut) va chi dung 1 lan.
|
||||
Neu exchange that bai, can yeu cau user login lai.
|
||||
|
||||
## 7) Mau frontend pseudo-code
|
||||
const returnUrl = window.location.origin + "/sso/complete";
|
||||
window.location.href = `/api/auth/sso/login?returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||
|
||||
// tai /sso/complete
|
||||
const code = new URLSearchParams(window.location.search).get("code");
|
||||
if (code) {
|
||||
const res = await fetch("/api/auth/sso/exchange", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// hien thi thong bao loi va cho user thu lai
|
||||
} else {
|
||||
const data = await res.json();
|
||||
// save data.token, data.username, data.role, data.access
|
||||
}
|
||||
}
|
||||
|
||||
## 8) Checklist truoc khi UAT
|
||||
- Redirect URI trong Azure dung: https://comp.soict.io/api/auth/sso/callback
|
||||
- Frontend returnUrl la URL FE hop le (vi du: https://comp.soict.io/sso/complete)
|
||||
- Frontend parse duoc query code
|
||||
- Frontend goi exchange ngay sau khi nhan code
|
||||
- Frontend xu ly day du HTTP 400/401/404
|
||||
181
nginx/nginx.conf
181
nginx/nginx.conf
|
|
@ -3,208 +3,91 @@
|
|||
# server 127.0.0.1:8080;
|
||||
# server 172.18.10.8:8080;
|
||||
# }
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name comp.soict.io;
|
||||
server_name comp.soict.io;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
# root /usr/share/nginx/html;
|
||||
# index index.html index.htm;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
server{
|
||||
listen 443 ssl;
|
||||
server_name comp.soict.io;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/comp.soict.io/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/comp.soict.io/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# MeshCentral proxied flow can set sizable auth cookies.
|
||||
client_header_buffer_size 16k;
|
||||
large_client_header_buffers 8 32k;
|
||||
|
||||
# Required when proxy_pass uses variables.
|
||||
# In Docker, 127.0.0.11 is the embedded DNS resolver.
|
||||
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||
resolver_timeout 5s;
|
||||
|
||||
set $backend_server ttmt-web:8080;
|
||||
# Internal MeshCentral hop to avoid upstream TLS handshake instability.
|
||||
set $meshserver meshcentral:8082;
|
||||
# Public host MeshCentral expects in Host header.
|
||||
set $meshhost soict-overleaf.tailc51e09.ts.net:8443;
|
||||
|
||||
|
||||
set $backend_server 172.18.10.8:8080;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
# Default file to serve for directory requests
|
||||
index index.html index.htm;
|
||||
|
||||
# MeshCentral auth entrypoint. If iframe/browser lands on /login due to
|
||||
# redirect, keep it on MeshCentral instead of frontend routing.
|
||||
location = /login {
|
||||
proxy_pass http://$meshserver;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $meshhost;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# MeshCentral may redirect to "/" with remote params after login.
|
||||
# Detect those requests and proxy them to MeshCentral instead of SPA.
|
||||
location = / {
|
||||
if ($arg_node != "") {
|
||||
rewrite ^ /__mesh_root_proxy__ last;
|
||||
}
|
||||
|
||||
if ($arg_viewmode != "") {
|
||||
rewrite ^ /__mesh_root_proxy__ last;
|
||||
}
|
||||
|
||||
if ($arg_gotonode != "") {
|
||||
rewrite ^ /__mesh_root_proxy__ last;
|
||||
}
|
||||
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location = /__mesh_root_proxy__ {
|
||||
proxy_pass http://$meshserver;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $meshhost;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
|
||||
location / {
|
||||
# Try to serve the requested file directly ($uri)
|
||||
# If it's a directory, try serving the index file ($uri/)
|
||||
# If neither exists, fall back to serving /index.html
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Optional: Add cache control headers for static assets for better performance
|
||||
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|webp)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
access_log off;
|
||||
access_log off; # Optional: Don't log accesses for static files
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://$backend_server;
|
||||
|
||||
client_max_body_size 900M;
|
||||
# Cho phép upload file lớn (vd: 200MB)
|
||||
client_max_body_size 200M;
|
||||
|
||||
# Truyền thẳng stream sang backend
|
||||
proxy_request_buffering off;
|
||||
|
||||
# Tăng timeout khi upload
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
# 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_server;
|
||||
proxy_pass http://$backend_server/api/Sse/events;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# cần thiết cho SSE
|
||||
proxy_set_header Connection '';
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
|
||||
# MeshCentral client builds WebSocket URL from current location,
|
||||
# e.g. wss://comp.soict.io/control.ashx.
|
||||
location ~ ^/(control|meshrelay|commander|mesh)\.ashx$ {
|
||||
proxy_pass http://$meshserver;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_set_header Host $meshhost;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location = /api/meshcentral/proxy {
|
||||
return 301 /api/meshcentral/proxy/;
|
||||
}
|
||||
|
||||
location ~ ^/api/meshcentral/proxy/(.*)$ {
|
||||
# Forward directly to MeshCentral via Tailscale HTTPS so MeshCentral receives
|
||||
# requests over TLS and generates correct absolute URLs based on $meshhost.
|
||||
proxy_pass https://soict-overleaf.tailc51e09.ts.net:8443/$1$is_args$args;
|
||||
proxy_ssl_verify off;
|
||||
proxy_ssl_server_name on;
|
||||
location /mesh-proxy/ {
|
||||
proxy_pass https://202.191.59.59/;
|
||||
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
|
||||
|
||||
proxy_set_header Host $meshhost;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# Keep browser navigation under /api/meshcentral/proxy/*.
|
||||
proxy_redirect ~^https?://[^/]+(/.*)$ /api/meshcentral/proxy$1;
|
||||
proxy_redirect ~^(/.*)$ /api/meshcentral/proxy$1;
|
||||
|
||||
|
||||
# Cấu hình WebSocket cho commander.ashx
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# FE production currently builds mesh proxy path as /meshapi/api/meshcentral/proxy/...
|
||||
location = /meshapi/api/meshcentral/proxy {
|
||||
return 301 /meshapi/api/meshcentral/proxy/;
|
||||
}
|
||||
|
||||
location ^~ /meshapi/api/meshcentral/proxy/ {
|
||||
# Legacy frontend path -> backend MeshCentralProxyController
|
||||
rewrite ^/meshapi/api/meshcentral/proxy/(.*)$ /$1 break;
|
||||
proxy_pass http://$backend_server;
|
||||
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
154
package-lock.json
generated
154
package-lock.json
generated
|
|
@ -26,7 +26,6 @@
|
|||
"@tanstack/react-router": "^1.121.2",
|
||||
"@tanstack/react-router-devtools": "^1.121.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.26",
|
||||
"@tanstack/router-plugin": "^1.121.2",
|
||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||
"axios": "^1.11.0",
|
||||
|
|
@ -1120,9 +1119,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"version": "1.19.10",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz",
|
||||
"integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
|
|
@ -3621,22 +3620,6 @@
|
|||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.26",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz",
|
||||
"integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/router-core": {
|
||||
"version": "1.129.8",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
|
||||
|
|
@ -3801,15 +3784,6 @@
|
|||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz",
|
||||
"integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-file-routes": {
|
||||
"version": "1.129.7",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.129.7.tgz",
|
||||
|
|
@ -4434,37 +4408,13 @@
|
|||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
||||
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-dead-code-elimination": {
|
||||
|
|
@ -5284,9 +5234,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "5.8.1",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="
|
||||
"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",
|
||||
|
|
@ -5611,11 +5561,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"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.2.0"
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
|
|
@ -5648,9 +5598,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -5724,9 +5674,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
|
@ -6025,9 +5975,9 @@
|
|||
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.23",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
||||
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
|
||||
"version": "4.12.9",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
|
||||
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
|
|
@ -6170,9 +6120,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"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"
|
||||
}
|
||||
|
|
@ -6924,15 +6874,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
|
|
@ -7246,9 +7197,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -7263,8 +7214,9 @@
|
|||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
@ -7347,12 +7299,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
|
|
@ -7375,9 +7324,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
|
|
@ -8857,9 +8806,9 @@
|
|||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
|
|
@ -8898,9 +8847,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
|
@ -9252,10 +9201,11 @@
|
|||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@
|
|||
"@tanstack/react-router": "^1.121.2",
|
||||
"@tanstack/react-router-devtools": "^1.121.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.26",
|
||||
"@tanstack/router-plugin": "^1.121.2",
|
||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||
"axios": "^1.11.0",
|
||||
|
|
|
|||
|
|
@ -1,226 +0,0 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { AlertTriangle, CheckCircle2, Power, PowerOff, RotateCcw, ShieldBan, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||
import { useExecuteSensitiveCommand, useGetSensitiveCommands } from "@/hooks/queries/useCommandQueries";
|
||||
import { CommandType } from "@/types/command-registry";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface DeviceActionBarProps {
|
||||
roomName: string;
|
||||
selectedDevices: any[];
|
||||
onClearSelection: () => void;
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{
|
||||
type: CommandType.RESTART,
|
||||
label: "Khởi động lại",
|
||||
icon: Power,
|
||||
variant: "outline" as const,
|
||||
},
|
||||
{
|
||||
type: CommandType.SHUTDOWN,
|
||||
label: "Tắt máy",
|
||||
icon: PowerOff,
|
||||
variant: "destructive" as const,
|
||||
},
|
||||
{
|
||||
type: CommandType.TASKKILL,
|
||||
label: "Kết thúc tác vụ",
|
||||
icon: XCircle,
|
||||
variant: "outline" as const,
|
||||
},
|
||||
{
|
||||
type: CommandType.BLOCK,
|
||||
label: "Chặn",
|
||||
icon: ShieldBan,
|
||||
variant: "outline" as const,
|
||||
},
|
||||
{
|
||||
type: CommandType.RESET,
|
||||
label: "Reset",
|
||||
icon: RotateCcw,
|
||||
variant: "destructive" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const DANGER_TYPES = new Set<CommandType>([CommandType.SHUTDOWN, CommandType.RESET]);
|
||||
|
||||
export function DeviceActionBar({
|
||||
roomName,
|
||||
selectedDevices,
|
||||
onClearSelection,
|
||||
}: DeviceActionBarProps) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [activeType, setActiveType] = useState<CommandType | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
|
||||
const getMachineNumber = useMachineNumber();
|
||||
|
||||
const { data: sensitiveCommands = [] } = useGetSensitiveCommands();
|
||||
const executeSensitiveMutation = useExecuteSensitiveCommand();
|
||||
|
||||
const commandsByType = useMemo(() => {
|
||||
return (Object.values(CommandType) as Array<number | string>)
|
||||
.filter((value) => typeof value === "number")
|
||||
.reduce((acc: Record<number, any[]>, type) => {
|
||||
acc[type as number] = (sensitiveCommands || []).filter(
|
||||
(command: any) => Number(command.command) === Number(type)
|
||||
);
|
||||
return acc;
|
||||
}, {} as Record<number, any[]>);
|
||||
}, [sensitiveCommands]);
|
||||
|
||||
const selectedCount = selectedDevices.length;
|
||||
const activeCommand = activeType ? commandsByType[activeType]?.[0] : null;
|
||||
|
||||
const buildDeviceLabel = (device: any) => {
|
||||
const number = getMachineNumber(device?.id || "");
|
||||
const ipAddress = device?.networkInfos?.[0]?.ipAddress;
|
||||
if (number > 0) {
|
||||
return `#${number}${ipAddress ? ` (${ipAddress})` : ""}`;
|
||||
}
|
||||
return `${device?.id ?? ""}${ipAddress ? ` (${ipAddress})` : ""}`;
|
||||
};
|
||||
|
||||
const openConfirm = (type: CommandType) => {
|
||||
if (!commandsByType[type]?.length) {
|
||||
toast.error("Chưa có lệnh phù hợp cho thao tác này.");
|
||||
return;
|
||||
}
|
||||
setActiveType(type);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isExecuting) return;
|
||||
setConfirmOpen(false);
|
||||
setActiveType(null);
|
||||
setPassword("");
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!activeCommand || !activeType) return;
|
||||
if (!password.trim()) {
|
||||
toast.error("Vui lòng nhập mật khẩu xác nhận.");
|
||||
return;
|
||||
}
|
||||
setIsExecuting(true);
|
||||
try {
|
||||
await executeSensitiveMutation.mutateAsync({
|
||||
roomName,
|
||||
command: activeCommand.commandName,
|
||||
password,
|
||||
});
|
||||
toast.success(`Đã gửi lệnh: ${activeCommand.commandName}`);
|
||||
handleClose();
|
||||
onClearSelection();
|
||||
} catch (error) {
|
||||
console.error("Execute command error:", error);
|
||||
toast.error("Lỗi khi gửi lệnh!");
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky bottom-4 z-30">
|
||||
<div className="flex flex-col gap-3 rounded-xl border bg-background/95 px-4 py-3 shadow-lg backdrop-blur sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
Đã chọn {selectedCount} thiết bị
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{ACTIONS.map((action) => {
|
||||
const Icon = action.icon;
|
||||
const isDisabled = !commandsByType[action.type]?.length;
|
||||
return (
|
||||
<Button
|
||||
key={action.type}
|
||||
variant={action.variant}
|
||||
size="sm"
|
||||
disabled={isDisabled}
|
||||
onClick={() => openConfirm(action.type)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={onClearSelection}>
|
||||
Bỏ chọn
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={handleClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-600" />
|
||||
Xác nhận thực thi lệnh
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-left space-y-3">
|
||||
<p>
|
||||
Bạn có chắc chắn muốn thực thi lệnh{" "}
|
||||
<strong>{activeCommand?.commandName ?? ""}</strong>?
|
||||
</p>
|
||||
{DANGER_TYPES.has(activeType ?? CommandType.RESTART) && (
|
||||
<p className="text-sm text-destructive">
|
||||
Hành động này không thể hoàn tác.
|
||||
</p>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">Thiết bị được chọn</div>
|
||||
<ScrollArea className="max-h-40 rounded-lg border p-2">
|
||||
<div className="space-y-1 text-sm">
|
||||
{selectedDevices.map((device) => (
|
||||
<div key={device.id} className="text-muted-foreground">
|
||||
{buildDeviceLabel(device)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Mật khẩu</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Nhập mật khẩu để xác nhận"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2 sm:gap-3">
|
||||
<Button variant="outline" onClick={handleClose} disabled={isExecuting}>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button
|
||||
variant={DANGER_TYPES.has(activeType ?? CommandType.RESTART) ? "destructive" : "default"}
|
||||
onClick={handleConfirm}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
{isExecuting ? "Đang gửi..." : "Xác nhận"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -205,7 +205,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<div className="flex gap-2 flex-nowrap overflow-x-auto items-center whitespace-nowrap">
|
||||
{Object.values(CommandType)
|
||||
.filter((value) => typeof value === "number")
|
||||
.map((commandType) => renderCommandButton(commandType as CommandType))}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,25 @@
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Monitor, Wifi, WifiOff, Loader2, Maximize2, X } from "lucide-react";
|
||||
import { useState, type MouseEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FolderStatusPopover } from "../folder-status-popover";
|
||||
import { useGetClientFolderStatusForDevice } from "@/hooks/queries";
|
||||
import type { ClientFolderStatus } from "@/types/folder";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
|
||||
import { buildMeshProxyUrl } from "@/config/api";
|
||||
import { BASE_URL } from "@/config/api";
|
||||
import { toast } from "sonner";
|
||||
export function ComputerCard({
|
||||
device,
|
||||
position,
|
||||
folderStatus,
|
||||
isCheckingFolder,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
device: any | undefined;
|
||||
position: number;
|
||||
folderStatus?: ClientFolderStatus;
|
||||
isCheckingFolder?: boolean;
|
||||
isSelected?: boolean;
|
||||
onSelect?: (event: MouseEvent<HTMLElement>) => void;
|
||||
}) {
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [showRemote, setShowRemote] = useState(false);
|
||||
|
|
@ -31,16 +27,12 @@ export function ComputerCard({
|
|||
|
||||
if (!device) {
|
||||
return (
|
||||
<div className="flex flex-col items-stretch rounded-lg border border-dashed border-muted-foreground/20 overflow-hidden w-[88px]">
|
||||
<div className="flex items-center justify-between px-1.5 py-1 bg-muted/30">
|
||||
<span className="text-[11px] font-bold text-muted-foreground/50 leading-none">
|
||||
{position}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center py-2 gap-0.5">
|
||||
<Monitor className="h-5 w-5 text-muted-foreground/20" />
|
||||
<span className="text-[10px] text-muted-foreground/40">Trống</span>
|
||||
<div className="relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 border-dashed border-muted-foreground/30 bg-muted/20">
|
||||
<div className="absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold bg-muted text-muted-foreground">
|
||||
{position}
|
||||
</div>
|
||||
<Monitor className="h-8 w-8 mb-1 text-muted-foreground/40" />
|
||||
<span className="text-xs text-muted-foreground">Trống</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -60,7 +52,11 @@ export function ComputerCard({
|
|||
const response = await getRemoteDesktopUrl(device.id);
|
||||
const originalUrl = new URL(response.url);
|
||||
const pathAndQuery = originalUrl.pathname + originalUrl.search;
|
||||
const proxyUrlFull = buildMeshProxyUrl(pathAndQuery);
|
||||
const cleanPath = pathAndQuery.startsWith("/")
|
||||
? pathAndQuery.substring(1)
|
||||
: pathAndQuery;
|
||||
const baseWithoutApi = BASE_URL.replace("/api", "");
|
||||
const proxyUrlFull = `${baseWithoutApi}/api/meshcentral/proxy/${cleanPath}`;
|
||||
|
||||
setProxyUrl(proxyUrlFull);
|
||||
setShowRemote(true);
|
||||
|
|
@ -224,68 +220,53 @@ export function ComputerCard({
|
|||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"flex flex-col items-stretch w-[88px] rounded-lg border-2 overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer select-none",
|
||||
"relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 transition-all hover:scale-105 cursor-pointer",
|
||||
isOffline
|
||||
? "border-red-400 bg-white hover:border-red-500"
|
||||
: "border-emerald-400 bg-white hover:border-emerald-500",
|
||||
isSelected && "ring-2 ring-primary ring-offset-1 ring-offset-background"
|
||||
? "bg-red-50 border-red-300 hover:border-red-400 hover:shadow-lg"
|
||||
: "bg-green-50 border-green-300 hover:border-green-400 hover:shadow-lg"
|
||||
)}
|
||||
>
|
||||
{/* Top bar: position + folder status */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-1.5 py-1",
|
||||
isOffline ? "bg-red-500" : "bg-emerald-500"
|
||||
"absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold",
|
||||
isOffline ? "bg-red-500 text-white" : "bg-green-500 text-white"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="text-[11px] font-bold text-white leading-none"
|
||||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
{position}
|
||||
</span>
|
||||
{!isOffline && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="[&_button]:p-0 [&_button]:rounded [&_button]:hover:bg-emerald-400 [&_svg]:h-3.5 [&_svg]:w-3.5 [&_svg]:text-white"
|
||||
>
|
||||
<FolderStatusPopover
|
||||
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
|
||||
status={folderStatus}
|
||||
isLoading={isCheckingFolder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{position}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col items-center justify-center gap-0.5 py-2 px-1">
|
||||
<Monitor
|
||||
className={cn(
|
||||
"h-5 w-5",
|
||||
isOffline ? "text-red-300" : "text-emerald-400"
|
||||
{/* Folder Status Icon */}
|
||||
{device && !isOffline && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<FolderStatusPopover
|
||||
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
|
||||
status={folderStatus}
|
||||
isLoading={isCheckingFolder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
|
||||
{firstNetworkInfo?.ipAddress && (
|
||||
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
||||
{firstNetworkInfo.ipAddress}
|
||||
{agentVersion && (
|
||||
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
||||
v{agentVersion}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{firstNetworkInfo?.ipAddress && (
|
||||
<div className="text-[9px] font-mono text-center leading-tight w-full truncate text-muted-foreground px-0.5">
|
||||
{firstNetworkInfo.ipAddress}
|
||||
</div>
|
||||
)}
|
||||
{agentVersion && (
|
||||
<div className="text-[9px] font-mono text-center text-muted-foreground/60 leading-tight">
|
||||
v{agentVersion}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-semibold leading-none mt-0.5",
|
||||
isOffline ? "text-red-500" : "text-emerald-600"
|
||||
"text-xs font-medium",
|
||||
isOffline ? "text-red-700" : "text-green-700"
|
||||
)}
|
||||
>
|
||||
{isOffline ? "Off" : "On"}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
|
|
|
|||
|
|
@ -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 { buildGoogleOAuthLoginUrl, buildMicrosoftSsoLoginUrl, login } from "@/services/auth.service";
|
||||
import { buildGoogleOAuthLoginUrl, login } from "@/services/auth.service";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { Route } from "@/routes/(auth)/login";
|
||||
|
|
@ -46,22 +46,12 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
|||
|
||||
const handleGoogleLogin = () => {
|
||||
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
||||
returnUrl.searchParams.set("provider", "google");
|
||||
if (search.redirect) {
|
||||
returnUrl.searchParams.set("redirect", search.redirect);
|
||||
}
|
||||
window.location.assign(buildGoogleOAuthLoginUrl(returnUrl.toString()));
|
||||
};
|
||||
|
||||
const handleMicrosoftLogin = () => {
|
||||
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
||||
returnUrl.searchParams.set("provider", "azuread");
|
||||
if (search.redirect) {
|
||||
returnUrl.searchParams.set("redirect", search.redirect);
|
||||
}
|
||||
window.location.assign(buildMicrosoftSsoLoginUrl(returnUrl.toString()));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
|
@ -143,15 +133,6 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
|||
</svg>
|
||||
Đăng nhập với Google
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleMicrosoftLogin}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
|
||||
<rect x="2" y="2" width="9" height="9" fill="#F35325" />
|
||||
<rect x="13" y="2" width="9" height="9" fill="#81BC06" />
|
||||
<rect x="2" y="13" width="9" height="9" fill="#05A6F0" />
|
||||
<rect x="13" y="13" width="9" height="9" fill="#FFBA08" />
|
||||
</svg>
|
||||
Đăng nhập với Microsoft
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
|
|||
const [isDone, setIsDone] = useState(false);
|
||||
|
||||
// Match server allowed extensions
|
||||
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1", ".zip"];
|
||||
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1"];
|
||||
const isFileValid = (file: File) => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
import { useMemo, type MouseEvent } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||
|
||||
interface DeviceGridCompactProps {
|
||||
devices: any[];
|
||||
selectedIds?: string[];
|
||||
onSelectDevice?: (
|
||||
deviceId: string,
|
||||
index: number,
|
||||
event: MouseEvent<HTMLElement>
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function DeviceGridCompact({
|
||||
devices,
|
||||
selectedIds = [],
|
||||
onSelectDevice,
|
||||
}: DeviceGridCompactProps) {
|
||||
const getMachineNumber = useMachineNumber();
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
return [...devices]
|
||||
.map((device, index) => ({
|
||||
device,
|
||||
index,
|
||||
number: getMachineNumber(device?.id || ""),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const aNumber = a.number > 0 ? a.number : Number.MAX_SAFE_INTEGER;
|
||||
const bNumber = b.number > 0 ? b.number : Number.MAX_SAFE_INTEGER;
|
||||
if (aNumber !== bNumber) return aNumber - bNumber;
|
||||
return a.index - b.index;
|
||||
});
|
||||
}, [devices, getMachineNumber]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-2">
|
||||
{items.map((item, index) => {
|
||||
const device = item.device;
|
||||
const position = item.number > 0 ? item.number : item.index + 1;
|
||||
const ipAddress = device?.networkInfos?.[0]?.ipAddress;
|
||||
const version = device?.version;
|
||||
const titleParts = [`#${position}`];
|
||||
if (ipAddress) titleParts.push(`IP: ${ipAddress}`);
|
||||
if (version) titleParts.push(`v${version}`);
|
||||
const isOffline = device?.isOffline;
|
||||
const isSelected = selectedSet.has(device?.id);
|
||||
|
||||
// last 2 octets of IP for compact display
|
||||
const shortIp = ipAddress
|
||||
? ipAddress.split(".").slice(-2).join(".")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={device?.id || `device-${item.index}`}
|
||||
type="button"
|
||||
title={titleParts.join(" | ")}
|
||||
onClick={(event) => {
|
||||
if (!device?.id) return;
|
||||
onSelectDevice?.(device.id, index, event);
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-stretch rounded-lg border-2 overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer",
|
||||
isOffline
|
||||
? "border-red-400 bg-white hover:border-red-500"
|
||||
: "border-emerald-400 bg-white hover:border-emerald-500",
|
||||
isSelected &&
|
||||
"ring-2 ring-primary ring-offset-1 ring-offset-background"
|
||||
)}
|
||||
>
|
||||
{/* Top color bar with position number */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-1.5 py-1",
|
||||
isOffline ? "bg-red-500" : "bg-emerald-500"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="text-[11px] font-bold text-white leading-none"
|
||||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
{position}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full border border-white/40",
|
||||
isOffline ? "bg-red-200" : "bg-emerald-200"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col items-center justify-center gap-0.5 py-1.5 px-1">
|
||||
{shortIp ? (
|
||||
<span
|
||||
className="font-mono text-muted-foreground truncate w-full text-center leading-tight"
|
||||
style={{ fontSize: "9px" }}
|
||||
>
|
||||
{shortIp}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] text-muted-foreground/50">—</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-semibold leading-none",
|
||||
isOffline ? "text-red-500" : "text-emerald-600"
|
||||
)}
|
||||
>
|
||||
{isOffline ? "Off" : "On"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { useMemo, type MouseEvent } from "react";
|
||||
import { Monitor, DoorOpen } from "lucide-react";
|
||||
import { ComputerCard } from "../cards/computer-card";
|
||||
import { useMachineNumber } from "../../hooks/useMachineNumber";
|
||||
|
|
@ -8,146 +7,68 @@ export function DeviceGrid({
|
|||
devices,
|
||||
folderStatuses,
|
||||
isCheckingFolder,
|
||||
totalSeats,
|
||||
selectedIds = [],
|
||||
onSelectDevice,
|
||||
}: {
|
||||
devices: any[];
|
||||
folderStatuses?: Map<string, ClientFolderStatus>;
|
||||
isCheckingFolder?: boolean;
|
||||
totalSeats?: number;
|
||||
selectedIds?: string[];
|
||||
onSelectDevice?: (
|
||||
deviceId: string,
|
||||
index: number,
|
||||
event: MouseEvent<HTMLElement>
|
||||
) => void;
|
||||
}) {
|
||||
const getMachineNumber = useMachineNumber();
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
const parsedDevices = devices
|
||||
.map((device, index) => ({
|
||||
device,
|
||||
index,
|
||||
number: getMachineNumber(device.id || ""),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const aNumber = a.number > 0 ? a.number : Number.MAX_SAFE_INTEGER;
|
||||
const bNumber = b.number > 0 ? b.number : Number.MAX_SAFE_INTEGER;
|
||||
const deviceMap = new Map<number, any>();
|
||||
|
||||
if (aNumber !== bNumber) {
|
||||
return aNumber - bNumber;
|
||||
}
|
||||
devices.forEach((device) => {
|
||||
const number = getMachineNumber(device.id || "");
|
||||
if (number > 0 && number <= 40) deviceMap.set(number, device);
|
||||
});
|
||||
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
const orderedDevices = parsedDevices.map((item, orderIndex) => ({
|
||||
...item,
|
||||
orderIndex,
|
||||
}));
|
||||
|
||||
const seatCount =
|
||||
typeof totalSeats === "number" && totalSeats > 0 ? totalSeats : orderedDevices.length;
|
||||
const rightCapacity = Math.ceil(seatCount / 2);
|
||||
const inRangeCount = orderedDevices.filter(
|
||||
(item) => item.number > 0 && item.number <= seatCount
|
||||
).length;
|
||||
const useThresholdSplit =
|
||||
seatCount > 0 && inRangeCount >= Math.ceil(orderedDevices.length * 0.6);
|
||||
|
||||
let rightDevices = orderedDevices;
|
||||
let leftDevices: typeof orderedDevices = [];
|
||||
|
||||
if (useThresholdSplit) {
|
||||
rightDevices = orderedDevices.filter(
|
||||
(item) => item.number > 0 && item.number <= rightCapacity
|
||||
);
|
||||
leftDevices = orderedDevices.filter((item) => item.number > rightCapacity);
|
||||
|
||||
const unassigned = orderedDevices.filter(
|
||||
(item) => item.number <= 0 || item.number > seatCount
|
||||
);
|
||||
leftDevices = [...leftDevices, ...unassigned];
|
||||
} else {
|
||||
const splitIndex = Math.ceil(orderedDevices.length / 2);
|
||||
rightDevices = orderedDevices.slice(0, splitIndex);
|
||||
leftDevices = orderedDevices.slice(splitIndex);
|
||||
}
|
||||
|
||||
const renderDevice = (item: (typeof orderedDevices)[number]) => {
|
||||
const device = item.device;
|
||||
const position = item.number > 0 ? item.number : item.index + 1;
|
||||
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
||||
const folderStatus = folderStatuses?.get(macAddress);
|
||||
const isSelected = device?.id ? selectedSet.has(device.id) : false;
|
||||
|
||||
return (
|
||||
<ComputerCard
|
||||
key={device?.id || `device-${item.index}`}
|
||||
device={device}
|
||||
position={position}
|
||||
folderStatus={folderStatus}
|
||||
isCheckingFolder={isCheckingFolder}
|
||||
isSelected={isSelected}
|
||||
onSelect={(event) => {
|
||||
if (!device?.id) return;
|
||||
onSelectDevice?.(device.id, item.orderIndex, event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const columnsPerSide = 4;
|
||||
const chunkRows = <T,>(items: T[], size: number) => {
|
||||
const rows: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
rows.push(items.slice(i, i + size));
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
const leftRows = chunkRows(leftDevices, columnsPerSide);
|
||||
const rightRows = chunkRows(rightDevices, columnsPerSide);
|
||||
const totalRows = Math.max(leftRows.length, rightRows.length);
|
||||
|
||||
const renderPlaceholder = (key: string) => (
|
||||
<div key={key} className="w-24 h-24 shrink-0" aria-hidden="true" />
|
||||
);
|
||||
const totalRows = 5;
|
||||
|
||||
const renderRow = (rowIndex: number) => {
|
||||
const leftRow = leftRows[rowIndex] ?? [];
|
||||
const rightRow = rightRows[rowIndex] ?? [];
|
||||
const leftFill = Math.max(0, columnsPerSide - leftRow.length);
|
||||
const rightFill = Math.max(0, columnsPerSide - rightRow.length);
|
||||
|
||||
// Cả 2 panel đều mirror: số nhỏ nhất sát divider, tăng ra ngoài
|
||||
// Right: [8,7,6,5,4,3,2,1 | divider] Left: [divider | 9,10,11,12,13,14,15,16]
|
||||
// Nhìn từ bàn GV (phải) sang trái: 1,2,3,4,... liên tục
|
||||
const rightRowReversed = [...rightRow].reverse();
|
||||
const leftRowReversed = [...leftRow].reverse();
|
||||
// Đảo ngược: 21-40 sang trái, 1-20 sang phải
|
||||
const leftStart = 21 + (totalRows - 1 - rowIndex) * 4;
|
||||
const rightStart = (totalRows - 1 - rowIndex) * 4 + 1;
|
||||
|
||||
return (
|
||||
<div key={`row-${rowIndex}`} className="flex items-center justify-center gap-3">
|
||||
{/* Left panel: số lớn sát divider, giảm ra ngoài trái */}
|
||||
<div className="flex items-center gap-3">
|
||||
{Array.from({ length: leftFill }).map((_, i) =>
|
||||
renderPlaceholder(`left-pad-${rowIndex}-${i}`)
|
||||
)}
|
||||
{leftRowReversed.map(renderDevice)}
|
||||
<div key={rowIndex} className="flex items-center justify-center gap-3">
|
||||
{/* Bên trái (21–40) */}
|
||||
{Array.from({ length: 4 }).map((_, i) => {
|
||||
const pos = leftStart + (3 - i);
|
||||
const device = deviceMap.get(pos);
|
||||
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
||||
const folderStatus = folderStatuses?.get(macAddress);
|
||||
|
||||
return (
|
||||
<ComputerCard
|
||||
key={pos}
|
||||
device={device}
|
||||
position={pos}
|
||||
folderStatus={folderStatus}
|
||||
isCheckingFolder={isCheckingFolder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Đường chia giữa */}
|
||||
<div className="w-32 flex items-center justify-center">
|
||||
<div className="h-px w-full bg-border border-t-2 border-dashed" />
|
||||
</div>
|
||||
|
||||
<div className="w-10 flex items-center justify-center">
|
||||
<div className="h-10 w-px bg-border border-l-2 border-dashed" />
|
||||
</div>
|
||||
{/* Bên phải (1–20) */}
|
||||
{Array.from({ length: 4 }).map((_, i) => {
|
||||
const pos = rightStart + (3 - i);
|
||||
const device = deviceMap.get(pos);
|
||||
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
||||
const folderStatus = folderStatuses?.get(macAddress);
|
||||
|
||||
{/* Right panel: số 1 sát divider, tăng ra ngoài phải */}
|
||||
<div className="flex items-center gap-3">
|
||||
{rightRowReversed.map(renderDevice)}
|
||||
{Array.from({ length: rightFill }).map((_, i) =>
|
||||
renderPlaceholder(`right-pad-${rowIndex}-${i}`)
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<ComputerCard
|
||||
key={pos}
|
||||
device={device}
|
||||
position={pos}
|
||||
folderStatus={folderStatus}
|
||||
isCheckingFolder={isCheckingFolder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -155,7 +76,7 @@ export function DeviceGrid({
|
|||
return (
|
||||
<div className="px-0.5 py-8 space-y-6">
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: totalRows }).map((_, i) => renderRow(totalRows - 1 - i))}
|
||||
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 pb-6 border-b-2 border-dashed">
|
||||
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
import type { Room } from "@/types/room";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RoomListPanelProps {
|
||||
rooms: Room[];
|
||||
activeRoomName?: string;
|
||||
onSelectRoom: (roomName: string) => void;
|
||||
}
|
||||
|
||||
const formatDeviceCount = (value: number) => {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
if (value < 1000) return String(value);
|
||||
const k = value / 1000;
|
||||
const needsDecimal = value < 10000 && value % 1000 !== 0;
|
||||
const formatted = k.toFixed(needsDecimal ? 1 : 0).replace(/\.0$/, "");
|
||||
return `${formatted}k`;
|
||||
};
|
||||
|
||||
export function RoomListPanel({
|
||||
rooms,
|
||||
activeRoomName,
|
||||
onSelectRoom,
|
||||
}: RoomListPanelProps) {
|
||||
const sortedRooms = useMemo(() => {
|
||||
return [...rooms].sort((a, b) => {
|
||||
const nameA = String(a?.name ?? "");
|
||||
const nameB = String(b?.name ?? "");
|
||||
return nameA.localeCompare(nameB, "vi", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
});
|
||||
}, [rooms]);
|
||||
|
||||
return (
|
||||
<div className="w-[200px] shrink-0 overflow-hidden rounded-xl border bg-background shadow-sm">
|
||||
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Danh sách phòng
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{rooms.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<ScrollArea className="h-[calc(100vh-240px)] p-2">
|
||||
<div className="space-y-1.5">
|
||||
{sortedRooms.length === 0 && (
|
||||
<div className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
Chưa có phòng
|
||||
</div>
|
||||
)}
|
||||
{sortedRooms.map((room) => {
|
||||
const isActive = room.name === activeRoomName;
|
||||
const hasOffline = room.numberOfOfflineDevices > 0;
|
||||
return (
|
||||
<button
|
||||
key={room.name}
|
||||
type="button"
|
||||
onClick={() => onSelectRoom(room.name)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left text-sm transition-colors",
|
||||
isActive
|
||||
? "border-border/60 bg-background text-foreground shadow-sm"
|
||||
: "border-transparent text-muted-foreground hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
hasOffline ? "bg-red-500" : "bg-green-500"
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 truncate font-medium text-foreground">
|
||||
{room.name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{formatDeviceCount(room.numberOfDevices)}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,8 +5,6 @@ import {
|
|||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { useMemo, useRef, type MouseEvent } from "react";
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -18,7 +16,6 @@ import {
|
|||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||
import { FolderStatusPopover } from "../folder-status-popover";
|
||||
|
|
@ -26,13 +23,6 @@ import { FolderStatusPopover } from "../folder-status-popover";
|
|||
interface DeviceTableProps {
|
||||
devices: any[];
|
||||
isCheckingFolder?: boolean;
|
||||
selectedIds?: string[];
|
||||
onToggleDevice?: (
|
||||
deviceId: string,
|
||||
index: number,
|
||||
event: MouseEvent<HTMLElement>
|
||||
) => void;
|
||||
onToggleAll?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -41,46 +31,10 @@ interface DeviceTableProps {
|
|||
export function DeviceTable({
|
||||
devices,
|
||||
isCheckingFolder,
|
||||
selectedIds = [],
|
||||
onToggleDevice,
|
||||
onToggleAll,
|
||||
}: DeviceTableProps) {
|
||||
const getMachineNumber = useMachineNumber();
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
const allSelected = devices.length > 0 && devices.every((d) => selectedSet.has(d.id));
|
||||
const someSelected = devices.some((d) => selectedSet.has(d.id));
|
||||
|
||||
const selectionEnabled = Boolean(onToggleDevice || onToggleAll);
|
||||
const selectionColumn: ColumnDef<any> = {
|
||||
id: "select",
|
||||
header: () => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
||||
onCheckedChange={(value) => onToggleAll?.(value === true)}
|
||||
aria-label="Chọn tất cả"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const device = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={selectedSet.has(device.id)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleDevice?.(device.id, row.index, event);
|
||||
}}
|
||||
aria-label="Chọn thiết bị"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
...(selectionEnabled ? [selectionColumn] : []),
|
||||
{
|
||||
header: "STT",
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -154,11 +108,11 @@ export function DeviceTable({
|
|||
key={idx}
|
||||
className="flex items-center gap-2 text-sm font-mono px-2 py-1 rounded bg-muted/30"
|
||||
>
|
||||
<span className="text-primary">*</span>
|
||||
<span className="text-primary">•</span>
|
||||
<code className="bg-background px-2 py-0.5 rounded">
|
||||
{info.macAddress ?? "-"}
|
||||
</code>
|
||||
<span className="text-muted-foreground">-></span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<code className="bg-background px-2 py-0.5 rounded">
|
||||
{info.ipAddress ?? "-"}
|
||||
</code>
|
||||
|
|
@ -217,24 +171,8 @@ export function DeviceTable({
|
|||
initialState: { pagination: { pageSize: 16 } },
|
||||
});
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const rows = table.getRowModel().rows;
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 72,
|
||||
overscan: 8,
|
||||
});
|
||||
const virtualRows = rowVirtualizer.getVirtualItems() as VirtualItem[];
|
||||
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
|
||||
const paddingBottom =
|
||||
virtualRows.length > 0
|
||||
? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end
|
||||
: 0;
|
||||
const columnCount = table.getVisibleLeafColumns().length;
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="max-h-[600px] overflow-y-auto">
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
|
@ -248,40 +186,15 @@ export function DeviceTable({
|
|||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paddingTop > 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columnCount}
|
||||
className="p-0"
|
||||
style={{ height: `${paddingTop}px` }}
|
||||
/>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="hover:bg-muted/50 transition-colors">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-4">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)}
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className="hover:bg-muted/50 transition-colors"
|
||||
style={{ height: `${virtualRow.size}px` }}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-4">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{paddingBottom > 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columnCount}
|
||||
className="p-0"
|
||||
style={{ height: `${paddingBottom}px` }}
|
||||
/>
|
||||
</TableRow>
|
||||
)}
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
|
@ -6,23 +7,50 @@ function ScrollArea({
|
|||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<div
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative overflow-auto", className)}
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={className} {...props} />
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
|
|
|
|||
|
|
@ -1,39 +1,12 @@
|
|||
const isDev = import.meta.env.MODE === "development";
|
||||
|
||||
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
|
||||
|
||||
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 || import.meta.env.VITE_API_MESH_DEV || "")
|
||||
: (import.meta.env.VITE_API_MESH || "");
|
||||
|
||||
export const buildMeshProxyUrl = (meshPathAndQuery: string) => {
|
||||
const cleanPath = meshPathAndQuery.startsWith("/")
|
||||
? meshPathAndQuery.substring(1)
|
||||
: meshPathAndQuery;
|
||||
const proxyPath = `/api/meshcentral/proxy/${cleanPath}`;
|
||||
|
||||
// If an explicit mesh host is configured, always use it.
|
||||
// This allows forcing proxy URLs to https://<IP>:<port>/api/meshcentral/proxy/...
|
||||
if (BASE_MESH_URL && BASE_MESH_URL.startsWith("http")) {
|
||||
return `${trimTrailingSlash(BASE_MESH_URL)}${proxyPath}`;
|
||||
}
|
||||
|
||||
// In development, BASE_URL is usually absolute (e.g. http://localhost:5218/api).
|
||||
// Build an absolute proxy URL to backend so iframe requests do not hit Vite dev server.
|
||||
if (BASE_URL.startsWith("http")) {
|
||||
const apiBase = trimTrailingSlash(BASE_URL);
|
||||
const backendOrigin = apiBase.endsWith("/api")
|
||||
? apiBase.slice(0, -4)
|
||||
: apiBase;
|
||||
return `${backendOrigin}${proxyPath}`;
|
||||
}
|
||||
|
||||
return proxyPath;
|
||||
};
|
||||
? import.meta.env.VITE_API_MESH_DEV
|
||||
: "/meshapi";
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
AUTH: {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import * as deviceCommService from "@/services/device-comm.service";
|
||||
import type { DeviceHealthCheck } from "@/types/device";
|
||||
import type { ClientFolderStatus } from "@/types/folder";
|
||||
import type { Room } from "@/types/room";
|
||||
|
||||
const DEVICE_COMM_QUERY_KEYS = {
|
||||
all: ["device-comm"] as const,
|
||||
|
|
@ -30,7 +29,7 @@ export function useGetAllDevices(enabled = true) {
|
|||
* Hook để lấy danh sách phòng
|
||||
*/
|
||||
export function useGetRoomList(enabled = true) {
|
||||
return useQuery<Room[]>({
|
||||
return useQuery({
|
||||
queryKey: DEVICE_COMM_QUERY_KEYS.roomList(),
|
||||
queryFn: () => deviceCommService.getRoomList(),
|
||||
enabled,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import axios from "axios";
|
||||
import { exchangeCodeByProvider } from "@/services/auth.service";
|
||||
import { exchangeOAuthCode } from "@/services/auth.service";
|
||||
import type { LoginResponse } from "@/types/auth";
|
||||
|
||||
const inFlightExchanges = new Map<string, Promise<LoginResponse>>();
|
||||
|
|
@ -18,21 +18,18 @@ export const Route = createFileRoute("/(auth)/oauth/callback/")({
|
|||
function OAuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const search = Route.useSearch() as { code?: string; redirect?: string; provider?: string };
|
||||
const search = Route.useSearch() as { code?: string; redirect?: string };
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isExchanging, setIsExchanging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const code = search.code;
|
||||
const provider = (search.provider || "").toLowerCase();
|
||||
if (!code) {
|
||||
setErrorMessage("OAuth code is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
const exchangeId = `${provider || "auto"}:${code}`;
|
||||
|
||||
if (consumedCodes.has(exchangeId)) {
|
||||
if (consumedCodes.has(code)) {
|
||||
setErrorMessage("Mã đăng nhập đã được sử dụng. Vui lòng đăng nhập lại.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -41,10 +38,10 @@ function OAuthCallbackPage() {
|
|||
setIsExchanging(true);
|
||||
|
||||
let cancelled = false;
|
||||
let exchangePromise = inFlightExchanges.get(exchangeId);
|
||||
let exchangePromise = inFlightExchanges.get(code);
|
||||
if (!exchangePromise) {
|
||||
exchangePromise = exchangeCodeByProvider(code, provider);
|
||||
inFlightExchanges.set(exchangeId, exchangePromise);
|
||||
exchangePromise = exchangeOAuthCode(code);
|
||||
inFlightExchanges.set(code, exchangePromise);
|
||||
}
|
||||
|
||||
exchangePromise
|
||||
|
|
@ -56,7 +53,7 @@ function OAuthCallbackPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
consumedCodes.add(exchangeId);
|
||||
consumedCodes.add(code);
|
||||
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("username", data.username || "");
|
||||
|
|
@ -77,14 +74,14 @@ function OAuthCallbackPage() {
|
|||
if (cancelled) return;
|
||||
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
consumedCodes.add(exchangeId);
|
||||
consumedCodes.add(code);
|
||||
setErrorMessage("Mã đăng nhập đã hết hạn hoặc đã được sử dụng. Vui lòng đăng nhập lại.");
|
||||
return;
|
||||
}
|
||||
setErrorMessage("OAuth exchange failed.");
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightExchanges.delete(exchangeId);
|
||||
inFlightExchanges.delete(code);
|
||||
if (!cancelled) {
|
||||
setIsExchanging(false);
|
||||
}
|
||||
|
|
@ -93,7 +90,7 @@ function OAuthCallbackPage() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [auth, navigate, search.code, search.provider, search.redirect]);
|
||||
}, [auth, 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">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CommandSubmitTemplate,
|
||||
type SendCommandOptions,
|
||||
} from "@/template/command-submit-template";
|
||||
import { CommandSubmitTemplate } from "@/template/command-submit-template";
|
||||
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
|
||||
import {
|
||||
useGetCommandList,
|
||||
|
|
@ -70,17 +67,6 @@ function CommandPage() {
|
|||
const deleteCommandMutation = useDeleteCommand();
|
||||
const sendCommandMutation = useSendCommand();
|
||||
|
||||
const formInitialData = selectedCommand
|
||||
? {
|
||||
commandName: selectedCommand.commandName,
|
||||
commandType: selectedCommand.commandType,
|
||||
description: selectedCommand.description,
|
||||
commandContent: selectedCommand.commandContent,
|
||||
qos: selectedCommand.qoS,
|
||||
isRetained: selectedCommand.isRetained,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Columns for command table
|
||||
const columns: ColumnDef<CommandRegistry>[] = [
|
||||
{
|
||||
|
|
@ -239,10 +225,7 @@ function CommandPage() {
|
|||
};
|
||||
|
||||
// Handle execute commands from list
|
||||
const handleExecuteSelected = async (
|
||||
targets: string[],
|
||||
options?: SendCommandOptions
|
||||
) => {
|
||||
const handleExecuteSelected = async (targets: string[]) => {
|
||||
if (!table) {
|
||||
toast.error("Không thể lấy thông tin bảng!");
|
||||
return;
|
||||
|
|
@ -262,8 +245,6 @@ function CommandPage() {
|
|||
Command: row.original.commandContent,
|
||||
QoS: row.original.qoS,
|
||||
IsRetained: row.original.isRetained,
|
||||
TtlMinutes: options?.ttlMinutes,
|
||||
SendTime: options?.sendTime,
|
||||
};
|
||||
|
||||
await sendCommandMutation.mutateAsync({
|
||||
|
|
@ -282,11 +263,7 @@ function CommandPage() {
|
|||
};
|
||||
|
||||
// Handle execute custom command
|
||||
const handleExecuteCustom = async (
|
||||
targets: string[],
|
||||
commandData: ShellCommandData,
|
||||
options?: SendCommandOptions
|
||||
) => {
|
||||
const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => {
|
||||
try {
|
||||
for (const target of targets) {
|
||||
// API expects PascalCase directly
|
||||
|
|
@ -294,8 +271,6 @@ function CommandPage() {
|
|||
Command: commandData.command,
|
||||
QoS: commandData.qos,
|
||||
IsRetained: commandData.isRetained,
|
||||
TtlMinutes: options?.ttlMinutes,
|
||||
SendTime: options?.sendTime,
|
||||
};
|
||||
await sendCommandMutation.mutateAsync({
|
||||
roomName: target,
|
||||
|
|
@ -328,7 +303,7 @@ function CommandPage() {
|
|||
<CommandRegistryForm
|
||||
onSubmit={handleFormSubmit}
|
||||
closeDialog={() => setIsDialogOpen(false)}
|
||||
initialData={formInitialData}
|
||||
initialData={selectedCommand || undefined}
|
||||
title={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
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/")({
|
||||
|
|
@ -34,8 +35,15 @@ function RemoteControlPage() {
|
|||
onSuccess: (data) => {
|
||||
setErrorMessage(null);
|
||||
|
||||
console.log("[RemoteControl] URL:", data.url);
|
||||
setProxyUrl(data.url);
|
||||
// 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) => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
} from "@tanstack/react-router";
|
||||
import { useMemo } from "react";
|
||||
import { useGetClientFolderStatus } from "@/hooks/queries";
|
||||
import type { ClientFolderStatus, ExtraFile, MissingFile } from "@/types/folder";
|
||||
import type { ClientFolderStatus } from "@/types/folder";
|
||||
import FolderStatusTemplate from "@/template/folder-status-template";
|
||||
import {
|
||||
createColumnHelper,
|
||||
|
|
@ -36,46 +36,8 @@ function RouteComponent() {
|
|||
roomName as string,
|
||||
);
|
||||
|
||||
const sortedFolderStatusList = useMemo(() => {
|
||||
return [...(folderStatusList ?? [])].sort((a, b) => {
|
||||
const aRoom = (a as ClientFolderStatus & { roomName?: string }).roomName;
|
||||
const bRoom = (b as ClientFolderStatus & { roomName?: string }).roomName;
|
||||
|
||||
if (aRoom || bRoom) {
|
||||
return (aRoom ?? "").localeCompare(bRoom ?? "", "vi", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
return (a.deviceId ?? "").localeCompare(b.deviceId ?? "", "vi", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
});
|
||||
}, [folderStatusList]);
|
||||
|
||||
const columnHelper = createColumnHelper<ClientFolderStatus>();
|
||||
|
||||
const renderFileList = (files?: MissingFile[] | ExtraFile[]) => {
|
||||
if (!files || files.length === 0) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-32 overflow-auto space-y-1">
|
||||
{files.map((file) => (
|
||||
<div key={`${file.folderPath}/${file.fileName}`} className="text-xs">
|
||||
<div className="font-mono break-all">{file.fileName}</div>
|
||||
<div className="text-muted-foreground break-all">
|
||||
{file.folderPath}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("deviceId", {
|
||||
|
|
@ -84,13 +46,14 @@ function RouteComponent() {
|
|||
}),
|
||||
columnHelper.display({
|
||||
id: "missing",
|
||||
header: "File thiếu",
|
||||
cell: (info) => renderFileList(info.row.original.missingFiles),
|
||||
header: "Số lượng file thiếu",
|
||||
cell: (info) =>
|
||||
(info.row.original.missingFiles?.length ?? 0).toString(),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "extra",
|
||||
header: "File thừa",
|
||||
cell: (info) => renderFileList(info.row.original.extraFiles),
|
||||
header: "Số lượng file thừa",
|
||||
cell: (info) => (info.row.original.extraFiles?.length ?? 0).toString(),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "current",
|
||||
|
|
@ -117,7 +80,7 @@ function RouteComponent() {
|
|||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: sortedFolderStatusList,
|
||||
data: folderStatusList ?? [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
|
@ -125,7 +88,7 @@ function RouteComponent() {
|
|||
return (
|
||||
<FolderStatusTemplate
|
||||
roomName={roomName as string}
|
||||
data={sortedFolderStatusList}
|
||||
data={folderStatusList}
|
||||
isLoading={isLoading}
|
||||
onBack={() =>
|
||||
navigate({ to: "/rooms/$roomName/", params: { roomName } } as any)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,14 @@
|
|||
import { createFileRoute, useParams, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LayoutGrid, TableIcon, Monitor, FolderCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useGetDeviceFromRoom, useGetRoomList } from "@/hooks/queries";
|
||||
import { useGetDeviceFromRoom } from "@/hooks/queries";
|
||||
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
||||
import { DeviceGrid } from "@/components/grids/device-grid";
|
||||
import { DeviceGridCompact } from "@/components/grids/device-grid-compact";
|
||||
import { DeviceTable } from "@/components/tables/device-table";
|
||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||
import { CommandActionButtons } from "@/components/buttons/command-action-buttons";
|
||||
import { DeviceActionBar } from "@/components/bars/device-action-bar";
|
||||
|
||||
export const Route = createFileRoute("/_auth/rooms/$roomName/")({
|
||||
head: ({ params }) => ({
|
||||
|
|
@ -34,174 +25,69 @@ export const Route = createFileRoute("/_auth/rooms/$roomName/")({
|
|||
|
||||
function RoomDetailPage() {
|
||||
const { roomName } = useParams({ from: "/_auth/rooms/$roomName/" });
|
||||
const [viewMode, setViewMode] = useState<"grid" | "table" | "map">("map");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "on" | "off">("all");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
||||
|
||||
// SSE real-time updates
|
||||
useDeviceEvents(roomName);
|
||||
|
||||
// Folder status from SS
|
||||
const { data: devices = [] } = useGetDeviceFromRoom(roomName);
|
||||
const { data: roomData = [] } = useGetRoomList();
|
||||
|
||||
const parseMachineNumber = useMachineNumber();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sortedDevices = useMemo(() => {
|
||||
return [...devices].sort((a, b) => {
|
||||
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
|
||||
});
|
||||
}, [devices, parseMachineNumber]);
|
||||
|
||||
const currentRoom = roomData.find((room) => room.name === roomName);
|
||||
const totalSeats = currentRoom?.numberOfDevices;
|
||||
const deviceCount = sortedDevices.length;
|
||||
const offlineCount = sortedDevices.filter((device) => device.isOffline).length;
|
||||
const onlineCount = Math.max(0, deviceCount - offlineCount);
|
||||
|
||||
const mapDisabled = deviceCount > 200;
|
||||
const forceTable = deviceCount > 2000;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearch(searchInput.trim());
|
||||
}, 300);
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchInput]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds([]);
|
||||
setLastSelectedIndex(null);
|
||||
}, [roomName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (forceTable && viewMode !== "table") {
|
||||
setViewMode("table");
|
||||
}
|
||||
}, [forceTable, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapDisabled && viewMode === "map") {
|
||||
setViewMode("grid");
|
||||
}
|
||||
}, [mapDisabled, viewMode]);
|
||||
|
||||
const filteredDevices = useMemo(() => {
|
||||
const query = debouncedSearch.toLowerCase();
|
||||
return sortedDevices.filter((device) => {
|
||||
if (statusFilter === "on" && device.isOffline) return false;
|
||||
if (statusFilter === "off" && !device.isOffline) return false;
|
||||
|
||||
if (!query) return true;
|
||||
|
||||
const ipAddress = device.networkInfos?.[0]?.ipAddress ?? "";
|
||||
const macAddress = device.networkInfos?.[0]?.macAddress ?? "";
|
||||
const id = device.id ?? "";
|
||||
const haystack = `${id} ${ipAddress} ${macAddress}`.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [sortedDevices, statusFilter, debouncedSearch]);
|
||||
|
||||
const filteredDeviceIds = useMemo(
|
||||
() => filteredDevices.map((device) => device.id),
|
||||
[filteredDevices]
|
||||
);
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
const selectedDevices = useMemo(
|
||||
() => devices.filter((device) => selectedSet.has(device.id)),
|
||||
[devices, selectedSet]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds((prev) => prev.filter((id) => devices.some((d) => d.id === id)));
|
||||
}, [devices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSelectedIndex !== null && lastSelectedIndex >= filteredDeviceIds.length) {
|
||||
setLastSelectedIndex(null);
|
||||
}
|
||||
}, [filteredDeviceIds.length, lastSelectedIndex]);
|
||||
|
||||
const handleSelectDevice = (
|
||||
deviceId: string,
|
||||
index: number,
|
||||
event: MouseEvent<HTMLElement>
|
||||
) => {
|
||||
const isShift = event.shiftKey;
|
||||
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isShift && lastSelectedIndex !== null) {
|
||||
const start = Math.min(lastSelectedIndex, index);
|
||||
const end = Math.max(lastSelectedIndex, index);
|
||||
const rangeIds = filteredDeviceIds.slice(start, end + 1);
|
||||
rangeIds.forEach((id) => next.add(id));
|
||||
return Array.from(next);
|
||||
}
|
||||
|
||||
if (next.has(deviceId)) {
|
||||
next.delete(deviceId);
|
||||
} else {
|
||||
next.add(deviceId);
|
||||
}
|
||||
return Array.from(next);
|
||||
});
|
||||
|
||||
setLastSelectedIndex(index);
|
||||
};
|
||||
|
||||
const handleToggleAll = (checked: boolean) => {
|
||||
setSelectedIds(checked ? filteredDeviceIds : []);
|
||||
setLastSelectedIndex(null);
|
||||
};
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelectedIds([]);
|
||||
setLastSelectedIndex(null);
|
||||
};
|
||||
|
||||
const chipOptions = [
|
||||
{ key: "all" as const, label: `Tất cả (${deviceCount})` },
|
||||
{ key: "on" as const, label: `On (${onlineCount})` },
|
||||
{ key: "off" as const, label: `Off (${offlineCount})` },
|
||||
];
|
||||
const sortedDevices = [...devices].sort((a, b) => {
|
||||
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full px-6">
|
||||
<div className="space-y-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-muted/50 space-y-3 pb-3">
|
||||
{/* Row 1: Title + stats */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Monitor className="h-5 w-5" />
|
||||
<CardTitle>Phòng {roomName}</CardTitle>
|
||||
</div>
|
||||
<div className="w-full px-6 space-y-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-muted/50 space-y-4">
|
||||
{/* Hàng 1: Thông tin phòng và controls */}
|
||||
<div className="flex items-center justify-between w-full gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
<CardTitle>Danh sách thiết bị phòng {roomName}</CardTitle>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="outline" className="text-[11px] text-emerald-700 border-emerald-200 bg-emerald-50">
|
||||
On {onlineCount}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[11px] text-red-600 border-red-200 bg-red-50">
|
||||
Off {offlineCount}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-[11px]">
|
||||
Tổng {deviceCount}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border shrink-0">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("grid")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Sơ đồ
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "table" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("table")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<TableIcon className="h-4 w-4" />
|
||||
Bảng
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Command buttons + folder button cùng hàng */}
|
||||
{/* Hàng 2: Thực thi lệnh */}
|
||||
<div className="flex items-center justify-between w-full gap-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
Thực thi lệnh
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 justify-end">
|
||||
{/* Command Action Buttons */}
|
||||
{devices.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<>
|
||||
<CommandActionButtons roomName={roomName} />
|
||||
<div className="h-5 w-px bg-border shrink-0" />
|
||||
|
||||
<div className="h-8 w-px bg-border" />
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate({
|
||||
|
|
@ -216,158 +102,33 @@ function RoomDetailPage() {
|
|||
<FolderCheck className="h-4 w-4" />
|
||||
Kiểm tra thư mục Setup
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Row 3: View toggle + search + filter */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* View mode */}
|
||||
<div className="flex items-center gap-1 rounded-lg border bg-background p-0.5">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("grid")}
|
||||
className="h-7 gap-1.5 px-2.5 text-xs"
|
||||
disabled={forceTable}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
Lưới
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={viewMode === "table" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("table")}
|
||||
className="h-7 gap-1.5 px-2.5 text-xs"
|
||||
>
|
||||
<TableIcon className="h-3.5 w-3.5" />
|
||||
Bảng
|
||||
</Button>
|
||||
|
||||
{mapDisabled || forceTable ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2.5 text-xs"
|
||||
disabled
|
||||
>
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
Sơ đồ
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Sơ đồ chỉ hỗ trợ phòng <= 200 thiết bị.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant={viewMode === "map" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("map")}
|
||||
className="h-7 gap-1.5 px-2.5 text-xs"
|
||||
>
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
Sơ đồ
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="Tìm theo số máy, IP hoặc mã thiết bị"
|
||||
className="h-8 w-56 shrink-0"
|
||||
/>
|
||||
|
||||
{/* Status filter */}
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
{chipOptions.map((chip) => (
|
||||
<Button
|
||||
key={chip.key}
|
||||
variant={statusFilter === chip.key ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setStatusFilter(chip.key)}
|
||||
>
|
||||
{chip.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{devices.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Không có thiết bị</h3>
|
||||
<p className="text-muted-foreground text-center max-w-sm">
|
||||
Phòng này chưa có thiết bị nào được kết nối.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 p-4">
|
||||
{forceTable && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||
Phòng này có {deviceCount} thiết bị. Chỉ hiển thị chế độ
|
||||
Bảng để đảm bảo hiệu năng.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!forceTable && viewMode === "grid" && deviceCount > 500 && (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
|
||||
Phòng này có nhiều thiết bị. Bạn có thể chuyển sang chế độ
|
||||
Bảng để thao tác nhanh hơn.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredDevices.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Monitor className="h-10 w-10 text-muted-foreground mb-3" />
|
||||
<h3 className="text-base font-semibold mb-1">
|
||||
Không có thiết bị phù hợp
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-center max-w-sm">
|
||||
Hãy thử thay đổi từ khóa hoặc bộ lọc trạng thái.
|
||||
</p>
|
||||
</div>
|
||||
) : forceTable || viewMode === "table" ? (
|
||||
<DeviceTable
|
||||
devices={filteredDevices}
|
||||
selectedIds={selectedIds}
|
||||
onToggleDevice={handleSelectDevice}
|
||||
onToggleAll={handleToggleAll}
|
||||
/>
|
||||
) : viewMode === "map" ? (
|
||||
<DeviceGrid
|
||||
devices={filteredDevices}
|
||||
totalSeats={totalSeats}
|
||||
selectedIds={selectedIds}
|
||||
onSelectDevice={handleSelectDevice}
|
||||
/>
|
||||
) : (
|
||||
<DeviceGridCompact
|
||||
devices={filteredDevices}
|
||||
selectedIds={selectedIds}
|
||||
onSelectDevice={handleSelectDevice}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DeviceActionBar
|
||||
roomName={roomName}
|
||||
selectedDevices={selectedDevices}
|
||||
onClearSelection={handleClearSelection}
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
{devices.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Không có thiết bị</h3>
|
||||
<p className="text-muted-foreground text-center max-w-sm">
|
||||
Phòng này chưa có thiết bị nào được kết nối.
|
||||
</p>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<DeviceGrid
|
||||
devices={sortedDevices}
|
||||
/>
|
||||
) : (
|
||||
<DeviceTable
|
||||
devices={sortedDevices}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,16 +35,6 @@ export function buildGoogleOAuthLoginUrl(returnUrl: string): string {
|
|||
return buildOAuthLoginUrl("google", returnUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Microsoft SSO login URL (legacy endpoint)
|
||||
* @param returnUrl - FE callback url
|
||||
*/
|
||||
export function buildMicrosoftSsoLoginUrl(returnUrl: string): string {
|
||||
const base = API_ENDPOINTS.AUTH.SSO_LOGIN;
|
||||
const encoded = encodeURIComponent(returnUrl);
|
||||
return `${base}?returnUrl=${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange one-time OAuth code for login payload
|
||||
* @param code - one-time code
|
||||
|
|
@ -85,27 +75,6 @@ export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
|
|||
return exchangeOAuthCode(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange one-time code by provider without breaking existing flows.
|
||||
* - azuread/microsoft: force legacy SSO exchange endpoint
|
||||
* - default: use OAuth exchange flow
|
||||
*/
|
||||
export async function exchangeCodeByProvider(
|
||||
code: string,
|
||||
provider?: string
|
||||
): Promise<LoginResponse> {
|
||||
const providerKey = (provider || "").toLowerCase();
|
||||
if (providerKey === "microsoft" || providerKey === "azuread") {
|
||||
const response = await rawAxios.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
||||
{ code }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return exchangeOAuthCode(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Đăng xuất
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import axios from "@/config/axios";
|
||||
import { API_ENDPOINTS } from "@/config/api";
|
||||
import type { DeviceHealthCheck } from "@/types/device";
|
||||
import type { Room } from "@/types/room";
|
||||
|
||||
/**
|
||||
* Lấy tất cả thiết bị trong hệ thống
|
||||
|
|
@ -14,8 +13,8 @@ export async function getAllDevices(): Promise<any[]> {
|
|||
/**
|
||||
* Lấy danh sách phòng
|
||||
*/
|
||||
export async function getRoomList(): Promise<Room[]> {
|
||||
const response = await axios.get<Room[]>(API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST);
|
||||
export async function getRoomList(): Promise<any[]> {
|
||||
const response = await axios.get<any[]>(API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
|
|||
import { DeleteMenu } from "@/components/menu/delete-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
||||
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
|
||||
import { UploadVersionForm } from "@/components/forms/upload-file-form";
|
||||
|
|
@ -80,22 +80,6 @@ export function AppManagerTemplate<TData>({
|
|||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogType, setDialogType] = useState<"room" | "device" | "download-room" | "download-device" | null>(null);
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
const firstItem = data?.[0] as { fileName?: string } | undefined;
|
||||
if (!firstItem || typeof firstItem.fileName !== "string") {
|
||||
return data;
|
||||
}
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
const aName = (a as { fileName?: string }).fileName ?? "";
|
||||
const bName = (b as { fileName?: string }).fileName ?? "";
|
||||
return aName.localeCompare(bName, "vi", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const openRoomDialog = () => {
|
||||
if (rooms.length > 0 && onUpdate) {
|
||||
setDialogType("room");
|
||||
|
|
@ -165,7 +149,7 @@ export function AppManagerTemplate<TData>({
|
|||
|
||||
<CardContent>
|
||||
<VersionTable
|
||||
data={sortedData}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
columns={columns}
|
||||
onTableInit={onTableInit}
|
||||
|
|
|
|||
|
|
@ -11,13 +11,9 @@ import { Plus, CommandIcon, Zap, Building2 } from "lucide-react";
|
|||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { VersionTable } from "@/components/tables/version-table";
|
||||
import {
|
||||
|
|
@ -32,11 +28,6 @@ import { getDeviceFromRoom } from "@/services/device-comm.service";
|
|||
import type { Room } from "@/types/room";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface SendCommandOptions {
|
||||
ttlMinutes?: number;
|
||||
sendTime?: Date;
|
||||
}
|
||||
|
||||
interface CommandSubmitTemplateProps<T extends { id: number }> {
|
||||
title: string;
|
||||
description: string;
|
||||
|
|
@ -60,15 +51,8 @@ interface CommandSubmitTemplateProps<T extends { id: number }> {
|
|||
onTableInit?: (table: any) => void;
|
||||
|
||||
// Execute
|
||||
onExecuteSelected?: (
|
||||
targets: string[],
|
||||
options?: SendCommandOptions
|
||||
) => void | Promise<void>;
|
||||
onExecuteCustom?: (
|
||||
targets: string[],
|
||||
commandData: ShellCommandData,
|
||||
options?: SendCommandOptions
|
||||
) => void | Promise<void>;
|
||||
onExecuteSelected?: (targets: string[]) => void;
|
||||
onExecuteCustom?: (targets: string[], commandData: ShellCommandData) => void;
|
||||
isExecuting?: boolean;
|
||||
|
||||
// Execution scope
|
||||
|
|
@ -129,158 +113,17 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
|||
const [customCommand, setCustomCommand] = useState("");
|
||||
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
|
||||
const [customRetained, setCustomRetained] = useState(false);
|
||||
const [table, setTable] = useState<any>();
|
||||
const [dialogOpen2, setDialogOpen2] = useState(false);
|
||||
const [dialogType, setDialogType] = useState<
|
||||
"room" | "device" | "room-custom" | "device-custom" | null
|
||||
>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [ttlMinutesInput, setTtlMinutesInput] = useState("");
|
||||
const [sendTimeInput, setSendTimeInput] = useState("");
|
||||
const [confirmError, setConfirmError] = useState<string | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<
|
||||
| { type: "selected"; targets: string[] }
|
||||
| { type: "custom"; targets: string[]; commandData: ShellCommandData }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const handleTableInit = (t: any) => {
|
||||
setTable(t);
|
||||
onTableInit?.(t);
|
||||
};
|
||||
|
||||
const closeTargetDialog = () => {
|
||||
setDialogOpen2(false);
|
||||
setDialogType(null);
|
||||
};
|
||||
|
||||
const resetConfirmState = () => {
|
||||
setConfirmOpen(false);
|
||||
setPendingAction(null);
|
||||
setTtlMinutesInput("");
|
||||
setSendTimeInput("");
|
||||
setConfirmError(null);
|
||||
};
|
||||
|
||||
const formatLocalSendTime = (date: Date) => {
|
||||
const pad2 = (value: number) => String(value).padStart(2, "0");
|
||||
const hh = pad2(date.getHours());
|
||||
const mm = pad2(date.getMinutes());
|
||||
const ss = pad2(date.getSeconds());
|
||||
const dd = pad2(date.getDate());
|
||||
const MM = pad2(date.getMonth() + 1);
|
||||
const yy = pad2(date.getFullYear() % 100);
|
||||
return `${hh}:${mm}:${ss} ${dd}/${MM}/${yy}`;
|
||||
};
|
||||
|
||||
const openConfirmForSelected = (targets: string[]) => {
|
||||
setPendingAction({ type: "selected", targets });
|
||||
setSendTimeInput(formatLocalSendTime(new Date()));
|
||||
setConfirmOpen(true);
|
||||
setConfirmError(null);
|
||||
};
|
||||
|
||||
const openConfirmForCustom = (
|
||||
targets: string[],
|
||||
commandData: ShellCommandData
|
||||
) => {
|
||||
setPendingAction({ type: "custom", targets, commandData });
|
||||
setSendTimeInput(formatLocalSendTime(new Date()));
|
||||
setConfirmOpen(true);
|
||||
setConfirmError(null);
|
||||
};
|
||||
|
||||
const parseSendOptions = (): {
|
||||
options?: SendCommandOptions;
|
||||
error?: string;
|
||||
} => {
|
||||
const options: SendCommandOptions = {};
|
||||
const ttlTrimmed = ttlMinutesInput.trim();
|
||||
if (ttlTrimmed) {
|
||||
const parsedTtl = Number(ttlTrimmed);
|
||||
if (!Number.isInteger(parsedTtl) || parsedTtl < 0) {
|
||||
return { error: "TtlMinutes phải là số nguyên >= 0." };
|
||||
}
|
||||
options.ttlMinutes = parsedTtl;
|
||||
}
|
||||
|
||||
const sendTrimmed = sendTimeInput.trim();
|
||||
if (sendTrimmed) {
|
||||
const match =
|
||||
/^(\d{2}):(\d{2}):(\d{2})\s+(\d{2})\/(\d{2})\/(\d{2})$/.exec(
|
||||
sendTrimmed
|
||||
);
|
||||
if (!match) {
|
||||
return { error: "SendTime không đúng định dạng HH:MM:SS DD/MM/YY." };
|
||||
}
|
||||
|
||||
const [, hh, mm, ss, dd, MM, yy] = match;
|
||||
const hour = Number(hh);
|
||||
const minute = Number(mm);
|
||||
const second = Number(ss);
|
||||
const day = Number(dd);
|
||||
const month = Number(MM);
|
||||
const year = 2000 + Number(yy);
|
||||
|
||||
if (
|
||||
hour > 23 ||
|
||||
minute > 59 ||
|
||||
second > 59 ||
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > 31
|
||||
) {
|
||||
return { error: "SendTime không hợp lệ." };
|
||||
}
|
||||
|
||||
const date = new Date(year, month - 1, day, hour, minute, second);
|
||||
if (
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month - 1 ||
|
||||
date.getDate() !== day ||
|
||||
date.getHours() !== hour ||
|
||||
date.getMinutes() !== minute ||
|
||||
date.getSeconds() !== second
|
||||
) {
|
||||
return { error: "SendTime không hợp lệ." };
|
||||
}
|
||||
|
||||
options.sendTime = date;
|
||||
}
|
||||
|
||||
return { options };
|
||||
};
|
||||
|
||||
const handleConfirmSend = async () => {
|
||||
if (!pendingAction) return;
|
||||
|
||||
const { options, error } = parseSendOptions();
|
||||
if (error) {
|
||||
setConfirmError(error);
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (pendingAction.type === "selected") {
|
||||
await onExecuteSelected?.(pendingAction.targets, options);
|
||||
} else {
|
||||
await onExecuteCustom?.(
|
||||
pendingAction.targets,
|
||||
pendingAction.commandData,
|
||||
options
|
||||
);
|
||||
setCustomCommand("");
|
||||
setCustomQoS(0);
|
||||
setCustomRetained(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Confirm send error:", e);
|
||||
} finally {
|
||||
resetConfirmState();
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
};
|
||||
|
||||
const openRoomDialog = () => {
|
||||
if (rooms.length > 0 && onExecuteSelected) {
|
||||
setDialogType("room");
|
||||
|
|
@ -295,6 +138,21 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
|||
}
|
||||
};
|
||||
|
||||
const handleExecuteSelected = () => {
|
||||
if (!table) {
|
||||
toast.error("Không thể lấy thông tin bảng!");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedRows = table.getSelectedRowModel().rows;
|
||||
if (selectedRows.length === 0) {
|
||||
toast.error("Vui lòng chọn ít nhất một mục để thực thi!");
|
||||
return;
|
||||
}
|
||||
|
||||
onExecuteSelected?.([]);
|
||||
};
|
||||
|
||||
const handleExecuteAll = () => {
|
||||
if (!onExecuteSelected) return;
|
||||
try {
|
||||
|
|
@ -302,7 +160,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
|||
typeof room === "string" ? room : room.name,
|
||||
);
|
||||
const allTargets = [...roomNames, ...devices];
|
||||
openConfirmForSelected(allTargets);
|
||||
onExecuteSelected(allTargets);
|
||||
} catch (e) {
|
||||
console.error("Execute error:", e);
|
||||
}
|
||||
|
|
@ -320,7 +178,14 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
|||
isRetained: customRetained,
|
||||
};
|
||||
|
||||
openConfirmForCustom(targets, shellCommandData);
|
||||
try {
|
||||
await onExecuteCustom?.(targets, shellCommandData);
|
||||
setCustomCommand("");
|
||||
setCustomQoS(0);
|
||||
setCustomRetained(false);
|
||||
} catch (e) {
|
||||
console.error("Execute custom command error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteCustomAll = () => {
|
||||
|
|
@ -478,7 +343,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
|||
<SelectDialog
|
||||
open={dialogOpen2}
|
||||
onClose={() => {
|
||||
closeTargetDialog();
|
||||
setDialogOpen2(false);
|
||||
setDialogType(null);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}}
|
||||
title="Chọn phòng"
|
||||
description="Chọn các phòng để thực thi lệnh"
|
||||
|
|
@ -487,11 +354,13 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
|||
onConfirm={async (selectedItems) => {
|
||||
if (!onExecuteSelected) return;
|
||||
try {
|
||||
openConfirmForSelected(selectedItems);
|
||||
await onExecuteSelected(selectedItems);
|
||||
} catch (e) {
|
||||
console.error("Execute error:", e);
|
||||
} finally {
|
||||
closeTargetDialog();
|
||||
setDialogOpen2(false);
|
||||
setDialogType(null);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -502,21 +371,27 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
|||
<DeviceSearchDialog
|
||||
open={dialogOpen2 && dialogType === "device"}
|
||||
onClose={() => {
|
||||
closeTargetDialog();
|
||||
setDialogOpen2(false);
|
||||
setDialogType(null);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}}
|
||||
rooms={rooms}
|
||||
fetchDevices={getDeviceFromRoom}
|
||||
onSelect={async (deviceIds) => {
|
||||
if (!onExecuteSelected) {
|
||||
closeTargetDialog();
|
||||
setDialogOpen2(false);
|
||||
setDialogType(null);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
openConfirmForSelected(deviceIds);
|
||||
await onExecuteSelected(deviceIds);
|
||||
} catch (e) {
|
||||
console.error("Execute error:", e);
|
||||
} finally {
|
||||
closeTargetDialog();
|
||||
setDialogOpen2(false);
|
||||
setDialogType(null);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -527,7 +402,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
|||
<SelectDialog
|
||||
open={dialogOpen2}
|
||||
onClose={() => {
|
||||
closeTargetDialog();
|
||||
setDialogOpen2(false);
|
||||
setDialogType(null);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}}
|
||||
title="Chọn phòng"
|
||||
description="Chọn các phòng để thực thi lệnh tùy chỉnh"
|
||||
|
|
@ -539,7 +416,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
|||
} catch (e) {
|
||||
console.error("Execute error:", e);
|
||||
} finally {
|
||||
closeTargetDialog();
|
||||
setDialogOpen2(false);
|
||||
setDialogType(null);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -550,7 +429,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
|||
<DeviceSearchDialog
|
||||
open={dialogOpen2 && dialogType === "device-custom"}
|
||||
onClose={() => {
|
||||
closeTargetDialog();
|
||||
setDialogOpen2(false);
|
||||
setDialogType(null);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}}
|
||||
rooms={rooms}
|
||||
fetchDevices={getDeviceFromRoom}
|
||||
|
|
@ -560,67 +441,14 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
|||
} catch (e) {
|
||||
console.error("Execute error:", e);
|
||||
} finally {
|
||||
closeTargetDialog();
|
||||
setDialogOpen2(false);
|
||||
setDialogType(null);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dialog xác nhận gửi lệnh */}
|
||||
<Dialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) resetConfirmState();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Xác nhận gửi lệnh</DialogTitle>
|
||||
<DialogDescription>
|
||||
Vui lòng xác nhận và nhập thông tin bổ sung trước khi gửi.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ttl-minutes">TtlMinutes (phút)</Label>
|
||||
<Input
|
||||
id="ttl-minutes"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="VD: 60"
|
||||
value={ttlMinutesInput}
|
||||
onChange={(e) => setTtlMinutesInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="send-time">SendTime</Label>
|
||||
<Input
|
||||
id="send-time"
|
||||
placeholder="HH:MM:SS DD/MM/YY (VD: 14:30:00 25/05/26)"
|
||||
value={sendTimeInput}
|
||||
onChange={(e) => setSendTimeInput(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Để trống nếu muốn gửi ngay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{confirmError && (
|
||||
<p className="text-sm text-red-600">{confirmError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={resetConfirmState}>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button onClick={handleConfirmSend}>Xác nhận gửi</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog for add/edit */}
|
||||
{formContent && (
|
||||
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export type UserProfile = {
|
|||
name: string;
|
||||
role: string;
|
||||
roleId: number;
|
||||
accessRooms: string[];
|
||||
accessRooms: number[];
|
||||
createdAt?: string | null;
|
||||
createdBy?: string | null;
|
||||
updatedAt?: string | null;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user