Compare commits

..

No commits in common. "main" and "feature_update_button" have entirely different histories.

52 changed files with 946 additions and 3752 deletions

View File

@ -1,6 +1,6 @@
upstream backend {
server 100.66.170.15:8080;
server 127.0.0.1:8080;
server 127.0.0.1:5218;
server 172.18.10.8:8080;
}
server {
@ -25,7 +25,7 @@ server {
}
location /api/ {
proxy_pass http://100.66.170.15:8080;
proxy_pass http://backend/;
# Cho phép upload file lớn (vd: 200MB)
client_max_body_size 200M;

60
package-lock.json generated
View File

@ -26,7 +26,6 @@
"@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.121.2",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -129,7 +128,6 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@ -659,7 +657,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -683,7 +680,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -3257,7 +3253,6 @@
"version": "1.129.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.129.8.tgz",
"integrity": "sha512-d5mfM+67h3wq7aHkLjRKXD1ddbzx1YuxaEbNvW45jjZXMgaikZSVfJrZBiUWXE/nhV1sTdbMQ48JcPagvGPmYQ==",
"peer": true,
"dependencies": {
"@tanstack/history": "1.129.7",
"@tanstack/react-store": "^0.7.0",
@ -3338,7 +3333,6 @@
"version": "1.129.8",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
"integrity": "sha512-Izqf5q8TzJv0DJURynitJioPJT3dPAefrzHi2wlY/Q5+7nEG41SkjYMotTX2Q9i/Pjl91lW8gERCHpksszRdRw==",
"peer": true,
"dependencies": {
"@tanstack/history": "1.129.7",
"@tanstack/store": "^0.7.0",
@ -3511,40 +3505,12 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/zod-form-adapter": {
"version": "0.42.1",
"resolved": "https://registry.npmjs.org/@tanstack/zod-form-adapter/-/zod-form-adapter-0.42.1.tgz",
"integrity": "sha512-hPRM0lawVKP64yurW4c6KHZH6altMo2MQN14hfi+GMBTKjO9S7bW1x5LPZ5cayoJE3mBvdlahpSGT5rYZtSbXQ==",
"dependencies": {
"@tanstack/form-core": "0.42.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"zod": "^3.x"
}
},
"node_modules/@tanstack/zod-form-adapter/node_modules/@tanstack/form-core": {
"version": "0.42.1",
"resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-0.42.1.tgz",
"integrity": "sha512-jTU0jyHqFceujdtPNv3jPVej1dTqBwa8TYdIyWB5BCwRVUBZEp1PiYEBkC9r92xu5fMpBiKc+JKud3eeVjuMiA==",
"dependencies": {
"@tanstack/store": "^0.7.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -3697,7 +3663,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"devOptional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.8.0"
}
@ -3708,7 +3673,6 @@
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -3719,7 +3683,6 @@
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@ -4161,7 +4124,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@ -4601,8 +4563,7 @@
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"peer": true
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
@ -4998,7 +4959,6 @@
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
@ -5730,7 +5690,6 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@ -6814,7 +6773,6 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -6823,7 +6781,6 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -7206,7 +7163,6 @@
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
"peer": true,
"engines": {
"node": ">=10"
}
@ -7665,8 +7621,7 @@
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"peer": true
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/tiny-warning": {
"version": "1.0.3",
@ -7722,7 +7677,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -7910,7 +7864,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -8088,10 +8041,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==",
"peer": true,
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -8203,7 +8155,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -8537,7 +8488,6 @@
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -30,7 +30,6 @@
"@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.121.2",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@ -1,325 +0,0 @@
import { useState, useMemo } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Building2,
Monitor,
ChevronDown,
ChevronRight,
Loader2,
} from "lucide-react";
import type { Room } from "@/types/room";
import type { DeviceHealthCheck } from "@/types/device";
interface DeviceSearchDialogProps {
open: boolean;
onClose: () => void;
rooms: Room[];
onSelect: (deviceIds: string[]) => void | Promise<void>;
fetchDevices: (roomName: string) => Promise<DeviceHealthCheck[]>; // API fetch
}
export function DeviceSearchDialog({
open,
onClose,
rooms,
onSelect,
fetchDevices,
}: DeviceSearchDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const [expandedRoom, setExpandedRoom] = useState<string | null>(null);
const [roomDevices, setRoomDevices] = useState<
Record<string, DeviceHealthCheck[]>
>({});
const [loadingRoom, setLoadingRoom] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const sortedRooms = useMemo(() => {
return [...rooms].sort((a, b) => {
const nameA = typeof a.name === "string" ? a.name : "";
const nameB = typeof b.name === "string" ? b.name : "";
return nameA.localeCompare(nameB);
});
}, [rooms]);
const filteredRooms = useMemo(() => {
if (!searchQuery) return sortedRooms;
return sortedRooms.filter((room) =>
room.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [sortedRooms, searchQuery]);
const handleRoomClick = async (roomName: string) => {
// Nếu đang mở thì đóng lại
if (expandedRoom === roomName) {
setExpandedRoom(null);
return;
}
// Nếu chưa fetch devices của room này thì gọi API
if (!roomDevices[roomName]) {
setLoadingRoom(roomName);
try {
const devices = await fetchDevices(roomName);
setRoomDevices((prev) => ({ ...prev, [roomName]: devices }));
setExpandedRoom(roomName);
} catch (error) {
console.error("Failed to fetch devices:", error);
// Có thể thêm toast notification ở đây
} finally {
setLoadingRoom(null);
}
} else {
// Đã có data rồi thì chỉ toggle
setExpandedRoom(roomName);
}
};
const toggleDevice = (deviceId: string) => {
setSelected((prev) =>
prev.includes(deviceId)
? prev.filter((id) => id !== deviceId)
: [...prev, deviceId]
);
};
const toggleAllInRoom = (roomName: string) => {
const devices = roomDevices[roomName] || [];
const deviceIds = devices.map((d) => d.id);
const allSelected = deviceIds.every((id) => selected.includes(id));
if (allSelected) {
setSelected((prev) => prev.filter((id) => !deviceIds.includes(id)));
} else {
setSelected((prev) => [...new Set([...prev, ...deviceIds])]);
}
};
const handleConfirm = async () => {
try {
await onSelect(selected);
} catch (e) {
console.error("Error on select:", e);
} finally {
setSelected([]);
setExpandedRoom(null);
setRoomDevices({});
setSearchQuery("");
onClose();
}
};
const handleClose = () => {
setSelected([]);
setExpandedRoom(null);
setRoomDevices({});
setSearchQuery("");
onClose();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-none w-[95vw] max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Monitor className="w-6 h-6 text-primary" />
Chọn thiết bị
</DialogTitle>
</DialogHeader>
{/* Search bar */}
<Input
placeholder="Tìm kiếm phòng..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="my-2"
/>
{/* Room list */}
<ScrollArea className="max-h-[500px] rounded-lg border p-2">
<div className="space-y-1">
{filteredRooms.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
Không tìm thấy phòng
</p>
)}
{filteredRooms.map((room) => {
const isExpanded = expandedRoom === room.name;
const isLoading = loadingRoom === room.name;
const devices = roomDevices[room.name] || [];
const allSelected =
devices.length > 0 &&
devices.every((d) => selected.includes(d.id));
const someSelected = devices.some((d) => selected.includes(d.id));
const selectedCount = devices.filter((d) =>
selected.includes(d.id)
).length;
return (
<div
key={room.name}
className="border rounded-lg overflow-hidden"
>
{/* Room header - clickable */}
<div
className="flex items-center gap-1 px-2 py-1.5 hover:bg-muted/50 cursor-pointer"
onClick={() => handleRoomClick(room.name)}
>
{/* Expand icon or loading */}
{isLoading ? (
<Loader2 className="w-4 h-4 text-muted-foreground flex-shrink-0 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
{/* Select all checkbox - chỉ hiện khi đã load devices */}
{devices.length > 0 && (
<Checkbox
checked={allSelected}
onCheckedChange={() => {
toggleAllInRoom(room.name);
}}
onClick={(e) => e.stopPropagation()}
className={
someSelected && !allSelected ? "opacity-50" : ""
}
/>
)}
<Building2 className="w-4 h-4 text-primary flex-shrink-0" />
<span className="font-semibold flex-1 text-sm">
{room.name}
</span>
<div className="flex items-center gap-1 text-xs text-muted-foreground flex-shrink-0">
{selectedCount > 0 && (
<span className="text-primary font-medium">
{selectedCount}/
</span>
)}
<span>{room.numberOfDevices}</span>
{room.numberOfOfflineDevices > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-red-100 text-red-700">
{room.numberOfOfflineDevices}
</span>
)}
</div>
</div>
{/* Device table - collapsible */}
{isExpanded && devices.length > 0 && (
<div className="border-t bg-muted/20 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-muted/50 border-b sticky top-0">
<tr>
<th className="w-8 px-1 py-1"></th>
<th className="text-left px-1 py-1 font-medium min-w-20 text-xs">
Thiết bị
</th>
<th className="text-left px-1 py-1 font-medium min-w-24 text-xs">
IP
</th>
<th className="text-left px-1 py-1 font-medium min-w-28 text-xs">
MAC
</th>
<th className="text-left px-1 py-1 font-medium min-w-12 text-xs">
Ver
</th>
<th className="text-left px-1 py-1 font-medium min-w-16 text-xs">
Trạng thái
</th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr
key={device.id}
className="border-b last:border-b-0 hover:bg-muted/50"
>
<td className="px-1 py-1">
<Checkbox
checked={selected.includes(device.id)}
onCheckedChange={() =>
toggleDevice(device.id)
}
/>
</td>
<td className="px-1 py-1">
<div className="flex items-center gap-0.5">
<Monitor className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="font-mono text-xs truncate">
{device.id}
</span>
</div>
</td>
<td className="px-1 py-1 font-mono text-xs truncate">
{device.networkInfos[0]?.ipAddress || "-"}
</td>
<td className="px-1 py-1 font-mono text-xs truncate">
{device.networkInfos[0]?.macAddress || "-"}
</td>
<td className="px-1 py-1 text-xs whitespace-nowrap">
{device.version ? `v${device.version}` : "-"}
</td>
<td className="px-1 py-1 text-xs">
{device.isOffline ? (
<span className="text-xs px-1 py-0.5 rounded-full bg-red-100 text-red-700 font-medium whitespace-nowrap inline-block">
Offline
</span>
) : (
<span className="text-xs px-1 py-0.5 rounded-full bg-green-100 text-green-700 font-medium whitespace-nowrap inline-block">
Online
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
})}
</div>
</ScrollArea>
{/* Selected count */}
{selected.length > 0 && (
<div className="text-xs text-muted-foreground bg-muted/50 px-2 py-1.5 rounded">
Đã chọn:{" "}
<span className="font-semibold text-foreground">
{selected.length}
</span>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleClose} size="sm">
Hủy
</Button>
<Button
onClick={handleConfirm}
disabled={selected.length === 0}
size="sm"
>
Xác nhận ({selected.length})
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,97 +0,0 @@
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface DeleteButtonProps {
onClick: () => void | Promise<void>;
loading?: boolean;
disabled?: boolean;
label?: string;
title?: string;
description?: string;
}
export function DeleteButton({
onClick,
loading = false,
disabled = false,
label = "Xóa khỏi server",
title = "Xóa khỏi server",
description = "Bạn có chắc chắn muốn xóa các phần mềm này khỏi server không? Hành động này không thể hoàn tác.",
}: DeleteButtonProps) {
const [open, setOpen] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const handleConfirm = async () => {
setIsConfirming(true);
try {
await onClick();
} finally {
setIsConfirming(false);
setOpen(false);
}
};
return (
<>
<Button
variant="destructive"
onClick={() => setOpen(true)}
disabled={loading || disabled}
className="gap-2 px-4"
>
{loading || isConfirming ? (
<span className="animate-spin"></span>
) : (
<Trash2 className="h-4 w-4" />
)}
{loading || isConfirming ? "Đang xóa..." : label}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-lg">{title}</DialogTitle>
<DialogDescription className="text-base">{description}</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isConfirming}
className="flex-1"
>
Hủy
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={isConfirming || loading}
className="flex-1 gap-2"
>
{isConfirming ? (
<>
<span className="animate-spin"></span>
Đang xóa...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{label}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,68 @@
"use client";
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
interface ShellCommandFormProps {
command: string;
onCommandChange: (value: string) => void;
disabled?: boolean;
}
export function ShellCommandForm({
command,
onCommandChange,
disabled,
}: ShellCommandFormProps) {
const form = useForm({
defaultValues: { command },
onSubmit: () => {},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-5"
>
<form.Field
name="command"
validators={{
onChange: ({ value }: { value: string }) => {
const schema = z
.string()
.min(1, "Nhập command để thực thi")
.max(500, "Command quá dài");
const result = schema.safeParse(value);
if (!result.success) {
return result.error.issues.map((i) => i.message);
}
return [];
},
}}
children={(field) => (
<div className="w-full px-0">
<Textarea
className="w-full h-[25vh]"
placeholder="Nhập lệnh..."
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
onCommandChange(e.target.value);
}}
disabled={disabled}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
/>
</form>
);
}

View File

@ -2,19 +2,13 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Badge } from "@/components/ui/badge";
import { Monitor, Wifi, WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { FolderStatusPopover } from "../folder-status-popover";
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
export function ComputerCard({
device,
position,
folderStatus,
isCheckingFolder,
}: {
device: any | undefined;
position: number;
folderStatus?: ClientFolderStatus;
isCheckingFolder?: boolean;
}) {
if (!device) {
return (
@ -104,17 +98,6 @@ export function ComputerCard({
{position}
</div>
{/* Folder Status Icon */}
{device && !isOffline && (
<div className="absolute -top-2 -right-2">
<FolderStatusPopover
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
status={folderStatus}
isLoading={isCheckingFolder}
/>
</div>
)}
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
{firstNetworkInfo?.ipAddress && (
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">

View File

@ -1,17 +1,8 @@
import { Monitor, DoorOpen } from "lucide-react";
import { ComputerCard } from "../cards/computer-card";
import { useMachineNumber } from "../../hooks/useMachineNumber";
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
import { ComputerCard } from "./computer-card";
import { useMachineNumber } from "../hooks/useMachineNumber";
export function DeviceGrid({
devices,
folderStatuses,
isCheckingFolder,
}: {
devices: any[];
folderStatuses?: Map<string, ClientFolderStatus>;
isCheckingFolder?: boolean;
}) {
export function DeviceGrid({ devices }: { devices: any[] }) {
const getMachineNumber = useMachineNumber();
const deviceMap = new Map<number, any>();
@ -23,27 +14,18 @@ export function DeviceGrid({
const totalRows = 5;
const renderRow = (rowIndex: number) => {
// Đảo ngược: 21-40 sang trái, 1-20 sang phải
const leftStart = 21 + (totalRows - 1 - rowIndex) * 4;
const rightStart = (totalRows - 1 - rowIndex) * 4 + 1;
// Trái: 120
const leftStart = rowIndex * 4 + 1;
// Phải: 2140
const rightStart = 21 + rowIndex * 4;
return (
<div key={rowIndex} className="flex items-center justify-center gap-3">
{/* Bên trái (2140) */}
{/* Bên trái (120) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = leftStart + (3 - i);
const device = deviceMap.get(pos);
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
const pos = leftStart + i;
return (
<ComputerCard
key={pos}
device={device}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
/>
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
);
})}
@ -52,21 +34,11 @@ export function DeviceGrid({
<div className="h-px w-full bg-border border-t-2 border-dashed" />
</div>
{/* Bên phải (120) */}
{/* Bên phải (2140) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = rightStart + (3 - i);
const device = deviceMap.get(pos);
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
const pos = rightStart + i;
return (
<ComputerCard
key={pos}
device={device}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
/>
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
);
})}
</div>
@ -75,18 +47,19 @@ 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))}
</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">
<DoorOpen className="h-6 w-6 text-muted-foreground" />
<span className="font-semibold text-lg">Cửa Ra Vào</span>
</div>
<div className="flex items-center gap-3 px-6 py-4 bg-primary/10 rounded-lg border-2 border-primary/20">
<Monitor className="h-6 w-6 text-primary" />
<span className="font-semibold text-lg">Bàn Giảng Viên</span>
</div>
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
<DoorOpen className="h-6 w-6 text-muted-foreground" />
<span className="font-semibold text-lg">Cửa Ra Vào</span>
</div>
</div>
<div className="space-y-4">
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
</div>
</div>
);

View File

@ -17,24 +17,16 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { FolderStatusPopover } from "../folder-status-popover";
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
import { useMachineNumber } from "../hooks/useMachineNumber";
interface DeviceTableProps {
devices: any[];
folderStatuses?: Map<string, ClientFolderStatus>;
isCheckingFolder?: boolean;
}
/**
* Component hiển thị danh sách thiết bị dạng bảng
*/
export function DeviceTable({
devices,
folderStatuses,
isCheckingFolder,
}: DeviceTableProps) {
export function DeviceTable({ devices }: DeviceTableProps) {
const getMachineNumber = useMachineNumber();
const columns: ColumnDef<any>[] = [
@ -145,27 +137,6 @@ export function DeviceTable({
);
},
},
{
header: "Thư mục Setup",
cell: ({ row }) => {
const device = row.original;
const isOffline = device.isOffline;
const macAddress = device.networkInfos?.[0]?.macAddress || device.id;
const folderStatus = folderStatuses?.get(macAddress);
if (isOffline) {
return <span className="text-muted-foreground text-sm">-</span>;
}
return (
<FolderStatusPopover
deviceId={macAddress}
status={folderStatus}
isLoading={isCheckingFolder}
/>
);
},
},
];
const table = useReactTable({

View File

@ -1,30 +0,0 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { type ReactNode, useState } from "react";
interface FormDialogProps {
triggerLabel: string;
title: string;
children: (closeDialog: () => void) => ReactNode;
}
export function FormDialog({ triggerLabel, title, children }: FormDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const closeDialog = () => setIsOpen(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>{triggerLabel}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{children(closeDialog)}
</DialogContent>
</Dialog>
);
}

View File

@ -1,98 +0,0 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { useState, useMemo } from "react";
export interface SelectItem {
label: string;
value: string;
}
interface SelectDialogProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
icon?: React.ReactNode;
items: SelectItem[];
onConfirm: (values: string[]) => Promise<void> | void;
}
export function SelectDialog({
open,
onClose,
title,
description,
icon,
items,
onConfirm,
}: SelectDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const [search, setSearch] = useState("");
const filteredItems = useMemo(() => {
return items.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())
);
}, [items, search]);
const toggleItem = (value: string) => {
setSelected((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
);
};
const handleConfirm = async () => {
await onConfirm(selected);
setSelected([]);
setSearch("");
onClose();
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{icon}
{title}
</DialogTitle>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</DialogHeader>
<Input
placeholder="Tìm kiếm..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="my-2"
/>
<div className="max-h-64 overflow-y-auto space-y-2 mt-2 border rounded p-2">
{filteredItems.map((item) => (
<div key={item.value} className="flex items-center gap-2">
<Checkbox
checked={selected.includes(item.value)}
onCheckedChange={() => toggleItem(item.value)}
/>
<span>{item.label}</span>
</div>
))}
{filteredItems.length === 0 && (
<p className="text-sm text-muted-foreground text-center">Không kết quả</p>
)}
</div>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" onClick={onClose}>
Hủy
</Button>
<Button onClick={handleConfirm} disabled={selected.length === 0}>
Xác nhận
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,145 +0,0 @@
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CheckCircle2, AlertCircle, Loader2, AlertTriangle } from "lucide-react";
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
interface FolderStatusPopoverProps {
deviceId: string;
status?: ClientFolderStatus;
isLoading?: boolean;
}
export function FolderStatusPopover({
deviceId,
status,
isLoading,
}: FolderStatusPopoverProps) {
const hasMissing = status && status.missingFiles.length > 0;
const hasExtra = status && status.extraFiles.length > 0;
const hasIssues = hasMissing || hasExtra;
// Xác định màu sắc và icon dựa trên trạng thái
let statusColor = "text-green-500";
let statusIcon = (
<CheckCircle2 className={`h-5 w-5 ${statusColor}`} />
);
if (isLoading) {
statusColor = "text-blue-500";
statusIcon = <Loader2 className={`h-5 w-5 animate-spin ${statusColor}`} />;
} else if (hasMissing && hasExtra) {
// Vừa thiếu vừa thừa -> Đỏ + Alert
statusColor = "text-red-600";
statusIcon = <AlertTriangle className={`h-5 w-5 ${statusColor}`} />;
} else if (hasMissing) {
// Chỉ thiếu -> Đỏ
statusColor = "text-red-500";
statusIcon = <AlertCircle className={`h-5 w-5 ${statusColor}`} />;
} else if (hasExtra) {
// Chỉ thừa -> Cam
statusColor = "text-orange-500";
statusIcon = <AlertCircle className={`h-5 w-5 ${statusColor}`} />;
}
return (
<Popover>
<PopoverTrigger asChild>
<button className="p-2 hover:bg-muted rounded-md transition-colors">
{statusIcon}
</button>
</PopoverTrigger>
<PopoverContent className="w-96 p-4" side="right">
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="text-sm font-semibold">Thư mục Setup: {deviceId}</div>
{hasIssues && (
<Badge variant="destructive" className="text-xs">
{hasMissing && hasExtra
? "Không đồng bộ"
: hasMissing
? "Thiếu file"
: "Thừa file"}
</Badge>
)}
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Đang kiểm tra...
</div>
) : !status ? (
<div className="text-sm text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<div className="space-y-3">
{/* File thiếu */}
{hasMissing && (
<div className="border-l-4 border-red-500 pl-3">
<h4 className="text-sm font-semibold text-red-600 mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
File thiếu ({status.missingFiles.length})
</h4>
<ScrollArea className="h-32 rounded-md border bg-red-50/30 p-2">
<div className="space-y-2">
{status.missingFiles.map((file, idx) => (
<div
key={idx}
className="text-xs bg-white rounded p-2 border border-red-200"
>
<div className="font-mono font-semibold text-red-700">
{file.fileName}
</div>
<div className="text-xs text-muted-foreground mt-1">
{file.folderPath}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{/* File thừa */}
{hasExtra && (
<div className="border-l-4 border-orange-500 pl-3">
<h4 className="text-sm font-semibold text-orange-600 mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
File thừa ({status.extraFiles.length})
</h4>
<ScrollArea className="h-32 rounded-md border bg-orange-50/30 p-2">
<div className="space-y-2">
{status.extraFiles.map((file, idx) => (
<div
key={idx}
className="text-xs bg-white rounded p-2 border border-orange-200"
>
<div className="font-mono font-semibold text-orange-700">
{file.fileName}
</div>
<div className="text-xs text-muted-foreground mt-1">
{file.folderPath}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{/* Trạng thái OK */}
{!hasIssues && (
<div className="flex items-center gap-2 text-sm text-green-600 bg-green-50/30 rounded p-3 border border-green-200">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<span className="font-medium">Thư mục đt yêu cầu</span>
</div>
)}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -1,67 +0,0 @@
import { FormBuilder, FormField } from "@/components/forms/dynamic-submit-form";
import { type BlacklistFormData } from "@/types/black-list";
import { toast } from "sonner";
interface BlacklistFormProps {
onSubmit: (data: BlacklistFormData) => Promise<void>;
closeDialog: () => void;
initialData?: Partial<BlacklistFormData>;
}
export function BlacklistForm({
onSubmit,
closeDialog,
initialData,
}: BlacklistFormProps) {
return (
<FormBuilder<BlacklistFormData>
defaultValues={{
appName: initialData?.appName || "",
processName: initialData?.processName || "",
}}
onSubmit={async (values: BlacklistFormData) => {
if (!values.appName.trim()) {
toast.error("Vui lòng nhập tên ứng dụng");
return;
}
if (!values.processName.trim()) {
toast.error("Vui lòng nhập tên tiến trình");
return;
}
try {
await onSubmit(values);
toast.success("Thêm phần mềm bị chặn thành công!");
closeDialog();
} catch (error) {
console.error("Error:", error);
toast.error("Có lỗi xảy ra!");
}
}}
submitLabel="Thêm"
cancelLabel="Hủy"
onCancel={closeDialog}
showCancel={true}
>
{(form: any) => (
<>
<FormField<BlacklistFormData, "appName">
form={form}
name="appName"
label="Tên ứng dụng"
placeholder="VD: Google Chrome"
required
/>
<FormField<BlacklistFormData, "processName">
form={form}
name="processName"
label="Tên tiến trình"
placeholder="VD: chrome.exe"
required
/>
</>
)}
</FormBuilder>
);
}

View File

@ -1,164 +0,0 @@
"use client";
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
import { useState } from "react";
export interface ShellCommandData {
command: string;
qos: 0 | 1 | 2;
isRetained: boolean;
}
interface ShellCommandFormProps {
command: string;
onCommandChange: (value: string) => void;
qos?: 0 | 1 | 2;
onQoSChange?: (value: 0 | 1 | 2) => void;
isRetained?: boolean;
onIsRetainedChange?: (value: boolean) => void;
disabled?: boolean;
}
const QoSDescriptions = {
0: {
name: "At Most Once (Fire and Forget)",
description:
"Gửi lệnh một lần mà không đảm bảo. Nhanh nhất, tiêu tốn ít tài nguyên.",
},
1: {
name: "At Least Once",
description:
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Cân bằng giữa tốc độ và độ tin cậy.",
},
2: {
name: "Exactly Once",
description:
"Đảm bảo lệnh được nhận chính xác một lần. Chậm nhất nhưng đáng tin cậy nhất.",
},
};
export function ShellCommandForm({
command,
onCommandChange,
qos = 0,
onQoSChange,
isRetained = false,
onIsRetainedChange,
disabled,
}: ShellCommandFormProps) {
const [selectedQoS, setSelectedQoS] = useState<0 | 1 | 2>(qos);
const form = useForm({
defaultValues: { command },
onSubmit: () => {},
});
const handleQoSChange = (value: string) => {
const newQoS = Number(value) as 0 | 1 | 2;
setSelectedQoS(newQoS);
onQoSChange?.(newQoS);
};
const handleRetainedChange = (checked: boolean) => {
onIsRetainedChange?.(checked);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-5"
>
{/* Command Input */}
<form.Field
name="command"
validators={{
onChange: ({ value }: { value: string }) => {
const schema = z
.string()
.min(1, "Nhập command để thực thi")
.max(500, "Command quá dài");
const result = schema.safeParse(value);
if (!result.success) {
return result.error.issues.map((i) => i.message);
}
return [];
},
}}
children={(field) => (
<div className="w-full space-y-2">
<Label>Nội Dung Lệnh *</Label>
<Textarea
className="w-full h-[20vh] font-mono"
placeholder="VD: shutdown /s /t 60 /c 'Máy sẽ tắt trong 60 giây'"
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
onCommandChange(e.target.value);
}}
disabled={disabled}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
/>
{/* QoS Selection */}
<div className="space-y-2">
<Label>QoS (Quality of Service) *</Label>
<select
value={selectedQoS}
onChange={(e) => handleQoSChange(e.target.value)}
disabled={disabled}
className="w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="0">QoS 0 - At Most Once (Tốc đ cao)</option>
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
<option value="2">QoS 2 - Exactly Once (Đ tin cậy cao)</option>
</select>
{/* QoS Description */}
<Alert className="border-l-4 border-l-blue-500 bg-blue-50 mt-2">
<Info className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-sm text-blue-800 mt-1">
<div className="font-semibold">
{QoSDescriptions[selectedQoS].name}
</div>
<div className="mt-1">{QoSDescriptions[selectedQoS].description}</div>
</AlertDescription>
</Alert>
</div>
{/* Retained Checkbox */}
<div className="flex items-center gap-3 rounded-lg border p-4">
<Checkbox
id="retained"
checked={isRetained}
onCheckedChange={handleRetainedChange}
disabled={disabled}
/>
<div className="flex-1">
<Label htmlFor="retained" className="text-base cursor-pointer">
Lưu giữ lệnh (Retained)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Broker MQTT sẽ lưu lệnh này gửi cho client mới khi kết nối. Hữu ích
cho các lệnh cấu hình cần duy trì trạng thái.
</p>
</div>
</div>
</form>
);
}

View File

@ -1,379 +0,0 @@
import { useForm } from "@tanstack/react-form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
interface CommandRegistryFormProps {
onSubmit: (data: CommandRegistryFormData) => Promise<void>;
closeDialog?: () => void;
initialData?: Partial<CommandRegistryFormData>;
title?: string;
}
export interface CommandRegistryFormData {
commandName: string;
description?: string;
commandContent: string;
qos: 0 | 1 | 2;
isRetained: boolean;
}
// Zod validation schema
const commandRegistrySchema = z.object({
commandName: z
.string()
.min(1, "Tên lệnh không được để trống")
.min(3, "Tên lệnh phải có ít nhất 3 ký tự")
.max(100, "Tên lệnh tối đa 100 ký tự")
.trim(),
description: z.string().max(500, "Mô tả tối đa 500 ký tự").optional(),
commandContent: z
.string()
.min(1, "Nội dung lệnh không được để trống")
.min(5, "Nội dung lệnh phải có ít nhất 5 ký tự")
.trim(),
qos: z.union([z.literal(0), z.literal(1), z.literal(2)]),
isRetained: z.boolean(),
});
const QoSLevels = [
{
level: 0,
name: "At Most Once (Fire and Forget)",
description:
"Gửi lệnh một lần mà không đảm bảo. Nếu broker hoặc client bị ngắt kết nối, lệnh có thể bị mất. Tốc độ nhanh nhất, tiêu tốn ít tài nguyên.",
useCase: "Các lệnh không quan trọng, có thể mất mà không ảnh hưởng",
},
{
level: 1,
name: "At Least Once",
description:
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Có thể gửi lại nếu chưa nhận được ACK. Lệnh có thể được nhận nhiều lần.",
useCase: "Hầu hết các lệnh bình thường cần đảm bảo gửi thành công",
},
{
level: 2,
name: "Exactly Once",
description:
"Đảm bảo lệnh được nhận chính xác một lần. Sử dụng bắt tay 4 chiều để đảm bảo độ tin cậy cao nhất. Tốc độ chậm hơn, tiêu tốn nhiều tài nguyên.",
useCase: "Các lệnh quan trọng như xóa dữ liệu, thay đổi cấu hình",
},
];
export function CommandRegistryForm({
onSubmit,
closeDialog,
initialData,
title = "Đăng ký Lệnh Mới",
}: CommandRegistryFormProps) {
const [selectedQoS, setSelectedQoS] = useState<number>(
initialData?.qos ?? 0
);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm({
defaultValues: {
commandName: initialData?.commandName || "",
description: initialData?.description || "",
commandContent: initialData?.commandContent || "",
qos: (initialData?.qos || 0) as 0 | 1 | 2,
isRetained: initialData?.isRetained || false,
},
onSubmit: async ({ value }) => {
try {
// Validate using Zod
const validatedData = commandRegistrySchema.parse(value);
setIsSubmitting(true);
await onSubmit(validatedData as CommandRegistryFormData);
toast.success("Lưu lệnh thành công!");
if (closeDialog) {
closeDialog();
}
} catch (error: any) {
if (error.errors?.length > 0) {
toast.error(error.errors[0].message);
} else {
console.error("Submit error:", error);
toast.error("Có lỗi xảy ra khi lưu lệnh!");
}
} finally {
setIsSubmitting(false);
}
},
});
return (
<div className="w-full space-y-6">
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>
Tạo cấu hình lệnh MQTT mới đ điều khiển thiết bị
</CardDescription>
</CardHeader>
<CardContent>
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{/* Tên lệnh */}
<form.Field name="commandName">
{(field: any) => (
<div className="space-y-2">
<Label>
Tên Lệnh <span className="text-red-500">*</span>
</Label>
<Input
placeholder="VD: RestartDevice, ShutdownPC, UpdateSoftware..."
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
Tên đnh danh duy nhất cho lệnh này
</p>
</div>
)}
</form.Field>
{/* Mô tả */}
<form.Field name="description">
{(field: any) => (
<div className="space-y-2">
<Label> Tả (Tùy chọn)</Label>
<Textarea
placeholder="Nhập mô tả chi tiết về lệnh này..."
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
rows={3}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
tả chi tiết về chức năng cách sử dụng lệnh
</p>
</div>
)}
</form.Field>
{/* Nội dung lệnh */}
<form.Field name="commandContent">
{(field: any) => (
<div className="space-y-2">
<Label>
Nội Dung Lệnh <span className="text-red-500">*</span>
</Label>
<Textarea
placeholder="VD: shutdown /s /t 30 /c 'Máy sẽ tắt trong 30 giây'"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
rows={5}
className="font-mono text-sm"
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
Nội dung lệnh sẽ đưc gửi tới thiết bị (PowerShell, CMD, bash...)
</p>
</div>
)}
</form.Field>
{/* QoS Level */}
<form.Field name="qos">
{(field: any) => (
<div className="space-y-2">
<Label>
QoS (Quality of Service) <span className="text-red-500">*</span>
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
value={field.state.value}
onChange={(e) => {
const value = Number(e.target.value) as 0 | 1 | 2;
field.handleChange(value);
setSelectedQoS(value);
}}
onBlur={field.handleBlur}
disabled={isSubmitting}
>
<option value="0">QoS 0 - At Most Once (Tốc đ cao)</option>
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
<option value="2">QoS 2 - Exactly Once (Đ tin cậy cao)</option>
</select>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</form.Field>
{/* Chú thích QoS */}
{selectedQoS !== null && (
<Alert className="border-l-4 border-l-blue-500 bg-blue-50">
<Info className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-sm space-y-3 mt-2">
<div>
<div className="font-semibold text-blue-900">
{QoSLevels[selectedQoS].name}
</div>
<div className="text-blue-800 mt-1">
{QoSLevels[selectedQoS].description}
</div>
<div className="text-blue-700 mt-2">
<span className="font-medium">Trường hợp sử dụng:</span>{" "}
{QoSLevels[selectedQoS].useCase}
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* Bảng so sánh QoS */}
<Card className="bg-muted/50">
<CardHeader className="pb-3">
<CardTitle className="text-base">
Bảng So Sánh Các Mức QoS
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-2 font-semibold">
Tiêu Chí
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 0
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 1
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 2
</th>
</tr>
</thead>
<tbody>
<tr className="border-b bg-white">
<td className="py-2 px-2">Đm bảo gửi</td>
<td className="text-center">Không</td>
<td className="text-center"></td>
<td className="text-center">Chính xác</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Tốc đ</td>
<td className="text-center">Nhanh nhất</td>
<td className="text-center">Trung bình</td>
<td className="text-center">Chậm nhất</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Tài nguyên</td>
<td className="text-center">Ít nhất</td>
<td className="text-center">Trung bình</td>
<td className="text-center">Nhiều nhất</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Đ tin cậy</td>
<td className="text-center">Thấp</td>
<td className="text-center">Cao</td>
<td className="text-center">Cao nhất</td>
</tr>
<tr className="bg-white">
<td className="py-2 px-2">Số lần nhận tối đa</td>
<td className="text-center">1 (hoặc 0)</td>
<td className="text-center"> 1</td>
<td className="text-center">1</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* IsRetained Checkbox */}
<form.Field name="isRetained">
{(field: any) => (
<div className="flex items-center gap-3 rounded-lg border p-4">
<Checkbox
checked={field.state.value}
onCheckedChange={field.handleChange}
disabled={isSubmitting}
/>
<div className="flex-1">
<Label className="text-base cursor-pointer">
Lưu giữ lệnh (Retained)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Broker MQTT sẽ lưu lệnh này gửi cho client mới khi
kết nối. Hữu ích cho các lệnh cấu hình cần duy trì trạng
thái.
</p>
</div>
</div>
)}
</form.Field>
{/* Submit Button */}
<div className="flex gap-3 pt-4">
<Button
type="submit"
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting ? "Đang lưu..." : "Lưu Lệnh"}
</Button>
{closeDialog && (
<Button
type="button"
variant="outline"
disabled={isSubmitting}
className="flex-1"
onClick={closeDialog}
>
Hủy
</Button>
)}
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,161 +0,0 @@
import { useForm } from "@tanstack/react-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { type ReactNode } from "react";
interface FormBuilderProps<T extends Record<string, any>> {
defaultValues: T;
onSubmit: (values: T) => Promise<void> | void;
submitLabel?: string;
cancelLabel?: string;
onCancel?: () => void;
showCancel?: boolean;
children: (form: any) => ReactNode;
}
export function FormBuilder<T extends Record<string, any>>({
defaultValues,
onSubmit,
submitLabel = "Submit",
cancelLabel = "Hủy",
onCancel,
showCancel = false,
children,
}: FormBuilderProps<T>) {
const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
try {
await onSubmit(value as T);
} catch (error) {
console.error("Submit error:", error);
toast.error("Có lỗi xảy ra!");
}
},
});
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{children(form)}
<div className="flex justify-end gap-2">
{showCancel && onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
{cancelLabel}
</Button>
)}
<Button type="submit">{submitLabel}</Button>
</div>
</form>
);
}
interface FormFieldProps<T, K extends keyof T> {
form: any;
name: K;
label: string;
type?: string;
placeholder?: string;
required?: boolean;
}
export function FormField<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
type = "text",
placeholder,
required,
}: FormFieldProps<T, K>) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<Input
type={type}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder={placeholder}
/>
</div>
)}
</form.Field>
);
}
export function FormTextarea<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
placeholder,
required,
}: Omit<FormFieldProps<T, K>, "type">) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder={placeholder}
/>
</div>
)}
</form.Field>
);
}
export function FormSelect<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
options,
required,
}: {
form: any;
name: K;
label: string;
options: { value: string; label: string }[];
required?: boolean;
}) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</form.Field>
);
}

View File

@ -1,139 +0,0 @@
import { useForm } from "@tanstack/react-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import type { AxiosProgressEvent } from "axios";
import { useState } from "react";
import { toast } from "sonner";
interface UploadVersionFormProps {
onSubmit: (fd: FormData, config?: { onUploadProgress: (e: AxiosProgressEvent) => void }) => Promise<void>;
closeDialog: () => void;
}
export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormProps) {
const [uploadPercent, setUploadPercent] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [isDone, setIsDone] = useState(false);
// Match server allowed extensions
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1"];
const isFileValid = (file: File) => {
const fileName = file.name.toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
};
const form = useForm({
defaultValues: { files: new DataTransfer().files, newVersion: "" },
onSubmit: async ({ value }) => {
if (!value.newVersion || value.files.length === 0) {
toast.error("Vui lòng điền đầy đủ thông tin");
return;
}
// Validate file types
const invalidFiles = Array.from(value.files).filter((f) => !isFileValid(f));
if (invalidFiles.length > 0) {
toast.error(
`File không hợp lệ: ${invalidFiles.map((f) => f.name).join(", ")}. Chỉ chấp nhận ${ALLOWED_EXTENSIONS.join(", ")}`
);
return;
}
try {
setIsUploading(true);
setUploadPercent(0);
setIsDone(false);
const fd = new FormData();
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
fd.append("Version", value.newVersion);
await onSubmit(fd, {
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.total) {
const progress = Math.round((e.loaded * 100) / e.total);
setUploadPercent(progress);
}
},
});
setIsDone(true);
} catch (error) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
} finally {
setIsUploading(false);
}
},
});
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.Field name="newVersion">
{(field) => (
<div>
<Label>Phiên bản</Label>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="1.0.0"
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
<form.Field name="files">
{(field) => (
<div>
<Label>File</Label>
<Input
type="file"
accept=".exe,.apk,.conf,.json,.xml,.setting,.lnk,.url,.seb"
onChange={(e) => e.target.files && field.handleChange(e.target.files)}
disabled={isUploading || isDone}
/>
<p className="text-xs text-muted-foreground mt-1">
Chỉ chấp nhận file: .exe, .apk, .conf, .json, .xml, .setting, .lnk, .url, .seb
</p>
</div>
)}
</form.Field>
{(uploadPercent > 0 || isUploading || isDone) && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
<span>{uploadPercent}%</span>
</div>
<Progress value={uploadPercent} className="w-full" />
</div>
)}
<div className="flex justify-end gap-2">
{!isDone ? (
<>
<Button type="button" variant="outline" onClick={closeDialog} disabled={isUploading}>
Hủy
</Button>
<Button type="submit" disabled={isUploading}>
{isUploading ? "Đang tải..." : "Upload"}
</Button>
</>
) : (
<Button type="button" onClick={closeDialog}>
Hoàn tất
</Button>
)}
</div>
</form>
);
}

View File

@ -1,140 +0,0 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Loader2, Trash2, ChevronDown, AlertTriangle } from "lucide-react";
import { useState } from "react";
interface DeleteMenuProps {
onDeleteFromServer: () => void;
onDeleteFromRequired: () => void;
loading?: boolean;
label?: string;
serverLabel?: string;
requiredLabel?: string;
}
export function DeleteMenu({
onDeleteFromServer,
onDeleteFromRequired,
loading,
label = "Xóa",
serverLabel = "Xóa khỏi server",
requiredLabel = "Xóa khỏi danh sách yêu cầu",
}: DeleteMenuProps) {
const [open, setOpen] = useState(false);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const handleDeleteFromServer = async () => {
try {
await onDeleteFromServer();
} finally {
setOpen(false);
setShowConfirmDelete(false);
}
};
const handleDeleteFromRequired = async () => {
try {
await onDeleteFromRequired();
} finally {
setOpen(false);
}
};
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="destructive"
disabled={loading}
className="group relative overflow-hidden font-medium px-6 py-2.5 rounded-lg transition-all duration-300 hover:shadow-lg hover:shadow-red-200/50 hover:-translate-y-0.5 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
>
<div className="flex items-center gap-2">
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
<span className="text-sm font-semibold">
{loading ? "Đang xóa..." : label}
</span>
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
<div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
onClick={handleDeleteFromRequired}
disabled={loading}
className="focus:bg-orange-50 focus:text-orange-900"
>
<Trash2 className="h-4 w-4 mr-2 text-orange-600" />
<span>{requiredLabel}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowConfirmDelete(true)}
disabled={loading}
className="focus:bg-red-50 focus:text-red-900"
>
<Trash2 className="h-4 w-4 mr-2 text-red-600" />
<span>{serverLabel}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Confirmation Dialog for Delete from Server */}
{showConfirmDelete && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-sm mx-4 shadow-lg">
<div className="flex items-center gap-3 mb-4">
<AlertTriangle className="h-6 w-6 text-red-600" />
<h3 className="font-semibold text-lg">Cảnh báo: Xóa khỏi server</h3>
</div>
<p className="text-muted-foreground mb-6">
Bạn đang chuẩn bị xóa các phần mềm này khỏi server. Hành đng này <strong>không thể hoàn tác</strong> sẽ xóa vĩnh viễn tất cả các tệp liên quan.
</p>
<p className="text-sm text-red-600 mb-6 font-medium">
Vui lòng chắc chắn trước khi tiếp tục.
</p>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setShowConfirmDelete(false)}
disabled={loading}
>
Hủy
</Button>
<Button
variant="destructive"
onClick={handleDeleteFromServer}
disabled={loading}
className="gap-2"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Đang xóa...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Xóa khỏi server
</>
)}
</Button>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,151 @@
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Checkbox } from "@/components/ui/checkbox"
import { Play, PlayCircle } from "lucide-react"
import { useState } from "react"
interface PresetCommand {
id: string
label: string
command: string
description?: string
}
interface PresetCommandsProps {
onSelectCommand: (command: string) => void
onExecuteMultiple?: (commands: string[]) => void
disabled?: boolean
}
// Danh sách các command có sẵn
const PRESET_COMMANDS: PresetCommand[] = [
{
id: "check-disk",
label: "Kiểm tra dung lượng ổ đĩa",
command: "df -h",
description: "Hiển thị thông tin dung lượng các ổ đĩa",
},
{
id: "check-memory",
label: "Kiểm tra RAM",
command: "free -h",
description: "Hiển thị thông tin bộ nhớ RAM",
},
{
id: "check-cpu",
label: "Kiểm tra CPU",
command: "top -bn1 | head -20",
description: "Hiển thị thông tin CPU và tiến trình",
},
{
id: "list-processes",
label: "Danh sách tiến trình",
command: "ps aux",
description: "Liệt kê tất cả tiến trình đang chạy",
},
{
id: "network-info",
label: "Thông tin mạng",
command: "ifconfig",
description: "Hiển thị cấu hình mạng",
},
{
id: "system-info",
label: "Thông tin hệ thống",
command: "uname -a",
description: "Hiển thị thông tin hệ điều hành",
},
{
id: "uptime",
label: "Thời gian hoạt động",
command: "uptime",
description: "Hiển thị thời gian hệ thống đã chạy",
},
{
id: "reboot",
label: "Khởi động lại",
command: "reboot",
description: "Khởi động lại thiết bị",
},
]
export function PresetCommands({ onSelectCommand, onExecuteMultiple, disabled }: PresetCommandsProps) {
const [selectedCommands, setSelectedCommands] = useState<Set<string>>(new Set())
const handleToggleCommand = (commandId: string) => {
setSelectedCommands((prev) => {
const newSet = new Set(prev)
if (newSet.has(commandId)) {
newSet.delete(commandId)
} else {
newSet.add(commandId)
}
return newSet
})
}
const handleExecuteSelected = () => {
const commands = PRESET_COMMANDS.filter((cmd) => selectedCommands.has(cmd.id)).map((cmd) => cmd.command)
if (commands.length > 0 && onExecuteMultiple) {
onExecuteMultiple(commands)
setSelectedCommands(new Set()) // Clear selection after execution
}
}
const handleSelectAll = () => {
if (selectedCommands.size === PRESET_COMMANDS.length) {
setSelectedCommands(new Set())
} else {
setSelectedCommands(new Set(PRESET_COMMANDS.map((cmd) => cmd.id)))
}
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" onClick={handleSelectAll} disabled={disabled}>
<Checkbox checked={selectedCommands.size === PRESET_COMMANDS.length} className="mr-2" />
{selectedCommands.size === PRESET_COMMANDS.length ? "Bỏ chọn tất cả" : "Chọn tất cả"}
</Button>
{selectedCommands.size > 0 && (
<Button size="sm" onClick={handleExecuteSelected} disabled={disabled}>
<PlayCircle className="h-4 w-4 mr-2" />
Thực thi {selectedCommands.size} lệnh
</Button>
)}
</div>
<ScrollArea className="h-[25vh] w-full rounded-md border p-4">
<div className="space-y-2">
{PRESET_COMMANDS.map((preset) => (
<div
key={preset.id}
className="flex items-start gap-3 rounded-lg border p-3 hover:bg-accent transition-colors"
>
<Checkbox
checked={selectedCommands.has(preset.id)}
onCheckedChange={() => handleToggleCommand(preset.id)}
disabled={disabled}
className="mt-1"
/>
<div className="flex-1 space-y-1">
<div className="font-medium text-sm">{preset.label}</div>
{preset.description && <div className="text-xs text-muted-foreground">{preset.description}</div>}
<code className="text-xs bg-muted px-2 py-1 rounded block mt-1">{preset.command}</code>
</div>
<Button
size="sm"
variant="outline"
onClick={() => onSelectCommand(preset.command)}
disabled={disabled}
className="shrink-0"
>
<Play className="h-4 w-4" />
</Button>
</div>
))}
</div>
</ScrollArea>
</div>
)
}

View File

@ -7,18 +7,12 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Loader2, RefreshCw, ChevronDown } from "lucide-react";
import { useState } from "react";
interface RequestUpdateMenuProps {
onUpdateDevice: () => void;
onUpdateRoom: () => void;
onUpdateAll: () => void;
loading?: boolean;
label?: string;
deviceLabel?: string;
roomLabel?: string;
allLabel?: string;
icon?: React.ReactNode;
}
export function RequestUpdateMenu({
@ -26,39 +20,9 @@ export function RequestUpdateMenu({
onUpdateRoom,
onUpdateAll,
loading,
label = "Cập nhật",
deviceLabel = "Thiết bị cụ thể",
roomLabel = "Theo phòng",
allLabel = "Tất cả thiết bị",
icon,
}: RequestUpdateMenuProps) {
const [open, setOpen] = useState(false);
const handleUpdateDevice = async () => {
try {
await onUpdateDevice();
} finally {
setOpen(false);
}
};
const handleUpdateRoom = async () => {
try {
await onUpdateRoom();
} finally {
setOpen(false);
}
};
const handleUpdateAll = async () => {
try {
await onUpdateAll();
} finally {
setOpen(false);
}
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
@ -68,13 +32,11 @@ export function RequestUpdateMenu({
<div className="flex items-center gap-2">
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-600" />
) : icon ? (
<div className="h-4 w-4 text-gray-600">{icon}</div>
) : (
<RefreshCw className="h-4 w-4 text-gray-600 transition-transform duration-300 group-hover:rotate-180" />
)}
<span className="text-sm font-semibold">
{loading ? "Đang gửi..." : label}
{loading ? "Đang gửi..." : "Cập nhật"}
</span>
<ChevronDown className="h-4 w-4 text-gray-600 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
@ -83,19 +45,19 @@ export function RequestUpdateMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem onClick={handleUpdateDevice} disabled={loading}>
<DropdownMenuItem onClick={onUpdateDevice} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>{deviceLabel}</span>
<span>Cập nhật thiết bị cụ thể</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleUpdateRoom} disabled={loading}>
<DropdownMenuItem onClick={onUpdateRoom} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>{roomLabel}</span>
<span>Cập nhật theo phòng</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleUpdateAll} disabled={loading}>
<DropdownMenuItem onClick={onUpdateAll} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>{allLabel}</span>
<span>Cập nhật tất cả thiết bị</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -0,0 +1,135 @@
import { useEffect, useState, useMemo } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Check, Search } from "lucide-react"
import { Input } from "@/components/ui/input"
interface SelectDialogProps {
open: boolean
onClose: () => void
items: string[] // danh sách chung: có thể là devices hoặc rooms
title?: string // tiêu đề động
description?: string // mô tả ngắn
icon?: React.ReactNode // icon thay đổi tùy loại
onConfirm: (selected: string[]) => void
}
export function SelectDialog({
open,
onClose,
items,
title = "Chọn mục",
description = "Bạn có thể chọn nhiều mục để thao tác",
icon,
onConfirm,
}: SelectDialogProps) {
const [selectedItems, setSelectedItems] = useState<string[]>([])
const [search, setSearch] = useState("")
useEffect(() => {
if (!open) {
setSelectedItems([])
setSearch("")
}
}, [open])
const toggleItem = (item: string) => {
setSelectedItems((prev) =>
prev.includes(item)
? prev.filter((i) => i !== item)
: [...prev, item]
)
}
// Lọc danh sách theo từ khóa
const filteredItems = useMemo(() => {
return items.filter((item) =>
item.toLowerCase().includes(search.toLowerCase())
)
}, [items, search])
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="text-center pb-4">
<div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-3">
{icon ?? <Search className="w-6 h-6 text-primary" />}
</div>
<DialogTitle className="text-xl font-semibold">{title}</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
</DialogHeader>
{/* 🔍 Thanh tìm kiếm */}
<div className="relative mb-3">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Tìm kiếm..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Danh sách các item */}
<div className="py-3 space-y-3 max-h-64 overflow-y-auto">
{filteredItems.length > 0 ? (
filteredItems.map((item) => (
<div
key={item}
className="flex items-center justify-between p-3 rounded-lg border border-border hover:border-primary/60 hover:bg-accent/50 transition-all duration-200 cursor-pointer"
onClick={() => toggleItem(item)}
>
<div className="flex items-center gap-3">
<Checkbox
checked={selectedItems.includes(item)}
onCheckedChange={() => toggleItem(item)}
/>
<Label className="font-medium cursor-pointer hover:text-primary">
{item}
</Label>
</div>
{selectedItems.includes(item) && (
<div className="w-5 h-5 bg-primary rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
)}
</div>
))
) : (
<p className="text-center text-sm text-muted-foreground py-4">
Không tìm thấy kết quả
</p>
)}
</div>
<DialogFooter className="gap-2 pt-4">
<Button variant="outline" onClick={onClose} className="flex-1 sm:flex-none">
Hủy
</Button>
<Button
onClick={() => {
if (selectedItems.length > 0) {
onConfirm(selectedItems)
onClose()
}
}}
disabled={selectedItems.length === 0}
className="flex-1 sm:flex-none"
>
<Check className="w-4 h-4 mr-2" />
Xác nhận ({selectedItems.length})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -3,7 +3,7 @@ import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/useMobile"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

View File

@ -0,0 +1,164 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { useForm, formOptions } from "@tanstack/react-form";
import { toast } from "sonner";
import type { AxiosProgressEvent } from "axios";
interface UploadDialogProps {
onSubmit: (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => Promise<void>;
}
const formOpts = formOptions({
defaultValues: { files: new DataTransfer().files, newVersion: "" },
});
export function UploadDialog({ onSubmit }: UploadDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [isDone, setIsDone] = useState(false);
const form = useForm({
...formOpts,
onSubmit: async ({ value }) => {
if (!value.newVersion || value.files.length === 0) {
toast.error("Vui lòng điền đầy đủ thông tin");
return;
}
try {
setIsUploading(true);
setUploadPercent(0);
setIsDone(false);
const fd = new FormData();
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
fd.append("Version", value.newVersion);
await onSubmit(fd, {
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.total) {
const progress = Math.round((e.loaded * 100) / e.total);
setUploadPercent(progress);
}
},
});
setIsDone(true);
} catch (error) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
} finally {
setIsUploading(false);
}
},
});
const handleDialogClose = (open: boolean) => {
if (isUploading) return;
setIsOpen(open);
if (!open) {
setUploadPercent(0);
setIsDone(false);
form.reset();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleDialogClose}>
<DialogTrigger asChild>
<Button>Tải lên phiên bản mới</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Cập nhật phiên bản</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field name="newVersion">
{(field) => (
<div>
<Label>Phiên bản</Label>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="1.0.0"
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
<form.Field name="files">
{(field) => (
<div>
<Label>File</Label>
<Input
type="file"
accept=".exe,.msi,.apk"
onChange={(e) =>
e.target.files && field.handleChange(e.target.files)
}
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
{(uploadPercent > 0 || isUploading || isDone) && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
<span>{uploadPercent}%</span>
</div>
<Progress value={uploadPercent} className="w-full" />
</div>
)}
<DialogFooter>
{!isDone ? (
<>
<Button
type="button"
variant="outline"
onClick={() => handleDialogClose(false)}
disabled={isUploading}
>
Hủy
</Button>
<Button type="submit" disabled={isUploading}>
{isUploading ? "Đang tải..." : "Upload"}
</Button>
</>
) : (
<Button type="button" onClick={() => handleDialogClose(false)}>
Hoàn tất
</Button>
)}
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -18,7 +18,7 @@ interface VersionTableProps<TData> {
data: TData[];
columns: ColumnDef<TData, any>[];
isLoading: boolean;
onTableInit?: (table: any) => void;
onTableInit?: (table: any) => void; // <-- thêm
}
export function VersionTable<TData>({

View File

@ -6,49 +6,28 @@ export const BASE_URL = isDev
export const API_ENDPOINTS = {
APP_VERSION: {
//agent and app api
GET_VERSION: `${BASE_URL}/AppVersion/version`,
UPLOAD: `${BASE_URL}/AppVersion/upload`,
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
//blacklist api
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
DELETE_BLACKLIST: (appId: number) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
DELETE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
UPDATE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/update/${appId}`,
REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
//require file api
GET_REQUIRED_FILES: `${BASE_URL}/AppVersion/requirefiles`,
ADD_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/add`,
DELETE_REQUIRED_FILE: (fileId: number) => `${BASE_URL}/AppVersion/requirefile/delete/${fileId}`,
DELETE_FILES: (fileId: number) => `${BASE_URL}/AppVersion/delete/${fileId}`,
},
DEVICE_COMM: {
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
INSTALL_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
GET_DEVICE_FROM_ROOM: (roomName: string) =>
`${BASE_URL}/DeviceComm/room/${roomName}`,
UPDATE_AGENT: (roomName: string) => `${BASE_URL}/DeviceComm/updateagent/${roomName}`,
UPDATE_BLACKLIST: (roomName: string) => `${BASE_URL}/DeviceComm/updateblacklist/${roomName}`,
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
REQUEST_GET_CLIENT_FOLDER_STATUS: (roomName: string) =>
`${BASE_URL}/DeviceComm/clientfolderstatus/${roomName}`,
},
COMMAND:
{
ADD_COMMAND: `${BASE_URL}/Command/add`,
GET_COMMANDS: `${BASE_URL}/Command/all`,
UPDATE_COMMAND: (commandId: number) => `${BASE_URL}/Command/update/${commandId}`,
DELETE_COMMAND: (commandId: number) => `${BASE_URL}/Command/delete/${commandId}`,
},
SSE_EVENTS: {
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
GET_CLIENT_FOLDER_STATUS: `${BASE_URL}/Sse/events/clientFolderStatuses`,
},
};

View File

@ -1,9 +0,0 @@
import type { Room } from "@/types/room";
import type { SelectItem } from "@/components/dialogs/select-dialog";
export function mapRoomsToSelectItems(rooms: Room[]): SelectItem[] {
return rooms.map((room) => ({
label: `${room.name} (${room.numberOfDevices} máy, ${room.numberOfOfflineDevices} offline)`,
value: room.name,
}));
}

View File

@ -1,83 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { API_ENDPOINTS } from "@/config/api";
export interface MissingFiles {
fileName: string;
folderPath: string;
}
export interface ExtraFiles {
fileName: string;
folderPath: string;
}
export interface ClientFolderStatus {
id: number;
deviceId: string;
missingFiles: MissingFiles[];
extraFiles: ExtraFiles[];
createdAt: string;
updatedAt: string;
}
export function useClientFolderStatus(roomName?: string) {
const queryClient = useQueryClient();
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [folderStatuses, setFolderStatuses] = useState<
Map<string, ClientFolderStatus>
>(new Map());
useEffect(() => {
let eventSource: EventSource | null = null;
const connect = () => {
eventSource = new EventSource(
API_ENDPOINTS.SSE_EVENTS.GET_CLIENT_FOLDER_STATUS
);
eventSource.addEventListener("clientFolderStatus", (event) => {
try {
const data: ClientFolderStatus = JSON.parse(event.data);
if (roomName && data.deviceId) {
setFolderStatuses((prev) => {
const newMap = new Map(prev);
newMap.set(data.deviceId, data);
return newMap;
});
// Also cache in React Query for persistence
queryClient.setQueryData(
["folderStatus", data.deviceId],
data
);
}
} catch (err) {
console.error("Error parsing clientFolderStatus event:", err);
}
});
const onError = (err: any) => {
console.error("SSE connection error:", err);
cleanup();
reconnectTimeout.current = setTimeout(connect, 5000);
};
eventSource.onerror = onError;
};
const cleanup = () => {
if (eventSource) eventSource.close();
if (reconnectTimeout.current) {
clearTimeout(reconnectTimeout.current);
reconnectTimeout.current = null;
}
};
connect();
return cleanup;
}, [roomName, queryClient]);
return folderStatuses;
}

View File

@ -1,37 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
type DeleteDataOptions<TOutput> = {
onSuccess?: (data: TOutput) => void;
onError?: (error: any) => void;
invalidate?: string[][];
};
export function useDeleteData<TOutput = any>({
onSuccess,
onError,
invalidate = [],
}: DeleteDataOptions<TOutput> = {}) {
const queryClient = useQueryClient();
return useMutation<
TOutput,
any,
{
url: string;
config?: any;
}
>({
mutationFn: async ({ url, config }) => {
const response = await axios.delete(url, config);
return response.data;
},
onSuccess: (data) => {
invalidate.forEach((key) =>
queryClient.invalidateQueries({ queryKey: key })
);
onSuccess?.(data);
},
onError,
});
}

View File

@ -42,14 +42,10 @@ export function useMutationData<TInput = any, TOutput = any>({
});
return response.data;
},
onSuccess: async (data) => {
// Invalidate queries trước
await Promise.all(
invalidate.map((key) =>
queryClient.invalidateQueries({ queryKey: key })
)
onSuccess: (data) => {
invalidate.forEach((key) =>
queryClient.invalidateQueries({ queryKey: key })
);
// Sau đó gọi callback
onSuccess?.(data);
},
onError,

View File

@ -1,13 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
type QueryDataOptions<T> = {
queryKey: string[];
url: string;
params?: Record<string, any>;
select?: (data: any) => T;
enabled?: boolean;
};
queryKey: string[];
url: string;
params?: Record<string, any>;
select?: (data: any) => T;
enabled?: boolean;
}
export function useQueryData<T = any>({
queryKey,
@ -21,5 +21,6 @@ export function useQueryData<T = any>({
queryFn: () => axios.get(url, { params }).then((res) => res.data),
select,
enabled,
});
})
}

View File

@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { AppSidebar } from "@/components/sidebars/app-sidebar";
import { AppSidebar } from "@/components/app-sidebar";
import {
SidebarProvider,
SidebarInset,
@ -8,7 +8,7 @@ import {
import { Home, Building, AppWindow, Terminal, CircleX } from "lucide-react";
import { Toaster } from "@/components/ui/sonner";
import { useQueryClient } from "@tanstack/react-query";
import { API_ENDPOINTS } from "@/config/api";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { Separator } from "@/components/ui/separator";
type AppLayoutProps = {
@ -22,7 +22,7 @@ export default function AppLayout({ children }: AppLayoutProps) {
queryClient.prefetchQuery({
queryKey: ["agent-version"],
queryFn: () =>
fetch(API_ENDPOINTS.APP_VERSION.GET_VERSION).then((res) =>
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION).then((res) =>
res.json()
),
staleTime: 60 * 1000,
@ -32,7 +32,7 @@ export default function AppLayout({ children }: AppLayoutProps) {
queryClient.prefetchQuery({
queryKey: ["software-version"],
queryFn: () =>
fetch(API_ENDPOINTS.APP_VERSION.GET_SOFTWARE).then((res) =>
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE).then((res) =>
res.json()
),
staleTime: 60 * 1000,
@ -43,7 +43,7 @@ export default function AppLayout({ children }: AppLayoutProps) {
queryClient.prefetchQuery({
queryKey: ["room-list"],
queryFn: () =>
fetch(API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST).then((res) =>
fetch(BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST).then((res) =>
res.json()
),
staleTime: 60 * 1000,
@ -54,7 +54,7 @@ export default function AppLayout({ children }: AppLayoutProps) {
queryClient.prefetchQuery({
queryKey: ["blacklist"],
queryFn: () =>
fetch(API_ENDPOINTS.APP_VERSION + "").then((res) =>
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION).then((res) =>
res.json()
),
staleTime: 60 * 1000,

View File

@ -10,7 +10,7 @@ import "./styles.css";
const auth = useAuthToken.getState();
export const queryClient = new QueryClient();
const queryClient = new QueryClient();
// Create a new router instance
const router = createRouter({

View File

@ -2,11 +2,20 @@ import { createFileRoute } from "@tanstack/react-router";
import { AppManagerTemplate } from "@/template/app-manager-template";
import { useQueryData } from "@/hooks/useQueryData";
import { useMutationData } from "@/hooks/useMutationData";
import { API_ENDPOINTS } from "@/config/api";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import type { AxiosProgressEvent } from "axios";
import type { Version } from "@/types/file";
import { type Room } from "@/types/room";
type Version = {
id?: string;
version: string;
fileName: string;
folderPath: string;
updatedAt?: string;
requestUpdateAt?: string;
};
export const Route = createFileRoute("/_authenticated/agent/")({
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
@ -17,23 +26,29 @@ function AgentsPage() {
// Lấy danh sách version
const { data, isLoading } = useQueryData({
queryKey: ["agent-version"],
url: API_ENDPOINTS.APP_VERSION.GET_VERSION,
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
});
// Lấy danh sách phòng
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
const versionList: Version[] = Array.isArray(data)
? data
: data
? [data]
: [];
// Mutation upload
const uploadMutation = useMutationData<FormData>({
url: API_ENDPOINTS.APP_VERSION.UPLOAD,
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
method: "POST",
invalidate: [["agent-version"]],
onSuccess: () => toast.success("Upload thành công!"),
@ -47,10 +62,7 @@ function AgentsPage() {
url: "",
method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
onError: (error) => {
console.error("Update mutation error:", error);
toast.error("Gửi yêu cầu thất bại!");
},
onError: () => toast.error("Gửi yêu cầu thất bại!"),
});
const handleUpload = async (
@ -64,18 +76,18 @@ function AgentsPage() {
};
const handleUpdate = async (roomNames: string[]) => {
try {
for (const roomName of roomNames) {
for (const roomName of roomNames) {
try {
await updateMutation.mutateAsync({
url: API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT(roomName),
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT(roomName),
method: "POST",
data: undefined
});
} catch {
toast.error(`Gửi yêu cầu thất bại cho ${roomName}`);
}
toast.success("Đã gửi yêu cầu update cho các phòng đã chọn!");
} catch (e) {
toast.error("Có lỗi xảy ra khi cập nhật!");
}
toast.success("Đã gửi yêu cầu update cho các phòng đã chọn!");
};
// Cột bảng
@ -111,7 +123,7 @@ function AgentsPage() {
onUpload={handleUpload}
onUpdate={handleUpdate}
updateLoading={updateMutation.isPending}
rooms={roomData}
rooms={rooms}
/>
);
}

View File

@ -2,30 +2,43 @@ import { createFileRoute } from "@tanstack/react-router";
import { AppManagerTemplate } from "@/template/app-manager-template";
import { useQueryData } from "@/hooks/useQueryData";
import { useMutationData } from "@/hooks/useMutationData";
import { API_ENDPOINTS } from "@/config/api";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import { useState } from "react";
import type { AxiosProgressEvent } from "axios";
import type { Version } from "@/types/file";
import { Check, X } from "lucide-react";
import type { Room } from "@/types/room";
export const Route = createFileRoute("/_authenticated/apps/")({
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
component: AppsComponent,
});
type Version = {
id: number;
version: string;
fileName: string;
folderPath: string;
updatedAt?: string;
requestUpdateAt?: string;
};
function AppsComponent() {
const { data, isLoading } = useQueryData({
queryKey: ["software-version"],
url: API_ENDPOINTS.APP_VERSION.GET_SOFTWARE,
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE,
});
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
const versionList: Version[] = Array.isArray(data)
? data
: data
@ -35,7 +48,7 @@ function AppsComponent() {
const [table, setTable] = useState<any>();
const uploadMutation = useMutationData<FormData>({
url: API_ENDPOINTS.APP_VERSION.UPLOAD,
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
method: "POST",
invalidate: [["software-version"]],
onSuccess: () => toast.success("Upload thành công!"),
@ -48,59 +61,13 @@ function AppsComponent() {
const installMutation = useMutationData<{ MsiFileIds: number[] }>({
url: "",
method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt file!"),
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
onError: (error) => {
console.error("Install error:", error);
toast.error("Gửi yêu cầu thất bại!");
},
});
const downloadMutation = useMutationData<{ MsiFileIds: number[] }>({
url: "",
method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu tải file!"),
onError: (error) => {
console.error("Download error:", error);
toast.error("Gửi yêu cầu thất bại!");
},
});
const deleteMutation = useMutationData<{ MsiFileIds: number[] }>({
url: API_ENDPOINTS.APP_VERSION.DELETE_FILES + "",
method: "POST",
invalidate: [["software-version"]],
onSuccess: () => toast.success("Xóa phần mềm thành công!"),
onError: (error) => {
console.error("Delete error:", error);
toast.error("Xóa phần mềm thất bại!");
},
});
const addRequiredFileMutation = useMutationData<{
fileName: string;
version: string;
}>({
url: API_ENDPOINTS.APP_VERSION.ADD_REQUIRED_FILE,
method: "POST",
invalidate: [["software-version"]],
onSuccess: () => toast.success("Thêm file vào danh sách thành công!"),
onError: (error) => {
console.error("Add required file error:", error);
toast.error("Thêm file vào danh sách thất bại!");
},
});
const deleteRequiredFileMutation = useMutationData<{ id: number }>({
url: "",
method: "POST",
invalidate: [["software-version"]],
onSuccess: () => toast.success("Xóa file khỏi danh sách thành công!"),
onError: (error) => {
console.error("Delete required file error:", error);
toast.error("Xóa file khỏi danh sách thất bại!");
},
});
// Cột bảng
const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
@ -108,7 +75,7 @@ function AppsComponent() {
{ accessorKey: "folderPath", header: "Đường dẫn" },
{
accessorKey: "updatedAt",
header: () => <div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>,
header: "Thời gian cập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
@ -116,35 +83,11 @@ function AppsComponent() {
},
{
accessorKey: "requestUpdateAt",
header: () => <div className="whitespace-normal max-w-xs">Thời gian yêu cầu cài đt/tải xuống</div>,
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
id: "required",
header: () => <div className="whitespace-normal max-w-xs">Đã thêm vào danh sách</div>,
cell: ({ row }) => {
const isRequired = row.original.isRequired;
return isRequired ? (
<div className="flex items-center gap-1">
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</div>
) : (
<div className="flex items-center gap-1">
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</div>
);
},
enableSorting: false,
enableHiding: false,
header: "Thời gian yêu cầu cài đặt",
},
{
id: "select",
header: () => <div className="whitespace-normal max-w-xs">Chọn</div>,
header: () => <span>Thêm vào danh sách yêu cầu</span>,
cell: ({ row }) => (
<input
type="checkbox"
@ -184,152 +127,28 @@ function AppsComponent() {
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const roomName of roomNames) {
await installMutation.mutateAsync({
url: API_ENDPOINTS.DEVICE_COMM.INSTALL_MSI(roomName),
data: { MsiFileIds },
});
}
toast.success("Đã gửi yêu cầu cài đặt phần mềm cho các phòng đã chọn!");
} catch (e) {
toast.error("Có lỗi xảy ra khi cài đặt!");
}
};
const handleDonwload = async (roomNames: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
for (const roomName of roomNames) {
await installMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI(roomName),
data: { MsiFileIds },
});
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const roomName of roomNames) {
await downloadMutation.mutateAsync({
url: API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_FILES(roomName),
data: { MsiFileIds },
});
}
toast.success("Đã gửi yêu cầu cài đặt phần mềm cho các phòng đã chọn!");
} catch (e) {
toast.error("Có lỗi xảy ra khi cài đặt!");
}
};
const handleDelete = async () => {
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 file để xóa!");
return;
}
};
const handleDeleteFromRequiredList = async () => {
if (!table) return;
const selectedRows = table.getSelectedRowModel().rows;
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteRequiredFileMutation.mutateAsync({
data: { id },
url: API_ENDPOINTS.APP_VERSION.DELETE_REQUIRED_FILE(id),
});
}
if (table) {
table.setRowSelection({});
}
} catch (e) {
console.error("Delete from required list error:", e);
toast.error("Có lỗi xảy ra khi xóa!");
}
};
const handleDeleteFromServer = async () => {
if (!table) return;
const selectedRows = table.getSelectedRowModel().rows;
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteMutation.mutateAsync({
data: { MsiFileIds: [id] },
url: API_ENDPOINTS.APP_VERSION.DELETE_FILES(id),
});
}
if (table) {
table.setRowSelection({});
}
} catch (e) {
console.error("Delete error:", e);
toast.error("Có lỗi xảy ra khi xóa!");
}
};
const handleAddToRequired = async () => {
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 file!");
return;
}
try {
for (const row of selectedRows) {
const { fileName, version } = row.original;
await addRequiredFileMutation.mutateAsync({
data: { fileName, version },
});
}
table.setRowSelection({});
} catch (e) {
console.error("Add required file error:", e);
toast.error("Có lỗi xảy ra!");
}
toast.success("Đã gửi yêu cầu cài đặt phần mềm cho các phòng đã chọn!");
};
return (
<>
<AppManagerTemplate<Version>
title="Quản lý phần mềm"
uploadFormTitle="Tải lên || Cập nhật file phần mềm"
description="Quản lý và gửi yêu cầu cài đặt phần mềm hoặc file cấu hình"
data={versionList}
isLoading={isLoading}
columns={columns}
onUpload={handleUpload}
onUpdate={handleInstall}
onDownload={handleDonwload}
onDelete={handleDelete}
onDeleteFromServer={handleDeleteFromServer}
onDeleteFromRequired={handleDeleteFromRequiredList}
onAddToRequired={handleAddToRequired}
updateLoading={installMutation.isPending}
downloadLoading={downloadMutation.isPending}
deleteLoading={deleteMutation.isPending || deleteRequiredFileMutation.isPending}
addToRequiredLoading={addRequiredFileMutation.isPending}
onTableInit={setTable}
rooms={roomData}
/>
</>
<AppManagerTemplate<Version>
title="Quản lý phần mềm"
description="Quản lý và gửi yêu cầu cài đặt phần mềm MSI"
data={versionList}
isLoading={isLoading}
columns={columns}
onUpload={handleUpload}
onUpdate={handleInstall}
updateLoading={installMutation.isPending}
onTableInit={setTable}
rooms={rooms}
/>
);
}

View File

@ -1,181 +1,66 @@
import { API_ENDPOINTS } from "@/config/api";
import { useMutationData } from "@/hooks/useMutationData";
import { useDeleteData } from "@/hooks/useDeleteData";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { useQueryData } from "@/hooks/useQueryData";
import { createFileRoute } from "@tanstack/react-router";
import type { ColumnDef } from "@tanstack/react-table";
import type { Blacklist } from "@/types/black-list";
import { BlackListManagerTemplate } from "@/template/table-manager-template";
import { toast } from "sonner";
import { useState } from "react";
type Blacklist = {
id: number;
appName: string;
processName: string;
createdAt?: string;
updatedAt?: string;
createdBy?: string;
};
export const Route = createFileRoute("/_authenticated/blacklist/")({
head: () => ({ meta: [{ title: "Danh sách các ứng dụng bị chặn" }] }),
component: BlacklistComponent,
});
function BlacklistComponent() {
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
// Lấy danh sách blacklist
const { data, isLoading } = useQueryData({
queryKey: ["blacklist"],
url: API_ENDPOINTS.APP_VERSION.GET_VERSION,
});
// Lấy danh sách phòng
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
});
const blacklist: Blacklist[] = Array.isArray(data)
? (data as Blacklist[])
: [];
const columns: ColumnDef<Blacklist>[] = [
const columns : ColumnDef<Blacklist>[] =
[
{
accessorKey: "id",
header: "STT",
cell: (info) => info.getValue(),
accessorKey: "id",
header: "ID",
cell: info => info.getValue(),
},
{
accessorKey: "appName",
header: "Tên ứng dụng",
cell: (info) => info.getValue(),
accessorKey: "appName",
header: "Tên ứng dụng",
cell: info => info.getValue(),
},
{
accessorKey: "processName",
header: "Tên tiến trình",
cell: (info) => info.getValue(),
accessorKey: "processName",
header: "Tên tiến trình",
cell: info => info.getValue(),
},
{
accessorKey: "createdAt",
header: "Ngày tạo",
cell: (info) => info.getValue(),
accessorKey: "createdAt",
header: "Ngày tạo",
cell: info => info.getValue(),
},
{
accessorKey: "updatedAt",
header: "Ngày cập nhật",
cell: (info) => info.getValue(),
accessorKey: "updatedAt",
header: "Ngày cập nhật",
cell: info => info.getValue(),
},
{
accessorKey: "createdBy",
header: "Người tạo",
cell: (info) => info.getValue(),
accessorKey: "createdBy",
header: "Người tạo",
cell: info => info.getValue(),
},
{
id: "select",
header: () => (
<input
type="checkbox"
onChange={(e) => {
if (e.target.checked) {
const allIds = data.map((item: { id: number }) => item.id);
setSelectedRows(new Set(allIds));
} else {
setSelectedRows(new Set());
}
}}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={selectedRows.has(row.original.id)}
onChange={(e) => {
const newSelected = new Set(selectedRows);
if (e.target.checked) {
newSelected.add(row.original.id);
} else {
newSelected.delete(row.original.id);
}
setSelectedRows(newSelected);
}}
/>
),
},
];
]
// API thêm blacklist
const addNewBlacklistMutation = useMutationData<void>({
url: "",
method: "POST",
onSuccess: () => toast.success("Thêm mới thành công!"),
onError: () => toast.error("Thêm mới thất bại!"),
});
// API cập nhật thiết bị
const updateDeviceMutation = useMutationData<void>({
url: "",
method: "POST",
onSuccess: () => toast.success("Cập nhật thành công!"),
onError: () => toast.error("Cập nhật thất bại!"),
});
// API xoá
const deleteBlacklistMutation = useDeleteData<void>({
invalidate: [["blacklist"]],
onSuccess: () => toast.success("Xóa thành công!"),
onError: () => toast.error("Xóa thất bại!"),
});
// Thêm blacklist
const handleAddNewBlacklist = async (blacklist: {
appName: string;
processName: string;
}) => {
try {
await addNewBlacklistMutation.mutateAsync({
url: API_ENDPOINTS.APP_VERSION.ADD_BLACKLIST,
method: "POST",
config: { headers: { "Content-Type": "application/json" } },
data: undefined,
});
} catch {
toast.error("Thêm mới thất bại!");
}
};
// Xoá blacklist
const handleDeleteBlacklist = async () => {
try {
for (const blacklistId of selectedRows) {
await deleteBlacklistMutation.mutateAsync({
url:
API_ENDPOINTS.APP_VERSION.DELETE_BLACKLIST(blacklistId),
config: { headers: { "Content-Type": "application/json" } },
});
}
setSelectedRows(new Set());
} catch {}
};
const handleUpdateDevice = async (target: string | string[]) => {
const targets = Array.isArray(target) ? target : [target];
try {
for (const deviceId of targets) {
await updateDeviceMutation.mutateAsync({
url: API_ENDPOINTS.DEVICE_COMM.UPDATE_BLACKLIST(deviceId),
data: undefined,
});
toast.success(`Đã gửi cập nhật cho ${deviceId}`);
}
} catch (e) {
toast.error("Có lỗi xảy ra khi cập nhật!");
}
};
return (
<BlackListManagerTemplate<Blacklist>
title="Danh sách các ứng dụng bị chặn"
description="Quản lý các ứng dụng và tiến trình bị chặn trên thiết bị"
data={blacklist}
columns={columns}
isLoading={isLoading}
rooms={roomData}
onAdd={handleAddNewBlacklist}
onUpdate={handleUpdateDevice}
/>
);
return <div>Hello "/_authenticated/blacklist/"!</div>;
}

View File

@ -1,344 +1,73 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { CommandSubmitTemplate } from "@/template/command-submit-template";
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
import { useQueryData } from "@/hooks/useQueryData";
import { FormSubmitTemplate } from "@/template/form-submit-template";
import { ShellCommandForm } from "@/components/command-form";
import { useMutationData } from "@/hooks/useMutationData";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import type { ColumnDef } from "@tanstack/react-table";
import { useQueryData } from "@/hooks/useQueryData";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
import { Check, X, Edit2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ShellCommandData } from "@/components/forms/command-form";
import type { Room } from "@/types/room";
interface CommandRegistry {
id: number;
commandName: string;
description?: string;
commandContent: string;
qoS: 0 | 1 | 2;
isRetained: boolean;
createdAt?: string;
updatedAt?: string;
}
type SendCommandRequest = { Command: string };
type SendCommandResponse = { status: string; message: string };
export const Route = createFileRoute("/_authenticated/command/")({
head: () => ({ meta: [{ title: "Gửi lệnh từ xa" }] }),
head: () => ({ meta: [{ title: "Gửi lệnh CMD" }] }),
component: CommandPage,
});
function CommandPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedCommand, setSelectedCommand] = useState<CommandRegistry | null>(null);
const [table, setTable] = useState<any>();
// Fetch commands
const { data: commands = [], isLoading } = useQueryData({
queryKey: ["commands"],
url: API_ENDPOINTS.COMMAND.GET_COMMANDS,
});
// Fetch rooms
// Lấy danh sách phòng từ API
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
const commandList: CommandRegistry[] = Array.isArray(commands)
? commands.map((cmd: any) => ({
...cmd,
qoS: cmd.qoS ?? 0,
isRetained: cmd.isRetained ?? false,
}))
: commands
? [{
...commands,
qoS: commands.qoS ?? 0,
isRetained: commands.isRetained ?? false,
}]
: [];
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
// Add command mutation
const addCommandMutation = useMutationData<CommandRegistryFormData>({
url: API_ENDPOINTS.COMMAND.ADD_COMMAND,
// Mutation gửi lệnh
const sendCommandMutation = useMutationData<
SendCommandRequest,
SendCommandResponse
>({
url: "", // sẽ set động theo roomName khi gọi
method: "POST",
invalidate: [["commands"]],
onSuccess: () => toast.success("Thêm lệnh thành công!"),
onError: (error) => {
console.error("Add command error:", error);
toast.error("Thêm lệnh thất bại!");
},
});
// Update command mutation
const updateCommandMutation = useMutationData<CommandRegistryFormData>({
url: "",
method: "POST",
invalidate: [["commands"]],
onSuccess: () => toast.success("Cập nhật lệnh thành công!"),
onError: (error) => {
console.error("Update command error:", error);
toast.error("Cập nhật lệnh thất bại!");
},
});
// Delete command mutation
const deleteCommandMutation = useMutationData<any>({
url: "",
method: "DELETE",
invalidate: [["commands"]],
onSuccess: () => toast.success("Xóa lệnh thành công!"),
onError: (error) => {
console.error("Delete command error:", error);
toast.error("Xóa lệnh thất bại!");
},
});
// Execute command mutation
const executeCommandMutation = useMutationData<{
commandIds?: number[];
command?: ShellCommandData;
}>({
url: "",
method: "POST",
onSuccess: () => toast.success("Gửi yêu cầu thực thi lệnh thành công!"),
onError: (error) => {
console.error("Execute command error:", error);
toast.error("Gửi yêu cầu thực thi thất bại!");
},
});
// Columns for command table
const columns: ColumnDef<CommandRegistry>[] = [
{
accessorKey: "commandName",
header: "Tên lệnh",
cell: ({ getValue }) => (
<span className="font-semibold break-words">{getValue() as string}</span>
),
},
{
accessorKey: "description",
header: "Mô tả",
cell: ({ getValue }) => (
<span className="text-sm text-muted-foreground break-words whitespace-normal">
{(getValue() as string) || "-"}
</span>
),
},
{
accessorKey: "commandContent",
header: "Nội dung lệnh",
cell: ({ getValue }) => (
<code className="text-xs bg-muted/50 p-1 rounded break-words whitespace-normal block">
{(getValue() as string).substring(0, 100)}...
</code>
),
},
{
accessorKey: "qoS",
header: "QoS",
cell: ({ getValue }) => {
const qos = getValue() as number | undefined;
const qosValue = qos !== undefined ? qos : 0;
const colors = {
0: "text-blue-600",
1: "text-amber-600",
2: "text-red-600",
};
return (
<span className={colors[qosValue as 0 | 1 | 2]}>{qosValue}</span>
);
},
},
{
accessorKey: "isRetained",
header: "Lưu trữ",
cell: ({ getValue }) => {
const retained = getValue() as boolean;
return retained ? (
<div className="flex items-center gap-1">
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</div>
) : (
<div className="flex items-center gap-1">
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</div>
);
},
},
{
id: "select",
header: () => <div className="text-center text-xs">Chọn đ thực thi</div>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
/>
),
enableSorting: false,
enableHiding: false,
},
{
id: "actions",
header: () => <div className="text-center text-xs">Hành đng</div>,
cell: ({ row }) => (
<div className="flex gap-2 justify-center">
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedCommand(row.original);
setIsDialogOpen(true);
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteCommand(row.original.id)}
disabled={deleteCommandMutation.isPending}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
),
enableSorting: false,
enableHiding: false,
},
];
// Handle form submit
const handleFormSubmit = async (data: CommandRegistryFormData) => {
if (selectedCommand) {
// Update
await updateCommandMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.COMMAND.UPDATE_COMMAND(selectedCommand.id),
data,
});
} else {
// Add
await addCommandMutation.mutateAsync({
data,
});
}
setIsDialogOpen(false);
setSelectedCommand(null);
};
// Handle delete
const handleDeleteCommand = async (commandId: number) => {
if (!confirm("Bạn có chắc muốn xóa lệnh này?")) return;
try {
await deleteCommandMutation.mutateAsync({
url: API_ENDPOINTS.COMMAND.DELETE_COMMAND(commandId),
data: null,
});
} catch (error) {
console.error("Delete error:", error);
}
};
// Handle execute commands from list
const handleExecuteSelected = async (targets: string[]) => {
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 lệnh để thực thi!");
return;
}
try {
for (const target of targets) {
for (const row of selectedRows) {
// API expects PascalCase directly
const apiData = {
Command: row.original.commandContent,
QoS: row.original.qoS,
IsRetained: row.original.isRetained,
};
console.log("[DEBUG] Sending to:", target, "Data:", apiData);
await executeCommandMutation.mutateAsync({
url: API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(target),
data: apiData as any,
});
}
onSuccess: (data) => {
if (data.status === "OK") {
toast.success("Gửi lệnh thành công!");
} else {
toast.error("Gửi lệnh thất bại!");
}
toast.success("Đã gửi yêu cầu thực thi lệnh cho các mục đã chọn!");
if (table) {
table.setRowSelection({});
}
} catch (error) {
console.error("[DEBUG] Execute error:", error);
console.error("[DEBUG] Response:", (error as any)?.response?.data);
toast.error("Có lỗi xảy ra khi thực thi!");
}
};
// Handle execute custom command
const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => {
try {
for (const target of targets) {
// API expects PascalCase directly
const apiData = {
Command: commandData.command,
QoS: commandData.qos,
IsRetained: commandData.isRetained,
};
console.log("[DEBUG] Sending custom to:", target, "Data:", apiData);
await executeCommandMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(target),
data: apiData as any,
});
}
toast.success("Đã gửi lệnh tùy chỉnh cho các mục đã chọn!");
} catch (error) {
console.error("[DEBUG] Execute custom error:", error);
console.error("[DEBUG] Response:", (error as any)?.response?.data);
toast.error("Gửi lệnh tùy chỉnh thất bại!");
}
};
},
onError: (error) => {
console.error("Send command error:", error);
toast.error("Gửi lệnh thất bại!");
},
});
return (
<CommandSubmitTemplate
title="Gửi lệnh từ xa"
description="Quản lý và thực thi các lệnh trên thiết bị"
data={commandList}
isLoading={isLoading}
columns={columns}
dialogOpen={isDialogOpen}
onDialogOpen={setIsDialogOpen}
dialogTitle={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
onAddNew={() => {
setSelectedCommand(null);
setIsDialogOpen(true);
<FormSubmitTemplate
title="CMD Command"
description="Gửi lệnh shell xuống thiết bị để thực thi"
isLoading={sendCommandMutation.isPending}
rooms={rooms}
onSubmit={(roomName, command) => {
sendCommandMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(roomName),
data: { Command: command },
});
}}
onTableInit={setTable}
formContent={
<CommandRegistryForm
onSubmit={handleFormSubmit}
closeDialog={() => setIsDialogOpen(false)}
initialData={selectedCommand || undefined}
title={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
submitLoading={sendCommandMutation.isPending}
>
{({ command, setCommand }) => (
<ShellCommandForm
command={command}
onCommandChange={setCommand}
disabled={sendCommandMutation.isPending}
/>
}
onExecuteSelected={handleExecuteSelected}
onExecuteCustom={handleExecuteCustom}
isExecuting={executeCommandMutation.isPending}
rooms={roomData}
/>
)}
</FormSubmitTemplate>
);
}

View File

@ -1,16 +1,12 @@
import { createFileRoute, useParams } from "@tanstack/react-router";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LayoutGrid, TableIcon, Monitor, FolderCheck, Loader2 } from "lucide-react";
import { LayoutGrid, TableIcon, Monitor } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useQueryData } from "@/hooks/useQueryData";
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
import { useClientFolderStatus } from "@/hooks/useClientFolderStatus";
import { API_ENDPOINTS } from "@/config/api";
import { DeviceGrid } from "@/components/grids/device-grid";
import { DeviceTable } from "@/components/tables/device-table";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { toast } from "sonner";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { DeviceGrid } from "@/components/device-grid";
import { DeviceTable } from "@/components/device-table";
export const Route = createFileRoute("/_authenticated/room/$roomName/")({
head: ({ params }) => ({
@ -22,45 +18,9 @@ export const Route = createFileRoute("/_authenticated/room/$roomName/")({
function RoomDetailPage() {
const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const [isCheckingFolder, setIsCheckingFolder] = useState(false);
// SSE real-time updates
useDeviceEvents(roomName);
// Folder status from SSE
const folderStatuses = useClientFolderStatus(roomName);
const { data: devices = [] } = useQueryData({
queryKey: ["devices", roomName],
url: API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
});
const parseMachineNumber = useMachineNumber();
const handleCheckFolderStatus = async () => {
try {
setIsCheckingFolder(true);
const response = await fetch(
API_ENDPOINTS.DEVICE_COMM.REQUEST_GET_CLIENT_FOLDER_STATUS(roomName),
{
method: "POST",
}
);
if (!response.ok) {
throw new Error("Failed to request folder status");
}
toast.success("Đang kiểm tra thư mục Setup...");
} catch (error) {
console.error("Check folder error:", error);
toast.error("Lỗi khi kiểm tra thư mục!");
setIsCheckingFolder(false);
}
};
const sortedDevices = [...devices].sort((a, b) => {
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
});
return (
@ -72,42 +32,25 @@ function RoomDetailPage() {
Danh sách thiết bị phòng {roomName}
</CardTitle>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border">
<Button
onClick={handleCheckFolderStatus}
disabled={isCheckingFolder}
variant="outline"
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="flex items-center gap-2"
>
{isCheckingFolder ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FolderCheck className="h-4 w-4" />
)}
{isCheckingFolder ? "Đang kiểm tra..." : "Kiểm tra thư mục Setup"}
<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-2 bg-background rounded-lg p-1 border">
<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>
</div>
</CardHeader>
@ -121,17 +64,9 @@ function RoomDetailPage() {
</p>
</div>
) : viewMode === "grid" ? (
<DeviceGrid
devices={sortedDevices}
folderStatuses={folderStatuses}
isCheckingFolder={isCheckingFolder}
/>
<DeviceGrid devices={devices} />
) : (
<DeviceTable
devices={sortedDevices}
folderStatuses={folderStatuses}
isCheckingFolder={isCheckingFolder}
/>
<DeviceTable devices={devices} />
)}
</CardContent>
</Card>

View File

@ -1,4 +1,4 @@
import { API_ENDPOINTS } from "@/config/api";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { useQueryData } from "@/hooks/useQueryData";
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
@ -26,6 +26,7 @@ import {
ChevronRight,
Loader2,
Wifi,
WifiOff,
} from "lucide-react";
import React from "react";
@ -44,7 +45,7 @@ function RoomComponent() {
const { data: roomData = [], isLoading } = useQueryData({
queryKey: ["rooms"],
url: API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
const [sorting, setSorting] = React.useState<SortingState>([]);
@ -76,16 +77,34 @@ function RoomComponent() {
),
},
{
header: "Số lượng thiết bị online",
header: "Số lượng thiết bị",
accessorKey: "numberOfDevices",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4 text-green-600" />
<Badge variant="secondary" className="font-medium">
{row.original.numberOfDevices} thiết bị
</Badge>
</div>
),
},
{
header: "Thiết bị offline",
accessorKey: "numberOfOfflineDevices",
cell: ({ row }) => {
const onlineCount = row.original.numberOfDevices - row.original.numberOfOfflineDevices;
const totalCount = row.original.numberOfDevices;
const offlineCount = row.original.numberOfOfflineDevices;
const isOffline = offlineCount > 0;
return (
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4 text-green-600" />
<Badge variant="secondary" className="font-medium">
{onlineCount} / {totalCount}
<WifiOff
className={`h-4 w-4 ${isOffline ? "text-red-500" : "text-muted-foreground"}`}
/>
<Badge
variant={isOffline ? "destructive" : "outline"}
className="font-medium"
>
{offlineCount} offline
</Badge>
</div>
);

View File

@ -1,21 +0,0 @@
import axios from "axios";
import { queryClient } from "@/main";
import { API_ENDPOINTS } from "@/config/api";
import type { DeviceHealthCheck } from "@/types/device";
export async function fetchDevicesFromRoom(
roomName: string
): Promise<DeviceHealthCheck[]> {
const data = await queryClient.ensureQueryData({
queryKey: ["devices-from-room", roomName],
queryFn: async () => {
const response = await axios.get<DeviceHealthCheck[]>(
API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName)
);
return response.data ?? [];
},
staleTime: 1000 * 60 * 3,
});
return data;
}

View File

@ -7,24 +7,16 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { FileText, Building2, Download } from "lucide-react";
import { FormDialog } from "@/components/dialogs/form-dialog";
import { VersionTable } from "@/components/tables/version-table";
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { DeleteMenu } from "@/components/menu/delete-menu";
import { Button } from "@/components/ui/button";
import { FileText, Building2, Monitor } from "lucide-react";
import { UploadDialog } from "@/components/upload-dialog";
import { VersionTable } from "@/components/version-table";
import { RequestUpdateMenu } from "@/components/request-update-menu";
import type { AxiosProgressEvent } from "axios";
import { useState } from "react";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import { UploadVersionForm } from "@/components/forms/upload-file-form";
import type { Room } from "@/types/room";
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
import { fetchDevicesFromRoom } from "@/services/device.service";
import { SelectDialog } from "@/components/select-dialog"; // <-- dùng dialog chung
interface AppManagerTemplateProps<TData> {
title: string;
uploadFormTitle?: string;
description: string;
data: TData[];
isLoading: boolean;
@ -35,22 +27,13 @@ interface AppManagerTemplateProps<TData> {
) => Promise<void>;
onUpdate?: (targetNames: string[]) => Promise<void> | void;
updateLoading?: boolean;
onDownload?: (targetNames: string[]) => Promise<void> | void;
downloadLoading?: boolean;
onDelete?: () => Promise<void> | void;
onDeleteFromServer?: () => Promise<void> | void;
onDeleteFromRequired?: () => Promise<void> | void;
deleteLoading?: boolean;
onAddToRequired?: () => Promise<void> | void;
addToRequiredLoading?: boolean;
onTableInit?: (table: any) => void;
rooms?: Room[];
rooms?: string[];
devices?: string[];
}
export function AppManagerTemplate<TData>({
title,
uploadFormTitle,
description,
data,
isLoading,
@ -58,20 +41,12 @@ export function AppManagerTemplate<TData>({
onUpload,
onUpdate,
updateLoading,
onDownload,
downloadLoading,
onDelete,
onDeleteFromServer,
onDeleteFromRequired,
deleteLoading,
onAddToRequired,
addToRequiredLoading,
onTableInit,
rooms = [],
devices = [],
}: AppManagerTemplateProps<TData>) {
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"room" | "device" | "download-room" | "download-device" | null>(null);
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
const openRoomDialog = () => {
if (rooms.length > 0 && onUpdate) {
@ -81,39 +56,40 @@ export function AppManagerTemplate<TData>({
};
const openDeviceDialog = () => {
if (onUpdate) {
if (devices.length > 0 && onUpdate) {
setDialogType("device");
setDialogOpen(true);
}
};
const openDownloadRoomDialog = () => {
if (rooms.length > 0 && onDownload) {
setDialogType("download-room");
setDialogOpen(true);
}
};
const openDownloadDeviceDialog = () => {
if (onDownload) {
setDialogType("download-device");
setDialogOpen(true);
}
};
const handleUpdateAll = async () => {
if (!onUpdate) return;
try {
const roomIds = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomIds, ...devices];
await onUpdate(allTargets);
} catch (e) {
console.error("Update error:", e);
}
const allTargets = [...rooms, ...devices];
await onUpdate(allTargets);
};
const getDialogProps = () => {
if (dialogType === "room") {
return {
title: "Chọn phòng",
description: "Chọn các phòng cần cập nhật",
icon: <Building2 className="w-6 h-6 text-primary" />,
items: rooms,
};
}
if (dialogType === "device") {
return {
title: "Chọn thiết bị",
description: "Chọn các thiết bị cần cập nhật",
icon: <Monitor className="w-6 h-6 text-primary" />,
items: devices,
};
}
return null;
};
const dialogProps = getDialogProps();
return (
<div className="w-full px-6 space-y-4">
{/* Header */}
@ -122,16 +98,10 @@ export function AppManagerTemplate<TData>({
<h1 className="text-3xl font-bold">{title}</h1>
<p className="text-muted-foreground mt-2">{description}</p>
</div>
<FormDialog
triggerLabel={uploadFormTitle || "Tải phiên bản mới"}
title={uploadFormTitle || "Cập nhật phiên bản"}
>
{(closeDialog) => (
<UploadVersionForm onSubmit={onUpload} closeDialog={closeDialog} />
)}
</FormDialog>
<UploadDialog onSubmit={onUpload} />
</div>
{/* Table */}
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@ -149,172 +119,31 @@ export function AppManagerTemplate<TData>({
/>
</CardContent>
{(onUpdate || onDelete || onAddToRequired) && (
<CardFooter className="flex items-center justify-between gap-4">
<div className="flex gap-2">
<RequestUpdateMenu
onUpdateDevice={openDeviceDialog}
onUpdateRoom={openRoomDialog}
onUpdateAll={handleUpdateAll}
loading={updateLoading}
label="Cài đặt"
deviceLabel="Cài đặt thiết bị cụ thể"
roomLabel="Cài đặt theo phòng"
allLabel="Cài đặt tất cả thiết bị"
/>
{onDownload && (
<RequestUpdateMenu
onUpdateDevice={openDownloadDeviceDialog}
onUpdateRoom={openDownloadRoomDialog}
onUpdateAll={() => {
if (!onDownload) return;
const roomIds = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomIds, ...devices];
onDownload(allTargets);
}}
loading={downloadLoading}
label="Tải xuống"
deviceLabel="Tải xuống thiết bị cụ thể"
roomLabel="Tải xuống theo phòng"
allLabel="Tải xuống tất cả thiết bị"
icon={<Download className="h-4 w-4" />}
/>
)}
{onAddToRequired && (
<Button
onClick={onAddToRequired}
disabled={addToRequiredLoading}
className="gap-2"
>
{addToRequiredLoading ? "Đang thêm..." : "Thêm vào danh sách"}
</Button>
)}
</div>
{onDeleteFromServer && onDeleteFromRequired && (
<DeleteMenu
onDeleteFromServer={onDeleteFromServer}
onDeleteFromRequired={onDeleteFromRequired}
loading={deleteLoading}
/>
)}
{onUpdate && (
<CardFooter>
<RequestUpdateMenu
onUpdateDevice={openDeviceDialog}
onUpdateRoom={openRoomDialog}
onUpdateAll={handleUpdateAll}
loading={updateLoading}
/>
</CardFooter>
)}
</Card>
{/* Dialog chọn phòng */}
{dialogType === "room" && (
{/* 🧩 SelectDialog tái sử dụng */}
{dialogProps && (
<SelectDialog
open={dialogOpen}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng cần cập nhật"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onClose={() => setDialogOpen(false)}
title={dialogProps.title}
description={dialogProps.description}
icon={dialogProps.icon}
items={dialogProps.items}
onConfirm={async (selectedItems) => {
if (!onUpdate) return;
try {
await onUpdate(selectedItems);
} catch (e) {
console.error("Update error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tìm thiết bị */}
{dialogType === "device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "device"}
onClose={() => {
await onUpdate(selectedItems);
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom}
onSelect={async (deviceIds) => {
if (!onUpdate) {
setDialogOpen(false);
setDialogType(null);
return;
}
try {
await onUpdate(deviceIds);
} catch (e) {
console.error("Update error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tải file - chọn phòng */}
{dialogType === "download-room" && (
<SelectDialog
open={dialogOpen}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để tải file xuống"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => {
if (!onDownload) return;
try {
await onDownload(selectedItems);
} catch (e) {
console.error("Download error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tải file - tìm thiết bị */}
{dialogType === "download-device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "download-device"}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom}
onSelect={async (deviceIds) => {
if (!onDownload) {
setDialogOpen(false);
setDialogType(null);
return;
}
try {
await onDownload(deviceIds);
} catch (e) {
console.error("Download error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}

View File

@ -1,424 +0,0 @@
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Plus, CommandIcon, Zap, Building2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { ColumnDef } from "@tanstack/react-table";
import { VersionTable } from "@/components/tables/version-table";
import {
ShellCommandForm,
type ShellCommandData,
} from "@/components/forms/command-form";
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
import { fetchDevicesFromRoom } from "@/services/device.service";
import type { Room } from "@/types/room";
import { toast } from "sonner";
interface CommandSubmitTemplateProps<T extends { id: number }> {
title: string;
description: string;
// Data & Loading
data: T[];
isLoading?: boolean;
// Table config
columns: ColumnDef<T>[];
// Dialog
dialogOpen: boolean;
onDialogOpen: (open: boolean) => void;
dialogTitle?: string;
formContent?: React.ReactNode;
dialogContentClassName?: string;
// Actions
onAddNew?: () => void;
onTableInit?: (table: any) => void;
// Execute
onExecuteSelected?: (targets: string[]) => void;
onExecuteCustom?: (targets: string[], commandData: ShellCommandData) => void;
isExecuting?: boolean;
// Execution scope
rooms?: Room[];
devices?: string[];
}
export function CommandSubmitTemplate<T extends { id: number }>({
title,
description,
data,
isLoading = false,
columns,
dialogOpen,
onDialogOpen,
dialogTitle = "Thêm Mục Mới",
formContent,
dialogContentClassName,
onAddNew,
onTableInit,
onExecuteSelected,
onExecuteCustom,
isExecuting = false,
rooms = [],
devices = [],
}: CommandSubmitTemplateProps<T>) {
const [activeTab, setActiveTab] = useState<"list" | "execute">("list");
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 handleTableInit = (t: any) => {
setTable(t);
onTableInit?.(t);
};
const openRoomDialog = () => {
if (rooms.length > 0 && onExecuteSelected) {
setDialogType("room");
setDialogOpen2(true);
}
};
const openDeviceDialog = () => {
if (onExecuteSelected) {
setDialogType("device");
setDialogOpen2(true);
}
};
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 {
const roomNames = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomNames, ...devices];
onExecuteSelected(allTargets);
} catch (e) {
console.error("Execute error:", e);
}
};
const handleExecuteCustom = async (targets: string[]) => {
if (!customCommand.trim()) {
toast.error("Vui lòng nhập lệnh!");
return;
}
const shellCommandData: ShellCommandData = {
command: customCommand,
qos: customQoS,
isRetained: customRetained,
};
try {
await onExecuteCustom?.(targets, shellCommandData);
setCustomCommand("");
setCustomQoS(0);
setCustomRetained(false);
} catch (e) {
console.error("Execute custom command error:", e);
}
};
const handleExecuteCustomAll = () => {
if (!onExecuteCustom) return;
try {
const roomNames = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomNames, ...devices];
handleExecuteCustom(allTargets);
} catch (e) {
console.error("Execute error:", e);
}
};
const openRoomDialogCustom = () => {
if (rooms.length > 0 && onExecuteCustom) {
setDialogType("room-custom");
setDialogOpen2(true);
}
};
const openDeviceDialogCustom = () => {
if (onExecuteCustom) {
setDialogType("device-custom");
setDialogOpen2(true);
}
};
return (
<div className="w-full px-6 space-y-6">
<div>
<h1 className="text-3xl font-bold">{title}</h1>
<p className="text-muted-foreground mt-2">{description}</p>
</div>
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<CommandIcon className="h-5 w-5" />
{title}
</CardTitle>
{onAddNew && (
<Button onClick={onAddNew} className="gap-2">
<Plus className="h-4 w-4" />
Thêm Mới
</Button>
)}
</CardHeader>
<CardContent className="p-6">
{/* Tabs Navigation */}
<div className="flex gap-4 mb-6 border-b">
<button
onClick={() => setActiveTab("list")}
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
activeTab === "list"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
<CommandIcon className="h-4 w-4" />
Danh sách lệnh sẵn
</button>
<button
onClick={() => setActiveTab("execute")}
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
activeTab === "execute"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Zap className="h-4 w-4" />
Lệnh thủ công
</button>
</div>
{/* Tab 1: Danh sách */}
{activeTab === "list" && (
<div className="space-y-4">
<VersionTable<T>
data={data}
columns={columns}
isLoading={isLoading}
onTableInit={handleTableInit}
/>
<RequestUpdateMenu
onUpdateDevice={openDeviceDialog}
onUpdateRoom={openRoomDialog}
onUpdateAll={handleExecuteAll}
loading={isExecuting}
label="Thực Thi"
deviceLabel="Thực thi cho thiết bị cụ thể"
roomLabel="Thực thi cho phòng"
allLabel="Thực thi cho tất cả thiết bị"
icon={<Zap className="h-4 w-4" />}
/>
</div>
)}
{/* Tab 2: Thực thi */}
{activeTab === "execute" && (
<div className="space-y-4">
{/* Lệnh tùy chỉnh */}
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-base">
Thực Thi Lệnh Tùy Chỉnh
</CardTitle>
<CardDescription>
Nhập lệnh tuỳ chỉnh với QoS Retained settings
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<ShellCommandForm
command={customCommand}
onCommandChange={setCustomCommand}
qos={customQoS}
onQoSChange={setCustomQoS}
isRetained={customRetained}
onIsRetainedChange={setCustomRetained}
disabled={isExecuting}
/>
<RequestUpdateMenu
onUpdateDevice={openDeviceDialogCustom}
onUpdateRoom={openRoomDialogCustom}
onUpdateAll={handleExecuteCustomAll}
loading={isExecuting}
label="Thực Thi"
deviceLabel="Thực thi cho thiết bị cụ thể"
roomLabel="Thực thi cho phòng"
allLabel="Thực thi cho tất cả thiết bị"
icon={<Zap className="h-4 w-4" />}
/>
</div>
</CardContent>
</Card>
</div>
)}
</CardContent>
</Card>
{/* Dialog chọn phòng - Thực thi */}
{dialogType === "room" && (
<SelectDialog
open={dialogOpen2}
onClose={() => {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để thực thi lệnh"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => {
if (!onExecuteSelected) return;
try {
await onExecuteSelected(selectedItems);
} catch (e) {
console.error("Execute error:", e);
} finally {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tìm thiết bị - Thực thi */}
{dialogType === "device" && (
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device"}
onClose={() => {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom}
onSelect={async (deviceIds) => {
if (!onExecuteSelected) {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
return;
}
try {
await onExecuteSelected(deviceIds);
} catch (e) {
console.error("Execute error:", e);
} finally {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog chọn phòng - Thực thi lệnh tùy chỉnh */}
{dialogType === "room-custom" && (
<SelectDialog
open={dialogOpen2}
onClose={() => {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để thực thi lệnh tùy chỉnh"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => {
try {
await handleExecuteCustom(selectedItems);
} catch (e) {
console.error("Execute error:", e);
} finally {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tìm thiết bị - Thực thi lệnh tùy chỉnh */}
{dialogType === "device-custom" && (
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device-custom"}
onClose={() => {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom}
onSelect={async (deviceIds) => {
try {
await handleExecuteCustom(deviceIds);
} catch (e) {
console.error("Execute error:", e);
} finally {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog for add/edit */}
{formContent && (
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>
<DialogContent className={dialogContentClassName || "max-w-2xl max-h-[90vh] overflow-y-auto"}>
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
{formContent}
</DialogContent>
</Dialog>
)}
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState } from "react"
import {
Card,
CardContent,
@ -6,73 +6,82 @@ import {
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Terminal, Building2 } from "lucide-react";
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import type { Room } from "@/types/room";
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
import { fetchDevicesFromRoom } from "@/services/device.service";
} from "@/components/ui/card"
import { Terminal, Building2, Monitor } from "lucide-react"
import { RequestUpdateMenu } from "@/components/request-update-menu"
import { SelectDialog } from "@/components/select-dialog"
interface FormSubmitTemplateProps {
title: string;
description: string;
isLoading?: boolean;
title: string
description: string
isLoading?: boolean
children: (props: {
command: string;
setCommand: (val: string) => void;
}) => React.ReactNode;
onSubmit?: (target: string, command: string) => void | Promise<void>;
submitLoading?: boolean;
rooms?: Room[];
devices?: string[];
command: string
setCommand: (val: string) => void
}) => React.ReactNode
onSubmit?: (target: string, command: string) => void | Promise<void>
submitLoading?: boolean
rooms?: string[]
devices?: string[]
}
export function FormSubmitTemplate({
title,
description,
isLoading,
children,
onSubmit,
submitLoading,
rooms = [],
devices = [],
}: FormSubmitTemplateProps) {
const [command, setCommand] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
const [command, setCommand] = useState("")
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null)
// Mở dialog chọn phòng
const openRoomDialog = () => {
if (rooms.length > 0 && onSubmit) {
setDialogType("room");
setDialogOpen(true);
setDialogType("room")
setDialogOpen(true)
}
};
}
// Mở dialog tìm thiết bị (search bar)
const openDeviceDialog = () => {
if (onSubmit) {
setDialogType("device");
setDialogOpen(true);
if (devices.length > 0 && onSubmit) {
setDialogType("device")
setDialogOpen(true)
}
};
}
// Gửi cho tất cả
const handleSubmitAll = async () => {
if (!onSubmit) return;
try {
const roomIds = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomIds, ...devices];
for (const target of allTargets) {
await onSubmit(target, command);
}
} catch (e) {
console.error("Submit error:", e);
const handleSubmitAll = () => {
if (!onSubmit) return
const allTargets = [...rooms, ...devices]
for (const target of allTargets) {
onSubmit(target, command)
}
};
}
const getDialogProps = () => {
if (dialogType === "room") {
return {
title: "Chọn phòng để gửi lệnh",
description: "Chọn các phòng muốn gửi lệnh CMD tới",
icon: <Building2 className="w-6 h-6 text-primary" />,
items: rooms,
}
}
if (dialogType === "device") {
return {
title: "Chọn thiết bị để gửi lệnh",
description: "Chọn các thiết bị muốn gửi lệnh CMD tới",
icon: <Monitor className="w-6 h-6 text-primary" />,
items: devices,
}
}
return null
}
const dialogProps = getDialogProps()
return (
<div className="w-full px-6 space-y-4">
@ -103,61 +112,24 @@ export function FormSubmitTemplate({
)}
</Card>
{/* Dialog chọn phòng */}
{dialogType === "room" && (
{/* 🧩 Dùng SelectDialog chung */}
{dialogProps && (
<SelectDialog
open={dialogOpen}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
title="Chọn phòng để gửi lệnh"
description="Chọn các phòng muốn gửi lệnh CMD tới"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onClose={() => setDialogOpen(false)}
title={dialogProps.title}
description={dialogProps.description}
icon={dialogProps.icon}
items={dialogProps.items}
onConfirm={async (selectedItems) => {
if (!onSubmit) return;
try {
for (const item of selectedItems) {
await onSubmit(item, command);
}
} catch (e) {
console.error("Submit error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tìm thiết bị */}
{dialogType === "device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "device"}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom}
onSelect={async (deviceIds) => {
if (!onSubmit) return;
try {
for (const deviceId of deviceIds) {
await onSubmit(deviceId, command);
}
} catch (e) {
console.error("Submit error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
if (!onSubmit) return
for (const item of selectedItems) {
await onSubmit(item, command)
}
setDialogOpen(false)
}}
/>
)}
</div>
);
)
}

View File

@ -1,6 +1,5 @@
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import { RequestUpdateMenu } from "@/components/request-update-menu";
import { SelectDialog } from "@/components/select-dialog";
import {
Card,
CardContent,
@ -9,16 +8,12 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { FormDialog } from "@/components/dialogs/form-dialog";
import { VersionTable } from "@/components/tables/version-table";
import { UploadDialog } from "@/components/upload-dialog";
import { VersionTable } from "@/components/version-table";
import type { ColumnDef } from "@tanstack/react-table";
import { FileText, Building2 } from "lucide-react";
import type { AxiosProgressEvent } from "axios";
import { FileText, Building2, Monitor } from "lucide-react";
import { useState } from "react";
import { BlacklistForm } from "@/components/forms/black-list-form";
import type { BlacklistFormData } from "@/types/black-list";
import type { Room } from "@/types/room";
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
import { fetchDevicesFromRoom } from "@/services/device.service";
interface BlackListManagerTemplateProps<TData> {
title: string;
@ -26,12 +21,15 @@ interface BlackListManagerTemplateProps<TData> {
data: TData[];
isLoading: boolean;
columns: ColumnDef<TData, any>[];
onAdd: (data: BlacklistFormData) => Promise<void>;
onDelete?: (id: number) => Promise<void>;
onUpdate?: (target: string | string[]) => void | Promise<void>;
onUpload: (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => Promise<void>;
onUpdate?: (roomName: string) => void;
updateLoading?: boolean;
onTableInit?: (table: any) => void;
rooms: Room[];
rooms: string[];
devices?: string[];
}
export function BlackListManagerTemplate<TData>({
@ -40,17 +38,18 @@ export function BlackListManagerTemplate<TData>({
data,
isLoading,
columns,
onAdd,
onUpload,
onUpdate,
updateLoading,
onTableInit,
rooms = [],
devices = [],
}: BlackListManagerTemplateProps<TData>) {
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
const handleUpdateAll = async () => {
if (onUpdate) await onUpdate("All");
const handleUpdateAll = () => {
if (onUpdate) onUpdate("All");
};
const openRoomDialog = () => {
@ -61,12 +60,33 @@ export function BlackListManagerTemplate<TData>({
};
const openDeviceDialog = () => {
if (onUpdate) {
if (devices.length > 0 && onUpdate) {
setDialogType("device");
setDialogOpen(true);
}
};
const getDialogProps = () => {
if (dialogType === "room") {
return {
title: "Chọn phòng",
description: "Chọn các phòng cần cập nhật",
icon: <Building2 className="w-6 h-6 text-primary" />,
items: rooms,
};
}
if (dialogType === "device") {
return {
title: "Chọn thiết bị",
description: "Chọn các thiết bị cần cập nhật",
icon: <Monitor className="w-6 h-6 text-primary" />,
items: devices,
};
}
return null;
};
const dialogProps = getDialogProps();
return (
<div className="w-full px-6 space-y-4">
{/* Header */}
@ -75,14 +95,7 @@ export function BlackListManagerTemplate<TData>({
<h1 className="text-3xl font-bold">{title}</h1>
<p className="text-muted-foreground mt-2">{description}</p>
</div>
<FormDialog
triggerLabel="Thêm phần mềm bị chặn"
title="Thêm phần mềm bị chặn"
>
{(closeDialog) => (
<BlacklistForm onSubmit={onAdd} closeDialog={closeDialog} />
)}
</FormDialog>
<UploadDialog onSubmit={onUpload} />
</div>
{/* Table */}
@ -118,33 +131,23 @@ export function BlackListManagerTemplate<TData>({
)}
</Card>
{/* Dialog chọn phòng */}
{dialogType === "room" && (
{dialogProps && (
<SelectDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title="Chọn phòng"
description="Chọn các phòng cần cập nhật danh sách đen"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedRooms) => {
title={dialogProps.title}
description={dialogProps.description}
icon={dialogProps.icon}
items={dialogProps.items}
onConfirm={async (selectedItems) => {
if (!onUpdate) return;
await onUpdate(selectedRooms);
for (const item of selectedItems) {
onUpdate(item);
}
setDialogOpen(false);
}}
/>
)}
{/* Dialog tìm thiết bị */}
{dialogType === "device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "device"}
onClose={() => setDialogOpen(false)}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom} // ⬅ thêm vào đây
onSelect={(deviceIds) => onUpdate && onUpdate(deviceIds)}
/>
)}
</div>
);
}

View File

@ -1,10 +0,0 @@
export type Blacklist = {
id: number;
appName: string;
processName: string;
createdAt?: string;
updatedAt?: string;
createdBy?: string;
};
export type BlacklistFormData = Pick<Blacklist, "appName" | "processName">;

View File

@ -1,9 +0,0 @@
export type Version = {
id: number;
version: string;
fileName: string;
folderPath: string;
updatedAt?: string;
requestUpdateAt?: string;
isRequired: boolean;
};

View File

@ -1,3 +0,0 @@
export type InstallHistory = {
}