diff --git a/package-lock.json b/package-lock.json index cd1c1eb..c6418fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@tanstack/react-router": "^1.121.2", "@tanstack/react-router-devtools": "^1.121.2", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.26", "@tanstack/router-plugin": "^1.121.2", "@tanstack/zod-form-adapter": "^0.42.1", "axios": "^1.11.0", @@ -1119,9 +1120,9 @@ "license": "MIT" }, "node_modules/@hono/node-server": { - "version": "1.19.10", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz", - "integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "engines": { "node": ">=18.14.1" }, @@ -3620,6 +3621,22 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.26", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz", + "integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==", + "dependencies": { + "@tanstack/virtual-core": "3.16.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/router-core": { "version": "1.129.8", "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz", @@ -3784,6 +3801,15 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz", + "integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-file-routes": { "version": "1.129.7", "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.129.7.tgz", @@ -4408,13 +4434,37 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "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": { @@ -5234,9 +5284,9 @@ "license": "MIT" }, "node_modules/devalue": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", - "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==" + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==" }, "node_modules/diff": { "version": "8.0.3", @@ -5561,11 +5611,11 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -5598,9 +5648,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -5674,9 +5724,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -5975,9 +6025,9 @@ "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==" }, "node_modules/hono": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", - "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "engines": { "node": ">=16.9.0" } @@ -6120,9 +6170,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "engines": { "node": ">= 12" } @@ -6874,16 +6924,15 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -7197,9 +7246,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -7214,9 +7263,8 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7299,9 +7347,12 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } }, "node_modules/psl": { "version": "1.15.0", @@ -7324,9 +7375,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dependencies": { "side-channel": "^1.1.0" }, @@ -8806,9 +8857,9 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -8847,9 +8898,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -9201,11 +9252,10 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, - "license": "MIT", "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index aa68986..601aa4b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@tanstack/react-router": "^1.121.2", "@tanstack/react-router-devtools": "^1.121.2", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.26", "@tanstack/router-plugin": "^1.121.2", "@tanstack/zod-form-adapter": "^0.42.1", "axios": "^1.11.0", diff --git a/src/components/bars/device-action-bar.tsx b/src/components/bars/device-action-bar.tsx new file mode 100644 index 0000000..f6d2a88 --- /dev/null +++ b/src/components/bars/device-action-bar.tsx @@ -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.SHUTDOWN, CommandType.RESET]); + +export function DeviceActionBar({ + roomName, + selectedDevices, + onClearSelection, +}: DeviceActionBarProps) { + const [confirmOpen, setConfirmOpen] = useState(false); + const [activeType, setActiveType] = useState(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) + .filter((value) => typeof value === "number") + .reduce((acc: Record, type) => { + acc[type as number] = (sensitiveCommands || []).filter( + (command: any) => Number(command.command) === Number(type) + ); + return acc; + }, {} as Record); + }, [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 ( + <> +
+
+
+ + Đã chọn {selectedCount} thiết bị +
+ +
+ {ACTIONS.map((action) => { + const Icon = action.icon; + const isDisabled = !commandsByType[action.type]?.length; + return ( + + ); + })} + + +
+
+
+ + + + + + + Xác nhận thực thi lệnh + + +

+ Bạn có chắc chắn muốn thực thi lệnh{" "} + {activeCommand?.commandName ?? ""}? +

+ {DANGER_TYPES.has(activeType ?? CommandType.RESTART) && ( +

+ Hành động này không thể hoàn tác. +

+ )} +
+
+ +
+
Thiết bị được chọn
+ +
+ {selectedDevices.map((device) => ( +
+ {buildDeviceLabel(device)} +
+ ))} +
+
+ +
+ + setPassword(event.target.value)} + placeholder="Nhập mật khẩu để xác nhận" + /> +
+
+ + + + + +
+
+ + ); +} diff --git a/src/components/buttons/command-action-buttons.tsx b/src/components/buttons/command-action-buttons.tsx index 0a76646..5e2ae3b 100644 --- a/src/components/buttons/command-action-buttons.tsx +++ b/src/components/buttons/command-action-buttons.tsx @@ -205,7 +205,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command return ( <> -
+
{Object.values(CommandType) .filter((value) => typeof value === "number") .map((commandType) => renderCommandButton(commandType as CommandType))} diff --git a/src/components/cards/computer-card.tsx b/src/components/cards/computer-card.tsx index 09a21fb..7e16111 100644 --- a/src/components/cards/computer-card.tsx +++ b/src/components/cards/computer-card.tsx @@ -1,7 +1,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Badge } from "@/components/ui/badge"; import { Monitor, Wifi, WifiOff, Loader2, Maximize2, X } from "lucide-react"; -import { useState } from "react"; +import { useState, type MouseEvent } from "react"; import { cn } from "@/lib/utils"; import { FolderStatusPopover } from "../folder-status-popover"; import { useGetClientFolderStatusForDevice } from "@/hooks/queries"; @@ -15,11 +15,15 @@ export function ComputerCard({ position, folderStatus, isCheckingFolder, + isSelected, + onSelect, }: { device: any | undefined; position: number; folderStatus?: ClientFolderStatus; isCheckingFolder?: boolean; + isSelected?: boolean; + onSelect?: (event: MouseEvent) => void; }) { const [isConnecting, setIsConnecting] = useState(false); const [showRemote, setShowRemote] = useState(false); @@ -27,12 +31,16 @@ export function ComputerCard({ if (!device) { return ( -
-
- {position} +
+
+ + {position} + +
+
+ + Trống
- - Trống
); } @@ -216,53 +224,68 @@ export function ComputerCard({
+ {/* Top bar: position + folder status */}
- {position} + + {position} + + {!isOffline && ( +
e.stopPropagation()} + className="[&_button]:p-0 [&_button]:rounded [&_button]:hover:bg-emerald-400 [&_svg]:h-3.5 [&_svg]:w-3.5 [&_svg]:text-white" + > + +
+ )}
- {/* Folder Status Icon */} - {device && !isOffline && ( -
- -
- )} - - - {firstNetworkInfo?.ipAddress && ( -
- {firstNetworkInfo.ipAddress} - {agentVersion && ( -
- v{agentVersion} -
- )} -
- )} -
- + + {firstNetworkInfo?.ipAddress && ( +
+ {firstNetworkInfo.ipAddress} +
+ )} + {agentVersion && ( +
+ v{agentVersion} +
+ )} +
{isOffline ? "Off" : "On"} - +
diff --git a/src/components/grids/device-grid-compact.tsx b/src/components/grids/device-grid-compact.tsx new file mode 100644 index 0000000..35795ff --- /dev/null +++ b/src/components/grids/device-grid-compact.tsx @@ -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 + ) => 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 ( +
+ {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 ( + + ); + })} +
+ ); +} diff --git a/src/components/grids/device-grid.tsx b/src/components/grids/device-grid.tsx index 9e366c4..9d4935a 100644 --- a/src/components/grids/device-grid.tsx +++ b/src/components/grids/device-grid.tsx @@ -1,3 +1,4 @@ +import { useMemo, type MouseEvent } from "react"; import { Monitor, DoorOpen } from "lucide-react"; import { ComputerCard } from "../cards/computer-card"; import { useMachineNumber } from "../../hooks/useMachineNumber"; @@ -8,13 +9,22 @@ export function DeviceGrid({ folderStatuses, isCheckingFolder, totalSeats, + selectedIds = [], + onSelectDevice, }: { devices: any[]; folderStatuses?: Map; isCheckingFolder?: boolean; totalSeats?: number; + selectedIds?: string[]; + onSelectDevice?: ( + deviceId: string, + index: number, + event: MouseEvent + ) => void; }) { const getMachineNumber = useMachineNumber(); + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); const parsedDevices = devices .map((device, index) => ({ device, @@ -32,39 +42,45 @@ export function DeviceGrid({ return a.index - b.index; }); + const orderedDevices = parsedDevices.map((item, orderIndex) => ({ + ...item, + orderIndex, + })); + 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 inRangeCount = parsedDevices.filter( + const inRangeCount = orderedDevices.filter( (item) => item.number > 0 && item.number <= seatCount ).length; const useThresholdSplit = - seatCount > 0 && inRangeCount >= Math.ceil(parsedDevices.length * 0.6); + seatCount > 0 && inRangeCount >= Math.ceil(orderedDevices.length * 0.6); - let rightDevices = parsedDevices; - let leftDevices: typeof parsedDevices = []; + let rightDevices = orderedDevices; + let leftDevices: typeof orderedDevices = []; if (useThresholdSplit) { - rightDevices = parsedDevices.filter( + rightDevices = orderedDevices.filter( (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 ); leftDevices = [...leftDevices, ...unassigned]; } else { - const splitIndex = Math.ceil(parsedDevices.length / 2); - rightDevices = parsedDevices.slice(0, splitIndex); - leftDevices = parsedDevices.slice(splitIndex); + const splitIndex = Math.ceil(orderedDevices.length / 2); + rightDevices = orderedDevices.slice(0, splitIndex); + leftDevices = orderedDevices.slice(splitIndex); } - const renderDevice = (item: (typeof parsedDevices)[number]) => { + const renderDevice = (item: (typeof orderedDevices)[number]) => { const device = item.device; const position = item.number > 0 ? item.number : item.index + 1; const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id; const folderStatus = folderStatuses?.get(macAddress); + const isSelected = device?.id ? selectedSet.has(device.id) : false; return ( { + 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 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 (
+ {/* Left panel: số lớn sát divider, giảm ra ngoài trái */}
{Array.from({ length: leftFill }).map((_, i) => renderPlaceholder(`left-pad-${rowIndex}-${i}`) )} - {leftRow.map(renderDevice)} + {leftRowReversed.map(renderDevice)}
+ {/* Right panel: số 1 sát divider, tăng ra ngoài phải */}
- {rightRow.map(renderDevice)} + {rightRowReversed.map(renderDevice)} {Array.from({ length: rightFill }).map((_, i) => renderPlaceholder(`right-pad-${rowIndex}-${i}`) )} @@ -126,7 +155,7 @@ export function DeviceGrid({ return (
- {Array.from({ length: totalRows }).map((_, i) => renderRow(i))} + {Array.from({ length: totalRows }).map((_, i) => renderRow(totalRows - 1 - i))}
diff --git a/src/components/sidebars/room-list-panel.tsx b/src/components/sidebars/room-list-panel.tsx new file mode 100644 index 0000000..31f69e5 --- /dev/null +++ b/src/components/sidebars/room-list-panel.tsx @@ -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 ( +
+
+ + Danh sách phòng + + + {rooms.length} + +
+ +
+ {sortedRooms.length === 0 && ( +
+ Chưa có phòng +
+ )} + {sortedRooms.map((room) => { + const isActive = room.name === activeRoomName; + const hasOffline = room.numberOfOfflineDevices > 0; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/components/tables/device-table.tsx b/src/components/tables/device-table.tsx index 8a6e08e..95aca25 100644 --- a/src/components/tables/device-table.tsx +++ b/src/components/tables/device-table.tsx @@ -5,6 +5,8 @@ import { useReactTable, type ColumnDef, } from "@tanstack/react-table"; +import { useMemo, useRef, type MouseEvent } from "react"; +import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"; import { Table, TableBody, @@ -16,6 +18,7 @@ import { import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react"; import { useMachineNumber } from "@/hooks/useMachineNumber"; import { FolderStatusPopover } from "../folder-status-popover"; @@ -23,6 +26,13 @@ import { FolderStatusPopover } from "../folder-status-popover"; interface DeviceTableProps { devices: any[]; isCheckingFolder?: boolean; + selectedIds?: string[]; + onToggleDevice?: ( + deviceId: string, + index: number, + event: MouseEvent + ) => void; + onToggleAll?: (checked: boolean) => void; } /** @@ -31,10 +41,46 @@ interface DeviceTableProps { export function DeviceTable({ devices, isCheckingFolder, + selectedIds = [], + onToggleDevice, + onToggleAll, }: DeviceTableProps) { const getMachineNumber = useMachineNumber(); + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); + const allSelected = devices.length > 0 && devices.every((d) => selectedSet.has(d.id)); + const someSelected = devices.some((d) => selectedSet.has(d.id)); + + const selectionEnabled = Boolean(onToggleDevice || onToggleAll); + const selectionColumn: ColumnDef = { + id: "select", + header: () => ( +
+ onToggleAll?.(value === true)} + aria-label="Chọn tất cả" + /> +
+ ), + cell: ({ row }) => { + const device = row.original; + return ( +
+ { + event.stopPropagation(); + onToggleDevice?.(device.id, row.index, event); + }} + aria-label="Chọn thiết bị" + /> +
+ ); + }, + }; const columns: ColumnDef[] = [ + ...(selectionEnabled ? [selectionColumn] : []), { header: "STT", cell: ({ row }) => { @@ -108,11 +154,11 @@ export function DeviceTable({ key={idx} className="flex items-center gap-2 text-sm font-mono px-2 py-1 rounded bg-muted/30" > - + * {info.macAddress ?? "-"} - + -> {info.ipAddress ?? "-"} @@ -171,8 +217,24 @@ export function DeviceTable({ initialState: { pagination: { pageSize: 16 } }, }); + const parentRef = useRef(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 ( -
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -186,15 +248,40 @@ export function DeviceTable({ ))} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + {paddingTop > 0 && ( + + - ))} + )} + {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index]; + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); + })} + {paddingBottom > 0 && ( + + + + )}
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx index 9376f59..8839c4e 100644 --- a/src/components/ui/scroll-area.tsx +++ b/src/components/ui/scroll-area.tsx @@ -1,5 +1,4 @@ import * as React from "react" -import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" import { cn } from "@/lib/utils" @@ -7,50 +6,23 @@ function ScrollArea({ className, children, ...props -}: React.ComponentProps) { +}: React.HTMLAttributes) { return ( - - - {children} - - - - + {children} +
) } function ScrollBar({ className, - orientation = "vertical", ...props -}: React.ComponentProps) { - return ( - - - - ) +}: React.HTMLAttributes) { + return
} export { ScrollArea, ScrollBar } diff --git a/src/routes/_auth/commands/index.tsx b/src/routes/_auth/commands/index.tsx index 635cb9f..36b4e58 100644 --- a/src/routes/_auth/commands/index.tsx +++ b/src/routes/_auth/commands/index.tsx @@ -1,6 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; 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 { useGetCommandList, @@ -236,7 +239,10 @@ function CommandPage() { }; // Handle execute commands from list - const handleExecuteSelected = async (targets: string[]) => { + const handleExecuteSelected = async ( + targets: string[], + options?: SendCommandOptions + ) => { if (!table) { toast.error("Không thể lấy thông tin bảng!"); return; @@ -256,6 +262,8 @@ function CommandPage() { Command: row.original.commandContent, QoS: row.original.qoS, IsRetained: row.original.isRetained, + TtlMinutes: options?.ttlMinutes, + SendTime: options?.sendTime, }; await sendCommandMutation.mutateAsync({ @@ -274,7 +282,11 @@ function CommandPage() { }; // Handle execute custom command - const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => { + const handleExecuteCustom = async ( + targets: string[], + commandData: ShellCommandData, + options?: SendCommandOptions + ) => { try { for (const target of targets) { // API expects PascalCase directly @@ -282,6 +294,8 @@ function CommandPage() { Command: commandData.command, QoS: commandData.qos, IsRetained: commandData.isRetained, + TtlMinutes: options?.ttlMinutes, + SendTime: options?.sendTime, }; await sendCommandMutation.mutateAsync({ roomName: target, diff --git a/src/routes/_auth/remote-control/index.tsx b/src/routes/_auth/remote-control/index.tsx index 9ff7a6c..011aa83 100644 --- a/src/routes/_auth/remote-control/index.tsx +++ b/src/routes/_auth/remote-control/index.tsx @@ -6,7 +6,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { getRemoteDesktopUrl } from "@/services/remote-control.service"; -import { buildMeshProxyUrl } from "@/config/api"; export const Route = createFileRoute("/_auth/remote-control/")({ diff --git a/src/routes/_auth/rooms/$roomName/index.tsx b/src/routes/_auth/rooms/$roomName/index.tsx index 1c4fb99..983f797 100644 --- a/src/routes/_auth/rooms/$roomName/index.tsx +++ b/src/routes/_auth/rooms/$roomName/index.tsx @@ -1,14 +1,23 @@ 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 { LayoutGrid, TableIcon, Monitor, FolderCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useGetDeviceFromRoom, useGetRoomList } from "@/hooks/queries"; import { useDeviceEvents } from "@/hooks/useDeviceEvents"; import { DeviceGrid } from "@/components/grids/device-grid"; +import { DeviceGridCompact } from "@/components/grids/device-grid-compact"; import { DeviceTable } from "@/components/tables/device-table"; import { useMachineNumber } from "@/hooks/useMachineNumber"; import { CommandActionButtons } from "@/components/buttons/command-action-buttons"; +import { DeviceActionBar } from "@/components/bars/device-action-bar"; export const Route = createFileRoute("/_auth/rooms/$roomName/")({ head: ({ params }) => ({ @@ -25,7 +34,12 @@ export const Route = createFileRoute("/_auth/rooms/$roomName/")({ function RoomDetailPage() { 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([]); + const [lastSelectedIndex, setLastSelectedIndex] = useState(null); // SSE real-time updates useDeviceEvents(roomName); @@ -38,60 +52,156 @@ function RoomDetailPage() { const navigate = useNavigate(); - const sortedDevices = [...devices].sort((a, b) => { - return parseMachineNumber(a.id) - parseMachineNumber(b.id); - }); + const sortedDevices = useMemo(() => { + return [...devices].sort((a, b) => { + return parseMachineNumber(a.id) - parseMachineNumber(b.id); + }); + }, [devices, parseMachineNumber]); const currentRoom = roomData.find((room) => room.name === roomName); const totalSeats = currentRoom?.numberOfDevices; + const deviceCount = sortedDevices.length; + const offlineCount = sortedDevices.filter((device) => device.isOffline).length; + const onlineCount = Math.max(0, deviceCount - offlineCount); + + const mapDisabled = deviceCount > 200; + const forceTable = deviceCount > 2000; + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearch(searchInput.trim()); + }, 300); + return () => clearTimeout(handler); + }, [searchInput]); + + useEffect(() => { + setSelectedIds([]); + setLastSelectedIndex(null); + }, [roomName]); + + useEffect(() => { + if (forceTable && viewMode !== "table") { + setViewMode("table"); + } + }, [forceTable, viewMode]); + + useEffect(() => { + if (mapDisabled && viewMode === "map") { + setViewMode("grid"); + } + }, [mapDisabled, viewMode]); + + const filteredDevices = useMemo(() => { + const query = debouncedSearch.toLowerCase(); + return sortedDevices.filter((device) => { + if (statusFilter === "on" && device.isOffline) return false; + if (statusFilter === "off" && !device.isOffline) return false; + + if (!query) return true; + + const ipAddress = device.networkInfos?.[0]?.ipAddress ?? ""; + const macAddress = device.networkInfos?.[0]?.macAddress ?? ""; + const id = device.id ?? ""; + const haystack = `${id} ${ipAddress} ${macAddress}`.toLowerCase(); + return haystack.includes(query); + }); + }, [sortedDevices, statusFilter, debouncedSearch]); + + const filteredDeviceIds = useMemo( + () => filteredDevices.map((device) => device.id), + [filteredDevices] + ); + + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); + const selectedDevices = useMemo( + () => devices.filter((device) => selectedSet.has(device.id)), + [devices, selectedSet] + ); + + useEffect(() => { + setSelectedIds((prev) => prev.filter((id) => devices.some((d) => d.id === id))); + }, [devices]); + + useEffect(() => { + if (lastSelectedIndex !== null && lastSelectedIndex >= filteredDeviceIds.length) { + setLastSelectedIndex(null); + } + }, [filteredDeviceIds.length, lastSelectedIndex]); + + const handleSelectDevice = ( + deviceId: string, + index: number, + event: MouseEvent + ) => { + 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 ( -
- - - {/* Hàng 1: Thông tin phòng và controls */} -
-
- - Danh sách thiết bị phòng {roomName} -
+
+
+ + + {/* Row 1: Title + stats */} +
+
+ + Phòng {roomName} +
-
- - -
-
+
+ + On {onlineCount} + + + Off {offlineCount} + + + Tổng {deviceCount} + +
+
- {/* Hàng 2: Thực thi lệnh */} -
-
- Thực thi lệnh -
- -
- {/* Command Action Buttons */} + {/* Row 2: Command buttons + folder button cùng hàng */} {devices.length > 0 && ( - <> +
- -
- +
- +
)} -
-
- - - {devices.length === 0 ? ( -
- -

Không có thiết bị

-

- Phòng này chưa có thiết bị nào được kết nối. -

-
- ) : viewMode === "grid" ? ( - - ) : ( - - )} -
- - + {/* Row 3: View toggle + search + filter */} +
+ {/* View mode */} +
+ + + + + {mapDisabled || forceTable ? ( + + + + + + + + Sơ đồ chỉ hỗ trợ phòng <= 200 thiết bị. + + + ) : ( + + )} +
+ + {/* Search */} + 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 */} +
+ {chipOptions.map((chip) => ( + + ))} +
+
+ + + + {devices.length === 0 ? ( +
+ +

Không có thiết bị

+

+ Phòng này chưa có thiết bị nào được kết nối. +

+
+ ) : ( +
+ {forceTable && ( +
+ Phòng này có {deviceCount} thiết bị. Chỉ hiển thị chế độ + Bảng để đảm bảo hiệu năng. +
+ )} + + {!forceTable && viewMode === "grid" && deviceCount > 500 && ( +
+ 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. +
+ )} + + {filteredDevices.length === 0 ? ( +
+ +

+ Không có thiết bị phù hợp +

+

+ Hãy thử thay đổi từ khóa hoặc bộ lọc trạng thái. +

+
+ ) : forceTable || viewMode === "table" ? ( + + ) : viewMode === "map" ? ( + + ) : ( + + )} +
+ )} +
+ + + +
); } diff --git a/src/template/command-submit-template.tsx b/src/template/command-submit-template.tsx index 3484bc2..dd85815 100644 --- a/src/template/command-submit-template.tsx +++ b/src/template/command-submit-template.tsx @@ -11,9 +11,13 @@ import { Plus, CommandIcon, Zap, Building2 } from "lucide-react"; import { Dialog, DialogContent, + DialogDescription, + DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import type { ColumnDef } from "@tanstack/react-table"; import { VersionTable } from "@/components/tables/version-table"; import { @@ -28,6 +32,11 @@ import { getDeviceFromRoom } from "@/services/device-comm.service"; import type { Room } from "@/types/room"; import { toast } from "sonner"; +export interface SendCommandOptions { + ttlMinutes?: number; + sendTime?: Date; +} + interface CommandSubmitTemplateProps { title: string; description: string; @@ -51,8 +60,15 @@ interface CommandSubmitTemplateProps { onTableInit?: (table: any) => void; // Execute - onExecuteSelected?: (targets: string[]) => void; - onExecuteCustom?: (targets: string[], commandData: ShellCommandData) => void; + onExecuteSelected?: ( + targets: string[], + options?: SendCommandOptions + ) => void | Promise; + onExecuteCustom?: ( + targets: string[], + commandData: ShellCommandData, + options?: SendCommandOptions + ) => void | Promise; isExecuting?: boolean; // Execution scope @@ -113,17 +129,158 @@ export function CommandSubmitTemplate({ const [customCommand, setCustomCommand] = useState(""); const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0); const [customRetained, setCustomRetained] = useState(false); - const [table, setTable] = useState(); const [dialogOpen2, setDialogOpen2] = useState(false); const [dialogType, setDialogType] = useState< "room" | "device" | "room-custom" | "device-custom" | null >(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [ttlMinutesInput, setTtlMinutesInput] = useState(""); + const [sendTimeInput, setSendTimeInput] = useState(""); + const [confirmError, setConfirmError] = useState(null); + const [pendingAction, setPendingAction] = useState< + | { type: "selected"; targets: string[] } + | { type: "custom"; targets: string[]; commandData: ShellCommandData } + | null + >(null); const handleTableInit = (t: any) => { - setTable(t); onTableInit?.(t); }; + const closeTargetDialog = () => { + setDialogOpen2(false); + setDialogType(null); + }; + + const resetConfirmState = () => { + setConfirmOpen(false); + setPendingAction(null); + setTtlMinutesInput(""); + setSendTimeInput(""); + setConfirmError(null); + }; + + const formatLocalSendTime = (date: Date) => { + const pad2 = (value: number) => String(value).padStart(2, "0"); + const hh = pad2(date.getHours()); + const mm = pad2(date.getMinutes()); + const ss = pad2(date.getSeconds()); + const dd = pad2(date.getDate()); + const MM = pad2(date.getMonth() + 1); + const yy = pad2(date.getFullYear() % 100); + return `${hh}:${mm}:${ss} ${dd}/${MM}/${yy}`; + }; + + const openConfirmForSelected = (targets: string[]) => { + setPendingAction({ type: "selected", targets }); + setSendTimeInput(formatLocalSendTime(new Date())); + setConfirmOpen(true); + setConfirmError(null); + }; + + const openConfirmForCustom = ( + targets: string[], + commandData: ShellCommandData + ) => { + setPendingAction({ type: "custom", targets, commandData }); + setSendTimeInput(formatLocalSendTime(new Date())); + setConfirmOpen(true); + setConfirmError(null); + }; + + const parseSendOptions = (): { + options?: SendCommandOptions; + error?: string; + } => { + const options: SendCommandOptions = {}; + const ttlTrimmed = ttlMinutesInput.trim(); + if (ttlTrimmed) { + const parsedTtl = Number(ttlTrimmed); + if (!Number.isInteger(parsedTtl) || parsedTtl < 0) { + return { error: "TtlMinutes phải là số nguyên >= 0." }; + } + options.ttlMinutes = parsedTtl; + } + + const sendTrimmed = sendTimeInput.trim(); + if (sendTrimmed) { + const match = + /^(\d{2}):(\d{2}):(\d{2})\s+(\d{2})\/(\d{2})\/(\d{2})$/.exec( + sendTrimmed + ); + if (!match) { + return { error: "SendTime không đúng định dạng HH:MM:SS DD/MM/YY." }; + } + + const [, hh, mm, ss, dd, MM, yy] = match; + const hour = Number(hh); + const minute = Number(mm); + const second = Number(ss); + const day = Number(dd); + const month = Number(MM); + const year = 2000 + Number(yy); + + if ( + hour > 23 || + minute > 59 || + second > 59 || + month < 1 || + month > 12 || + day < 1 || + day > 31 + ) { + return { error: "SendTime không hợp lệ." }; + } + + const date = new Date(year, month - 1, day, hour, minute, second); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day || + date.getHours() !== hour || + date.getMinutes() !== minute || + date.getSeconds() !== second + ) { + return { error: "SendTime không hợp lệ." }; + } + + options.sendTime = date; + } + + return { options }; + }; + + const handleConfirmSend = async () => { + if (!pendingAction) return; + + const { options, error } = parseSendOptions(); + if (error) { + setConfirmError(error); + toast.error(error); + return; + } + + try { + if (pendingAction.type === "selected") { + await onExecuteSelected?.(pendingAction.targets, options); + } else { + await onExecuteCustom?.( + pendingAction.targets, + pendingAction.commandData, + options + ); + setCustomCommand(""); + setCustomQoS(0); + setCustomRetained(false); + } + } catch (e) { + console.error("Confirm send error:", e); + } finally { + resetConfirmState(); + setTimeout(() => window.location.reload(), 500); + } + }; + const openRoomDialog = () => { if (rooms.length > 0 && onExecuteSelected) { setDialogType("room"); @@ -138,21 +295,6 @@ export function CommandSubmitTemplate({ } }; - const handleExecuteSelected = () => { - if (!table) { - toast.error("Không thể lấy thông tin bảng!"); - return; - } - - const selectedRows = table.getSelectedRowModel().rows; - if (selectedRows.length === 0) { - toast.error("Vui lòng chọn ít nhất một mục để thực thi!"); - return; - } - - onExecuteSelected?.([]); - }; - const handleExecuteAll = () => { if (!onExecuteSelected) return; try { @@ -160,7 +302,7 @@ export function CommandSubmitTemplate({ typeof room === "string" ? room : room.name, ); const allTargets = [...roomNames, ...devices]; - onExecuteSelected(allTargets); + openConfirmForSelected(allTargets); } catch (e) { console.error("Execute error:", e); } @@ -178,14 +320,7 @@ export function CommandSubmitTemplate({ isRetained: customRetained, }; - try { - await onExecuteCustom?.(targets, shellCommandData); - setCustomCommand(""); - setCustomQoS(0); - setCustomRetained(false); - } catch (e) { - console.error("Execute custom command error:", e); - } + openConfirmForCustom(targets, shellCommandData); }; const handleExecuteCustomAll = () => { @@ -343,9 +478,7 @@ export function CommandSubmitTemplate({ { - setDialogOpen2(false); - setDialogType(null); - setTimeout(() => window.location.reload(), 500); + closeTargetDialog(); }} title="Chọn phòng" description="Chọn các phòng để thực thi lệnh" @@ -354,13 +487,11 @@ export function CommandSubmitTemplate({ onConfirm={async (selectedItems) => { if (!onExecuteSelected) return; try { - await onExecuteSelected(selectedItems); + openConfirmForSelected(selectedItems); } catch (e) { console.error("Execute error:", e); } finally { - setDialogOpen2(false); - setDialogType(null); - setTimeout(() => window.location.reload(), 500); + closeTargetDialog(); } }} /> @@ -371,27 +502,21 @@ export function CommandSubmitTemplate({ { - setDialogOpen2(false); - setDialogType(null); - setTimeout(() => window.location.reload(), 500); + closeTargetDialog(); }} rooms={rooms} fetchDevices={getDeviceFromRoom} onSelect={async (deviceIds) => { if (!onExecuteSelected) { - setDialogOpen2(false); - setDialogType(null); - setTimeout(() => window.location.reload(), 500); + closeTargetDialog(); return; } try { - await onExecuteSelected(deviceIds); + openConfirmForSelected(deviceIds); } catch (e) { console.error("Execute error:", e); } finally { - setDialogOpen2(false); - setDialogType(null); - setTimeout(() => window.location.reload(), 500); + closeTargetDialog(); } }} /> @@ -402,9 +527,7 @@ export function CommandSubmitTemplate({ { - setDialogOpen2(false); - setDialogType(null); - setTimeout(() => window.location.reload(), 500); + closeTargetDialog(); }} title="Chọn phòng" description="Chọn các phòng để thực thi lệnh tùy chỉnh" @@ -416,9 +539,7 @@ export function CommandSubmitTemplate({ } catch (e) { console.error("Execute error:", e); } finally { - setDialogOpen2(false); - setDialogType(null); - setTimeout(() => window.location.reload(), 500); + closeTargetDialog(); } }} /> @@ -429,9 +550,7 @@ export function CommandSubmitTemplate({ { - setDialogOpen2(false); - setDialogType(null); - setTimeout(() => window.location.reload(), 500); + closeTargetDialog(); }} rooms={rooms} fetchDevices={getDeviceFromRoom} @@ -441,14 +560,67 @@ export function CommandSubmitTemplate({ } catch (e) { console.error("Execute error:", e); } finally { - setDialogOpen2(false); - setDialogType(null); - setTimeout(() => window.location.reload(), 500); + closeTargetDialog(); } }} /> )} + {/* Dialog xác nhận gửi lệnh */} + { + if (!open) resetConfirmState(); + }} + > + + + Xác nhận gửi lệnh + + Vui lòng xác nhận và nhập thông tin bổ sung trước khi gửi. + + + +
+
+ + setTtlMinutesInput(e.target.value)} + /> +
+ +
+ + setSendTimeInput(e.target.value)} + /> +

+ Để trống nếu muốn gửi ngay. +

+
+ + {confirmError && ( +

{confirmError}

+ )} +
+ + + + + +
+
+ {/* Dialog for add/edit */} {formContent && (