add css
This commit is contained in:
parent
8f41579972
commit
26bb177d54
202
package-lock.json
generated
202
package-lock.json
generated
|
@ -6,9 +6,11 @@
|
||||||
"": {
|
"": {
|
||||||
"name": ".",
|
"name": ".",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@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-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@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",
|
||||||
|
@ -1346,6 +1348,59 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-avatar": {
|
||||||
|
"version": "1.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
|
||||||
|
"integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@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-is-hydrated": "0.1.0",
|
||||||
|
"@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-collection": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@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-slot": "1.2.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-compose-refs": {
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
|
@ -1412,6 +1467,21 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-direction": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||||
|
"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-dismissable-layer": {
|
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
"version": "1.1.10",
|
"version": "1.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
|
||||||
|
@ -1647,6 +1717,105 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-radio-group": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
|
||||||
|
"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-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "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-radio-group/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-radio-group/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-roving-focus": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-roving-focus/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-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",
|
||||||
|
@ -1792,6 +1961,24 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-is-hydrated": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.5.0"
|
||||||
|
},
|
||||||
|
"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-use-layout-effect": {
|
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
|
@ -1807,6 +1994,21 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-previous": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
|
||||||
|
"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-use-rect": {
|
"node_modules/@radix-ui/react-use-rect": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||||
|
|
|
@ -10,9 +10,11 @@
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@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-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@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",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// app-sidebar.tsx - Đã hỗ trợ onPointerEnter
|
import type React from "react";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { Building } from "lucide-react";
|
import { Building2, Cpu } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
@ -13,6 +13,7 @@ import {
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type MenuItem = {
|
type MenuItem = {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -27,34 +28,58 @@ type AppSidebarProps = {
|
||||||
|
|
||||||
export function AppSidebar({ items }: AppSidebarProps) {
|
export function AppSidebar({ items }: AppSidebarProps) {
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" className="border-r">
|
<Sidebar
|
||||||
<SidebarHeader className="border-b">
|
collapsible="icon"
|
||||||
<div className="flex items-center gap-2 px-2 py-2">
|
className="border-r border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
>
|
||||||
<Building className="size-4" />
|
<SidebarHeader className="border-b border-border/40 p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-gray-900 to-black text-white shadow-lg ring-1 ring-gray-800/30">
|
||||||
|
<Building2 className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
<span className="font-semibold">TTMT Computer Management</span>
|
<span className="font-bold text-base tracking-tight bg-gradient-to-r from-foreground to-foreground/80 bg-clip-text">
|
||||||
<span className="text-xs text-muted-foreground">v1.0.0</span>
|
TTMT Computer Management
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-medium flex items-center gap-1">
|
||||||
|
<Cpu className="size-3" />
|
||||||
|
v1.0.0
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent>
|
<SidebarContent className="p-4">
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider mb-2">
|
||||||
|
Navigation
|
||||||
|
</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu className="space-y-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
tooltip={item.title}
|
tooltip={item.title}
|
||||||
onPointerEnter={item.onPointerEnter}
|
onPointerEnter={item.onPointerEnter}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start gap-3 px-4 py-3 rounded-xl",
|
||||||
|
"hover:bg-accent/60 hover:text-accent-foreground hover:shadow-sm",
|
||||||
|
"transition-all duration-200 ease-in-out",
|
||||||
|
"group relative overflow-hidden",
|
||||||
|
"data-[active=true]:bg-primary data-[active=true]:text-primary-foreground",
|
||||||
|
"data-[active=true]:shadow-md data-[active=true]:ring-1 data-[active=true]:ring-primary/20"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Link href={item.to} to={"."}>
|
<Link
|
||||||
<item.icon className="size-4" />
|
href={item.to}
|
||||||
<span>{item.title}</span>
|
to={"."}
|
||||||
|
className="flex items-center gap-3 w-full"
|
||||||
|
>
|
||||||
|
<item.icon className="size-5 shrink-0 transition-all duration-200 group-hover:scale-110 group-hover:text-primary" />
|
||||||
|
<span className="font-medium text-sm truncate">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
@ -63,10 +88,10 @@ export function AppSidebar({ items }: AppSidebarProps) {
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter className="border-t">
|
<SidebarFooter className="border-t border-border/40 p-4 space-y-3">
|
||||||
<div className="p-2 text-xs text-muted-foreground">
|
<div className="px-2 text-xs text-muted-foreground/60 font-medium">
|
||||||
© 2025 NAVIS Centre
|
© 2025 NAVIS Centre
|
||||||
</div>
|
</div>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|
|
@ -1,90 +1,68 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Loader2, Terminal, AlertTriangle, CheckCircle } from "lucide-react";
|
|
||||||
|
|
||||||
interface ShellCommandFormProps {
|
interface ShellCommandFormProps {
|
||||||
onExecute: (command: string) => Promise<{ success: boolean; output: string }>;
|
command: string;
|
||||||
|
onCommandChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShellCommandForm({ onExecute }: ShellCommandFormProps) {
|
export function ShellCommandForm({
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
command,
|
||||||
|
onCommandChange,
|
||||||
// init form
|
disabled,
|
||||||
|
}: ShellCommandFormProps) {
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: { command },
|
||||||
command: "",
|
onSubmit: () => {},
|
||||||
},
|
|
||||||
onSubmit: async ({ value }) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await onExecute(value.command);
|
|
||||||
if (res.success) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
{/* Form */}
|
e.preventDefault();
|
||||||
<form
|
form.handleSubmit();
|
||||||
onSubmit={(e) => {
|
}}
|
||||||
e.preventDefault();
|
className="space-y-5"
|
||||||
form.handleSubmit();
|
>
|
||||||
}}
|
<form.Field
|
||||||
className="space-y-5"
|
name="command"
|
||||||
>
|
validators={{
|
||||||
{/* Field: command */}
|
onChange: ({ value }: { value: string }) => {
|
||||||
<form.Field
|
const schema = z
|
||||||
name="command"
|
.string()
|
||||||
validators={{
|
.min(1, "Nhập command để thực thi")
|
||||||
onChange: z
|
.max(500, "Command quá dài");
|
||||||
.string()
|
const result = schema.safeParse(value);
|
||||||
.min(1, "Nhập command để thực thi")
|
if (!result.success) {
|
||||||
.max(500, "Command quá dài"),
|
return result.error.issues.map((i) => i.message);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
children={(field) => (
|
||||||
|
<div className="w-full px-0">
|
||||||
|
<Textarea
|
||||||
|
className="w-full h-[25vh]"
|
||||||
|
placeholder="Nhập lệnh..."
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.handleChange(e.target.value);
|
||||||
|
onCommandChange(e.target.value);
|
||||||
}}
|
}}
|
||||||
children={(field) => (
|
disabled={disabled}
|
||||||
<div className="w-full px-0">
|
|
||||||
<Textarea
|
|
||||||
className="w-full h-[25vh]"
|
|
||||||
placeholder="Nhập lệnh..."
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
{field.state.meta.errors?.length > 0 && (
|
|
||||||
<p className="text-sm text-red-500">
|
|
||||||
{field.state.meta.errors.join(", ")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
{field.state.meta.errors?.length > 0 && (
|
||||||
<Button type="submit" disabled={isLoading}>
|
<p className="text-sm text-red-500">
|
||||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{String(field.state.meta.errors[0])}
|
||||||
Yêu cầu thiết bị thực thi
|
</p>
|
||||||
</Button>
|
)}
|
||||||
</form>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
98
src/components/room-select-dialog.tsx
Normal file
98
src/components/room-select-dialog.tsx
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Check, Home } from "lucide-react"
|
||||||
|
|
||||||
|
interface RoomSelectDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
rooms: string[]
|
||||||
|
onConfirm: (roomName: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomSelectDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
rooms,
|
||||||
|
onConfirm,
|
||||||
|
}: RoomSelectDialogProps) {
|
||||||
|
const [selectedRoom, setSelectedRoom] = useState<string>("")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader className="text-center pb-4">
|
||||||
|
<div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-3">
|
||||||
|
<Home className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-xl font-semibold">
|
||||||
|
Chọn phòng để cập nhật
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Vui lòng chọn phòng để gửi lệnh cập nhật
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-3">
|
||||||
|
<RadioGroup
|
||||||
|
value={selectedRoom}
|
||||||
|
onValueChange={setSelectedRoom}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
{rooms.map((room) => (
|
||||||
|
<div
|
||||||
|
key={room}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg border border-border hover:border-primary/60 hover:bg-accent/50 transition-all duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value={room} id={room} />
|
||||||
|
<Label
|
||||||
|
htmlFor={room}
|
||||||
|
className="font-medium cursor-pointer hover:text-primary"
|
||||||
|
>
|
||||||
|
{room}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRoom === room && (
|
||||||
|
<div className="w-5 h-5 bg-primary rounded-full flex items-center justify-center">
|
||||||
|
<Check className="w-3 h-3 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 pt-4">
|
||||||
|
<Button variant="outline" onClick={onClose} className="flex-1 sm:flex-none">
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedRoom) {
|
||||||
|
onConfirm(selectedRoom)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!selectedRoom}
|
||||||
|
className="flex-1 sm:flex-none"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
Xác nhận
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
51
src/components/ui/avatar.tsx
Normal file
51
src/components/ui/avatar.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
43
src/components/ui/radio-group.tsx
Normal file
43
src/components/ui/radio-group.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary 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 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
|
@ -1,14 +1,34 @@
|
||||||
import { Button } from "@/components/ui/button";
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Loader2, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
interface UpdateButtonProps {
|
interface UpdateButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void
|
||||||
loading?: boolean;
|
loading?: boolean
|
||||||
|
label?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UpdateButton({ onClick, loading }: UpdateButtonProps) {
|
export function UpdateButton({ onClick, loading, label }: UpdateButtonProps) {
|
||||||
return (
|
return (
|
||||||
<Button variant="outline" onClick={onClick} disabled={loading}>
|
<Button
|
||||||
{loading ? "Đang gửi..." : "Yêu cầu thiết bị cập nhật"}
|
variant="outline"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={loading}
|
||||||
|
className="group relative overflow-hidden border-2 border-gray-300 bg-white text-gray-800 font-medium px-6 py-2.5 rounded-lg transition-all duration-300 hover:border-gray-400 hover:bg-gray-50 hover:shadow-lg hover:shadow-gray-200/50 hover:-translate-y-0.5 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-gray-600" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-4 w-4 text-gray-600 transition-transform duration-300 group-hover:rotate-180" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{loading ? "Đang gửi..." : (label ?? "Yêu cầu thiết bị cập nhật")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-gray-100/30 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,12 +11,12 @@ export const API_ENDPOINTS = {
|
||||||
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
||||||
},
|
},
|
||||||
DEVICE_COMM: {
|
DEVICE_COMM: {
|
||||||
DOWNLOAD_MSI: `${BASE_URL}/DeviceComm/installmsi`,
|
DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
|
||||||
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
|
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
|
||||||
GET_DEVICE_FROM_ROOM: (roomName: string) =>
|
GET_DEVICE_FROM_ROOM: (roomName: string) =>
|
||||||
`${BASE_URL}/DeviceComm/room/${roomName}`,
|
`${BASE_URL}/DeviceComm/room/${roomName}`,
|
||||||
UPDATE_AGENT: `${BASE_URL}/DeviceComm/updateagent`,
|
UPDATE_AGENT: (roomName: string) => `${BASE_URL}/DeviceComm/updateagent/${roomName}`,
|
||||||
SEND_COMMAND: `${BASE_URL}/DeviceComm/shellcommand`,
|
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
|
||||||
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
|
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
|
||||||
},
|
},
|
||||||
SSE_EVENTS: {
|
SSE_EVENTS: {
|
||||||
|
|
|
@ -18,18 +18,25 @@ export function useMutationData<TInput = any, TOutput = any>({
|
||||||
}: MutationDataOptions<TInput, TOutput>) {
|
}: MutationDataOptions<TInput, TOutput>) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation<TOutput, any, { data: TInput; config?: any }>({
|
return useMutation<
|
||||||
mutationFn: async ({ data, config }) => {
|
TOutput,
|
||||||
const isFormData = data instanceof FormData;
|
any,
|
||||||
|
{ data: TInput; url?: string; config?: any; method?: Method }
|
||||||
|
>({
|
||||||
|
mutationFn: async ({
|
||||||
|
data,
|
||||||
|
config,
|
||||||
|
url: customUrl,
|
||||||
|
method: customMethod,
|
||||||
|
}) => {
|
||||||
|
const isFormData = data instanceof FormData;
|
||||||
|
|
||||||
const response = await axios.request({
|
const response = await axios.request({
|
||||||
url,
|
url: customUrl ?? url,
|
||||||
method,
|
method: customMethod ?? method,
|
||||||
data,
|
data,
|
||||||
headers: {
|
headers: {
|
||||||
...(isFormData
|
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
||||||
? {}
|
|
||||||
: { "Content-Type": "application/json" }),
|
|
||||||
},
|
},
|
||||||
...config,
|
...config,
|
||||||
});
|
});
|
||||||
|
|
|
@ -64,37 +64,43 @@ export default function AppLayout({ children }: AppLayoutProps) {
|
||||||
icon: AppWindow,
|
icon: AppWindow,
|
||||||
onPointerEnter: handlePrefetchAgents,
|
onPointerEnter: handlePrefetchAgents,
|
||||||
},
|
},
|
||||||
{ title: "Quản lý phần mềm", to: "/apps", icon: AppWindow,
|
{
|
||||||
|
title: "Quản lý phần mềm",
|
||||||
|
to: "/apps",
|
||||||
|
icon: AppWindow,
|
||||||
onPointerEnter: handlePrefetchSofware,
|
onPointerEnter: handlePrefetchSofware,
|
||||||
},
|
},
|
||||||
{ title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
|
{ title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<div className="flex min-h-screen w-full">
|
<div className="flex min-h-screen w-full bg-background">
|
||||||
<AppSidebar items={items} />
|
<AppSidebar items={items} />
|
||||||
<SidebarInset className="flex-1">
|
<SidebarInset className="flex-1">
|
||||||
{/* Mobile header with sidebar trigger */}
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-border/40 bg-background/95 backdrop-blur px-4 lg:hidden supports-[backdrop-filter]:bg-background/60">
|
||||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 lg:hidden">
|
<SidebarTrigger className="-ml-1 hover:bg-accent/50 rounded-lg p-2 transition-colors" />
|
||||||
<SidebarTrigger className="-ml-1" />
|
<Separator
|
||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
orientation="vertical"
|
||||||
<div className="flex items-center gap-2">
|
className="mr-2 h-6 bg-border/60"
|
||||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex aspect-square size-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-md">
|
||||||
<Building className="size-4" />
|
<Building className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
<span className="font-semibold text-sm">
|
<span className="font-bold text-sm tracking-tight">
|
||||||
TTMT Computer Management
|
TTMT Computer Management
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">v1.0.0</span>
|
<span className="text-xs text-muted-foreground font-medium">
|
||||||
|
v1.0.0
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main content with responsive padding */}
|
<main className="flex-1 p-4 sm:p-6 lg:p-8 overflow-auto bg-gradient-to-br from-background to-muted/20 min-h-[calc(100vh-4rem)] lg:min-h-screen">
|
||||||
<main className="flex-1 p-4 sm:p-6 lg:p-8 overflow-auto">
|
<div className="mx-auto max-w-7xl">{children}</div>
|
||||||
{children}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Toaster
|
<Toaster
|
||||||
|
@ -102,9 +108,12 @@ export default function AppLayout({ children }: AppLayoutProps) {
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast:
|
toast:
|
||||||
"text-sm sm:text-lg px-4 sm:px-6 py-3 sm:py-4 rounded-xl max-w-sm sm:max-w-md shadow-lg",
|
"text-sm sm:text-base px-4 sm:px-6 py-3 sm:py-4 rounded-xl max-w-sm sm:max-w-md shadow-xl border border-border/50 backdrop-blur-sm bg-background/95",
|
||||||
title: "text-sm sm:text-lg font-semibold",
|
title: "text-sm sm:text-base font-semibold",
|
||||||
description: "text-xs sm:text-base",
|
description: "text-xs sm:text-sm text-muted-foreground",
|
||||||
|
success: "border-green-200 bg-green-50/90 text-green-900",
|
||||||
|
error: "border-red-200 bg-red-50/90 text-red-900",
|
||||||
|
warning: "border-yellow-200 bg-yellow-50/90 text-yellow-900",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
|
import { type Room } from "@/types/room";
|
||||||
|
|
||||||
type Version = {
|
type Version = {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
@ -28,11 +29,22 @@ function AgentsPage() {
|
||||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
|
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Lấy danh sách phòng
|
||||||
|
const { data: roomData } = useQueryData({
|
||||||
|
queryKey: ["rooms"],
|
||||||
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map từ object sang string[]
|
||||||
|
const rooms: string[] = Array.isArray(roomData)
|
||||||
|
? (roomData as Room[]).map((r) => r.name)
|
||||||
|
: [];
|
||||||
|
|
||||||
const versionList: Version[] = Array.isArray(data)
|
const versionList: Version[] = Array.isArray(data)
|
||||||
? data
|
? data
|
||||||
: data
|
: data
|
||||||
? [data]
|
? [data]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Mutation upload
|
// Mutation upload
|
||||||
const uploadMutation = useMutationData<FormData>({
|
const uploadMutation = useMutationData<FormData>({
|
||||||
|
@ -41,19 +53,38 @@ function AgentsPage() {
|
||||||
invalidate: [["agent-version"]],
|
invalidate: [["agent-version"]],
|
||||||
onSuccess: () => toast.success("Upload thành công!"),
|
onSuccess: () => toast.success("Upload thành công!"),
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Upload error:", error)
|
console.error("Upload error:", error);
|
||||||
toast.error("Upload thất bại!")
|
toast.error("Upload thất bại!");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutation update
|
|
||||||
const updateMutation = useMutationData<void>({
|
const updateMutation = useMutationData<void>({
|
||||||
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT,
|
url: "",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
|
onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
|
||||||
onError: () => toast.error("Gửi yêu cầu thất bại!"),
|
onError: () => toast.error("Gửi yêu cầu thất bại!"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleUpload = async (
|
||||||
|
fd: FormData,
|
||||||
|
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
|
||||||
|
) => {
|
||||||
|
return uploadMutation.mutateAsync({
|
||||||
|
data: fd,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Callback khi chọn phòng update
|
||||||
|
const handleUpdate = async (roomName: string) => {
|
||||||
|
return updateMutation.mutateAsync({
|
||||||
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT(roomName),
|
||||||
|
method: "POST",
|
||||||
|
data: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cột bảng
|
||||||
const columns: ColumnDef<Version>[] = [
|
const columns: ColumnDef<Version>[] = [
|
||||||
{ accessorKey: "version", header: "Phiên bản" },
|
{ accessorKey: "version", header: "Phiên bản" },
|
||||||
{ accessorKey: "fileName", header: "Tên file" },
|
{ accessorKey: "fileName", header: "Tên file" },
|
||||||
|
@ -73,25 +104,9 @@ function AgentsPage() {
|
||||||
getValue()
|
getValue()
|
||||||
? new Date(getValue() as string).toLocaleString("vi-VN")
|
? new Date(getValue() as string).toLocaleString("vi-VN")
|
||||||
: "N/A",
|
: "N/A",
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleUpload = async (
|
|
||||||
fd: FormData,
|
|
||||||
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
|
|
||||||
) => {
|
|
||||||
return uploadMutation.mutateAsync({
|
|
||||||
data: fd,
|
|
||||||
config
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
|
||||||
return updateMutation.mutateAsync({
|
|
||||||
data: undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppManagerTemplate<Version>
|
<AppManagerTemplate<Version>
|
||||||
title="Quản lý Agent"
|
title="Quản lý Agent"
|
||||||
|
@ -102,6 +117,7 @@ function AgentsPage() {
|
||||||
onUpload={handleUpload}
|
onUpload={handleUpload}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
updateLoading={updateMutation.isPending}
|
updateLoading={updateMutation.isPending}
|
||||||
|
rooms={rooms}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { toast } from "sonner";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
|
import type { Room } from "@/types/room";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/apps/")({
|
export const Route = createFileRoute("/_authenticated/apps/")({
|
||||||
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
|
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
|
||||||
|
@ -25,21 +26,31 @@ type Version = {
|
||||||
function AppsComponent() {
|
function AppsComponent() {
|
||||||
const { data, isLoading } = useQueryData({
|
const { data, isLoading } = useQueryData({
|
||||||
queryKey: ["software-version"],
|
queryKey: ["software-version"],
|
||||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE, // API lấy danh sách file MSI
|
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: roomData } = useQueryData({
|
||||||
|
queryKey: ["rooms"],
|
||||||
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map từ object sang string[]
|
||||||
|
const rooms: string[] = Array.isArray(roomData)
|
||||||
|
? (roomData as Room[]).map((r) => r.name)
|
||||||
|
: [];
|
||||||
|
|
||||||
const versionList: Version[] = Array.isArray(data)
|
const versionList: Version[] = Array.isArray(data)
|
||||||
? data
|
? data
|
||||||
: data
|
: data
|
||||||
? [data]
|
? [data]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const [table, setTable] = useState<any>();
|
const [table, setTable] = useState<any>();
|
||||||
|
|
||||||
const uploadMutation = useMutationData<FormData>({
|
const uploadMutation = useMutationData<FormData>({
|
||||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
|
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
invalidate: [["software-version"]], // Add this to refresh data after upload
|
invalidate: [["software-version"]],
|
||||||
onSuccess: () => toast.success("Upload thành công!"),
|
onSuccess: () => toast.success("Upload thành công!"),
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Upload error:", error);
|
console.error("Upload error:", error);
|
||||||
|
@ -48,7 +59,7 @@ function AppsComponent() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const installMutation = useMutationData<{ MsiFileIds: number[] }>({
|
const installMutation = useMutationData<{ MsiFileIds: number[] }>({
|
||||||
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI,
|
url: "",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
|
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
@ -57,8 +68,8 @@ function AppsComponent() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cột bảng
|
||||||
const columns: ColumnDef<Version>[] = [
|
const columns: ColumnDef<Version>[] = [
|
||||||
|
|
||||||
{ accessorKey: "version", header: "Phiên bản" },
|
{ accessorKey: "version", header: "Phiên bản" },
|
||||||
{ accessorKey: "fileName", header: "Tên file" },
|
{ accessorKey: "fileName", header: "Tên file" },
|
||||||
{ accessorKey: "folderPath", header: "Đường dẫn" },
|
{ accessorKey: "folderPath", header: "Đường dẫn" },
|
||||||
|
@ -87,9 +98,10 @@ function AppsComponent() {
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Upload file MSI
|
||||||
const handleUpload = async (
|
const handleUpload = async (
|
||||||
fd: FormData,
|
fd: FormData,
|
||||||
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
|
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
|
||||||
|
@ -100,24 +112,23 @@ function AppsComponent() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInstall = async () => {
|
// Callback khi chọn phòng
|
||||||
|
const handleInstall = async (roomName: string) => {
|
||||||
if (!table) {
|
if (!table) {
|
||||||
toast.error("Không thể lấy thông tin bảng!");
|
toast.error("Không thể lấy thông tin bảng!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows;
|
const selectedRows = table.getSelectedRowModel().rows;
|
||||||
|
|
||||||
if (selectedRows.length === 0) {
|
if (selectedRows.length === 0) {
|
||||||
toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
|
toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
|
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
|
||||||
|
|
||||||
console.log("Selected MSI file IDs:", MsiFileIds);
|
|
||||||
|
|
||||||
return installMutation.mutateAsync({
|
return installMutation.mutateAsync({
|
||||||
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI(roomName), // set url động
|
||||||
data: { MsiFileIds },
|
data: { MsiFileIds },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -130,9 +141,10 @@ function AppsComponent() {
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onUpload={handleUpload}
|
onUpload={handleUpload}
|
||||||
onUpdate={handleInstall}
|
onUpdate={handleInstall}
|
||||||
updateLoading={installMutation.isPending}
|
updateLoading={installMutation.isPending}
|
||||||
onTableInit={setTable}
|
onTableInit={setTable}
|
||||||
|
rooms={rooms}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,13 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { FormSubmitTemplate } from "@/template/form-submit-template";
|
import { FormSubmitTemplate } from "@/template/form-submit-template";
|
||||||
import { ShellCommandForm } from "@/components/command-form";
|
import { ShellCommandForm } from "@/components/command-form";
|
||||||
import { useMutationData } from "@/hooks/useMutationData";
|
import { useMutationData } from "@/hooks/useMutationData";
|
||||||
|
import { useQueryData } from "@/hooks/useQueryData";
|
||||||
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import type { Room } from "@/types/room";
|
||||||
|
|
||||||
|
type SendCommandRequest = { Command: string };
|
||||||
|
type SendCommandResponse = { status: string; message: string };
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/command/")({
|
export const Route = createFileRoute("/_authenticated/command/")({
|
||||||
head: () => ({ meta: [{ title: "Gửi lệnh CMD" }] }),
|
head: () => ({ meta: [{ title: "Gửi lệnh CMD" }] }),
|
||||||
|
@ -11,17 +16,29 @@ export const Route = createFileRoute("/_authenticated/command/")({
|
||||||
});
|
});
|
||||||
|
|
||||||
function CommandPage() {
|
function CommandPage() {
|
||||||
|
// Lấy danh sách phòng từ API
|
||||||
|
const { data: roomData } = useQueryData({
|
||||||
|
queryKey: ["rooms"],
|
||||||
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map từ object sang string[]
|
||||||
|
const rooms: string[] = Array.isArray(roomData)
|
||||||
|
? (roomData as Room[]).map((r) => r.name)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Mutation gửi lệnh
|
||||||
const sendCommandMutation = useMutationData<
|
const sendCommandMutation = useMutationData<
|
||||||
string,
|
SendCommandRequest,
|
||||||
{ success: boolean; output: string }
|
SendCommandResponse
|
||||||
>({
|
>({
|
||||||
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND,
|
url: "", // sẽ set động theo roomName khi gọi
|
||||||
method: "POST",
|
method: "POST",
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.success) {
|
if (data.status === "OK") {
|
||||||
toast.success("Lệnh đã được gửi thành công!");
|
toast.success("Gửi lệnh thành công!");
|
||||||
} else {
|
} else {
|
||||||
toast.error("Lệnh không thể thực thi trên thiết bị!");
|
toast.error("Gửi lệnh thất bại!");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
@ -35,12 +52,22 @@ function CommandPage() {
|
||||||
title="CMD Command"
|
title="CMD Command"
|
||||||
description="Gửi lệnh shell xuống thiết bị để thực thi"
|
description="Gửi lệnh shell xuống thiết bị để thực thi"
|
||||||
isLoading={sendCommandMutation.isPending}
|
isLoading={sendCommandMutation.isPending}
|
||||||
|
rooms={rooms}
|
||||||
|
onSubmit={(roomName, command) => {
|
||||||
|
sendCommandMutation.mutateAsync({
|
||||||
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(roomName),
|
||||||
|
data: { Command: command },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
submitLoading={sendCommandMutation.isPending}
|
||||||
>
|
>
|
||||||
<ShellCommandForm
|
{({ command, setCommand }) => (
|
||||||
onExecute={async (cmd: string) => {
|
<ShellCommandForm
|
||||||
return await sendCommandMutation.mutateAsync({ data: cmd });
|
command={command}
|
||||||
}}
|
onCommandChange={setCommand}
|
||||||
/>
|
disabled={sendCommandMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</FormSubmitTemplate>
|
</FormSubmitTemplate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
@ -17,6 +18,20 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
Hash,
|
||||||
|
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 }) => ({
|
||||||
|
@ -39,37 +54,99 @@ function RoomDetailComponent() {
|
||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
header: "STT",
|
header: "STT",
|
||||||
cell: ({ row }) => row.index + 1,
|
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: "MAC Address",
|
header: () => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Hash className="h-4 w-4" />
|
||||||
|
MAC Address
|
||||||
|
</div>
|
||||||
|
),
|
||||||
accessorKey: "macAddress",
|
accessorKey: "macAddress",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<code className="bg-muted px-2 py-1 rounded text-sm font-mono">
|
||||||
|
{getValue() as string}
|
||||||
|
</code>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Thời gian thiết bị",
|
header: () => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Thời gian thiết bị
|
||||||
|
</div>
|
||||||
|
),
|
||||||
accessorKey: "deviceTime",
|
accessorKey: "deviceTime",
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const date = new Date(getValue() as string);
|
const date = new Date(getValue() as string);
|
||||||
return date.toLocaleString();
|
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",
|
header: "Phiên bản",
|
||||||
accessorKey: "version",
|
accessorKey: "version",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<Badge variant="secondary" className="font-mono">
|
||||||
|
v{getValue() as string}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Địa chỉ IP",
|
header: () => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
Địa chỉ IP
|
||||||
|
</div>
|
||||||
|
),
|
||||||
accessorKey: "ipAddress",
|
accessorKey: "ipAddress",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<code className="bg-muted px-2 py-1 rounded text-sm font-mono">
|
||||||
|
{getValue() as string}
|
||||||
|
</code>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Trạng thái",
|
header: "Trạng thái",
|
||||||
accessorKey: "isOffline",
|
accessorKey: "isOffline",
|
||||||
cell: ({ getValue }) =>
|
cell: ({ getValue }) => {
|
||||||
getValue() ? (
|
const isOffline = getValue() as boolean;
|
||||||
<span className="text-red-500 font-semibold">Offline</span>
|
return (
|
||||||
) : (
|
<Badge
|
||||||
<span className="text-green-500 font-semibold">Online</span>
|
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>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -77,55 +154,170 @@ function RoomDetailComponent() {
|
||||||
data: devices,
|
data: devices,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
initialState: { pagination: { pageSize: 16 } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) return <div>Đang tải thiết bị...</div>;
|
if (isLoading) {
|
||||||
|
return (
|
||||||
return (
|
<div className="w-full px-6 py-8">
|
||||||
<div className="w-full px-6 space-y-4">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div>
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
<h1 className="text-3xl font-bold">Phòng: {roomName}</h1>
|
<p className="text-muted-foreground">Đang tải danh sách phòng...</p>
|
||||||
<p className="text-muted-foreground mt-2">
|
</div>
|
||||||
Danh sách thiết bị trong phòng
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
);
|
||||||
<CardHeader>
|
}
|
||||||
<CardTitle>Thiết bị</CardTitle>
|
|
||||||
|
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">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Monitor className="h-5 w-5" />
|
||||||
|
Danh sách thiết bị
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<Table>
|
{devices.length === 0 ? (
|
||||||
<TableHeader>
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<TableRow key={headerGroup.id}>
|
<h3 className="text-lg font-semibold mb-2">Không có thiết bị</h3>
|
||||||
{headerGroup.headers.map((header) => (
|
<p className="text-muted-foreground text-center max-w-sm">
|
||||||
<TableHead key={header.id}>
|
Phòng này chưa có thiết bị nào được kết nối.
|
||||||
{flexRender(
|
</p>
|
||||||
header.column.columnDef.header,
|
</div>
|
||||||
header.getContext()
|
) : (
|
||||||
)}
|
<>
|
||||||
</TableHead>
|
<div className="max-h-[600px] overflow-y-auto">
|
||||||
))}
|
<Table>
|
||||||
</TableRow>
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
))}
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
</TableHeader>
|
<TableRow
|
||||||
<TableBody>
|
key={headerGroup.id}
|
||||||
{table.getRowModel().rows.map((row) => (
|
className="hover:bg-transparent border-b"
|
||||||
<TableRow key={row.id}>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<TableCell key={cell.id}>
|
<TableHead
|
||||||
{flexRender(
|
key={header.id}
|
||||||
cell.column.columnDef.cell,
|
className="font-semibold text-foreground bg-muted/30"
|
||||||
cell.getContext()
|
>
|
||||||
)}
|
{flexRender(
|
||||||
</TableCell>
|
header.column.columnDef.header,
|
||||||
))}
|
header.getContext()
|
||||||
</TableRow>
|
)}
|
||||||
))}
|
</TableHead>
|
||||||
</TableBody>
|
))}
|
||||||
</Table>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -19,7 +19,19 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { getPaginationRowModel } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/room/")({
|
export const Route = createFileRoute("/_authenticated/room/")({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
|
@ -43,19 +55,60 @@ function RoomComponent() {
|
||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
header: "STT",
|
header: "STT",
|
||||||
cell: ({ row }) => row.index + 1,
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium text-muted-foreground">{row.index + 1}</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Tên phòng",
|
header: "Tên phòng",
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Building2 className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{row.original.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Phòng #{row.index + 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Số lượng thiết bị",
|
header: "Số lượng thiết bị",
|
||||||
accessorKey: "numberOfDevices",
|
accessorKey: "numberOfDevices",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wifi className="h-4 w-4 text-green-600" />
|
||||||
|
<Badge variant="secondary" className="font-medium">
|
||||||
|
{row.original.numberOfDevices} thiết bị
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Thiết bị offline",
|
header: "Thiết bị offline",
|
||||||
accessorKey: "numberOfOfflineDevices",
|
accessorKey: "numberOfOfflineDevices",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const offlineCount = row.original.numberOfOfflineDevices;
|
||||||
|
const isOffline = offlineCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WifiOff
|
||||||
|
className={`h-4 w-4 ${isOffline ? "text-red-500" : "text-muted-foreground"}`}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant={isOffline ? "destructive" : "outline"}
|
||||||
|
className="font-medium"
|
||||||
|
>
|
||||||
|
{offlineCount} offline
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -66,72 +119,174 @@ function RoomComponent() {
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
initialState: {
|
||||||
|
pagination: {
|
||||||
|
pageSize: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) return <div>Đang tải...</div>;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-6 space-y-4">
|
<div className="w-full px-6 py-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold">Quản lý phòng</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Quản lý phòng</h1>
|
||||||
<p className="text-muted-foreground mt-2">
|
<p className="text-muted-foreground">
|
||||||
Danh sách các phòng hiện có trong hệ thống
|
Danh sách các phòng hiện có trong hệ thống ({roomData.length} phòng)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
<Card className="shadow-sm">
|
||||||
<CardTitle>Danh sách phòng</CardTitle>
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-5 w-5" />
|
||||||
|
Danh sách phòng
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<Table>
|
<div className="border-t">
|
||||||
<TableHeader>
|
<Table>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<TableHeader>
|
||||||
<TableRow key={headerGroup.id}>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
{headerGroup.headers.map((header) => {
|
<TableRow
|
||||||
const isSorted = header.column.getIsSorted();
|
key={headerGroup.id}
|
||||||
return (
|
className="hover:bg-transparent"
|
||||||
<TableHead
|
>
|
||||||
key={header.id}
|
{headerGroup.headers.map((header) => {
|
||||||
className="cursor-pointer select-none"
|
const isSorted = header.column.getIsSorted();
|
||||||
onClick={header.column.getToggleSortingHandler()}
|
return (
|
||||||
>
|
<TableHead
|
||||||
{flexRender(
|
key={header.id}
|
||||||
header.column.columnDef.header,
|
className="cursor-pointer select-none font-semibold hover:bg-muted/50 transition-colors"
|
||||||
header.getContext()
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
)}
|
>
|
||||||
{isSorted ? (isSorted === "asc" ? " ▲" : " ▼") : ""}
|
<div className="flex items-center gap-2">
|
||||||
</TableHead>
|
{flexRender(
|
||||||
);
|
header.column.columnDef.header,
|
||||||
})}
|
header.getContext()
|
||||||
</TableRow>
|
)}
|
||||||
))}
|
{isSorted && (
|
||||||
</TableHeader>
|
<span className="text-primary">
|
||||||
<TableBody>
|
{isSorted === "asc" ? "↑" : "↓"}
|
||||||
{table.getRowModel().rows.map((row) => (
|
</span>
|
||||||
<TableRow
|
)}
|
||||||
key={row.id}
|
</div>
|
||||||
className="cursor-pointer hover:bg-gray-100"
|
</TableHead>
|
||||||
onClick={() =>
|
);
|
||||||
navigate({
|
})}
|
||||||
to: "/room/$roomName",
|
</TableRow>
|
||||||
params: { roomName: row.original.name },
|
))}
|
||||||
})
|
</TableHeader>
|
||||||
}
|
<TableBody>
|
||||||
>
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
{row.getVisibleCells().map((cell) => (
|
<TableRow>
|
||||||
<TableCell key={cell.id}>
|
<TableCell
|
||||||
{flexRender(
|
colSpan={columns.length}
|
||||||
cell.column.columnDef.cell,
|
className="h-24 text-center"
|
||||||
cell.getContext()
|
>
|
||||||
)}
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
|
<Building2 className="h-8 w-8" />
|
||||||
|
<p>Không có phòng nào được tìm thấy</p>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
</TableRow>
|
||||||
</TableRow>
|
) : (
|
||||||
))}
|
table.getRowModel().rows.map((row) => (
|
||||||
</TableBody>
|
<TableRow
|
||||||
</Table>
|
key={row.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/room/$roomName",
|
||||||
|
params: { roomName: row.original.name },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id} className="py-4">
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{table.getPageCount() > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-t bg-muted/20">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Hiển thị{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{table.getState().pagination.pageIndex *
|
||||||
|
table.getState().pagination.pageSize +
|
||||||
|
1}
|
||||||
|
</span>{" "}
|
||||||
|
đến{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{Math.min(
|
||||||
|
(table.getState().pagination.pageIndex + 1) *
|
||||||
|
table.getState().pagination.pageSize,
|
||||||
|
roomData.length
|
||||||
|
)}
|
||||||
|
</span>{" "}
|
||||||
|
trong tổng số{" "}
|
||||||
|
<span className="font-medium">{roomData.length}</span> phòng
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{table.getState().pagination.pageIndex + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
/ {table.getPageCount()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -11,7 +11,9 @@ import { FileText } from "lucide-react";
|
||||||
import { UploadDialog } from "@/components/upload-dialog";
|
import { UploadDialog } from "@/components/upload-dialog";
|
||||||
import { VersionTable } from "@/components/version-table";
|
import { VersionTable } from "@/components/version-table";
|
||||||
import { UpdateButton } from "@/components/update-button";
|
import { UpdateButton } from "@/components/update-button";
|
||||||
|
import { RoomSelectDialog } from "@/components/room-select-dialog";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface AppManagerTemplateProps<TData> {
|
interface AppManagerTemplateProps<TData> {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -19,10 +21,14 @@ interface AppManagerTemplateProps<TData> {
|
||||||
data: TData[];
|
data: TData[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
columns: ColumnDef<TData, any>[];
|
columns: ColumnDef<TData, any>[];
|
||||||
onUpload: (fd: FormData, config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }) => Promise<void>;
|
onUpload: (
|
||||||
onUpdate?: () => void;
|
fd: FormData,
|
||||||
|
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
|
||||||
|
) => Promise<void>;
|
||||||
|
onUpdate?: (roomName: string) => void;
|
||||||
updateLoading?: boolean;
|
updateLoading?: boolean;
|
||||||
onTableInit?: (table: any) => void;
|
onTableInit?: (table: any) => void;
|
||||||
|
rooms: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppManagerTemplate<TData>({
|
export function AppManagerTemplate<TData>({
|
||||||
|
@ -35,7 +41,14 @@ export function AppManagerTemplate<TData>({
|
||||||
onUpdate,
|
onUpdate,
|
||||||
updateLoading,
|
updateLoading,
|
||||||
onTableInit,
|
onTableInit,
|
||||||
|
rooms,
|
||||||
}: AppManagerTemplateProps<TData>) {
|
}: AppManagerTemplateProps<TData>) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const handleUpdateClick = () => {
|
||||||
|
if (rooms && onUpdate) {
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-6 space-y-4">
|
<div className="w-full px-6 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
@ -63,10 +76,26 @@ export function AppManagerTemplate<TData>({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{onUpdate && (
|
{onUpdate && (
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<UpdateButton onClick={onUpdate} loading={updateLoading} />
|
<UpdateButton onClick={handleUpdateClick} loading={updateLoading} />
|
||||||
|
<UpdateButton
|
||||||
|
onClick={() => onUpdate("All")}
|
||||||
|
loading={updateLoading}
|
||||||
|
label="Cập nhật tất cả thiết bị"
|
||||||
|
/>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
)}
|
)}
|
||||||
|
{rooms && onUpdate && (
|
||||||
|
<RoomSelectDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onClose={() => setDialogOpen(false)}
|
||||||
|
rooms={rooms}
|
||||||
|
onConfirm={(roomName) => {
|
||||||
|
onUpdate(roomName);
|
||||||
|
setDialogOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import { ShellCommandForm } from "@/components/command-form";
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
@ -6,17 +8,22 @@ import {
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card"
|
||||||
import { UpdateButton } from "@/components/update-button";
|
import { UpdateButton } from "@/components/update-button"
|
||||||
import { FileText, Terminal } from "lucide-react";
|
import { Terminal } from "lucide-react"
|
||||||
|
import { RoomSelectDialog } from "@/components/room-select-dialog"
|
||||||
|
|
||||||
interface FormSubmitTemplateProps {
|
interface FormSubmitTemplateProps {
|
||||||
title: string;
|
title: string
|
||||||
description: string;
|
description: string
|
||||||
isLoading?: boolean;
|
isLoading?: boolean
|
||||||
children: React.ReactNode;
|
children: (props: {
|
||||||
onSubmit?: () => void;
|
command: string
|
||||||
submitLoading?: boolean;
|
setCommand: (val: string) => void
|
||||||
|
}) => React.ReactNode
|
||||||
|
onSubmit?: (roomName: string, command: string) => void
|
||||||
|
submitLoading?: boolean
|
||||||
|
rooms?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormSubmitTemplate({
|
export function FormSubmitTemplate({
|
||||||
|
@ -26,7 +33,17 @@ export function FormSubmitTemplate({
|
||||||
children,
|
children,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitLoading,
|
submitLoading,
|
||||||
|
rooms = [],
|
||||||
}: FormSubmitTemplateProps) {
|
}: FormSubmitTemplateProps) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [command, setCommand] = useState("")
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (rooms.length > 0 && onSubmit) {
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-6 space-y-4">
|
<div className="w-full px-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
@ -41,13 +58,37 @@ export function FormSubmitTemplate({
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Nhập và gửi lệnh xuống thiết bị</CardDescription>
|
<CardDescription>Nhập và gửi lệnh xuống thiết bị</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>{children}</CardContent>
|
<CardContent>
|
||||||
|
{children({ command, setCommand })}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
{onSubmit && (
|
{onSubmit && (
|
||||||
<CardFooter>
|
<CardFooter className="flex gap-2">
|
||||||
<UpdateButton onClick={onSubmit} loading={submitLoading} />
|
<UpdateButton
|
||||||
|
onClick={handleClick}
|
||||||
|
loading={submitLoading}
|
||||||
|
label="Yêu cầu theo phòng"
|
||||||
|
/>
|
||||||
|
<UpdateButton
|
||||||
|
onClick={() => onSubmit("All", command)}
|
||||||
|
loading={submitLoading}
|
||||||
|
label="Cập nhật tất cả thiết bị"
|
||||||
|
/>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{onSubmit && rooms.length > 0 && (
|
||||||
|
<RoomSelectDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onClose={() => setDialogOpen(false)}
|
||||||
|
rooms={rooms}
|
||||||
|
onConfirm={(roomName) => {
|
||||||
|
onSubmit(roomName, command)
|
||||||
|
setDialogOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
5
src/types/room.ts
Normal file
5
src/types/room.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export type Room = {
|
||||||
|
name: string;
|
||||||
|
numberOfDevices: number;
|
||||||
|
numberOfOfflineDevices: number;
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user