refactor route

This commit is contained in:
Do Manh Phuong 2025-10-20 16:46:17 +07:00
parent 1781b7cd2e
commit ff6a5f6741
19 changed files with 1189 additions and 321 deletions

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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="theme-color" content="#000000" />
<meta <meta
name="description" name="description"

266
package-lock.json generated
View File

@ -10,9 +10,11 @@
"@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-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10", "@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-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7", "@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": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", "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": { "node_modules/@radix-ui/react-separator": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",

View File

@ -14,9 +14,11 @@
"@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-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10", "@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-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",

6
public/computer-956.svg Normal file
View 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

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

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

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

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

View 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 phòng
</SelectItem>
)}
</SelectContent>
</Select>
);
}

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

View 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,
}

View File

@ -9,9 +9,15 @@ export const API_ENDPOINTS = {
GET_VERSION: `${BASE_URL}/AppVersion/version`, GET_VERSION: `${BASE_URL}/AppVersion/version`,
UPLOAD: `${BASE_URL}/AppVersion/upload`, UPLOAD: `${BASE_URL}/AppVersion/upload`,
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`, 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: { DEVICE_COMM: {
DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`, DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`, GET_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}`,
@ -22,5 +28,7 @@ export const API_ENDPOINTS = {
SSE_EVENTS: { SSE_EVENTS: {
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`,
}, },
}; };

24
src/hooks/useFloor.ts Normal file
View 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;
}

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

View File

@ -5,7 +5,7 @@ import {
SidebarInset, SidebarInset,
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } 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 { Toaster } from "@/components/ui/sonner";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { API_ENDPOINTS, BASE_URL } from "@/config/api"; 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 = [ const items = [
{ title: "Dashboard", to: "/", icon: Home }, { title: "Dashboard", to: "/", icon: Home },
{ {
@ -71,6 +82,12 @@ export default function AppLayout({ children }: AppLayoutProps) {
onPointerEnter: handlePrefetchSofware, onPointerEnter: handlePrefetchSofware,
}, },
{ title: "Gửi lệnh CMD", to: "/command", icon: Terminal }, { title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
{
title: "Danh sách đen",
to: "/blacklist",
icon: CircleX,
onPointerEnter: handlePrefetchBannedSoftware,
},
]; ];
return ( return (

View File

@ -13,6 +13,7 @@ import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthRouteImport } from './routes/_auth' import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/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 AuthenticatedCommandIndexRouteImport } from './routes/_authenticated/command/index'
import { Route as AuthenticatedBlacklistIndexRouteImport } from './routes/_authenticated/blacklist/index' import { Route as AuthenticatedBlacklistIndexRouteImport } from './routes/_authenticated/blacklist/index'
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index' import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
@ -38,6 +39,12 @@ const AuthenticatedRoomIndexRoute = AuthenticatedRoomIndexRouteImport.update({
path: '/room/', path: '/room/',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedDeviceIndexRoute =
AuthenticatedDeviceIndexRouteImport.update({
id: '/device/',
path: '/device/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedCommandIndexRoute = const AuthenticatedCommandIndexRoute =
AuthenticatedCommandIndexRouteImport.update({ AuthenticatedCommandIndexRouteImport.update({
id: '/command/', id: '/command/',
@ -79,6 +86,7 @@ export interface FileRoutesByFullPath {
'/apps': typeof AuthenticatedAppsIndexRoute '/apps': typeof AuthenticatedAppsIndexRoute
'/blacklist': typeof AuthenticatedBlacklistIndexRoute '/blacklist': typeof AuthenticatedBlacklistIndexRoute
'/command': typeof AuthenticatedCommandIndexRoute '/command': typeof AuthenticatedCommandIndexRoute
'/device': typeof AuthenticatedDeviceIndexRoute
'/room': typeof AuthenticatedRoomIndexRoute '/room': typeof AuthenticatedRoomIndexRoute
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute '/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
} }
@ -89,6 +97,7 @@ export interface FileRoutesByTo {
'/apps': typeof AuthenticatedAppsIndexRoute '/apps': typeof AuthenticatedAppsIndexRoute
'/blacklist': typeof AuthenticatedBlacklistIndexRoute '/blacklist': typeof AuthenticatedBlacklistIndexRoute
'/command': typeof AuthenticatedCommandIndexRoute '/command': typeof AuthenticatedCommandIndexRoute
'/device': typeof AuthenticatedDeviceIndexRoute
'/room': typeof AuthenticatedRoomIndexRoute '/room': typeof AuthenticatedRoomIndexRoute
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute '/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
} }
@ -102,6 +111,7 @@ export interface FileRoutesById {
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute '/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
'/_authenticated/blacklist/': typeof AuthenticatedBlacklistIndexRoute '/_authenticated/blacklist/': typeof AuthenticatedBlacklistIndexRoute
'/_authenticated/command/': typeof AuthenticatedCommandIndexRoute '/_authenticated/command/': typeof AuthenticatedCommandIndexRoute
'/_authenticated/device/': typeof AuthenticatedDeviceIndexRoute
'/_authenticated/room/': typeof AuthenticatedRoomIndexRoute '/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
'/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute '/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
} }
@ -114,6 +124,7 @@ export interface FileRouteTypes {
| '/apps' | '/apps'
| '/blacklist' | '/blacklist'
| '/command' | '/command'
| '/device'
| '/room' | '/room'
| '/room/$roomName' | '/room/$roomName'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@ -124,6 +135,7 @@ export interface FileRouteTypes {
| '/apps' | '/apps'
| '/blacklist' | '/blacklist'
| '/command' | '/command'
| '/device'
| '/room' | '/room'
| '/room/$roomName' | '/room/$roomName'
id: id:
@ -136,6 +148,7 @@ export interface FileRouteTypes {
| '/_authenticated/apps/' | '/_authenticated/apps/'
| '/_authenticated/blacklist/' | '/_authenticated/blacklist/'
| '/_authenticated/command/' | '/_authenticated/command/'
| '/_authenticated/device/'
| '/_authenticated/room/' | '/_authenticated/room/'
| '/_authenticated/room/$roomName/' | '/_authenticated/room/$roomName/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@ -176,6 +189,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedRoomIndexRouteImport preLoaderRoute: typeof AuthenticatedRoomIndexRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/device/': {
id: '/_authenticated/device/'
path: '/device'
fullPath: '/device'
preLoaderRoute: typeof AuthenticatedDeviceIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/command/': { '/_authenticated/command/': {
id: '/_authenticated/command/' id: '/_authenticated/command/'
path: '/command' path: '/command'
@ -236,6 +256,7 @@ interface AuthenticatedRouteChildren {
AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
AuthenticatedBlacklistIndexRoute: typeof AuthenticatedBlacklistIndexRoute AuthenticatedBlacklistIndexRoute: typeof AuthenticatedBlacklistIndexRoute
AuthenticatedCommandIndexRoute: typeof AuthenticatedCommandIndexRoute AuthenticatedCommandIndexRoute: typeof AuthenticatedCommandIndexRoute
AuthenticatedDeviceIndexRoute: typeof AuthenticatedDeviceIndexRoute
AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
} }
@ -245,6 +266,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute, AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
AuthenticatedBlacklistIndexRoute: AuthenticatedBlacklistIndexRoute, AuthenticatedBlacklistIndexRoute: AuthenticatedBlacklistIndexRoute,
AuthenticatedCommandIndexRoute: AuthenticatedCommandIndexRoute, AuthenticatedCommandIndexRoute: AuthenticatedCommandIndexRoute,
AuthenticatedDeviceIndexRoute: AuthenticatedDeviceIndexRoute,
AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute, AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute, AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,
} }

View File

@ -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/')({ type Blacklist = {
component: RouteComponent, id: number;
}) appName: string;
processName: string;
createdAt?: string;
updatedAt?: string;
createdBy?: string;
};
function RouteComponent() { export const Route = createFileRoute("/_authenticated/blacklist/")({
return <div>Hello "/_authenticated/blacklist/"!</div> 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>;
} }

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

View File

@ -1,245 +1,59 @@
import { createFileRoute, useParams } from "@tanstack/react-router"; 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 { useQueryData } from "@/hooks/useQueryData";
import { API_ENDPOINTS, BASE_URL } from "@/config/api"; import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { import { DeviceGrid } from "@/components/device-grid";
flexRender, import { DeviceTable } from "@/components/device-table";
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";
export const Route = createFileRoute("/_authenticated/room/$roomName/")({ export const Route = createFileRoute("/_authenticated/room/$roomName/")({
head: ({ params }) => ({ head: ({ params }) => ({
meta: [{ title: `Danh sách thiết bị phòng ${params.roomName}` }], 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 { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
const { data: devices = [], isLoading } = 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),
}); });
// 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 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 ( return (
<div className="w-full px-6 space-y-6"> <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 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"> <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"> <CardTitle className="flex items-center gap-2">
<Monitor className="h-5 w-5" /> <Monitor className="h-5 w-5" />
Danh sách thiết bị Danh sách thiết bị phòng {roomName}
</CardTitle> </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" />
đ
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{devices.length === 0 ? ( {devices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-12">
@ -249,100 +63,10 @@ function RoomDetailComponent() {
Phòng này chưa thiết bị nào đưc kết nối. Phòng này chưa thiết bị nào đưc kết nối.
</p> </p>
</div> </div>
) : viewMode === "grid" ? (
<DeviceGrid devices={devices} />
) : ( ) : (
<> <DeviceTable 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>
</>
)} )}
</CardContent> </CardContent>
</Card> </Card>