Merge pull request 'master' (#1) from master into main
Reviewed-on: http://203.171.20.94:3000/PhuongDM/TTMT.ManageWebGUI/pulls/1
This commit is contained in:
commit
3b1865c21d
214
package-lock.json
generated
214
package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
|
@ -25,6 +26,7 @@
|
||||||
"@tanstack/react-router-devtools": "^1.121.2",
|
"@tanstack/react-router-devtools": "^1.121.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/router-plugin": "^1.121.2",
|
"@tanstack/router-plugin": "^1.121.2",
|
||||||
|
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -1579,6 +1581,41 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-dropdown-menu": {
|
||||||
|
"version": "2.1.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
|
||||||
|
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-menu": "2.1.16",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-focus-guards": {
|
"node_modules/@radix-ui/react-focus-guards": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz",
|
||||||
|
|
@ -1660,6 +1697,150 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu": {
|
||||||
|
"version": "2.1.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
||||||
|
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.3",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.6.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-guards": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react-dom": "^2.0.0",
|
||||||
|
"@radix-ui/react-arrow": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-rect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1",
|
||||||
|
"@radix-ui/rect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-popover": {
|
"node_modules/@radix-ui/react-popover": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||||
|
|
@ -3325,6 +3506,33 @@
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"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": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
||||||
|
|
@ -7861,9 +8069,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.3.6",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
|
@ -29,6 +30,7 @@
|
||||||
"@tanstack/react-router-devtools": "^1.121.2",
|
"@tanstack/react-router-devtools": "^1.121.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/router-plugin": "^1.121.2",
|
"@tanstack/router-plugin": "^1.121.2",
|
||||||
|
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
325
src/components/bars/device-searchbar.tsx
Normal file
325
src/components/bars/device-searchbar.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/components/dialogs/add-new-dialog.tsx
Normal file
140
src/components/dialogs/add-new-dialog.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
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 { toast } from "sonner";
|
||||||
|
import { useForm, formOptions } from "@tanstack/react-form";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
interface AddBlacklistDialogProps {
|
||||||
|
onAdded?: () => void; // callback để refresh danh sách sau khi thêm
|
||||||
|
}
|
||||||
|
|
||||||
|
const formOpts = formOptions({
|
||||||
|
defaultValues: { appName: "", processName: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AddBlacklistDialog({ onAdded }: AddBlacklistDialogProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
...formOpts,
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
if (!value.appName || !value.processName) {
|
||||||
|
toast.error("Vui lòng nhập đầy đủ tên ứng dụng và tiến trình");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
await axios.post("/api/appversions/add-blacklist", {
|
||||||
|
appName: value.appName,
|
||||||
|
processName: value.processName,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Đã thêm vào blacklist!");
|
||||||
|
setIsDone(true);
|
||||||
|
|
||||||
|
if (onAdded) onAdded();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Không thể thêm vào blacklist");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDialogClose = (open: boolean) => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
setIsOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setIsDone(false);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleDialogClose}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>Thêm vào Blacklist</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Thêm ứng dụng vào danh sách cấm</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form.Field name="appName">
|
||||||
|
{(field) => (
|
||||||
|
<div>
|
||||||
|
<Label>Tên ứng dụng</Label>
|
||||||
|
<Input
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="Ví dụ: Google Chrome"
|
||||||
|
disabled={isSubmitting || isDone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="processName">
|
||||||
|
{(field) => (
|
||||||
|
<div>
|
||||||
|
<Label>Tên tiến trình</Label>
|
||||||
|
<Input
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="chrome.exe"
|
||||||
|
disabled={isSubmitting || isDone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{!isDone ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleDialogClose(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Đang thêm..." : "Thêm"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button type="button" onClick={() => handleDialogClose(false)}>
|
||||||
|
Hoàn tất
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/dialogs/form-dialog.tsx
Normal file
30
src/components/dialogs/form-dialog.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/dialogs/select-dialog.tsx
Normal file
98
src/components/dialogs/select-dialog.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
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 có 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/forms/black-list-form.tsx
Normal file
67
src/components/forms/black-list-form.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/components/forms/dynamic-submit-form.tsx
Normal file
161
src/components/forms/dynamic-submit-form.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/components/forms/upload-file-form.tsx
Normal file
119
src/components/forms/upload-file-form.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Monitor, DoorOpen } from "lucide-react";
|
import { Monitor, DoorOpen } from "lucide-react";
|
||||||
import { ComputerCard } from "./computer-card";
|
import { ComputerCard } from "../cards/computer-card";
|
||||||
import { useMachineNumber } from "../hooks/useMachineNumber";
|
import { useMachineNumber } from "../../hooks/useMachineNumber";
|
||||||
|
|
||||||
export function DeviceGrid({ devices }: { devices: any[] }) {
|
export function DeviceGrid({ devices }: { devices: any[] }) {
|
||||||
const getMachineNumber = useMachineNumber();
|
const getMachineNumber = useMachineNumber();
|
||||||
|
|
@ -11,23 +11,42 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
|
||||||
if (number > 0 && number <= 40) deviceMap.set(number, device);
|
if (number > 0 && number <= 40) deviceMap.set(number, device);
|
||||||
});
|
});
|
||||||
|
|
||||||
const computersPerRow = 8;
|
|
||||||
const totalRows = 5;
|
const totalRows = 5;
|
||||||
|
|
||||||
const renderRow = (rowIndex: number) => {
|
const renderRow = (rowIndex: number) => {
|
||||||
const start = rowIndex * computersPerRow + 1;
|
// Đảo ngược: 21-40 sang trái, 1-20 sang phải
|
||||||
|
const leftStart = 21 + (totalRows - 1 - rowIndex) * 4;
|
||||||
|
const rightStart = (totalRows - 1 - rowIndex) * 4 + 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={rowIndex} className="flex items-center justify-center gap-3">
|
<div key={rowIndex} className="flex items-center justify-center gap-3">
|
||||||
|
{/* Bên trái (21–40) */}
|
||||||
{Array.from({ length: 4 }).map((_, i) => {
|
{Array.from({ length: 4 }).map((_, i) => {
|
||||||
const pos = start + i;
|
const pos = leftStart + (3 - i);
|
||||||
return <ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />;
|
return (
|
||||||
|
<ComputerCard
|
||||||
|
key={pos}
|
||||||
|
device={deviceMap.get(pos)}
|
||||||
|
position={pos}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Đường chia giữa */}
|
||||||
<div className="w-32 flex items-center justify-center">
|
<div className="w-32 flex items-center justify-center">
|
||||||
<div className="h-px w-full bg-border border-t-2 border-dashed" />
|
<div className="h-px w-full bg-border border-t-2 border-dashed" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bên phải (1–20) */}
|
||||||
{Array.from({ length: 4 }).map((_, i) => {
|
{Array.from({ length: 4 }).map((_, i) => {
|
||||||
const pos = start + i + 4;
|
const pos = rightStart + (3 - i);
|
||||||
return <ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />;
|
return (
|
||||||
|
<ComputerCard
|
||||||
|
key={pos}
|
||||||
|
device={deviceMap.get(pos)}
|
||||||
|
position={pos}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -35,19 +54,18 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-0.5 py-8 space-y-6">
|
<div className="px-0.5 py-8 space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{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 justify-between px-4 pb-6 border-b-2 border-dashed">
|
||||||
<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">
|
<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" />
|
<DoorOpen className="h-6 w-6 text-muted-foreground" />
|
||||||
<span className="font-semibold text-lg">Cửa Ra Vào</span>
|
<span className="font-semibold text-lg">Cửa Ra Vào</span>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
||||||
<div className="space-y-4">
|
<span className="font-semibold text-lg">Bàn Giảng Viên</span>
|
||||||
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
91
src/components/menu/request-update-menu.tsx
Normal file
91
src/components/menu/request-update-menu.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequestUpdateMenu({
|
||||||
|
onUpdateDevice,
|
||||||
|
onUpdateRoom,
|
||||||
|
onUpdateAll,
|
||||||
|
loading,
|
||||||
|
}: 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}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading}
|
||||||
|
className="group relative overflow-hidden border-2 border-gray-300 bg-white text-gray-800 font-medium px-6 py-2.5 rounded-lg transition-all duration-300 hover:border-gray-400 hover:bg-gray-50 hover:shadow-lg hover:shadow-gray-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 text-gray-600" />
|
||||||
|
) : (
|
||||||
|
<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..." : "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>
|
||||||
|
|
||||||
|
<div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-gray-100/30 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
|
<DropdownMenuItem onClick={handleUpdateDevice} disabled={loading}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
<span>Cập nhật thiết bị cụ thể</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleUpdateRoom} disabled={loading}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
<span>Cập nhật theo phòng</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleUpdateAll} disabled={loading}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
<span>Cập nhật tất cả thiết bị</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Check, Home } from "lucide-react"
|
|
||||||
|
|
||||||
interface RoomSelectDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onClose: () => void
|
|
||||||
rooms: string[]
|
|
||||||
onConfirm: (roomName: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RoomSelectDialog({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
rooms,
|
|
||||||
onConfirm,
|
|
||||||
}: RoomSelectDialogProps) {
|
|
||||||
const [selectedRoom, setSelectedRoom] = useState<string>("")
|
|
||||||
|
|
||||||
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">
|
|
||||||
<Home className="w-6 h-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-xl font-semibold">
|
|
||||||
Chọn phòng để cập nhật
|
|
||||||
</DialogTitle>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Vui lòng chọn phòng để gửi lệnh cập nhật
|
|
||||||
</p>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="py-3">
|
|
||||||
<RadioGroup
|
|
||||||
value={selectedRoom}
|
|
||||||
onValueChange={setSelectedRoom}
|
|
||||||
className="space-y-3"
|
|
||||||
>
|
|
||||||
{rooms.map((room) => (
|
|
||||||
<div
|
|
||||||
key={room}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<RadioGroupItem value={room} id={room} />
|
|
||||||
<Label
|
|
||||||
htmlFor={room}
|
|
||||||
className="font-medium cursor-pointer hover:text-primary"
|
|
||||||
>
|
|
||||||
{room}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedRoom === room && (
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2 pt-4">
|
|
||||||
<Button variant="outline" onClick={onClose} className="flex-1 sm:flex-none">
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedRoom) {
|
|
||||||
onConfirm(selectedRoom)
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!selectedRoom}
|
|
||||||
className="flex-1 sm:flex-none"
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4 mr-2" />
|
|
||||||
Xác nhận
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { useMachineNumber } from "../hooks/useMachineNumber";
|
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||||
|
|
||||||
interface DeviceTableProps {
|
interface DeviceTableProps {
|
||||||
devices: any[];
|
devices: any[];
|
||||||
255
src/components/ui/dropdown-menu.tsx
Normal file
255
src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { PanelLeftIcon } from "lucide-react"
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/useMobile"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -11,17 +11,19 @@ export const API_ENDPOINTS = {
|
||||||
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
||||||
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
|
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
|
||||||
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
|
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
|
||||||
DELETE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
|
DELETE_BLACKLIST: (appId: number) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
|
||||||
UPDATE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/update/${appId}`,
|
UPDATE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/update/${appId}`,
|
||||||
REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
|
REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
|
||||||
},
|
},
|
||||||
DEVICE_COMM: {
|
DEVICE_COMM: {
|
||||||
DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
|
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
|
||||||
|
INSTALL_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
|
||||||
GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
|
GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
|
||||||
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
|
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
|
||||||
GET_DEVICE_FROM_ROOM: (roomName: string) =>
|
GET_DEVICE_FROM_ROOM: (roomName: string) =>
|
||||||
`${BASE_URL}/DeviceComm/room/${roomName}`,
|
`${BASE_URL}/DeviceComm/room/${roomName}`,
|
||||||
UPDATE_AGENT: (roomName: string) => `${BASE_URL}/DeviceComm/updateagent/${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}`,
|
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
|
||||||
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
|
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
|
||||||
},
|
},
|
||||||
|
|
@ -29,6 +31,5 @@ export const API_ENDPOINTS = {
|
||||||
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
||||||
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
|
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
|
||||||
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
|
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
|
||||||
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
9
src/helpers/mapRoomToSelectItems.ts
Normal file
9
src/helpers/mapRoomToSelectItems.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
37
src/hooks/useDeleteData.ts
Normal file
37
src/hooks/useDeleteData.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
|
|
||||||
type QueryDataOptions<T> = {
|
type QueryDataOptions<T> = {
|
||||||
queryKey: string[];
|
queryKey: string[];
|
||||||
url: string;
|
url: string;
|
||||||
params?: Record<string, any>;
|
params?: Record<string, any>;
|
||||||
select?: (data: any) => T;
|
select?: (data: any) => T;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function useQueryData<T = any>({
|
export function useQueryData<T = any>({
|
||||||
queryKey,
|
queryKey,
|
||||||
|
|
@ -21,6 +21,5 @@ export function useQueryData<T = any>({
|
||||||
queryFn: () => axios.get(url, { params }).then((res) => res.data),
|
queryFn: () => axios.get(url, { params }).then((res) => res.data),
|
||||||
select,
|
select,
|
||||||
enabled,
|
enabled,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
import { AppSidebar } from "@/components/sidebars/app-sidebar";
|
||||||
import {
|
import {
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import "./styles.css";
|
||||||
|
|
||||||
const auth = useAuthToken.getState();
|
const auth = useAuthToken.getState();
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
export const queryClient = new QueryClient();
|
||||||
|
|
||||||
// Create a new router instance
|
// Create a new router instance
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
import { type Room } from "@/types/room";
|
|
||||||
|
|
||||||
type Version = {
|
type Version = {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
@ -35,18 +34,12 @@ function AgentsPage() {
|
||||||
url: BASE_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)
|
const versionList: Version[] = Array.isArray(data)
|
||||||
? data
|
? data
|
||||||
: data
|
: data
|
||||||
? [data]
|
? [data]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Mutation upload
|
|
||||||
const uploadMutation = useMutationData<FormData>({
|
const uploadMutation = useMutationData<FormData>({
|
||||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
|
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -62,7 +55,10 @@ function AgentsPage() {
|
||||||
url: "",
|
url: "",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
|
onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
|
||||||
onError: () => toast.error("Gửi yêu cầu thất bại!"),
|
onError: (error) => {
|
||||||
|
console.error("Update mutation error:", error);
|
||||||
|
toast.error("Gửi yêu cầu thất bại!");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleUpload = async (
|
const handleUpload = async (
|
||||||
|
|
@ -75,13 +71,19 @@ function AgentsPage() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Callback khi chọn phòng update
|
const handleUpdate = async (roomNames: string[]) => {
|
||||||
const handleUpdate = async (roomName: string) => {
|
try {
|
||||||
return updateMutation.mutateAsync({
|
for (const roomName of roomNames) {
|
||||||
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT(roomName),
|
await updateMutation.mutateAsync({
|
||||||
method: "POST",
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT(roomName),
|
||||||
data: undefined,
|
method: "POST",
|
||||||
});
|
data: undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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!");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cột bảng
|
// Cột bảng
|
||||||
|
|
@ -117,7 +119,7 @@ function AgentsPage() {
|
||||||
onUpload={handleUpload}
|
onUpload={handleUpload}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
updateLoading={updateMutation.isPending}
|
updateLoading={updateMutation.isPending}
|
||||||
rooms={rooms}
|
rooms={roomData}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { toast } from "sonner";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
import type { Room } from "@/types/room";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/apps/")({
|
export const Route = createFileRoute("/_authenticated/apps/")({
|
||||||
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
|
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
|
||||||
|
|
@ -34,11 +33,6 @@ function AppsComponent() {
|
||||||
url: BASE_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)
|
const versionList: Version[] = Array.isArray(data)
|
||||||
? data
|
? data
|
||||||
: data
|
: data
|
||||||
|
|
@ -61,13 +55,23 @@ function AppsComponent() {
|
||||||
const installMutation = useMutationData<{ MsiFileIds: number[] }>({
|
const installMutation = useMutationData<{ MsiFileIds: number[] }>({
|
||||||
url: "",
|
url: "",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
|
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt file!"),
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Install error:", error);
|
console.error("Install error:", error);
|
||||||
toast.error("Gửi yêu cầu thất bại!");
|
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!");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Cột bảng
|
// Cột bảng
|
||||||
const columns: ColumnDef<Version>[] = [
|
const columns: ColumnDef<Version>[] = [
|
||||||
{ accessorKey: "version", header: "Phiên bản" },
|
{ accessorKey: "version", header: "Phiên bản" },
|
||||||
|
|
@ -113,7 +117,7 @@ function AppsComponent() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Callback khi chọn phòng
|
// Callback khi chọn phòng
|
||||||
const handleInstall = async (roomName: string) => {
|
const handleInstall = async (roomNames: string[]) => {
|
||||||
if (!table) {
|
if (!table) {
|
||||||
toast.error("Không thể lấy thông tin bảng!");
|
toast.error("Không thể lấy thông tin bảng!");
|
||||||
return;
|
return;
|
||||||
|
|
@ -127,24 +131,60 @@ function AppsComponent() {
|
||||||
|
|
||||||
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
|
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
|
||||||
|
|
||||||
return installMutation.mutateAsync({
|
try {
|
||||||
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI(roomName), // set url động
|
for (const roomName of roomNames) {
|
||||||
data: { MsiFileIds },
|
await installMutation.mutateAsync({
|
||||||
});
|
url: BASE_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: BASE_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!");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppManagerTemplate<Version>
|
<AppManagerTemplate<Version>
|
||||||
title="Quản lý phần mềm"
|
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"
|
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}
|
data={versionList}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onUpload={handleUpload}
|
onUpload={handleUpload}
|
||||||
onUpdate={handleInstall}
|
onUpdate={handleInstall}
|
||||||
|
onDownload={handleDonwload}
|
||||||
updateLoading={installMutation.isPending}
|
updateLoading={installMutation.isPending}
|
||||||
|
downloadLoading={downloadMutation.isPending}
|
||||||
onTableInit={setTable}
|
onTableInit={setTable}
|
||||||
rooms={rooms}
|
rooms={roomData}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,181 @@
|
||||||
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
||||||
|
import { useMutationData } from "@/hooks/useMutationData";
|
||||||
|
import { useDeleteData } from "@/hooks/useDeleteData";
|
||||||
import { useQueryData } from "@/hooks/useQueryData";
|
import { useQueryData } from "@/hooks/useQueryData";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
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";
|
import { useState } from "react";
|
||||||
|
|
||||||
type Blacklist = {
|
|
||||||
id: number;
|
|
||||||
appName: string;
|
|
||||||
processName: string;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
createdBy?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/blacklist/")({
|
export const Route = createFileRoute("/_authenticated/blacklist/")({
|
||||||
head: () => ({ meta: [{ title: "Danh sách các ứng dụng bị chặn" }] }),
|
head: () => ({ meta: [{ title: "Danh sách các ứng dụng bị chặn" }] }),
|
||||||
component: BlacklistComponent,
|
component: BlacklistComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
function BlacklistComponent() {
|
function BlacklistComponent() {
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Lấy danh sách blacklist
|
||||||
const { data, isLoading } = useQueryData({
|
const { data, isLoading } = useQueryData({
|
||||||
queryKey: ["blacklist"],
|
queryKey: ["blacklist"],
|
||||||
url: BASE_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: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
|
||||||
|
});
|
||||||
|
|
||||||
const blacklist: Blacklist[] = Array.isArray(data)
|
const blacklist: Blacklist[] = Array.isArray(data)
|
||||||
? (data as Blacklist[])
|
? (data as Blacklist[])
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const columns : ColumnDef<Blacklist>[] =
|
const columns: ColumnDef<Blacklist>[] = [
|
||||||
[
|
|
||||||
{
|
{
|
||||||
accessorKey: "id",
|
accessorKey: "id",
|
||||||
header: "ID",
|
header: "STT",
|
||||||
cell: info => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "appName",
|
accessorKey: "appName",
|
||||||
header: "Tên ứng dụng",
|
header: "Tên ứng dụng",
|
||||||
cell: info => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "processName",
|
accessorKey: "processName",
|
||||||
header: "Tên tiến trình",
|
header: "Tên tiến trình",
|
||||||
cell: info => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
header: "Ngày tạo",
|
header: "Ngày tạo",
|
||||||
cell: info => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "updatedAt",
|
accessorKey: "updatedAt",
|
||||||
header: "Ngày cập nhật",
|
header: "Ngày cập nhật",
|
||||||
cell: info => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "createdBy",
|
accessorKey: "createdBy",
|
||||||
header: "Người tạo",
|
header: "Người tạo",
|
||||||
cell: info => info.getValue(),
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return <div>Hello "/_authenticated/blacklist/"!</div>;
|
// 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: BASE_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:
|
||||||
|
BASE_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: BASE_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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { FormSubmitTemplate } from "@/template/form-submit-template";
|
import { FormSubmitTemplate } from "@/template/form-submit-template";
|
||||||
import { ShellCommandForm } from "@/components/command-form";
|
import { ShellCommandForm } from "@/components/forms/command-form";
|
||||||
import { useMutationData } from "@/hooks/useMutationData";
|
import { useMutationData } from "@/hooks/useMutationData";
|
||||||
import { useQueryData } from "@/hooks/useQueryData";
|
import { useQueryData } from "@/hooks/useQueryData";
|
||||||
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Room } from "@/types/room";
|
|
||||||
|
|
||||||
type SendCommandRequest = { Command: string };
|
type SendCommandRequest = { Command: string };
|
||||||
type SendCommandResponse = { status: string; message: string };
|
type SendCommandResponse = { status: string; message: string };
|
||||||
|
|
@ -22,11 +21,6 @@ function CommandPage() {
|
||||||
url: BASE_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)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Mutation gửi lệnh
|
// Mutation gửi lệnh
|
||||||
const sendCommandMutation = useMutationData<
|
const sendCommandMutation = useMutationData<
|
||||||
SendCommandRequest,
|
SendCommandRequest,
|
||||||
|
|
@ -52,7 +46,7 @@ function CommandPage() {
|
||||||
title="CMD Command"
|
title="CMD Command"
|
||||||
description="Gửi lệnh shell xuống thiết bị để thực thi"
|
description="Gửi lệnh shell xuống thiết bị để thực thi"
|
||||||
isLoading={sendCommandMutation.isPending}
|
isLoading={sendCommandMutation.isPending}
|
||||||
rooms={rooms}
|
rooms={roomData}
|
||||||
onSubmit={(roomName, command) => {
|
onSubmit={(roomName, command) => {
|
||||||
sendCommandMutation.mutateAsync({
|
sendCommandMutation.mutateAsync({
|
||||||
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(roomName),
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(roomName),
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ import { LayoutGrid, TableIcon, Monitor } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useQueryData } from "@/hooks/useQueryData";
|
import { useQueryData } from "@/hooks/useQueryData";
|
||||||
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
||||||
import { DeviceGrid } from "@/components/device-grid";
|
import { DeviceGrid } from "@/components/grids/device-grid";
|
||||||
import { DeviceTable } from "@/components/device-table";
|
import { DeviceTable } from "@/components/tables/device-table";
|
||||||
|
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/room/$roomName/")({
|
export const Route = createFileRoute("/_authenticated/room/$roomName/")({
|
||||||
head: ({ params }) => ({
|
head: ({ params }) => ({
|
||||||
|
|
@ -17,12 +18,18 @@ export const Route = createFileRoute("/_authenticated/room/$roomName/")({
|
||||||
|
|
||||||
function RoomDetailPage() {
|
function RoomDetailPage() {
|
||||||
const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
|
const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
|
||||||
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
||||||
const { data: devices = [] } = useQueryData({
|
const { data: devices = [] } = useQueryData({
|
||||||
queryKey: ["devices", roomName],
|
queryKey: ["devices", roomName],
|
||||||
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const parseMachineNumber = useMachineNumber();
|
||||||
|
|
||||||
|
const sortedDevices = [...devices].sort((a, b) => {
|
||||||
|
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-6 space-y-6">
|
<div className="w-full px-6 space-y-6">
|
||||||
<Card className="shadow-sm">
|
<Card className="shadow-sm">
|
||||||
|
|
@ -33,15 +40,6 @@ function RoomDetailPage() {
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border">
|
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border">
|
||||||
<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>
|
|
||||||
<Button
|
<Button
|
||||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -51,6 +49,15 @@ function RoomDetailPage() {
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
Sơ đồ
|
Sơ đồ
|
||||||
</Button>
|
</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>
|
</CardHeader>
|
||||||
|
|
||||||
|
|
@ -64,9 +71,9 @@ function RoomDetailPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "grid" ? (
|
) : viewMode === "grid" ? (
|
||||||
<DeviceGrid devices={devices} />
|
<DeviceGrid devices={sortedDevices} />
|
||||||
) : (
|
) : (
|
||||||
<DeviceTable devices={devices} />
|
<DeviceTable devices={sortedDevices} />
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
21
src/services/device.service.ts
Normal file
21
src/services/device.service.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import axios from "axios";
|
||||||
|
import { queryClient } from "@/main";
|
||||||
|
import { BASE_URL, 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[]>(
|
||||||
|
BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName)
|
||||||
|
);
|
||||||
|
return response.data ?? [];
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
@ -7,13 +7,18 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { FileText } from "lucide-react";
|
import { FileText, Building2 } from "lucide-react";
|
||||||
import { UploadDialog } from "@/components/upload-dialog";
|
import { FormDialog } from "@/components/dialogs/form-dialog";
|
||||||
import { VersionTable } from "@/components/version-table";
|
import { VersionTable } from "@/components/tables/version-table";
|
||||||
import { UpdateButton } from "@/components/update-button";
|
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
|
||||||
import { RoomSelectDialog } from "@/components/room-select-dialog";
|
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
||||||
|
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
|
||||||
|
import { UploadVersionForm } from "@/components/forms/upload-file-form";
|
||||||
|
import type { Room } from "@/types/room";
|
||||||
|
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
|
||||||
|
import { fetchDevicesFromRoom } from "@/services/device.service";
|
||||||
|
|
||||||
interface AppManagerTemplateProps<TData> {
|
interface AppManagerTemplateProps<TData> {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -25,10 +30,13 @@ interface AppManagerTemplateProps<TData> {
|
||||||
fd: FormData,
|
fd: FormData,
|
||||||
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
|
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onUpdate?: (roomName: string) => void;
|
onUpdate?: (targetNames: string[]) => Promise<void> | void;
|
||||||
updateLoading?: boolean;
|
updateLoading?: boolean;
|
||||||
|
onDownload?: (targetNames: string[]) => Promise<void> | void;
|
||||||
|
downloadLoading?: boolean;
|
||||||
onTableInit?: (table: any) => void;
|
onTableInit?: (table: any) => void;
|
||||||
rooms: string[];
|
rooms?: Room[];
|
||||||
|
devices?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppManagerTemplate<TData>({
|
export function AppManagerTemplate<TData>({
|
||||||
|
|
@ -40,23 +48,72 @@ export function AppManagerTemplate<TData>({
|
||||||
onUpload,
|
onUpload,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
updateLoading,
|
updateLoading,
|
||||||
|
onDownload,
|
||||||
|
downloadLoading,
|
||||||
onTableInit,
|
onTableInit,
|
||||||
rooms,
|
rooms = [],
|
||||||
|
devices = [],
|
||||||
}: AppManagerTemplateProps<TData>) {
|
}: AppManagerTemplateProps<TData>) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const handleUpdateClick = () => {
|
const [dialogType, setDialogType] = useState<"room" | "device" | "download-room" | "download-device" | null>(null);
|
||||||
if (rooms && onUpdate) {
|
|
||||||
|
const openRoomDialog = () => {
|
||||||
|
if (rooms.length > 0 && onUpdate) {
|
||||||
|
setDialogType("room");
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDeviceDialog = () => {
|
||||||
|
if (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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-6 space-y-4">
|
<div className="w-full px-6 space-y-4">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">{title}</h1>
|
<h1 className="text-3xl font-bold">{title}</h1>
|
||||||
<p className="text-muted-foreground mt-2">{description}</p>
|
<p className="text-muted-foreground mt-2">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<UploadDialog onSubmit={onUpload} />
|
<FormDialog
|
||||||
|
triggerLabel="Tải lên phiên bản mới"
|
||||||
|
title="Cập nhật phiên bản"
|
||||||
|
>
|
||||||
|
{(closeDialog) => (
|
||||||
|
<UploadVersionForm onSubmit={onUpload} closeDialog={closeDialog} />
|
||||||
|
)}
|
||||||
|
</FormDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
|
|
@ -66,6 +123,7 @@ export function AppManagerTemplate<TData>({
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Tất cả các phiên bản đã tải lên</CardDescription>
|
<CardDescription>Tất cả các phiên bản đã tải lên</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<VersionTable
|
<VersionTable
|
||||||
data={data}
|
data={data}
|
||||||
|
|
@ -74,28 +132,145 @@ export function AppManagerTemplate<TData>({
|
||||||
onTableInit={onTableInit}
|
onTableInit={onTableInit}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{onUpdate && (
|
{onUpdate && (
|
||||||
<CardFooter>
|
<CardFooter className="gap-2">
|
||||||
<UpdateButton onClick={handleUpdateClick} loading={updateLoading} />
|
<RequestUpdateMenu
|
||||||
<UpdateButton
|
onUpdateDevice={openDeviceDialog}
|
||||||
onClick={() => onUpdate("All")}
|
onUpdateRoom={openRoomDialog}
|
||||||
|
onUpdateAll={handleUpdateAll}
|
||||||
loading={updateLoading}
|
loading={updateLoading}
|
||||||
label="Cập nhậ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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
)}
|
)}
|
||||||
{rooms && onUpdate && (
|
|
||||||
<RoomSelectDialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onClose={() => setDialogOpen(false)}
|
|
||||||
rooms={rooms}
|
|
||||||
onConfirm={(roomName) => {
|
|
||||||
onUpdate(roomName);
|
|
||||||
setDialogOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Dialog chọn phòng */}
|
||||||
|
{dialogType === "room" && (
|
||||||
|
<SelectDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setDialogType(null);
|
||||||
|
}}
|
||||||
|
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)}
|
||||||
|
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={() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setDialogType(null);
|
||||||
|
}}
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
"use client"
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -8,41 +6,73 @@ import {
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card";
|
||||||
import { UpdateButton } from "@/components/update-button"
|
import { Terminal, Building2 } from "lucide-react";
|
||||||
import { Terminal } from "lucide-react"
|
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
|
||||||
import { RoomSelectDialog } from "@/components/room-select-dialog"
|
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";
|
||||||
|
|
||||||
interface FormSubmitTemplateProps {
|
interface FormSubmitTemplateProps {
|
||||||
title: string
|
title: string;
|
||||||
description: string
|
description: string;
|
||||||
isLoading?: boolean
|
isLoading?: boolean;
|
||||||
children: (props: {
|
children: (props: {
|
||||||
command: string
|
command: string;
|
||||||
setCommand: (val: string) => void
|
setCommand: (val: string) => void;
|
||||||
}) => React.ReactNode
|
}) => React.ReactNode;
|
||||||
onSubmit?: (roomName: string, command: string) => void
|
onSubmit?: (target: string, command: string) => void | Promise<void>;
|
||||||
submitLoading?: boolean
|
submitLoading?: boolean;
|
||||||
rooms?: string[]
|
rooms?: Room[];
|
||||||
|
devices?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormSubmitTemplate({
|
export function FormSubmitTemplate({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
isLoading,
|
|
||||||
children,
|
children,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitLoading,
|
submitLoading,
|
||||||
rooms = [],
|
rooms = [],
|
||||||
|
devices = [],
|
||||||
}: FormSubmitTemplateProps) {
|
}: FormSubmitTemplateProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [command, setCommand] = useState("");
|
||||||
const [command, setCommand] = useState("")
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
|
||||||
|
|
||||||
const handleClick = () => {
|
// Mở dialog chọn phòng
|
||||||
|
const openRoomDialog = () => {
|
||||||
if (rooms.length > 0 && onSubmit) {
|
if (rooms.length > 0 && onSubmit) {
|
||||||
setDialogOpen(true)
|
setDialogType("room");
|
||||||
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Mở dialog tìm thiết bị (search bar)
|
||||||
|
const openDeviceDialog = () => {
|
||||||
|
if (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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-6 space-y-4">
|
<div className="w-full px-6 space-y-4">
|
||||||
|
|
@ -58,37 +88,76 @@ export function FormSubmitTemplate({
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Nhập và gửi lệnh xuống thiết bị</CardDescription>
|
<CardDescription>Nhập và gửi lệnh xuống thiết bị</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
|
||||||
{children({ command, setCommand })}
|
<CardContent>{children({ command, setCommand })}</CardContent>
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
{onSubmit && (
|
{onSubmit && (
|
||||||
<CardFooter className="flex gap-2">
|
<CardFooter className="flex justify-end">
|
||||||
<UpdateButton
|
<RequestUpdateMenu
|
||||||
onClick={handleClick}
|
onUpdateDevice={openDeviceDialog}
|
||||||
|
onUpdateRoom={openRoomDialog}
|
||||||
|
onUpdateAll={handleSubmitAll}
|
||||||
loading={submitLoading}
|
loading={submitLoading}
|
||||||
label="Yêu cầu theo phòng"
|
|
||||||
/>
|
|
||||||
<UpdateButton
|
|
||||||
onClick={() => onSubmit("All", command)}
|
|
||||||
loading={submitLoading}
|
|
||||||
label="Cập nhật tất cả thiết bị"
|
|
||||||
/>
|
/>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{onSubmit && rooms.length > 0 && (
|
{/* Dialog chọn phòng */}
|
||||||
<RoomSelectDialog
|
{dialogType === "room" && (
|
||||||
|
<SelectDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onClose={() => setDialogOpen(false)}
|
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)}
|
||||||
|
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}
|
rooms={rooms}
|
||||||
onConfirm={(roomName) => {
|
fetchDevices={fetchDevicesFromRoom}
|
||||||
onSubmit(roomName, command)
|
onSelect={async (deviceIds) => {
|
||||||
setDialogOpen(false)
|
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);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
150
src/template/table-manager-template.tsx
Normal file
150
src/template/table-manager-template.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
|
||||||
|
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
||||||
|
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { FormDialog } from "@/components/dialogs/form-dialog";
|
||||||
|
import { VersionTable } from "@/components/tables/version-table";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { FileText, Building2 } 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;
|
||||||
|
description: string;
|
||||||
|
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>;
|
||||||
|
updateLoading?: boolean;
|
||||||
|
onTableInit?: (table: any) => void;
|
||||||
|
rooms: Room[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlackListManagerTemplate<TData>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
columns,
|
||||||
|
onAdd,
|
||||||
|
onUpdate,
|
||||||
|
updateLoading,
|
||||||
|
onTableInit,
|
||||||
|
rooms = [],
|
||||||
|
}: BlackListManagerTemplateProps<TData>) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
|
||||||
|
|
||||||
|
const handleUpdateAll = async () => {
|
||||||
|
if (onUpdate) await onUpdate("All");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRoomDialog = () => {
|
||||||
|
if (rooms.length > 0 && onUpdate) {
|
||||||
|
setDialogType("room");
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeviceDialog = () => {
|
||||||
|
if (onUpdate) {
|
||||||
|
setDialogType("device");
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full px-6 space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" /> Danh sách phần mềm bị chặn
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Các phần mềm không được cho phép trong hệ thống
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<VersionTable
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
columns={columns}
|
||||||
|
onTableInit={onTableInit}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{onUpdate && (
|
||||||
|
<CardFooter className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<RequestUpdateMenu
|
||||||
|
onUpdateDevice={openDeviceDialog}
|
||||||
|
onUpdateRoom={openRoomDialog}
|
||||||
|
onUpdateAll={handleUpdateAll}
|
||||||
|
loading={updateLoading}
|
||||||
|
/>
|
||||||
|
</CardFooter>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dialog chọn phòng */}
|
||||||
|
{dialogType === "room" && (
|
||||||
|
<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) => {
|
||||||
|
if (!onUpdate) return;
|
||||||
|
await onUpdate(selectedRooms);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/types/black-list.ts
Normal file
10
src/types/black-list.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
export type Blacklist = {
|
||||||
|
id: number;
|
||||||
|
appName: string;
|
||||||
|
processName: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlacklistFormData = Pick<Blacklist, "appName" | "processName">;
|
||||||
13
src/types/device.ts
Normal file
13
src/types/device.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export interface NetworkInfo {
|
||||||
|
macAddress?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceHealthCheck {
|
||||||
|
id: string;
|
||||||
|
deviceTime: string;
|
||||||
|
version?: string;
|
||||||
|
room?: string;
|
||||||
|
isOffline: boolean;
|
||||||
|
networkInfos: NetworkInfo[];
|
||||||
|
}
|
||||||
3
src/types/install-history.ts
Normal file
3
src/types/install-history.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type InstallHistory = {
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user