refactor route
This commit is contained in:
parent
1781b7cd2e
commit
ff6a5f6741
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" href="/public/computer-956.svg" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
|
|
|
|||
266
package-lock.json
generated
266
package-lock.json
generated
|
|
@ -10,9 +10,11 @@
|
|||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
|
|
@ -1658,6 +1660,147 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
|
||||
"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-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-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"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-popover/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-popover/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-popover/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-popover/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-popover/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-popper": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
|
||||
|
|
@ -1945,6 +2088,129 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@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-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3",
|
||||
"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-select/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-select/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-select/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-select/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-separator": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@
|
|||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
|
|
|
|||
6
public/computer-956.svg
Normal file
6
public/computer-956.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="256" height="256" viewBox="0 0 256 256" xml:space="preserve">
|
||||
<g style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: none; fill-rule: nonzero; opacity: 1;" transform="translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)">
|
||||
<path d="M 56.301 86.083 H 33.699 c -2.503 0 -4.539 -2.036 -4.539 -4.539 v -0.891 c 0 -2.503 2.036 -4.54 4.539 -4.54 h 6.732 v -5.125 H 5.607 C 2.516 70.987 0 68.472 0 65.38 V 9.525 c 0 -3.092 2.516 -5.608 5.607 -5.608 h 78.785 C 87.484 3.917 90 6.433 90 9.525 V 65.38 c 0 3.092 -2.516 5.607 -5.608 5.607 H 49.569 v 5.125 h 6.732 c 2.503 0 4.539 2.037 4.539 4.54 v 0.891 C 60.841 84.047 58.805 86.083 56.301 86.083 z M 33.699 79.078 c -0.867 0 -1.572 0.706 -1.572 1.573 v 0.891 c 0 0.867 0.706 1.572 1.572 1.572 h 22.602 c 0.867 0 1.572 -0.705 1.572 -1.572 v -0.891 c 0 -0.867 -0.705 -1.573 -1.572 -1.573 h -9.699 V 68.02 h 37.79 c 1.456 0 2.641 -1.184 2.641 -2.64 V 9.525 c 0 -1.456 -1.184 -2.641 -2.641 -2.641 H 5.607 c -1.456 0 -2.64 1.185 -2.64 2.641 V 65.38 c 0 1.456 1.184 2.64 2.64 2.64 h 37.791 v 11.059 H 33.699 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round"/>
|
||||
<path d="M 79.755 65.227 H 10.244 c -1.762 0 -3.194 -1.433 -3.194 -3.194 V 13.469 c 0 -1.762 1.433 -3.195 3.194 -3.195 h 69.511 c 1.762 0 3.195 1.433 3.195 3.195 v 48.565 C 82.95 63.794 81.517 65.227 79.755 65.227 z M 10.244 13.241 c -0.121 0 -0.227 0.107 -0.227 0.228 v 48.565 c 0 0.121 0.106 0.227 0.227 0.227 h 69.511 c 0.122 0 0.228 -0.106 0.228 -0.227 V 13.469 c 0 -0.121 -0.106 -0.228 -0.228 -0.228 H 10.244 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
129
src/components/computer-card.tsx
Normal file
129
src/components/computer-card.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Monitor, Wifi, WifiOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ComputerCard({
|
||||
device,
|
||||
position,
|
||||
}: {
|
||||
device: any | undefined;
|
||||
position: number;
|
||||
}) {
|
||||
if (!device) {
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 border-dashed border-muted-foreground/30 bg-muted/20">
|
||||
<div className="absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold bg-muted text-muted-foreground">
|
||||
{position}
|
||||
</div>
|
||||
<Monitor className="h-8 w-8 mb-1 text-muted-foreground/40" />
|
||||
<span className="text-xs text-muted-foreground">Trống</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOffline = device.isOffline;
|
||||
const firstNetworkInfo = device.networkInfos?.[0];
|
||||
|
||||
const DeviceInfo = () => (
|
||||
<div className="space-y-3 min-w-[280px]">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Thời gian thiết bị</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{new Date(device.deviceTime).toLocaleDateString("vi-VN")}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{new Date(device.deviceTime).toLocaleTimeString("vi-VN")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Phiên bản</div>
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
v{device.version}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Phòng</div>
|
||||
<div className="text-sm font-medium">{device.room}</div>
|
||||
</div>
|
||||
|
||||
{device.networkInfos?.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Thông tin mạng</div>
|
||||
<div className="space-y-1">
|
||||
{device.networkInfos.map((info: any, idx: number) => (
|
||||
<div key={idx} className="text-xs font-mono bg-muted/50 p-2 rounded">
|
||||
<div>MAC: {info.macAddress ?? "-"}</div>
|
||||
<div>IP: {info.ipAddress ?? "-"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Trạng thái</div>
|
||||
<Badge
|
||||
variant={isOffline ? "destructive" : "default"}
|
||||
className={`flex items-center gap-1 w-fit ${
|
||||
isOffline ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"
|
||||
}`}
|
||||
>
|
||||
{isOffline ? <WifiOff className="h-3 w-3" /> : <Wifi className="h-3 w-3" />}
|
||||
{isOffline ? "Offline" : "Online"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 transition-all hover:scale-105 cursor-pointer",
|
||||
isOffline
|
||||
? "bg-red-50 border-red-300 hover:border-red-400 hover:shadow-lg"
|
||||
: "bg-green-50 border-green-300 hover:border-green-400 hover:shadow-lg"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold",
|
||||
isOffline ? "bg-red-500 text-white" : "bg-green-500 text-white"
|
||||
)}
|
||||
>
|
||||
{position}
|
||||
</div>
|
||||
|
||||
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
|
||||
{firstNetworkInfo?.ipAddress && (
|
||||
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
||||
{firstNetworkInfo.ipAddress}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{isOffline ? (
|
||||
<WifiOff className="h-3 w-3 text-red-600" />
|
||||
) : (
|
||||
<Wifi className="h-3 w-3 text-green-600" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
isOffline ? "text-red-700" : "text-green-700"
|
||||
)}
|
||||
>
|
||||
{isOffline ? "Off" : "On"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto" side="top" align="center">
|
||||
<DeviceInfo />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
54
src/components/device-grid.tsx
Normal file
54
src/components/device-grid.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Monitor, DoorOpen } from "lucide-react";
|
||||
import { ComputerCard } from "./computer-card";
|
||||
import { useMachineNumber } from "../hooks/useMachineNumber";
|
||||
|
||||
export function DeviceGrid({ devices }: { devices: any[] }) {
|
||||
const getMachineNumber = useMachineNumber();
|
||||
const deviceMap = new Map<number, any>();
|
||||
|
||||
devices.forEach((device) => {
|
||||
const number = getMachineNumber(device.id || "");
|
||||
if (number > 0 && number <= 40) deviceMap.set(number, device);
|
||||
});
|
||||
|
||||
const computersPerRow = 8;
|
||||
const totalRows = 5;
|
||||
|
||||
const renderRow = (rowIndex: number) => {
|
||||
const start = rowIndex * computersPerRow + 1;
|
||||
return (
|
||||
<div key={rowIndex} className="flex items-center justify-center gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => {
|
||||
const pos = start + i;
|
||||
return <ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />;
|
||||
})}
|
||||
<div className="w-32 flex items-center justify-center">
|
||||
<div className="h-px w-full bg-border border-t-2 border-dashed" />
|
||||
</div>
|
||||
{Array.from({ length: 4 }).map((_, i) => {
|
||||
const pos = start + i + 4;
|
||||
return <ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-0.5 py-8 space-y-6">
|
||||
<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">
|
||||
<DoorOpen className="h-6 w-6 text-muted-foreground" />
|
||||
<span className="font-semibold text-lg">Cửa Ra Vào</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
src/components/device-table.tsx
Normal file
225
src/components/device-table.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useMachineNumber } from "../hooks/useMachineNumber";
|
||||
|
||||
interface DeviceTableProps {
|
||||
devices: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component hiển thị danh sách thiết bị ở dạng bảng
|
||||
*/
|
||||
export function DeviceTable({ devices }: DeviceTableProps) {
|
||||
const getMachineNumber = useMachineNumber();
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
header: "STT",
|
||||
cell: ({ row }) => {
|
||||
const machineNumber = getMachineNumber(row.original.id || "");
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="bg-primary/10 text-primary">
|
||||
<Monitor className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium text-sm">{machineNumber}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Thời gian thiết bị
|
||||
</div>
|
||||
),
|
||||
accessorKey: "deviceTime",
|
||||
cell: ({ getValue }) => {
|
||||
const date = new Date(getValue() as string);
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{date.toLocaleDateString("vi-VN")}</div>
|
||||
<div className="text-muted-foreground">{date.toLocaleTimeString("vi-VN")}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Phiên bản",
|
||||
accessorKey: "version",
|
||||
cell: ({ getValue }) => (
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
v{getValue() as string}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Phòng
|
||||
</div>
|
||||
),
|
||||
accessorKey: "room",
|
||||
cell: ({ getValue }) => <span className="text-sm font-medium">{getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi className="h-4 w-4" />
|
||||
Thông tin mạng
|
||||
</div>
|
||||
),
|
||||
accessorKey: "networkInfos",
|
||||
cell: ({ getValue }) => {
|
||||
const infos = getValue() as { macAddress?: string; ipAddress?: string }[];
|
||||
if (!infos || infos.length === 0) {
|
||||
return <span className="text-muted-foreground text-sm">Không có dữ liệu</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{infos.map((info, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-2 text-sm font-mono px-2 py-1 rounded bg-muted/30"
|
||||
>
|
||||
<span className="text-primary">•</span>
|
||||
<code className="bg-background px-2 py-0.5 rounded">
|
||||
{info.macAddress ?? "-"}
|
||||
</code>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<code className="bg-background px-2 py-0.5 rounded">
|
||||
{info.ipAddress ?? "-"}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Trạng thái",
|
||||
accessorKey: "isOffline",
|
||||
cell: ({ getValue }) => {
|
||||
const isOffline = getValue() as boolean;
|
||||
return (
|
||||
<Badge
|
||||
variant={isOffline ? "destructive" : "default"}
|
||||
className={`flex items-center gap-1 w-fit ${
|
||||
isOffline
|
||||
? "bg-red-100 text-red-700 hover:bg-red-100"
|
||||
: "bg-green-100 text-green-700 hover:bg-green-100"
|
||||
}`}
|
||||
>
|
||||
{isOffline ? <WifiOff className="h-3 w-3" /> : <Wifi className="h-3 w-3" />}
|
||||
{isOffline ? "Offline" : "Online"}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: devices,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageSize: 16 } },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="hover:bg-transparent border-b">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} className="font-semibold text-foreground bg-muted/30">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="hover:bg-muted/50 transition-colors">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-4">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between p-4 border-t bg-muted/20">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Hiển thị{" "}
|
||||
{table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} -{" "}
|
||||
{Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
|
||||
devices.length
|
||||
)}{" "}
|
||||
trong tổng số {devices.length} thiết bị
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Trước
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1 text-sm font-medium">
|
||||
<span>Trang</span>
|
||||
<span className="bg-primary text-primary-foreground px-2 py-1 rounded">
|
||||
{table.getState().pagination.pageIndex + 1}
|
||||
</span>
|
||||
<span>của {table.getPageCount()}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
Sau
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/components/floor-select.tsx
Normal file
33
src/components/floor-select.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface FloorSelectProps {
|
||||
selectedFloor?: string;
|
||||
onChange: (value: string) => void;
|
||||
floors: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component chọn tầng (render động từ floors)
|
||||
*/
|
||||
export function FloorSelect({ selectedFloor, onChange, floors }: FloorSelectProps) {
|
||||
return (
|
||||
<Select value={selectedFloor} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Chọn tầng" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{floors.map((floor) => (
|
||||
<SelectItem key={floor} value={floor.toString()}>
|
||||
Tầng {floor}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
51
src/components/room-select.tsx
Normal file
51
src/components/room-select.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import type { Room } from "@/types/room";
|
||||
|
||||
interface RoomSelectProps {
|
||||
selectedRoom?: string;
|
||||
onChange: (value: string) => void;
|
||||
rooms: Room[];
|
||||
selectedFloor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component chọn phòng — tự động lọc theo tầng đã chọn
|
||||
*/
|
||||
export function RoomSelect({
|
||||
selectedRoom,
|
||||
onChange,
|
||||
rooms,
|
||||
selectedFloor,
|
||||
}: RoomSelectProps) {
|
||||
const filteredRooms = selectedFloor
|
||||
? rooms.filter((room) => room.name.startsWith(selectedFloor))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Select value={selectedRoom} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Chọn phòng" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredRooms.length > 0 ? (
|
||||
filteredRooms.map((room) => (
|
||||
<SelectItem key={room.name} value={room.name}>
|
||||
Phòng {room.name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem disabled value="none">
|
||||
Không có phòng
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
46
src/components/ui/popover.tsx
Normal file
46
src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
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 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
185
src/components/ui/select.tsx
Normal file
185
src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
|
|
@ -9,9 +9,15 @@ export const API_ENDPOINTS = {
|
|||
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
||||
UPLOAD: `${BASE_URL}/AppVersion/upload`,
|
||||
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
||||
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
|
||||
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
|
||||
DELETE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
|
||||
UPDATE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/update/${appId}`,
|
||||
REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
|
||||
},
|
||||
DEVICE_COMM: {
|
||||
DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
|
||||
GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
|
||||
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
|
||||
GET_DEVICE_FROM_ROOM: (roomName: string) =>
|
||||
`${BASE_URL}/DeviceComm/room/${roomName}`,
|
||||
|
|
@ -22,5 +28,7 @@ export const API_ENDPOINTS = {
|
|||
SSE_EVENTS: {
|
||||
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
||||
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
|
||||
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
|
||||
|
||||
},
|
||||
};
|
||||
|
|
|
|||
24
src/hooks/useFloor.ts
Normal file
24
src/hooks/useFloor.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useMemo } from "react";
|
||||
import type { Room } from "@/types/room";
|
||||
|
||||
/**
|
||||
* Trích xuất danh sách tầng từ danh sách phòng (mảng object)
|
||||
*/
|
||||
export function useFloors(rooms: Room[]) {
|
||||
const floors = useMemo(() => {
|
||||
if (!rooms || rooms.length === 0) return [];
|
||||
|
||||
const extracted = rooms.map((room) => {
|
||||
const name = room.name || "";
|
||||
if (name.length === 4) return parseInt(name.slice(0, 2), 10);
|
||||
if (name.length === 3) return parseInt(name.slice(0, 1), 10);
|
||||
return 0;
|
||||
});
|
||||
|
||||
return Array.from(new Set(extracted))
|
||||
.filter((f) => f > 0)
|
||||
.sort((a, b) => a - b);
|
||||
}, [rooms]);
|
||||
|
||||
return floors;
|
||||
}
|
||||
9
src/hooks/useMachineNumber.ts
Normal file
9
src/hooks/useMachineNumber.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Lấy số máy từ deviceId (VD: "PC_M10" → 10)
|
||||
*/
|
||||
export function useMachineNumber() {
|
||||
return (deviceId: string): number => {
|
||||
const match = deviceId.match(/M(\d+)/);
|
||||
return match ? Number.parseInt(match[1], 10) : 0;
|
||||
};
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
SidebarInset,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Home, Building, AppWindow, Terminal } from "lucide-react";
|
||||
import { Home, Building, AppWindow, Terminal, CircleX } from "lucide-react";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
||||
|
|
@ -50,6 +50,17 @@ export default function AppLayout({ children }: AppLayoutProps) {
|
|||
});
|
||||
};
|
||||
|
||||
const handlePrefetchBannedSoftware = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["blacklist"],
|
||||
queryFn: () =>
|
||||
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION).then((res) =>
|
||||
res.json()
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
const items = [
|
||||
{ title: "Dashboard", to: "/", icon: Home },
|
||||
{
|
||||
|
|
@ -71,6 +82,12 @@ export default function AppLayout({ children }: AppLayoutProps) {
|
|||
onPointerEnter: handlePrefetchSofware,
|
||||
},
|
||||
{ title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
|
||||
{
|
||||
title: "Danh sách đen",
|
||||
to: "/blacklist",
|
||||
icon: CircleX,
|
||||
onPointerEnter: handlePrefetchBannedSoftware,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
|||
import { Route as AuthRouteImport } from './routes/_auth'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
|
||||
import { Route as AuthenticatedDeviceIndexRouteImport } from './routes/_authenticated/device/index'
|
||||
import { Route as AuthenticatedCommandIndexRouteImport } from './routes/_authenticated/command/index'
|
||||
import { Route as AuthenticatedBlacklistIndexRouteImport } from './routes/_authenticated/blacklist/index'
|
||||
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
|
||||
|
|
@ -38,6 +39,12 @@ const AuthenticatedRoomIndexRoute = AuthenticatedRoomIndexRouteImport.update({
|
|||
path: '/room/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedDeviceIndexRoute =
|
||||
AuthenticatedDeviceIndexRouteImport.update({
|
||||
id: '/device/',
|
||||
path: '/device/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedCommandIndexRoute =
|
||||
AuthenticatedCommandIndexRouteImport.update({
|
||||
id: '/command/',
|
||||
|
|
@ -79,6 +86,7 @@ export interface FileRoutesByFullPath {
|
|||
'/apps': typeof AuthenticatedAppsIndexRoute
|
||||
'/blacklist': typeof AuthenticatedBlacklistIndexRoute
|
||||
'/command': typeof AuthenticatedCommandIndexRoute
|
||||
'/device': typeof AuthenticatedDeviceIndexRoute
|
||||
'/room': typeof AuthenticatedRoomIndexRoute
|
||||
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||
}
|
||||
|
|
@ -89,6 +97,7 @@ export interface FileRoutesByTo {
|
|||
'/apps': typeof AuthenticatedAppsIndexRoute
|
||||
'/blacklist': typeof AuthenticatedBlacklistIndexRoute
|
||||
'/command': typeof AuthenticatedCommandIndexRoute
|
||||
'/device': typeof AuthenticatedDeviceIndexRoute
|
||||
'/room': typeof AuthenticatedRoomIndexRoute
|
||||
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||
}
|
||||
|
|
@ -102,6 +111,7 @@ export interface FileRoutesById {
|
|||
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
|
||||
'/_authenticated/blacklist/': typeof AuthenticatedBlacklistIndexRoute
|
||||
'/_authenticated/command/': typeof AuthenticatedCommandIndexRoute
|
||||
'/_authenticated/device/': typeof AuthenticatedDeviceIndexRoute
|
||||
'/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
|
||||
'/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||
}
|
||||
|
|
@ -114,6 +124,7 @@ export interface FileRouteTypes {
|
|||
| '/apps'
|
||||
| '/blacklist'
|
||||
| '/command'
|
||||
| '/device'
|
||||
| '/room'
|
||||
| '/room/$roomName'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
|
|
@ -124,6 +135,7 @@ export interface FileRouteTypes {
|
|||
| '/apps'
|
||||
| '/blacklist'
|
||||
| '/command'
|
||||
| '/device'
|
||||
| '/room'
|
||||
| '/room/$roomName'
|
||||
id:
|
||||
|
|
@ -136,6 +148,7 @@ export interface FileRouteTypes {
|
|||
| '/_authenticated/apps/'
|
||||
| '/_authenticated/blacklist/'
|
||||
| '/_authenticated/command/'
|
||||
| '/_authenticated/device/'
|
||||
| '/_authenticated/room/'
|
||||
| '/_authenticated/room/$roomName/'
|
||||
fileRoutesById: FileRoutesById
|
||||
|
|
@ -176,6 +189,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof AuthenticatedRoomIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/device/': {
|
||||
id: '/_authenticated/device/'
|
||||
path: '/device'
|
||||
fullPath: '/device'
|
||||
preLoaderRoute: typeof AuthenticatedDeviceIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/command/': {
|
||||
id: '/_authenticated/command/'
|
||||
path: '/command'
|
||||
|
|
@ -236,6 +256,7 @@ interface AuthenticatedRouteChildren {
|
|||
AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
|
||||
AuthenticatedBlacklistIndexRoute: typeof AuthenticatedBlacklistIndexRoute
|
||||
AuthenticatedCommandIndexRoute: typeof AuthenticatedCommandIndexRoute
|
||||
AuthenticatedDeviceIndexRoute: typeof AuthenticatedDeviceIndexRoute
|
||||
AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
|
||||
AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
|
||||
}
|
||||
|
|
@ -245,6 +266,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
|||
AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
|
||||
AuthenticatedBlacklistIndexRoute: AuthenticatedBlacklistIndexRoute,
|
||||
AuthenticatedCommandIndexRoute: AuthenticatedCommandIndexRoute,
|
||||
AuthenticatedDeviceIndexRoute: AuthenticatedDeviceIndexRoute,
|
||||
AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
|
||||
AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,66 @@
|
|||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
||||
import { useQueryData } from "@/hooks/useQueryData";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/blacklist/')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
type Blacklist = {
|
||||
id: number;
|
||||
appName: string;
|
||||
processName: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
};
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/_authenticated/blacklist/"!</div>
|
||||
export const Route = createFileRoute("/_authenticated/blacklist/")({
|
||||
head: () => ({ meta: [{ title: "Danh sách các ứng dụng bị chặn" }] }),
|
||||
component: BlacklistComponent,
|
||||
});
|
||||
|
||||
function BlacklistComponent() {
|
||||
const { data, isLoading } = useQueryData({
|
||||
queryKey: ["blacklist"],
|
||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
|
||||
});
|
||||
|
||||
const blacklist: Blacklist[] = Array.isArray(data)
|
||||
? (data as Blacklist[])
|
||||
: [];
|
||||
|
||||
const columns : ColumnDef<Blacklist>[] =
|
||||
[
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
cell: info => info.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: "appName",
|
||||
header: "Tên ứng dụng",
|
||||
cell: info => info.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: "processName",
|
||||
header: "Tên tiến trình",
|
||||
cell: info => info.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Ngày tạo",
|
||||
cell: info => info.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: "Ngày cập nhật",
|
||||
cell: info => info.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdBy",
|
||||
header: "Người tạo",
|
||||
cell: info => info.getValue(),
|
||||
},
|
||||
]
|
||||
|
||||
return <div>Hello "/_authenticated/blacklist/"!</div>;
|
||||
}
|
||||
|
|
|
|||
10
src/routes/_authenticated/device/index.tsx
Normal file
10
src/routes/_authenticated/device/index.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/device/')({
|
||||
head: () => ({ meta: [{ title: 'Danh sách tất cả thiết bị' }] }),
|
||||
component: AllDevicesComponent,
|
||||
})
|
||||
|
||||
function AllDevicesComponent() {
|
||||
return <div>Hello "/_authenticated/device/"!</div>
|
||||
}
|
||||
|
|
@ -1,245 +1,59 @@
|
|||
import { createFileRoute, useParams } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LayoutGrid, TableIcon, Monitor } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useQueryData } from "@/hooks/useQueryData";
|
||||
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Monitor,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DeviceGrid } from "@/components/device-grid";
|
||||
import { DeviceTable } from "@/components/device-table";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/room/$roomName/")({
|
||||
head: ({ params }) => ({
|
||||
meta: [{ title: `Danh sách thiết bị phòng ${params.roomName}` }],
|
||||
}),
|
||||
component: RoomDetailComponent,
|
||||
component: RoomDetailPage,
|
||||
});
|
||||
|
||||
function RoomDetailComponent() {
|
||||
function RoomDetailPage() {
|
||||
const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
|
||||
|
||||
const { data: devices = [], isLoading } = useQueryData({
|
||||
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
||||
const { data: devices = [] } = useQueryData({
|
||||
queryKey: ["devices", roomName],
|
||||
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
|
||||
});
|
||||
|
||||
// Lắng nghe SSE và update state
|
||||
useDeviceEvents(roomName);
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
header: "STT",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="bg-primary/10 text-primary">
|
||||
<Monitor className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium text-sm">{row.index + 1}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Thời gian thiết bị
|
||||
</div>
|
||||
),
|
||||
accessorKey: "deviceTime",
|
||||
cell: ({ getValue }) => {
|
||||
const date = new Date(getValue() as string);
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{date.toLocaleDateString("vi-VN")}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{date.toLocaleTimeString("vi-VN")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Phiên bản",
|
||||
accessorKey: "version",
|
||||
cell: ({ getValue }) => (
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
v{getValue() as string}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Phòng
|
||||
</div>
|
||||
),
|
||||
accessorKey: "room",
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-sm font-medium">{getValue() as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi className="h-4 w-4" />
|
||||
Thông tin mạng
|
||||
</div>
|
||||
),
|
||||
accessorKey: "networkInfos",
|
||||
cell: ({ getValue }) => {
|
||||
const networkInfos = getValue() as {
|
||||
macAddress?: string;
|
||||
ipAddress?: string;
|
||||
}[];
|
||||
|
||||
if (!networkInfos || networkInfos.length === 0) {
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Không có dữ liệu
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{networkInfos.map((info, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-2 text-sm font-mono px-2 py-1 rounded bg-muted/30"
|
||||
>
|
||||
<span className="text-primary">•</span>
|
||||
<code className="bg-background px-2 py-0.5 rounded">
|
||||
{info.macAddress ?? "-"}
|
||||
</code>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<code className="bg-background px-2 py-0.5 rounded">
|
||||
{info.ipAddress ?? "-"}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Trạng thái",
|
||||
accessorKey: "isOffline",
|
||||
cell: ({ getValue }) => {
|
||||
const isOffline = getValue() as boolean;
|
||||
return (
|
||||
<Badge
|
||||
variant={isOffline ? "destructive" : "default"}
|
||||
className={`flex items-center gap-1 w-fit ${
|
||||
isOffline
|
||||
? "bg-red-100 text-red-700 hover:bg-red-100"
|
||||
: "bg-green-100 text-green-700 hover:bg-green-100"
|
||||
}`}
|
||||
>
|
||||
{isOffline ? (
|
||||
<WifiOff className="h-3 w-3" />
|
||||
) : (
|
||||
<Wifi className="h-3 w-3" />
|
||||
)}
|
||||
{isOffline ? "Offline" : "Online"}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: devices,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageSize: 16 } },
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full px-6 py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Đang tải danh sách phòng...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onlineDevices = devices.filter(
|
||||
(device: any) => !device.isOffline
|
||||
).length;
|
||||
const offlineDevices = devices.length - onlineDevices;
|
||||
|
||||
return (
|
||||
<div className="w-full px-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Phòng: {roomName}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Quản lý và theo dõi thiết bị trong phòng
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{onlineDevices}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Online</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{offlineDevices}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Offline</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{devices.length}</div>
|
||||
<div className="text-sm text-muted-foreground">Tổng cộng</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardHeader className="bg-muted/50 flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
Danh sách thiết bị
|
||||
Danh sách thiết bị phòng {roomName}
|
||||
</CardTitle>
|
||||
|
||||
<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
|
||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("grid")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Sơ đồ
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{devices.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
|
|
@ -249,100 +63,10 @@ function RoomDetailComponent() {
|
|||
Phòng này chưa có thiết bị nào được kết nối.
|
||||
</p>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<DeviceGrid devices={devices} />
|
||||
) : (
|
||||
<>
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="hover:bg-transparent border-b"
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="font-semibold text-foreground bg-muted/30"
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className="hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-4">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 border-t bg-muted/20">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Hiển thị{" "}
|
||||
{table.getState().pagination.pageIndex *
|
||||
table.getState().pagination.pageSize +
|
||||
1}{" "}
|
||||
-{" "}
|
||||
{Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) *
|
||||
table.getState().pagination.pageSize,
|
||||
devices.length
|
||||
)}{" "}
|
||||
trong tổng số {devices.length} thiết bị
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Trước
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1 text-sm font-medium">
|
||||
<span>Trang</span>
|
||||
<span className="bg-primary text-primary-foreground px-2 py-1 rounded">
|
||||
{table.getState().pagination.pageIndex + 1}
|
||||
</span>
|
||||
<span>của {table.getPageCount()}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
Sau
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<DeviceTable devices={devices} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user