big update
All checks were successful
CI / build-test (push) Successful in 43s
Deploy Staging (Docker) / deploy (push) Successful in 1m32s

This commit is contained in:
Do Manh Phuong 2026-05-30 22:16:02 +07:00
parent 5ebcde08e9
commit 2b3c9a6c28
14 changed files with 1305 additions and 287 deletions

154
package-lock.json generated
View File

@ -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"
},

View File

@ -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",

View 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 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>
</>
);
}

View File

@ -205,7 +205,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
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)
.filter((value) => typeof value === "number")
.map((commandType) => renderCommandButton(commandType as CommandType))}

View File

@ -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<HTMLElement>) => void;
}) {
const [isConnecting, setIsConnecting] = useState(false);
const [showRemote, setShowRemote] = useState(false);
@ -27,12 +31,16 @@ export function ComputerCard({
if (!device) {
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="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 flex-col items-stretch rounded-lg border border-dashed border-muted-foreground/20 overflow-hidden w-[88px]">
<div className="flex items-center justify-between px-1.5 py-1 bg-muted/30">
<span className="text-[11px] font-bold text-muted-foreground/50 leading-none">
{position}
</span>
</div>
<div className="flex flex-col items-center justify-center py-2 gap-0.5">
<Monitor className="h-5 w-5 text-muted-foreground/20" />
<span className="text-[10px] text-muted-foreground/40">Trống</span>
</div>
<Monitor className="h-8 w-8 mb-1 text-muted-foreground/40" />
<span className="text-xs text-muted-foreground">Trống</span>
</div>
);
}
@ -216,25 +224,33 @@ export function ComputerCard({
<Popover>
<PopoverTrigger asChild>
<div
onClick={onSelect}
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
? "bg-red-50 border-red-300 hover:border-red-400 hover:shadow-lg"
: "bg-green-50 border-green-300 hover:border-green-400 hover:shadow-lg"
? "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 bar: position + folder status */}
<div
className={cn(
"absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold",
isOffline ? "bg-red-500 text-white" : "bg-green-500 text-white"
"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}
</div>
{/* Folder Status Icon */}
{device && !isOffline && (
<div className="absolute -top-2 -right-2">
</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}
@ -242,27 +258,34 @@ export function ComputerCard({
/>
</div>
)}
</div>
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
{/* Body */}
<div className="flex flex-col items-center justify-center gap-0.5 py-2 px-1">
<Monitor
className={cn(
"h-5 w-5",
isOffline ? "text-red-300" : "text-emerald-400"
)}
/>
{firstNetworkInfo?.ipAddress && (
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
<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-[10px] font-mono text-center mb-1 px-1 truncate w-full">
<div className="text-[9px] font-mono text-center text-muted-foreground/60 leading-tight">
v{agentVersion}
</div>
)}
</div>
)}
<div className="flex items-center gap-1">
<span
<div
className={cn(
"text-xs font-medium",
isOffline ? "text-red-700" : "text-green-700"
"text-[10px] font-semibold leading-none mt-0.5",
isOffline ? "text-red-500" : "text-emerald-600"
)}
>
{isOffline ? "Off" : "On"}
</span>
</div>
</div>
</div>
</PopoverTrigger>

View 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>
);
}

View File

@ -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<string, ClientFolderStatus>;
isCheckingFolder?: boolean;
totalSeats?: number;
selectedIds?: string[];
onSelectDevice?: (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => void;
}) {
const getMachineNumber = useMachineNumber();
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const parsedDevices = devices
.map((device, index) => ({
device,
@ -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 (
<ComputerCard
@ -73,6 +89,11 @@ export function DeviceGrid({
position={position}
folderStatus={folderStatus}
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 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 (
<div key={`row-${rowIndex}`} className="flex items-center justify-center gap-3">
{/* Left panel: số lớn sát divider, giảm ra ngoài trái */}
<div className="flex items-center gap-3">
{Array.from({ length: leftFill }).map((_, i) =>
renderPlaceholder(`left-pad-${rowIndex}-${i}`)
)}
{leftRow.map(renderDevice)}
{leftRowReversed.map(renderDevice)}
</div>
<div className="w-10 flex items-center justify-center">
<div className="h-10 w-px bg-border border-l-2 border-dashed" />
</div>
{/* Right panel: số 1 sát divider, tăng ra ngoài phải */}
<div className="flex items-center gap-3">
{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 (
<div className="px-0.5 py-8 space-y-6">
<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 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">

View 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 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>
);
}

View File

@ -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<HTMLElement>
) => 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<any> = {
id: "select",
header: () => (
<div className="flex items-center justify-center">
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={(value) => onToggleAll?.(value === true)}
aria-label="Chọn tất cả"
/>
</div>
),
cell: ({ row }) => {
const device = row.original;
return (
<div className="flex items-center justify-center">
<Checkbox
checked={selectedSet.has(device.id)}
onClick={(event) => {
event.stopPropagation();
onToggleDevice?.(device.id, row.index, event);
}}
aria-label="Chọn thiết bị"
/>
</div>
);
},
};
const columns: ColumnDef<any>[] = [
...(selectionEnabled ? [selectionColumn] : []),
{
header: "STT",
cell: ({ row }) => {
@ -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"
>
<span className="text-primary"></span>
<span className="text-primary">*</span>
<code className="bg-background px-2 py-0.5 rounded">
{info.macAddress ?? "-"}
</code>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">-&gt;</span>
<code className="bg-background px-2 py-0.5 rounded">
{info.ipAddress ?? "-"}
</code>
@ -171,8 +217,24 @@ export function DeviceTable({
initialState: { pagination: { pageSize: 16 } },
});
const parentRef = useRef<HTMLDivElement>(null);
const rows = table.getRowModel().rows;
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 8,
});
const virtualRows = rowVirtualizer.getVirtualItems() as VirtualItem[];
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0
? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end
: 0;
const columnCount = table.getVisibleLeafColumns().length;
return (
<div className="max-h-[600px] overflow-y-auto">
<div ref={parentRef} className="max-h-[600px] overflow-y-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
{table.getHeaderGroups().map((headerGroup) => (
@ -186,15 +248,40 @@ export function DeviceTable({
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="hover:bg-muted/50 transition-colors">
{paddingTop > 0 && (
<TableRow>
<TableCell
colSpan={columnCount}
className="p-0"
style={{ height: `${paddingTop}px` }}
/>
</TableRow>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<TableRow
key={row.id}
className="hover:bg-muted/50 transition-colors"
style={{ height: `${virtualRow.size}px` }}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-4">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
);
})}
{paddingBottom > 0 && (
<TableRow>
<TableCell
colSpan={columnCount}
className="p-0"
style={{ height: `${paddingBottom}px` }}
/>
</TableRow>
)}
</TableBody>
</Table>

View File

@ -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<typeof ScrollAreaPrimitive.Root>) {
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<ScrollAreaPrimitive.Root
<div
data-slot="scroll-area"
className={cn("relative", className)}
className={cn("relative overflow-auto", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
</div>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={className} {...props} />
}
export { ScrollArea, ScrollBar }

View File

@ -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,

View File

@ -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/")({

View File

@ -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<string[]>([]);
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
// SSE real-time updates
useDeviceEvents(roomName);
@ -38,60 +52,156 @@ function RoomDetailPage() {
const navigate = useNavigate();
const sortedDevices = [...devices].sort((a, b) => {
const sortedDevices = useMemo(() => {
return [...devices].sort((a, b) => {
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
});
}, [devices, parseMachineNumber]);
const currentRoom = roomData.find((room) => room.name === roomName);
const totalSeats = currentRoom?.numberOfDevices;
const deviceCount = sortedDevices.length;
const offlineCount = sortedDevices.filter((device) => device.isOffline).length;
const onlineCount = Math.max(0, deviceCount - offlineCount);
const mapDisabled = deviceCount > 200;
const forceTable = deviceCount > 2000;
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(searchInput.trim());
}, 300);
return () => clearTimeout(handler);
}, [searchInput]);
useEffect(() => {
setSelectedIds([]);
setLastSelectedIndex(null);
}, [roomName]);
useEffect(() => {
if (forceTable && viewMode !== "table") {
setViewMode("table");
}
}, [forceTable, viewMode]);
useEffect(() => {
if (mapDisabled && viewMode === "map") {
setViewMode("grid");
}
}, [mapDisabled, viewMode]);
const filteredDevices = useMemo(() => {
const query = debouncedSearch.toLowerCase();
return sortedDevices.filter((device) => {
if (statusFilter === "on" && device.isOffline) return false;
if (statusFilter === "off" && !device.isOffline) return false;
if (!query) return true;
const ipAddress = device.networkInfos?.[0]?.ipAddress ?? "";
const macAddress = device.networkInfos?.[0]?.macAddress ?? "";
const id = device.id ?? "";
const haystack = `${id} ${ipAddress} ${macAddress}`.toLowerCase();
return haystack.includes(query);
});
}, [sortedDevices, statusFilter, debouncedSearch]);
const filteredDeviceIds = useMemo(
() => filteredDevices.map((device) => device.id),
[filteredDevices]
);
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedDevices = useMemo(
() => devices.filter((device) => selectedSet.has(device.id)),
[devices, selectedSet]
);
useEffect(() => {
setSelectedIds((prev) => prev.filter((id) => devices.some((d) => d.id === id)));
}, [devices]);
useEffect(() => {
if (lastSelectedIndex !== null && lastSelectedIndex >= filteredDeviceIds.length) {
setLastSelectedIndex(null);
}
}, [filteredDeviceIds.length, lastSelectedIndex]);
const handleSelectDevice = (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => {
const isShift = event.shiftKey;
setSelectedIds((prev) => {
const next = new Set(prev);
if (isShift && lastSelectedIndex !== null) {
const start = Math.min(lastSelectedIndex, index);
const end = Math.max(lastSelectedIndex, index);
const rangeIds = filteredDeviceIds.slice(start, end + 1);
rangeIds.forEach((id) => next.add(id));
return Array.from(next);
}
if (next.has(deviceId)) {
next.delete(deviceId);
} else {
next.add(deviceId);
}
return Array.from(next);
});
setLastSelectedIndex(index);
};
const handleToggleAll = (checked: boolean) => {
setSelectedIds(checked ? filteredDeviceIds : []);
setLastSelectedIndex(null);
};
const handleClearSelection = () => {
setSelectedIds([]);
setLastSelectedIndex(null);
};
const chipOptions = [
{ key: "all" as const, label: `Tất cả (${deviceCount})` },
{ key: "on" as const, label: `On (${onlineCount})` },
{ key: "off" as const, label: `Off (${offlineCount})` },
];
return (
<div className="w-full px-6 space-y-6">
<div className="w-full px-6">
<div className="space-y-6">
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 space-y-4">
{/* Hàng 1: Thông tin phòng và controls */}
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2">
<CardHeader className="bg-muted/50 space-y-3 pb-3">
{/* Row 1: Title + stats */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-2 shrink-0">
<Monitor className="h-5 w-5" />
<CardTitle>Danh sách thiết bị phòng {roomName}</CardTitle>
<CardTitle>Phòng {roomName}</CardTitle>
</div>
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border shrink-0">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="flex items-center gap-2"
>
<LayoutGrid className="h-4 w-4" />
đ
</Button>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="flex items-center gap-2"
>
<TableIcon className="h-4 w-4" />
Bảng
</Button>
<div className="flex items-center gap-1.5">
<Badge variant="outline" className="text-[11px] text-emerald-700 border-emerald-200 bg-emerald-50">
On {onlineCount}
</Badge>
<Badge variant="outline" className="text-[11px] text-red-600 border-red-200 bg-red-50">
Off {offlineCount}
</Badge>
<Badge variant="secondary" className="text-[11px]">
Tổng {deviceCount}
</Badge>
</div>
</div>
{/* Hàng 2: Thực thi lệnh */}
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2 text-sm font-semibold">
Thực thi lệnh
</div>
<div className="flex items-center gap-3 justify-end">
{/* Command Action Buttons */}
{/* Row 2: Command buttons + folder button cùng hàng */}
{devices.length > 0 && (
<>
<div className="flex flex-wrap items-center gap-2">
<CommandActionButtons roomName={roomName} />
<div className="h-8 w-px bg-border" />
<div className="h-5 w-px bg-border shrink-0" />
<Button
onClick={() =>
navigate({
@ -106,8 +216,87 @@ function RoomDetailPage() {
<FolderCheck className="h-4 w-4" />
Kiểm tra thư mục Setup
</Button>
</>
</div>
)}
{/* Row 3: View toggle + search + filter */}
<div className="flex flex-wrap items-center gap-2">
{/* View mode */}
<div className="flex items-center gap-1 rounded-lg border bg-background p-0.5">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="h-7 gap-1.5 px-2.5 text-xs"
disabled={forceTable}
>
<LayoutGrid className="h-3.5 w-3.5" />
Lưới
</Button>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="h-7 gap-1.5 px-2.5 text-xs"
>
<TableIcon className="h-3.5 w-3.5" />
Bảng
</Button>
{mapDisabled || forceTable ? (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2.5 text-xs"
disabled
>
<Monitor className="h-3.5 w-3.5" />
đ
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
đ chỉ hỗ trợ phòng &lt;= 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" />
đ
</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>
@ -121,19 +310,64 @@ function RoomDetailPage() {
Phòng này chưa thiết bị nào đưc kết nối.
</p>
</div>
) : viewMode === "grid" ? (
) : (
<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 {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 nhiều thiết bị. Bạn 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 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={sortedDevices}
devices={filteredDevices}
totalSeats={totalSeats}
selectedIds={selectedIds}
onSelectDevice={handleSelectDevice}
/>
) : (
<DeviceTable
devices={sortedDevices}
<DeviceGridCompact
devices={filteredDevices}
selectedIds={selectedIds}
onSelectDevice={handleSelectDevice}
/>
)}
</div>
)}
</CardContent>
</Card>
<DeviceActionBar
roomName={roomName}
selectedDevices={selectedDevices}
onClearSelection={handleClearSelection}
/>
</div>
</div>
);
}

View File

@ -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<T extends { id: number }> {
title: string;
description: string;
@ -51,8 +60,15 @@ interface CommandSubmitTemplateProps<T extends { id: number }> {
onTableInit?: (table: any) => void;
// Execute
onExecuteSelected?: (targets: string[]) => void;
onExecuteCustom?: (targets: string[], commandData: ShellCommandData) => void;
onExecuteSelected?: (
targets: string[],
options?: SendCommandOptions
) => void | Promise<void>;
onExecuteCustom?: (
targets: string[],
commandData: ShellCommandData,
options?: SendCommandOptions
) => void | Promise<void>;
isExecuting?: boolean;
// Execution scope
@ -113,17 +129,158 @@ export function CommandSubmitTemplate<T extends { id: number }>({
const [customCommand, setCustomCommand] = useState("");
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
const [customRetained, setCustomRetained] = useState(false);
const [table, setTable] = useState<any>();
const [dialogOpen2, setDialogOpen2] = useState(false);
const [dialogType, setDialogType] = useState<
"room" | "device" | "room-custom" | "device-custom" | null
>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [ttlMinutesInput, setTtlMinutesInput] = useState("");
const [sendTimeInput, setSendTimeInput] = useState("");
const [confirmError, setConfirmError] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<
| { type: "selected"; targets: string[] }
| { type: "custom"; targets: string[]; commandData: ShellCommandData }
| null
>(null);
const handleTableInit = (t: any) => {
setTable(t);
onTableInit?.(t);
};
const closeTargetDialog = () => {
setDialogOpen2(false);
setDialogType(null);
};
const resetConfirmState = () => {
setConfirmOpen(false);
setPendingAction(null);
setTtlMinutesInput("");
setSendTimeInput("");
setConfirmError(null);
};
const formatLocalSendTime = (date: Date) => {
const pad2 = (value: number) => String(value).padStart(2, "0");
const hh = pad2(date.getHours());
const mm = pad2(date.getMinutes());
const ss = pad2(date.getSeconds());
const dd = pad2(date.getDate());
const MM = pad2(date.getMonth() + 1);
const yy = pad2(date.getFullYear() % 100);
return `${hh}:${mm}:${ss} ${dd}/${MM}/${yy}`;
};
const openConfirmForSelected = (targets: string[]) => {
setPendingAction({ type: "selected", targets });
setSendTimeInput(formatLocalSendTime(new Date()));
setConfirmOpen(true);
setConfirmError(null);
};
const openConfirmForCustom = (
targets: string[],
commandData: ShellCommandData
) => {
setPendingAction({ type: "custom", targets, commandData });
setSendTimeInput(formatLocalSendTime(new Date()));
setConfirmOpen(true);
setConfirmError(null);
};
const parseSendOptions = (): {
options?: SendCommandOptions;
error?: string;
} => {
const options: SendCommandOptions = {};
const ttlTrimmed = ttlMinutesInput.trim();
if (ttlTrimmed) {
const parsedTtl = Number(ttlTrimmed);
if (!Number.isInteger(parsedTtl) || parsedTtl < 0) {
return { error: "TtlMinutes phải là số nguyên >= 0." };
}
options.ttlMinutes = parsedTtl;
}
const sendTrimmed = sendTimeInput.trim();
if (sendTrimmed) {
const match =
/^(\d{2}):(\d{2}):(\d{2})\s+(\d{2})\/(\d{2})\/(\d{2})$/.exec(
sendTrimmed
);
if (!match) {
return { error: "SendTime không đúng định dạng HH:MM:SS DD/MM/YY." };
}
const [, hh, mm, ss, dd, MM, yy] = match;
const hour = Number(hh);
const minute = Number(mm);
const second = Number(ss);
const day = Number(dd);
const month = Number(MM);
const year = 2000 + Number(yy);
if (
hour > 23 ||
minute > 59 ||
second > 59 ||
month < 1 ||
month > 12 ||
day < 1 ||
day > 31
) {
return { error: "SendTime không hợp lệ." };
}
const date = new Date(year, month - 1, day, hour, minute, second);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day ||
date.getHours() !== hour ||
date.getMinutes() !== minute ||
date.getSeconds() !== second
) {
return { error: "SendTime không hợp lệ." };
}
options.sendTime = date;
}
return { options };
};
const handleConfirmSend = async () => {
if (!pendingAction) return;
const { options, error } = parseSendOptions();
if (error) {
setConfirmError(error);
toast.error(error);
return;
}
try {
if (pendingAction.type === "selected") {
await onExecuteSelected?.(pendingAction.targets, options);
} else {
await onExecuteCustom?.(
pendingAction.targets,
pendingAction.commandData,
options
);
setCustomCommand("");
setCustomQoS(0);
setCustomRetained(false);
}
} catch (e) {
console.error("Confirm send error:", e);
} finally {
resetConfirmState();
setTimeout(() => window.location.reload(), 500);
}
};
const openRoomDialog = () => {
if (rooms.length > 0 && onExecuteSelected) {
setDialogType("room");
@ -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 = () => {
if (!onExecuteSelected) return;
try {
@ -160,7 +302,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
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<T extends { id: number }>({
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<T extends { id: number }>({
<SelectDialog
open={dialogOpen2}
onClose={() => {
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<T extends { id: number }>({
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<T extends { id: number }>({
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device"}
onClose={() => {
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<T extends { id: number }>({
<SelectDialog
open={dialogOpen2}
onClose={() => {
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<T extends { id: number }>({
} 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<T extends { id: number }>({
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device-custom"}
onClose={() => {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
closeTargetDialog();
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
@ -441,14 +560,67 @@ export function CommandSubmitTemplate<T extends { id: number }>({
} 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 */}
<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 nhập thông tin bổ sung trước khi gửi.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ttl-minutes">TtlMinutes (phút)</Label>
<Input
id="ttl-minutes"
type="number"
min={0}
placeholder="VD: 60"
value={ttlMinutesInput}
onChange={(e) => setTtlMinutesInput(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="send-time">SendTime</Label>
<Input
id="send-time"
placeholder="HH:MM:SS DD/MM/YY (VD: 14:30:00 25/05/26)"
value={sendTimeInput}
onChange={(e) => setSendTimeInput(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Đ trống nếu muốn gửi ngay.
</p>
</div>
{confirmError && (
<p className="text-sm text-red-600">{confirmError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={resetConfirmState}>
Hủy
</Button>
<Button onClick={handleConfirmSend}>Xác nhận gửi</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog for add/edit */}
{formContent && (
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>