big update
This commit is contained in:
parent
5ebcde08e9
commit
2b3c9a6c28
154
package-lock.json
generated
154
package-lock.json
generated
|
|
@ -26,6 +26,7 @@
|
||||||
"@tanstack/react-router": "^1.121.2",
|
"@tanstack/react-router": "^1.121.2",
|
||||||
"@tanstack/react-router-devtools": "^1.121.2",
|
"@tanstack/react-router-devtools": "^1.121.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.26",
|
||||||
"@tanstack/router-plugin": "^1.121.2",
|
"@tanstack/router-plugin": "^1.121.2",
|
||||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
|
@ -1119,9 +1120,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.10",
|
"version": "1.19.14",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
"integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==",
|
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
},
|
},
|
||||||
|
|
@ -3620,6 +3621,22 @@
|
||||||
"react-dom": ">=16.8"
|
"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": {
|
"node_modules/@tanstack/router-core": {
|
||||||
"version": "1.129.8",
|
"version": "1.129.8",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
|
||||||
|
|
@ -3784,6 +3801,15 @@
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"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": {
|
"node_modules/@tanstack/virtual-file-routes": {
|
||||||
"version": "1.129.7",
|
"version": "1.129.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.129.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.129.7.tgz",
|
||||||
|
|
@ -4408,13 +4434,37 @@
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.6",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
||||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.16.0",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/babel-dead-code-elimination": {
|
"node_modules/babel-dead-code-elimination": {
|
||||||
|
|
@ -5234,9 +5284,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/devalue": {
|
"node_modules/devalue": {
|
||||||
"version": "5.6.4",
|
"version": "5.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="
|
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="
|
||||||
},
|
},
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "8.0.3",
|
"version": "8.0.3",
|
||||||
|
|
@ -5561,11 +5611,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-rate-limit": {
|
"node_modules/express-rate-limit": {
|
||||||
"version": "8.3.1",
|
"version": "8.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||||
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
|
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "10.1.0"
|
"ip-address": "^10.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
|
|
@ -5598,9 +5648,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -5674,9 +5724,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
|
|
@ -5975,9 +6025,9 @@
|
||||||
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="
|
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.9",
|
"version": "4.12.23",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
||||||
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
|
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
|
|
@ -6120,9 +6170,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
}
|
}
|
||||||
|
|
@ -6874,16 +6924,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"nanoid": "bin/nanoid.cjs"
|
"nanoid": "bin/nanoid.cjs"
|
||||||
},
|
},
|
||||||
|
|
@ -7197,9 +7246,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|
@ -7214,9 +7263,8 @@
|
||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
|
|
@ -7299,9 +7347,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/psl": {
|
"node_modules/psl": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
|
|
@ -7324,9 +7375,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -8806,9 +8857,9 @@
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "13.0.0",
|
"version": "13.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
|
|
@ -8847,9 +8898,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|
@ -9201,11 +9252,10 @@
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.18.3",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
"@tanstack/react-router": "^1.121.2",
|
"@tanstack/react-router": "^1.121.2",
|
||||||
"@tanstack/react-router-devtools": "^1.121.2",
|
"@tanstack/react-router-devtools": "^1.121.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.26",
|
||||||
"@tanstack/router-plugin": "^1.121.2",
|
"@tanstack/router-plugin": "^1.121.2",
|
||||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
|
|
||||||
226
src/components/bars/device-action-bar.tsx
Normal file
226
src/components/bars/device-action-bar.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-2 flex-nowrap overflow-x-auto items-center whitespace-nowrap">
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
{Object.values(CommandType)
|
{Object.values(CommandType)
|
||||||
.filter((value) => typeof value === "number")
|
.filter((value) => typeof value === "number")
|
||||||
.map((commandType) => renderCommandButton(commandType as CommandType))}
|
.map((commandType) => renderCommandButton(commandType as CommandType))}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Monitor, Wifi, WifiOff, Loader2, Maximize2, X } from "lucide-react";
|
import { Monitor, Wifi, WifiOff, Loader2, Maximize2, X } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState, type MouseEvent } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FolderStatusPopover } from "../folder-status-popover";
|
import { FolderStatusPopover } from "../folder-status-popover";
|
||||||
import { useGetClientFolderStatusForDevice } from "@/hooks/queries";
|
import { useGetClientFolderStatusForDevice } from "@/hooks/queries";
|
||||||
|
|
@ -15,11 +15,15 @@ export function ComputerCard({
|
||||||
position,
|
position,
|
||||||
folderStatus,
|
folderStatus,
|
||||||
isCheckingFolder,
|
isCheckingFolder,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
device: any | undefined;
|
device: any | undefined;
|
||||||
position: number;
|
position: number;
|
||||||
folderStatus?: ClientFolderStatus;
|
folderStatus?: ClientFolderStatus;
|
||||||
isCheckingFolder?: boolean;
|
isCheckingFolder?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onSelect?: (event: MouseEvent<HTMLElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
const [showRemote, setShowRemote] = useState(false);
|
const [showRemote, setShowRemote] = useState(false);
|
||||||
|
|
@ -27,12 +31,16 @@ export function ComputerCard({
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
return (
|
return (
|
||||||
<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="flex flex-col items-stretch rounded-lg border border-dashed border-muted-foreground/20 overflow-hidden w-[88px]">
|
||||||
<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">
|
<div className="flex items-center justify-between px-1.5 py-1 bg-muted/30">
|
||||||
{position}
|
<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>
|
</div>
|
||||||
<Monitor className="h-8 w-8 mb-1 text-muted-foreground/40" />
|
|
||||||
<span className="text-xs text-muted-foreground">Trống</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -216,53 +224,68 @@ export function ComputerCard({
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<div
|
<div
|
||||||
|
onClick={onSelect}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 transition-all hover:scale-105 cursor-pointer",
|
"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",
|
||||||
isOffline
|
isOffline
|
||||||
? "bg-red-50 border-red-300 hover:border-red-400 hover:shadow-lg"
|
? "border-red-400 bg-white hover:border-red-500"
|
||||||
: "bg-green-50 border-green-300 hover:border-green-400 hover:shadow-lg"
|
: "border-emerald-400 bg-white hover:border-emerald-500",
|
||||||
|
isSelected && "ring-2 ring-primary ring-offset-1 ring-offset-background"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* Top bar: position + folder status */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold",
|
"flex items-center justify-between px-1.5 py-1",
|
||||||
isOffline ? "bg-red-500 text-white" : "bg-green-500 text-white"
|
isOffline ? "bg-red-500" : "bg-emerald-500"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{position}
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Folder Status Icon */}
|
{/* Body */}
|
||||||
{device && !isOffline && (
|
<div className="flex flex-col items-center justify-center gap-0.5 py-2 px-1">
|
||||||
<div className="absolute -top-2 -right-2">
|
<Monitor
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs font-medium",
|
"h-5 w-5",
|
||||||
isOffline ? "text-red-700" : "text-green-700"
|
isOffline ? "text-red-300" : "text-emerald-400"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{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
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-semibold leading-none mt-0.5",
|
||||||
|
isOffline ? "text-red-500" : "text-emerald-600"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isOffline ? "Off" : "On"}
|
{isOffline ? "Off" : "On"}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
|
||||||
122
src/components/grids/device-grid-compact.tsx
Normal file
122
src/components/grids/device-grid-compact.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
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,3 +1,4 @@
|
||||||
|
import { useMemo, type MouseEvent } from "react";
|
||||||
import { Monitor, DoorOpen } from "lucide-react";
|
import { Monitor, DoorOpen } from "lucide-react";
|
||||||
import { ComputerCard } from "../cards/computer-card";
|
import { ComputerCard } from "../cards/computer-card";
|
||||||
import { useMachineNumber } from "../../hooks/useMachineNumber";
|
import { useMachineNumber } from "../../hooks/useMachineNumber";
|
||||||
|
|
@ -8,13 +9,22 @@ export function DeviceGrid({
|
||||||
folderStatuses,
|
folderStatuses,
|
||||||
isCheckingFolder,
|
isCheckingFolder,
|
||||||
totalSeats,
|
totalSeats,
|
||||||
|
selectedIds = [],
|
||||||
|
onSelectDevice,
|
||||||
}: {
|
}: {
|
||||||
devices: any[];
|
devices: any[];
|
||||||
folderStatuses?: Map<string, ClientFolderStatus>;
|
folderStatuses?: Map<string, ClientFolderStatus>;
|
||||||
isCheckingFolder?: boolean;
|
isCheckingFolder?: boolean;
|
||||||
totalSeats?: number;
|
totalSeats?: number;
|
||||||
|
selectedIds?: string[];
|
||||||
|
onSelectDevice?: (
|
||||||
|
deviceId: string,
|
||||||
|
index: number,
|
||||||
|
event: MouseEvent<HTMLElement>
|
||||||
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const getMachineNumber = useMachineNumber();
|
const getMachineNumber = useMachineNumber();
|
||||||
|
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||||
const parsedDevices = devices
|
const parsedDevices = devices
|
||||||
.map((device, index) => ({
|
.map((device, index) => ({
|
||||||
device,
|
device,
|
||||||
|
|
@ -32,39 +42,45 @@ export function DeviceGrid({
|
||||||
return a.index - b.index;
|
return a.index - b.index;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const orderedDevices = parsedDevices.map((item, orderIndex) => ({
|
||||||
|
...item,
|
||||||
|
orderIndex,
|
||||||
|
}));
|
||||||
|
|
||||||
const seatCount =
|
const seatCount =
|
||||||
typeof totalSeats === "number" && totalSeats > 0 ? totalSeats : parsedDevices.length;
|
typeof totalSeats === "number" && totalSeats > 0 ? totalSeats : orderedDevices.length;
|
||||||
const rightCapacity = Math.ceil(seatCount / 2);
|
const rightCapacity = Math.ceil(seatCount / 2);
|
||||||
const inRangeCount = parsedDevices.filter(
|
const inRangeCount = orderedDevices.filter(
|
||||||
(item) => item.number > 0 && item.number <= seatCount
|
(item) => item.number > 0 && item.number <= seatCount
|
||||||
).length;
|
).length;
|
||||||
const useThresholdSplit =
|
const useThresholdSplit =
|
||||||
seatCount > 0 && inRangeCount >= Math.ceil(parsedDevices.length * 0.6);
|
seatCount > 0 && inRangeCount >= Math.ceil(orderedDevices.length * 0.6);
|
||||||
|
|
||||||
let rightDevices = parsedDevices;
|
let rightDevices = orderedDevices;
|
||||||
let leftDevices: typeof parsedDevices = [];
|
let leftDevices: typeof orderedDevices = [];
|
||||||
|
|
||||||
if (useThresholdSplit) {
|
if (useThresholdSplit) {
|
||||||
rightDevices = parsedDevices.filter(
|
rightDevices = orderedDevices.filter(
|
||||||
(item) => item.number > 0 && item.number <= rightCapacity
|
(item) => item.number > 0 && item.number <= rightCapacity
|
||||||
);
|
);
|
||||||
leftDevices = parsedDevices.filter((item) => item.number > rightCapacity);
|
leftDevices = orderedDevices.filter((item) => item.number > rightCapacity);
|
||||||
|
|
||||||
const unassigned = parsedDevices.filter(
|
const unassigned = orderedDevices.filter(
|
||||||
(item) => item.number <= 0 || item.number > seatCount
|
(item) => item.number <= 0 || item.number > seatCount
|
||||||
);
|
);
|
||||||
leftDevices = [...leftDevices, ...unassigned];
|
leftDevices = [...leftDevices, ...unassigned];
|
||||||
} else {
|
} else {
|
||||||
const splitIndex = Math.ceil(parsedDevices.length / 2);
|
const splitIndex = Math.ceil(orderedDevices.length / 2);
|
||||||
rightDevices = parsedDevices.slice(0, splitIndex);
|
rightDevices = orderedDevices.slice(0, splitIndex);
|
||||||
leftDevices = parsedDevices.slice(splitIndex);
|
leftDevices = orderedDevices.slice(splitIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderDevice = (item: (typeof parsedDevices)[number]) => {
|
const renderDevice = (item: (typeof orderedDevices)[number]) => {
|
||||||
const device = item.device;
|
const device = item.device;
|
||||||
const position = item.number > 0 ? item.number : item.index + 1;
|
const position = item.number > 0 ? item.number : item.index + 1;
|
||||||
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
||||||
const folderStatus = folderStatuses?.get(macAddress);
|
const folderStatus = folderStatuses?.get(macAddress);
|
||||||
|
const isSelected = device?.id ? selectedSet.has(device.id) : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComputerCard
|
<ComputerCard
|
||||||
|
|
@ -73,6 +89,11 @@ export function DeviceGrid({
|
||||||
position={position}
|
position={position}
|
||||||
folderStatus={folderStatus}
|
folderStatus={folderStatus}
|
||||||
isCheckingFolder={isCheckingFolder}
|
isCheckingFolder={isCheckingFolder}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onSelect={(event) => {
|
||||||
|
if (!device?.id) return;
|
||||||
|
onSelectDevice?.(device.id, item.orderIndex, event);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -100,21 +121,29 @@ export function DeviceGrid({
|
||||||
const leftFill = Math.max(0, columnsPerSide - leftRow.length);
|
const leftFill = Math.max(0, columnsPerSide - leftRow.length);
|
||||||
const rightFill = Math.max(0, columnsPerSide - rightRow.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();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`row-${rowIndex}`} className="flex items-center justify-center gap-3">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
{Array.from({ length: leftFill }).map((_, i) =>
|
{Array.from({ length: leftFill }).map((_, i) =>
|
||||||
renderPlaceholder(`left-pad-${rowIndex}-${i}`)
|
renderPlaceholder(`left-pad-${rowIndex}-${i}`)
|
||||||
)}
|
)}
|
||||||
{leftRow.map(renderDevice)}
|
{leftRowReversed.map(renderDevice)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-10 flex items-center justify-center">
|
<div className="w-10 flex items-center justify-center">
|
||||||
<div className="h-10 w-px bg-border border-l-2 border-dashed" />
|
<div className="h-10 w-px bg-border border-l-2 border-dashed" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel: số 1 sát divider, tăng ra ngoài phải */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{rightRow.map(renderDevice)}
|
{rightRowReversed.map(renderDevice)}
|
||||||
{Array.from({ length: rightFill }).map((_, i) =>
|
{Array.from({ length: rightFill }).map((_, i) =>
|
||||||
renderPlaceholder(`right-pad-${rowIndex}-${i}`)
|
renderPlaceholder(`right-pad-${rowIndex}-${i}`)
|
||||||
)}
|
)}
|
||||||
|
|
@ -126,7 +155,7 @@ export function DeviceGrid({
|
||||||
return (
|
return (
|
||||||
<div className="px-0.5 py-8 space-y-6">
|
<div className="px-0.5 py-8 space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
|
{Array.from({ length: totalRows }).map((_, i) => renderRow(totalRows - 1 - i))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between px-4 pb-6 border-b-2 border-dashed">
|
<div className="flex items-center justify-between px-4 pb-6 border-b-2 border-dashed">
|
||||||
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
|
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
|
||||||
|
|
|
||||||
89
src/components/sidebars/room-list-panel.tsx
Normal file
89
src/components/sidebars/room-list-panel.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
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,6 +5,8 @@ import {
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
import { useMemo, useRef, type MouseEvent } from "react";
|
||||||
|
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -16,6 +18,7 @@ import {
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||||
import { FolderStatusPopover } from "../folder-status-popover";
|
import { FolderStatusPopover } from "../folder-status-popover";
|
||||||
|
|
@ -23,6 +26,13 @@ import { FolderStatusPopover } from "../folder-status-popover";
|
||||||
interface DeviceTableProps {
|
interface DeviceTableProps {
|
||||||
devices: any[];
|
devices: any[];
|
||||||
isCheckingFolder?: boolean;
|
isCheckingFolder?: boolean;
|
||||||
|
selectedIds?: string[];
|
||||||
|
onToggleDevice?: (
|
||||||
|
deviceId: string,
|
||||||
|
index: number,
|
||||||
|
event: MouseEvent<HTMLElement>
|
||||||
|
) => void;
|
||||||
|
onToggleAll?: (checked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -31,10 +41,46 @@ interface DeviceTableProps {
|
||||||
export function DeviceTable({
|
export function DeviceTable({
|
||||||
devices,
|
devices,
|
||||||
isCheckingFolder,
|
isCheckingFolder,
|
||||||
|
selectedIds = [],
|
||||||
|
onToggleDevice,
|
||||||
|
onToggleAll,
|
||||||
}: DeviceTableProps) {
|
}: DeviceTableProps) {
|
||||||
const getMachineNumber = useMachineNumber();
|
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>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
|
...(selectionEnabled ? [selectionColumn] : []),
|
||||||
{
|
{
|
||||||
header: "STT",
|
header: "STT",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
|
|
@ -108,11 +154,11 @@ export function DeviceTable({
|
||||||
key={idx}
|
key={idx}
|
||||||
className="flex items-center gap-2 text-sm font-mono px-2 py-1 rounded bg-muted/30"
|
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">
|
<code className="bg-background px-2 py-0.5 rounded">
|
||||||
{info.macAddress ?? "-"}
|
{info.macAddress ?? "-"}
|
||||||
</code>
|
</code>
|
||||||
<span className="text-muted-foreground">→</span>
|
<span className="text-muted-foreground">-></span>
|
||||||
<code className="bg-background px-2 py-0.5 rounded">
|
<code className="bg-background px-2 py-0.5 rounded">
|
||||||
{info.ipAddress ?? "-"}
|
{info.ipAddress ?? "-"}
|
||||||
</code>
|
</code>
|
||||||
|
|
@ -171,8 +217,24 @@ export function DeviceTable({
|
||||||
initialState: { pagination: { pageSize: 16 } },
|
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 (
|
return (
|
||||||
<div className="max-h-[600px] overflow-y-auto">
|
<div ref={parentRef} className="max-h-[600px] overflow-y-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-background z-10">
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
|
@ -186,15 +248,40 @@ export function DeviceTable({
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows.map((row) => (
|
{paddingTop > 0 && (
|
||||||
<TableRow key={row.id} className="hover:bg-muted/50 transition-colors">
|
<TableRow>
|
||||||
{row.getVisibleCells().map((cell) => (
|
<TableCell
|
||||||
<TableCell key={cell.id} className="py-4">
|
colSpan={columnCount}
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
className="p-0"
|
||||||
</TableCell>
|
style={{ height: `${paddingTop}px` }}
|
||||||
))}
|
/>
|
||||||
</TableRow>
|
</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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
@ -7,50 +6,23 @@ function ScrollArea({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<ScrollAreaPrimitive.Root
|
<div
|
||||||
data-slot="scroll-area"
|
data-slot="scroll-area"
|
||||||
className={cn("relative", className)}
|
className={cn("relative overflow-auto", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport
|
{children}
|
||||||
data-slot="scroll-area-viewport"
|
</div>
|
||||||
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({
|
function ScrollBar({
|
||||||
className,
|
className,
|
||||||
orientation = "vertical",
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={className} {...props} />
|
||||||
<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 }
|
export { ScrollArea, ScrollBar }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CommandSubmitTemplate } from "@/template/command-submit-template";
|
import {
|
||||||
|
CommandSubmitTemplate,
|
||||||
|
type SendCommandOptions,
|
||||||
|
} from "@/template/command-submit-template";
|
||||||
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
|
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
|
||||||
import {
|
import {
|
||||||
useGetCommandList,
|
useGetCommandList,
|
||||||
|
|
@ -236,7 +239,10 @@ function CommandPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle execute commands from list
|
// Handle execute commands from list
|
||||||
const handleExecuteSelected = async (targets: string[]) => {
|
const handleExecuteSelected = async (
|
||||||
|
targets: string[],
|
||||||
|
options?: SendCommandOptions
|
||||||
|
) => {
|
||||||
if (!table) {
|
if (!table) {
|
||||||
toast.error("Không thể lấy thông tin bảng!");
|
toast.error("Không thể lấy thông tin bảng!");
|
||||||
return;
|
return;
|
||||||
|
|
@ -256,6 +262,8 @@ function CommandPage() {
|
||||||
Command: row.original.commandContent,
|
Command: row.original.commandContent,
|
||||||
QoS: row.original.qoS,
|
QoS: row.original.qoS,
|
||||||
IsRetained: row.original.isRetained,
|
IsRetained: row.original.isRetained,
|
||||||
|
TtlMinutes: options?.ttlMinutes,
|
||||||
|
SendTime: options?.sendTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
await sendCommandMutation.mutateAsync({
|
await sendCommandMutation.mutateAsync({
|
||||||
|
|
@ -274,7 +282,11 @@ function CommandPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle execute custom command
|
// Handle execute custom command
|
||||||
const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => {
|
const handleExecuteCustom = async (
|
||||||
|
targets: string[],
|
||||||
|
commandData: ShellCommandData,
|
||||||
|
options?: SendCommandOptions
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
// API expects PascalCase directly
|
// API expects PascalCase directly
|
||||||
|
|
@ -282,6 +294,8 @@ function CommandPage() {
|
||||||
Command: commandData.command,
|
Command: commandData.command,
|
||||||
QoS: commandData.qos,
|
QoS: commandData.qos,
|
||||||
IsRetained: commandData.isRetained,
|
IsRetained: commandData.isRetained,
|
||||||
|
TtlMinutes: options?.ttlMinutes,
|
||||||
|
SendTime: options?.sendTime,
|
||||||
};
|
};
|
||||||
await sendCommandMutation.mutateAsync({
|
await sendCommandMutation.mutateAsync({
|
||||||
roomName: target,
|
roomName: target,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
|
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
|
||||||
import { buildMeshProxyUrl } from "@/config/api";
|
|
||||||
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/remote-control/")({
|
export const Route = createFileRoute("/_auth/remote-control/")({
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
import { createFileRoute, useParams, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useParams, useNavigate } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { LayoutGrid, TableIcon, Monitor, FolderCheck } from "lucide-react";
|
import { LayoutGrid, TableIcon, Monitor, FolderCheck } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
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, useGetRoomList } from "@/hooks/queries";
|
||||||
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
||||||
import { DeviceGrid } from "@/components/grids/device-grid";
|
import { DeviceGrid } from "@/components/grids/device-grid";
|
||||||
|
import { DeviceGridCompact } from "@/components/grids/device-grid-compact";
|
||||||
import { DeviceTable } from "@/components/tables/device-table";
|
import { DeviceTable } from "@/components/tables/device-table";
|
||||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||||
import { CommandActionButtons } from "@/components/buttons/command-action-buttons";
|
import { CommandActionButtons } from "@/components/buttons/command-action-buttons";
|
||||||
|
import { DeviceActionBar } from "@/components/bars/device-action-bar";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/rooms/$roomName/")({
|
export const Route = createFileRoute("/_auth/rooms/$roomName/")({
|
||||||
head: ({ params }) => ({
|
head: ({ params }) => ({
|
||||||
|
|
@ -25,7 +34,12 @@ export const Route = createFileRoute("/_auth/rooms/$roomName/")({
|
||||||
|
|
||||||
function RoomDetailPage() {
|
function RoomDetailPage() {
|
||||||
const { roomName } = useParams({ from: "/_auth/rooms/$roomName/" });
|
const { roomName } = useParams({ from: "/_auth/rooms/$roomName/" });
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
const [viewMode, setViewMode] = useState<"grid" | "table" | "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);
|
||||||
|
|
||||||
// SSE real-time updates
|
// SSE real-time updates
|
||||||
useDeviceEvents(roomName);
|
useDeviceEvents(roomName);
|
||||||
|
|
@ -38,60 +52,156 @@ function RoomDetailPage() {
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const sortedDevices = [...devices].sort((a, b) => {
|
const sortedDevices = useMemo(() => {
|
||||||
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
|
return [...devices].sort((a, b) => {
|
||||||
});
|
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
|
||||||
|
});
|
||||||
|
}, [devices, parseMachineNumber]);
|
||||||
|
|
||||||
const currentRoom = roomData.find((room) => room.name === roomName);
|
const currentRoom = roomData.find((room) => room.name === roomName);
|
||||||
const totalSeats = currentRoom?.numberOfDevices;
|
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})` },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-6 space-y-6">
|
<div className="w-full px-6">
|
||||||
<Card className="shadow-sm">
|
<div className="space-y-6">
|
||||||
<CardHeader className="bg-muted/50 space-y-4">
|
<Card className="shadow-sm">
|
||||||
{/* Hàng 1: Thông tin phòng và controls */}
|
<CardHeader className="bg-muted/50 space-y-3 pb-3">
|
||||||
<div className="flex items-center justify-between w-full gap-4">
|
{/* Row 1: Title + stats */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
<Monitor className="h-5 w-5" />
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<CardTitle>Danh sách thiết bị phòng {roomName}</CardTitle>
|
<Monitor className="h-5 w-5" />
|
||||||
</div>
|
<CardTitle>Phòng {roomName}</CardTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border shrink-0">
|
<div className="flex items-center gap-1.5">
|
||||||
<Button
|
<Badge variant="outline" className="text-[11px] text-emerald-700 border-emerald-200 bg-emerald-50">
|
||||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
On {onlineCount}
|
||||||
size="sm"
|
</Badge>
|
||||||
onClick={() => setViewMode("grid")}
|
<Badge variant="outline" className="text-[11px] text-red-600 border-red-200 bg-red-50">
|
||||||
className="flex items-center gap-2"
|
Off {offlineCount}
|
||||||
>
|
</Badge>
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<Badge variant="secondary" className="text-[11px]">
|
||||||
Sơ đồ
|
Tổng {deviceCount}
|
||||||
</Button>
|
</Badge>
|
||||||
<Button
|
</div>
|
||||||
variant={viewMode === "table" ? "default" : "ghost"}
|
</div>
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode("table")}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<TableIcon className="h-4 w-4" />
|
|
||||||
Bảng
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hàng 2: Thực thi lệnh */}
|
{/* Row 2: Command buttons + folder button cùng hàng */}
|
||||||
<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 && (
|
{devices.length > 0 && (
|
||||||
<>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<CommandActionButtons roomName={roomName} />
|
<CommandActionButtons roomName={roomName} />
|
||||||
|
<div className="h-5 w-px bg-border shrink-0" />
|
||||||
<div className="h-8 w-px bg-border" />
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate({
|
navigate({
|
||||||
|
|
@ -106,34 +216,158 @@ function RoomDetailPage() {
|
||||||
<FolderCheck className="h-4 w-4" />
|
<FolderCheck className="h-4 w-4" />
|
||||||
Kiểm tra thư mục Setup
|
Kiểm tra thư mục Setup
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-0">
|
{/* Row 3: View toggle + search + filter */}
|
||||||
{devices.length === 0 ? (
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
{/* View mode */}
|
||||||
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
|
<div className="flex items-center gap-1 rounded-lg border bg-background p-0.5">
|
||||||
<h3 className="text-lg font-semibold mb-2">Không có thiết bị</h3>
|
<Button
|
||||||
<p className="text-muted-foreground text-center max-w-sm">
|
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||||
Phòng này chưa có thiết bị nào được kết nối.
|
size="sm"
|
||||||
</p>
|
onClick={() => setViewMode("grid")}
|
||||||
</div>
|
className="h-7 gap-1.5 px-2.5 text-xs"
|
||||||
) : viewMode === "grid" ? (
|
disabled={forceTable}
|
||||||
<DeviceGrid
|
>
|
||||||
devices={sortedDevices}
|
<LayoutGrid className="h-3.5 w-3.5" />
|
||||||
totalSeats={totalSeats}
|
Lưới
|
||||||
/>
|
</Button>
|
||||||
) : (
|
|
||||||
<DeviceTable
|
|
||||||
devices={sortedDevices}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,13 @@ import { Plus, CommandIcon, Zap, Building2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { VersionTable } from "@/components/tables/version-table";
|
import { VersionTable } from "@/components/tables/version-table";
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,6 +32,11 @@ import { getDeviceFromRoom } from "@/services/device-comm.service";
|
||||||
import type { Room } from "@/types/room";
|
import type { Room } from "@/types/room";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export interface SendCommandOptions {
|
||||||
|
ttlMinutes?: number;
|
||||||
|
sendTime?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
interface CommandSubmitTemplateProps<T extends { id: number }> {
|
interface CommandSubmitTemplateProps<T extends { id: number }> {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -51,8 +60,15 @@ interface CommandSubmitTemplateProps<T extends { id: number }> {
|
||||||
onTableInit?: (table: any) => void;
|
onTableInit?: (table: any) => void;
|
||||||
|
|
||||||
// Execute
|
// Execute
|
||||||
onExecuteSelected?: (targets: string[]) => void;
|
onExecuteSelected?: (
|
||||||
onExecuteCustom?: (targets: string[], commandData: ShellCommandData) => void;
|
targets: string[],
|
||||||
|
options?: SendCommandOptions
|
||||||
|
) => void | Promise<void>;
|
||||||
|
onExecuteCustom?: (
|
||||||
|
targets: string[],
|
||||||
|
commandData: ShellCommandData,
|
||||||
|
options?: SendCommandOptions
|
||||||
|
) => void | Promise<void>;
|
||||||
isExecuting?: boolean;
|
isExecuting?: boolean;
|
||||||
|
|
||||||
// Execution scope
|
// Execution scope
|
||||||
|
|
@ -113,17 +129,158 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
const [customCommand, setCustomCommand] = useState("");
|
const [customCommand, setCustomCommand] = useState("");
|
||||||
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
|
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
|
||||||
const [customRetained, setCustomRetained] = useState(false);
|
const [customRetained, setCustomRetained] = useState(false);
|
||||||
const [table, setTable] = useState<any>();
|
|
||||||
const [dialogOpen2, setDialogOpen2] = useState(false);
|
const [dialogOpen2, setDialogOpen2] = useState(false);
|
||||||
const [dialogType, setDialogType] = useState<
|
const [dialogType, setDialogType] = useState<
|
||||||
"room" | "device" | "room-custom" | "device-custom" | null
|
"room" | "device" | "room-custom" | "device-custom" | null
|
||||||
>(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) => {
|
const handleTableInit = (t: any) => {
|
||||||
setTable(t);
|
|
||||||
onTableInit?.(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 = () => {
|
const openRoomDialog = () => {
|
||||||
if (rooms.length > 0 && onExecuteSelected) {
|
if (rooms.length > 0 && onExecuteSelected) {
|
||||||
setDialogType("room");
|
setDialogType("room");
|
||||||
|
|
@ -138,21 +295,6 @@ 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 = () => {
|
const handleExecuteAll = () => {
|
||||||
if (!onExecuteSelected) return;
|
if (!onExecuteSelected) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -160,7 +302,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
typeof room === "string" ? room : room.name,
|
typeof room === "string" ? room : room.name,
|
||||||
);
|
);
|
||||||
const allTargets = [...roomNames, ...devices];
|
const allTargets = [...roomNames, ...devices];
|
||||||
onExecuteSelected(allTargets);
|
openConfirmForSelected(allTargets);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Execute error:", e);
|
console.error("Execute error:", e);
|
||||||
}
|
}
|
||||||
|
|
@ -178,14 +320,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
isRetained: customRetained,
|
isRetained: customRetained,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
openConfirmForCustom(targets, shellCommandData);
|
||||||
await onExecuteCustom?.(targets, shellCommandData);
|
|
||||||
setCustomCommand("");
|
|
||||||
setCustomQoS(0);
|
|
||||||
setCustomRetained(false);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Execute custom command error:", e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExecuteCustomAll = () => {
|
const handleExecuteCustomAll = () => {
|
||||||
|
|
@ -343,9 +478,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
<SelectDialog
|
<SelectDialog
|
||||||
open={dialogOpen2}
|
open={dialogOpen2}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDialogOpen2(false);
|
closeTargetDialog();
|
||||||
setDialogType(null);
|
|
||||||
setTimeout(() => window.location.reload(), 500);
|
|
||||||
}}
|
}}
|
||||||
title="Chọn phòng"
|
title="Chọn phòng"
|
||||||
description="Chọn các phòng để thực thi lệnh"
|
description="Chọn các phòng để thực thi lệnh"
|
||||||
|
|
@ -354,13 +487,11 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
onConfirm={async (selectedItems) => {
|
onConfirm={async (selectedItems) => {
|
||||||
if (!onExecuteSelected) return;
|
if (!onExecuteSelected) return;
|
||||||
try {
|
try {
|
||||||
await onExecuteSelected(selectedItems);
|
openConfirmForSelected(selectedItems);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Execute error:", e);
|
console.error("Execute error:", e);
|
||||||
} finally {
|
} finally {
|
||||||
setDialogOpen2(false);
|
closeTargetDialog();
|
||||||
setDialogType(null);
|
|
||||||
setTimeout(() => window.location.reload(), 500);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -371,27 +502,21 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
<DeviceSearchDialog
|
<DeviceSearchDialog
|
||||||
open={dialogOpen2 && dialogType === "device"}
|
open={dialogOpen2 && dialogType === "device"}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDialogOpen2(false);
|
closeTargetDialog();
|
||||||
setDialogType(null);
|
|
||||||
setTimeout(() => window.location.reload(), 500);
|
|
||||||
}}
|
}}
|
||||||
rooms={rooms}
|
rooms={rooms}
|
||||||
fetchDevices={getDeviceFromRoom}
|
fetchDevices={getDeviceFromRoom}
|
||||||
onSelect={async (deviceIds) => {
|
onSelect={async (deviceIds) => {
|
||||||
if (!onExecuteSelected) {
|
if (!onExecuteSelected) {
|
||||||
setDialogOpen2(false);
|
closeTargetDialog();
|
||||||
setDialogType(null);
|
|
||||||
setTimeout(() => window.location.reload(), 500);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await onExecuteSelected(deviceIds);
|
openConfirmForSelected(deviceIds);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Execute error:", e);
|
console.error("Execute error:", e);
|
||||||
} finally {
|
} finally {
|
||||||
setDialogOpen2(false);
|
closeTargetDialog();
|
||||||
setDialogType(null);
|
|
||||||
setTimeout(() => window.location.reload(), 500);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -402,9 +527,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
<SelectDialog
|
<SelectDialog
|
||||||
open={dialogOpen2}
|
open={dialogOpen2}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDialogOpen2(false);
|
closeTargetDialog();
|
||||||
setDialogType(null);
|
|
||||||
setTimeout(() => window.location.reload(), 500);
|
|
||||||
}}
|
}}
|
||||||
title="Chọn phòng"
|
title="Chọn phòng"
|
||||||
description="Chọn các phòng để thực thi lệnh tùy chỉnh"
|
description="Chọn các phòng để thực thi lệnh tùy chỉnh"
|
||||||
|
|
@ -416,9 +539,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Execute error:", e);
|
console.error("Execute error:", e);
|
||||||
} finally {
|
} finally {
|
||||||
setDialogOpen2(false);
|
closeTargetDialog();
|
||||||
setDialogType(null);
|
|
||||||
setTimeout(() => window.location.reload(), 500);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -429,9 +550,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
<DeviceSearchDialog
|
<DeviceSearchDialog
|
||||||
open={dialogOpen2 && dialogType === "device-custom"}
|
open={dialogOpen2 && dialogType === "device-custom"}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDialogOpen2(false);
|
closeTargetDialog();
|
||||||
setDialogType(null);
|
|
||||||
setTimeout(() => window.location.reload(), 500);
|
|
||||||
}}
|
}}
|
||||||
rooms={rooms}
|
rooms={rooms}
|
||||||
fetchDevices={getDeviceFromRoom}
|
fetchDevices={getDeviceFromRoom}
|
||||||
|
|
@ -441,14 +560,67 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Execute error:", e);
|
console.error("Execute error:", e);
|
||||||
} finally {
|
} finally {
|
||||||
setDialogOpen2(false);
|
closeTargetDialog();
|
||||||
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 */}
|
{/* Dialog for add/edit */}
|
||||||
{formContent && (
|
{formContent && (
|
||||||
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user