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": ".",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@radix-ui/react-avatar": "^1.1.10",
 | 
			
		||||
        "@radix-ui/react-dialog": "^1.1.14",
 | 
			
		||||
        "@radix-ui/react-label": "^2.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-slot": "^1.2.3",
 | 
			
		||||
        "@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": {
 | 
			
		||||
      "version": "1.1.2",
 | 
			
		||||
      "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": {
 | 
			
		||||
      "version": "1.1.10",
 | 
			
		||||
      "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": {
 | 
			
		||||
      "version": "1.1.7",
 | 
			
		||||
      "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": {
 | 
			
		||||
      "version": "1.1.1",
 | 
			
		||||
      "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": {
 | 
			
		||||
      "version": "1.1.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,9 +10,11 @@
 | 
			
		|||
    "test": "vitest run"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@radix-ui/react-avatar": "^1.1.10",
 | 
			
		||||
    "@radix-ui/react-dialog": "^1.1.14",
 | 
			
		||||
    "@radix-ui/react-label": "^2.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-slot": "^1.2.3",
 | 
			
		||||
    "@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 { Building } from "lucide-react";
 | 
			
		||||
import { Building2, Cpu } from "lucide-react";
 | 
			
		||||
import {
 | 
			
		||||
  Sidebar,
 | 
			
		||||
  SidebarContent,
 | 
			
		||||
| 
						 | 
				
			
			@ -13,6 +13,7 @@ import {
 | 
			
		|||
  SidebarMenuButton,
 | 
			
		||||
  SidebarMenuItem,
 | 
			
		||||
} from "@/components/ui/sidebar";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
type MenuItem = {
 | 
			
		||||
  title: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -27,34 +28,58 @@ type AppSidebarProps = {
 | 
			
		|||
 | 
			
		||||
export function AppSidebar({ items }: AppSidebarProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Sidebar collapsible="icon" className="border-r">
 | 
			
		||||
      <SidebarHeader className="border-b">
 | 
			
		||||
        <div className="flex items-center gap-2 px-2 py-2">
 | 
			
		||||
          <div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
 | 
			
		||||
            <Building className="size-4" />
 | 
			
		||||
    <Sidebar
 | 
			
		||||
      collapsible="icon"
 | 
			
		||||
      className="border-r border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
 | 
			
		||||
    >
 | 
			
		||||
      <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 className="flex flex-col gap-0.5 leading-none">
 | 
			
		||||
            <span className="font-semibold">TTMT Computer Management</span>
 | 
			
		||||
            <span className="text-xs text-muted-foreground">v1.0.0</span>
 | 
			
		||||
            <span className="font-bold text-base tracking-tight bg-gradient-to-r from-foreground to-foreground/80 bg-clip-text">
 | 
			
		||||
              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>
 | 
			
		||||
      </SidebarHeader>
 | 
			
		||||
      
 | 
			
		||||
      <SidebarContent>
 | 
			
		||||
 | 
			
		||||
      <SidebarContent className="p-4">
 | 
			
		||||
        <SidebarGroup>
 | 
			
		||||
          <SidebarGroupLabel>Navigation</SidebarGroupLabel>
 | 
			
		||||
          <SidebarGroupLabel className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider mb-2">
 | 
			
		||||
            Navigation
 | 
			
		||||
          </SidebarGroupLabel>
 | 
			
		||||
          <SidebarGroupContent>
 | 
			
		||||
            <SidebarMenu>
 | 
			
		||||
            <SidebarMenu className="space-y-1">
 | 
			
		||||
              {items.map((item) => (
 | 
			
		||||
                <SidebarMenuItem key={item.title}>
 | 
			
		||||
                  <SidebarMenuButton 
 | 
			
		||||
                    asChild 
 | 
			
		||||
                  <SidebarMenuButton
 | 
			
		||||
                    asChild
 | 
			
		||||
                    tooltip={item.title}
 | 
			
		||||
                    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={"."}>
 | 
			
		||||
                      <item.icon className="size-4" />
 | 
			
		||||
                      <span>{item.title}</span>
 | 
			
		||||
                    <Link
 | 
			
		||||
                      href={item.to}
 | 
			
		||||
                      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>
 | 
			
		||||
                  </SidebarMenuButton>
 | 
			
		||||
                </SidebarMenuItem>
 | 
			
		||||
| 
						 | 
				
			
			@ -63,10 +88,10 @@ export function AppSidebar({ items }: AppSidebarProps) {
 | 
			
		|||
          </SidebarGroupContent>
 | 
			
		||||
        </SidebarGroup>
 | 
			
		||||
      </SidebarContent>
 | 
			
		||||
      
 | 
			
		||||
      <SidebarFooter className="border-t">
 | 
			
		||||
        <div className="p-2 text-xs text-muted-foreground">
 | 
			
		||||
          © 2025 NAVIS Centre 
 | 
			
		||||
 | 
			
		||||
      <SidebarFooter className="border-t border-border/40 p-4 space-y-3">
 | 
			
		||||
        <div className="px-2 text-xs text-muted-foreground/60 font-medium">
 | 
			
		||||
          © 2025 NAVIS Centre
 | 
			
		||||
        </div>
 | 
			
		||||
      </SidebarFooter>
 | 
			
		||||
    </Sidebar>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,90 +1,68 @@
 | 
			
		|||
"use client";
 | 
			
		||||
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { useForm } from "@tanstack/react-form";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
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 {
 | 
			
		||||
  onExecute: (command: string) => Promise<{ success: boolean; output: string }>;
 | 
			
		||||
  command: string;
 | 
			
		||||
  onCommandChange: (value: string) => void;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ShellCommandForm({ onExecute }: ShellCommandFormProps) {
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(false);
 | 
			
		||||
 | 
			
		||||
  // init form
 | 
			
		||||
export function ShellCommandForm({
 | 
			
		||||
  command,
 | 
			
		||||
  onCommandChange,
 | 
			
		||||
  disabled,
 | 
			
		||||
}: ShellCommandFormProps) {
 | 
			
		||||
  const form = useForm({
 | 
			
		||||
    defaultValues: {
 | 
			
		||||
      command: "",
 | 
			
		||||
    },
 | 
			
		||||
    onSubmit: async ({ value }) => {
 | 
			
		||||
      setIsLoading(true);
 | 
			
		||||
      try {
 | 
			
		||||
        const res = await onExecute(value.command);
 | 
			
		||||
        if (res.success) {
 | 
			
		||||
            form.reset();
 | 
			
		||||
        }
 | 
			
		||||
      } finally {
 | 
			
		||||
        setIsLoading(false);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    defaultValues: { command },
 | 
			
		||||
    onSubmit: () => {},
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="space-y-6">
 | 
			
		||||
      
 | 
			
		||||
          {/* Form */}
 | 
			
		||||
          <form
 | 
			
		||||
            onSubmit={(e) => {
 | 
			
		||||
              e.preventDefault();
 | 
			
		||||
              form.handleSubmit();
 | 
			
		||||
            }}
 | 
			
		||||
            className="space-y-5"
 | 
			
		||||
          >
 | 
			
		||||
            {/* Field: command */}
 | 
			
		||||
            <form.Field
 | 
			
		||||
              name="command"
 | 
			
		||||
              validators={{
 | 
			
		||||
                onChange: z
 | 
			
		||||
                  .string()
 | 
			
		||||
                  .min(1, "Nhập command để thực thi")
 | 
			
		||||
                  .max(500, "Command quá dài"),
 | 
			
		||||
    <form
 | 
			
		||||
      onSubmit={(e) => {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        form.handleSubmit();
 | 
			
		||||
      }}
 | 
			
		||||
      className="space-y-5"
 | 
			
		||||
    >
 | 
			
		||||
      <form.Field
 | 
			
		||||
        name="command"
 | 
			
		||||
        validators={{
 | 
			
		||||
          onChange: ({ value }: { value: string }) => {
 | 
			
		||||
            const schema = z
 | 
			
		||||
              .string()
 | 
			
		||||
              .min(1, "Nhập command để thực thi")
 | 
			
		||||
              .max(500, "Command quá dài");
 | 
			
		||||
            const result = schema.safeParse(value);
 | 
			
		||||
            if (!result.success) {
 | 
			
		||||
              return result.error.issues.map((i) => i.message);
 | 
			
		||||
            }
 | 
			
		||||
            return [];
 | 
			
		||||
          },
 | 
			
		||||
        }}
 | 
			
		||||
        children={(field) => (
 | 
			
		||||
          <div className="w-full px-0">
 | 
			
		||||
            <Textarea
 | 
			
		||||
              className="w-full h-[25vh]"
 | 
			
		||||
              placeholder="Nhập lệnh..."
 | 
			
		||||
              value={field.state.value}
 | 
			
		||||
              onChange={(e) => {
 | 
			
		||||
                field.handleChange(e.target.value);
 | 
			
		||||
                onCommandChange(e.target.value);
 | 
			
		||||
              }}
 | 
			
		||||
              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)}
 | 
			
		||||
                    disabled={isLoading}
 | 
			
		||||
                  />
 | 
			
		||||
                  {field.state.meta.errors?.length > 0 && (
 | 
			
		||||
                    <p className="text-sm text-red-500">
 | 
			
		||||
                      {field.state.meta.errors.join(", ")}
 | 
			
		||||
                    </p>
 | 
			
		||||
                  )}
 | 
			
		||||
                </div>
 | 
			
		||||
              )}
 | 
			
		||||
              disabled={disabled}
 | 
			
		||||
            />
 | 
			
		||||
 | 
			
		||||
            <Button type="submit" disabled={isLoading}>
 | 
			
		||||
              {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
 | 
			
		||||
              Yêu cầu thiết bị thực thi
 | 
			
		||||
            </Button>
 | 
			
		||||
          </form>
 | 
			
		||||
    </div>
 | 
			
		||||
            {field.state.meta.errors?.length > 0 && (
 | 
			
		||||
              <p className="text-sm text-red-500">
 | 
			
		||||
                {String(field.state.meta.errors[0])}
 | 
			
		||||
              </p>
 | 
			
		||||
            )}
 | 
			
		||||
          </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 {
 | 
			
		||||
  onClick: () => void;
 | 
			
		||||
  loading?: boolean;
 | 
			
		||||
  onClick: () => void
 | 
			
		||||
  loading?: boolean
 | 
			
		||||
  label?: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function UpdateButton({ onClick, loading }: UpdateButtonProps) {
 | 
			
		||||
export function UpdateButton({ onClick, loading, label }: UpdateButtonProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Button variant="outline" onClick={onClick} disabled={loading}>
 | 
			
		||||
      {loading ? "Đang gửi..." : "Yêu cầu thiết bị cập nhật"}
 | 
			
		||||
    <Button
 | 
			
		||||
      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>
 | 
			
		||||
  );
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,12 +11,12 @@ export const API_ENDPOINTS = {
 | 
			
		|||
    GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
 | 
			
		||||
  },
 | 
			
		||||
  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_DEVICE_FROM_ROOM: (roomName: string) =>
 | 
			
		||||
      `${BASE_URL}/DeviceComm/room/${roomName}`,
 | 
			
		||||
    UPDATE_AGENT: `${BASE_URL}/DeviceComm/updateagent`,
 | 
			
		||||
    SEND_COMMAND: `${BASE_URL}/DeviceComm/shellcommand`,
 | 
			
		||||
    UPDATE_AGENT: (roomName: string) => `${BASE_URL}/DeviceComm/updateagent/${roomName}`,
 | 
			
		||||
    SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
 | 
			
		||||
    CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
 | 
			
		||||
  },
 | 
			
		||||
  SSE_EVENTS: {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,18 +18,25 @@ export function useMutationData<TInput = any, TOutput = any>({
 | 
			
		|||
}: MutationDataOptions<TInput, TOutput>) {
 | 
			
		||||
  const queryClient = useQueryClient();
 | 
			
		||||
 | 
			
		||||
  return useMutation<TOutput, any, { data: TInput; config?: any }>({
 | 
			
		||||
    mutationFn: async ({ data, config }) => {
 | 
			
		||||
       const isFormData = data instanceof FormData;
 | 
			
		||||
  return useMutation<
 | 
			
		||||
    TOutput,
 | 
			
		||||
    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({
 | 
			
		||||
        url,
 | 
			
		||||
        method,
 | 
			
		||||
        url: customUrl ?? url,
 | 
			
		||||
        method: customMethod ?? method,
 | 
			
		||||
        data,
 | 
			
		||||
        headers: {
 | 
			
		||||
          ...(isFormData
 | 
			
		||||
            ? {} 
 | 
			
		||||
            : { "Content-Type": "application/json" }),
 | 
			
		||||
          ...(isFormData ? {} : { "Content-Type": "application/json" }),
 | 
			
		||||
        },
 | 
			
		||||
        ...config,
 | 
			
		||||
      });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -64,37 +64,43 @@ export default function AppLayout({ children }: AppLayoutProps) {
 | 
			
		|||
      icon: AppWindow,
 | 
			
		||||
      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,
 | 
			
		||||
     },
 | 
			
		||||
    },
 | 
			
		||||
    { title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SidebarProvider>
 | 
			
		||||
      <div className="flex min-h-screen w-full">
 | 
			
		||||
      <div className="flex min-h-screen w-full bg-background">
 | 
			
		||||
        <AppSidebar items={items} />
 | 
			
		||||
        <SidebarInset className="flex-1">
 | 
			
		||||
          {/* Mobile header with sidebar trigger */}
 | 
			
		||||
          <header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 lg:hidden">
 | 
			
		||||
            <SidebarTrigger className="-ml-1" />
 | 
			
		||||
            <Separator orientation="vertical" className="mr-2 h-4" />
 | 
			
		||||
            <div className="flex items-center gap-2">
 | 
			
		||||
              <div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
 | 
			
		||||
          <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">
 | 
			
		||||
            <SidebarTrigger className="-ml-1 hover:bg-accent/50 rounded-lg p-2 transition-colors" />
 | 
			
		||||
            <Separator
 | 
			
		||||
              orientation="vertical"
 | 
			
		||||
              className="mr-2 h-6 bg-border/60"
 | 
			
		||||
            />
 | 
			
		||||
            <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" />
 | 
			
		||||
              </div>
 | 
			
		||||
              <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
 | 
			
		||||
                </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>
 | 
			
		||||
          </header>
 | 
			
		||||
 | 
			
		||||
          {/* Main content with responsive padding */}
 | 
			
		||||
          <main className="flex-1 p-4 sm:p-6 lg:p-8 overflow-auto">
 | 
			
		||||
            {children}
 | 
			
		||||
          <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">
 | 
			
		||||
            <div className="mx-auto max-w-7xl">{children}</div>
 | 
			
		||||
          </main>
 | 
			
		||||
 | 
			
		||||
          <Toaster
 | 
			
		||||
| 
						 | 
				
			
			@ -102,9 +108,12 @@ export default function AppLayout({ children }: AppLayoutProps) {
 | 
			
		|||
            toastOptions={{
 | 
			
		||||
              classNames: {
 | 
			
		||||
                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",
 | 
			
		||||
                title: "text-sm sm:text-lg font-semibold",
 | 
			
		||||
                description: "text-xs sm:text-base",
 | 
			
		||||
                  "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-base font-semibold",
 | 
			
		||||
                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 type { ColumnDef } from "@tanstack/react-table";
 | 
			
		||||
import type { AxiosProgressEvent } from "axios";
 | 
			
		||||
import { type Room } from "@/types/room";
 | 
			
		||||
 | 
			
		||||
type Version = {
 | 
			
		||||
  id?: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -28,11 +29,22 @@ function AgentsPage() {
 | 
			
		|||
    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)
 | 
			
		||||
    ? data
 | 
			
		||||
    : data
 | 
			
		||||
    ? [data]
 | 
			
		||||
    : [];
 | 
			
		||||
      ? [data]
 | 
			
		||||
      : [];
 | 
			
		||||
 | 
			
		||||
  // Mutation upload
 | 
			
		||||
  const uploadMutation = useMutationData<FormData>({
 | 
			
		||||
| 
						 | 
				
			
			@ -41,19 +53,38 @@ function AgentsPage() {
 | 
			
		|||
    invalidate: [["agent-version"]],
 | 
			
		||||
    onSuccess: () => toast.success("Upload thành công!"),
 | 
			
		||||
    onError: (error) => {
 | 
			
		||||
      console.error("Upload error:", error)
 | 
			
		||||
      toast.error("Upload thất bại!")
 | 
			
		||||
      console.error("Upload error:", error);
 | 
			
		||||
      toast.error("Upload thất bại!");
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Mutation update
 | 
			
		||||
  const updateMutation = useMutationData<void>({
 | 
			
		||||
    url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT,
 | 
			
		||||
    url: "", 
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
 | 
			
		||||
    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>[] = [
 | 
			
		||||
    { accessorKey: "version", header: "Phiên bản" },
 | 
			
		||||
    { accessorKey: "fileName", header: "Tên file" },
 | 
			
		||||
| 
						 | 
				
			
			@ -73,25 +104,9 @@ function AgentsPage() {
 | 
			
		|||
        getValue()
 | 
			
		||||
          ? new Date(getValue() as string).toLocaleString("vi-VN")
 | 
			
		||||
          : "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 (
 | 
			
		||||
    <AppManagerTemplate<Version>
 | 
			
		||||
      title="Quản lý Agent"
 | 
			
		||||
| 
						 | 
				
			
			@ -102,6 +117,7 @@ function AgentsPage() {
 | 
			
		|||
      onUpload={handleUpload}
 | 
			
		||||
      onUpdate={handleUpdate}
 | 
			
		||||
      updateLoading={updateMutation.isPending}
 | 
			
		||||
      rooms={rooms}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,6 +7,7 @@ import { toast } from "sonner";
 | 
			
		|||
import type { ColumnDef } from "@tanstack/react-table";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import type { AxiosProgressEvent } from "axios";
 | 
			
		||||
import type { Room } from "@/types/room";
 | 
			
		||||
 | 
			
		||||
export const Route = createFileRoute("/_authenticated/apps/")({
 | 
			
		||||
  head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
 | 
			
		||||
| 
						 | 
				
			
			@ -25,21 +26,31 @@ type Version = {
 | 
			
		|||
function AppsComponent() {
 | 
			
		||||
  const { data, isLoading } = useQueryData({
 | 
			
		||||
    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)
 | 
			
		||||
    ? data
 | 
			
		||||
    : data
 | 
			
		||||
    ? [data]
 | 
			
		||||
    : [];
 | 
			
		||||
      ? [data]
 | 
			
		||||
      : [];
 | 
			
		||||
 | 
			
		||||
  const [table, setTable] = useState<any>();
 | 
			
		||||
 | 
			
		||||
  const uploadMutation = useMutationData<FormData>({
 | 
			
		||||
    url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    invalidate: [["software-version"]], // Add this to refresh data after upload
 | 
			
		||||
    invalidate: [["software-version"]],
 | 
			
		||||
    onSuccess: () => toast.success("Upload thành công!"),
 | 
			
		||||
    onError: (error) => {
 | 
			
		||||
      console.error("Upload error:", error);
 | 
			
		||||
| 
						 | 
				
			
			@ -48,7 +59,7 @@ function AppsComponent() {
 | 
			
		|||
  });
 | 
			
		||||
 | 
			
		||||
  const installMutation = useMutationData<{ MsiFileIds: number[] }>({
 | 
			
		||||
    url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI,
 | 
			
		||||
    url: "",
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
 | 
			
		||||
    onError: (error) => {
 | 
			
		||||
| 
						 | 
				
			
			@ -57,8 +68,8 @@ function AppsComponent() {
 | 
			
		|||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Cột bảng
 | 
			
		||||
  const columns: ColumnDef<Version>[] = [
 | 
			
		||||
    
 | 
			
		||||
    { accessorKey: "version", header: "Phiên bản" },
 | 
			
		||||
    { accessorKey: "fileName", header: "Tên file" },
 | 
			
		||||
    { accessorKey: "folderPath", header: "Đường dẫn" },
 | 
			
		||||
| 
						 | 
				
			
			@ -87,9 +98,10 @@ function AppsComponent() {
 | 
			
		|||
      ),
 | 
			
		||||
      enableSorting: false,
 | 
			
		||||
      enableHiding: false,
 | 
			
		||||
    }
 | 
			
		||||
    },
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  // Upload file MSI
 | 
			
		||||
  const handleUpload = async (
 | 
			
		||||
    fd: FormData,
 | 
			
		||||
    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) {
 | 
			
		||||
      toast.error("Không thể lấy thông tin bảng!");
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const selectedRows = table.getSelectedRowModel().rows;
 | 
			
		||||
    
 | 
			
		||||
    if (selectedRows.length === 0) {
 | 
			
		||||
      toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const MsiFileIds = selectedRows.map((row: any) => row.original.id);
 | 
			
		||||
    
 | 
			
		||||
    console.log("Selected MSI file IDs:", MsiFileIds);
 | 
			
		||||
 | 
			
		||||
    return installMutation.mutateAsync({
 | 
			
		||||
      url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI(roomName), // set url động
 | 
			
		||||
      data: { MsiFileIds },
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
| 
						 | 
				
			
			@ -130,9 +141,10 @@ function AppsComponent() {
 | 
			
		|||
      isLoading={isLoading}
 | 
			
		||||
      columns={columns}
 | 
			
		||||
      onUpload={handleUpload}
 | 
			
		||||
      onUpdate={handleInstall} 
 | 
			
		||||
      updateLoading={installMutation.isPending} 
 | 
			
		||||
      onTableInit={setTable} 
 | 
			
		||||
      onUpdate={handleInstall}
 | 
			
		||||
      updateLoading={installMutation.isPending}
 | 
			
		||||
      onTableInit={setTable}
 | 
			
		||||
      rooms={rooms}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,8 +2,13 @@ import { createFileRoute } from "@tanstack/react-router";
 | 
			
		|||
import { FormSubmitTemplate } from "@/template/form-submit-template";
 | 
			
		||||
import { ShellCommandForm } from "@/components/command-form";
 | 
			
		||||
import { useMutationData } from "@/hooks/useMutationData";
 | 
			
		||||
import { useQueryData } from "@/hooks/useQueryData";
 | 
			
		||||
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
 | 
			
		||||
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/")({
 | 
			
		||||
  head: () => ({ meta: [{ title: "Gửi lệnh CMD" }] }),
 | 
			
		||||
| 
						 | 
				
			
			@ -11,17 +16,29 @@ export const Route = createFileRoute("/_authenticated/command/")({
 | 
			
		|||
});
 | 
			
		||||
 | 
			
		||||
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<
 | 
			
		||||
    string,
 | 
			
		||||
    { success: boolean; output: string }
 | 
			
		||||
    SendCommandRequest,
 | 
			
		||||
    SendCommandResponse
 | 
			
		||||
  >({
 | 
			
		||||
    url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND,
 | 
			
		||||
    url: "", // sẽ set động theo roomName khi gọi
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    onSuccess: (data) => {
 | 
			
		||||
      if (data.success) {
 | 
			
		||||
        toast.success("Lệnh đã được gửi thành công!");
 | 
			
		||||
      if (data.status === "OK") {
 | 
			
		||||
        toast.success("Gửi lệnh thành công!");
 | 
			
		||||
      } 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) => {
 | 
			
		||||
| 
						 | 
				
			
			@ -35,12 +52,22 @@ function CommandPage() {
 | 
			
		|||
      title="CMD Command"
 | 
			
		||||
      description="Gửi lệnh shell xuống thiết bị để thực thi"
 | 
			
		||||
      isLoading={sendCommandMutation.isPending}
 | 
			
		||||
      rooms={rooms}
 | 
			
		||||
      onSubmit={(roomName, command) => {
 | 
			
		||||
        sendCommandMutation.mutateAsync({
 | 
			
		||||
          url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(roomName),
 | 
			
		||||
          data: { Command: command },
 | 
			
		||||
        });
 | 
			
		||||
      }}
 | 
			
		||||
      submitLoading={sendCommandMutation.isPending}
 | 
			
		||||
    >
 | 
			
		||||
      <ShellCommandForm
 | 
			
		||||
        onExecute={async (cmd: string) => {
 | 
			
		||||
          return await sendCommandMutation.mutateAsync({ data: cmd });
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      {({ command, setCommand }) => (
 | 
			
		||||
        <ShellCommandForm
 | 
			
		||||
          command={command}
 | 
			
		||||
          onCommandChange={setCommand}
 | 
			
		||||
          disabled={sendCommandMutation.isPending}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </FormSubmitTemplate>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,7 @@ import { API_ENDPOINTS, BASE_URL } from "@/config/api";
 | 
			
		|||
import {
 | 
			
		||||
  flexRender,
 | 
			
		||||
  getCoreRowModel,
 | 
			
		||||
  getPaginationRowModel,
 | 
			
		||||
  useReactTable,
 | 
			
		||||
  type ColumnDef,
 | 
			
		||||
} from "@tanstack/react-table";
 | 
			
		||||
| 
						 | 
				
			
			@ -17,6 +18,20 @@ import {
 | 
			
		|||
  TableRow,
 | 
			
		||||
} from "@/components/ui/table";
 | 
			
		||||
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/")({
 | 
			
		||||
  head: ({ params }) => ({
 | 
			
		||||
| 
						 | 
				
			
			@ -39,37 +54,99 @@ function RoomDetailComponent() {
 | 
			
		|||
  const columns: ColumnDef<any>[] = [
 | 
			
		||||
    {
 | 
			
		||||
      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",
 | 
			
		||||
      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",
 | 
			
		||||
      cell: ({ getValue }) => {
 | 
			
		||||
        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",
 | 
			
		||||
      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",
 | 
			
		||||
      cell: ({ getValue }) => (
 | 
			
		||||
        <code className="bg-muted px-2 py-1 rounded text-sm font-mono">
 | 
			
		||||
          {getValue() as string}
 | 
			
		||||
        </code>
 | 
			
		||||
      ),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      header: "Trạng thái",
 | 
			
		||||
      accessorKey: "isOffline",
 | 
			
		||||
      cell: ({ getValue }) =>
 | 
			
		||||
        getValue() ? (
 | 
			
		||||
          <span className="text-red-500 font-semibold">Offline</span>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <span className="text-green-500 font-semibold">Online</span>
 | 
			
		||||
        ),
 | 
			
		||||
      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>
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -77,55 +154,170 @@ function RoomDetailComponent() {
 | 
			
		|||
    data: devices,
 | 
			
		||||
    columns,
 | 
			
		||||
    getCoreRowModel: getCoreRowModel(),
 | 
			
		||||
    getPaginationRowModel: getPaginationRowModel(),
 | 
			
		||||
    initialState: { pagination: { pageSize: 16 } },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (isLoading) return <div>Đang tải thiết bị...</div>;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="w-full px-6 space-y-4">
 | 
			
		||||
      <div className="flex items-center justify-between">
 | 
			
		||||
        <div>
 | 
			
		||||
          <h1 className="text-3xl font-bold">Phòng: {roomName}</h1>
 | 
			
		||||
          <p className="text-muted-foreground mt-2">
 | 
			
		||||
            Danh sách thiết bị trong phòng
 | 
			
		||||
          </p>
 | 
			
		||||
  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>
 | 
			
		||||
      <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>
 | 
			
		||||
        <CardContent>
 | 
			
		||||
          <Table>
 | 
			
		||||
            <TableHeader>
 | 
			
		||||
              {table.getHeaderGroups().map((headerGroup) => (
 | 
			
		||||
                <TableRow key={headerGroup.id}>
 | 
			
		||||
                  {headerGroup.headers.map((header) => (
 | 
			
		||||
                    <TableHead key={header.id}>
 | 
			
		||||
                      {flexRender(
 | 
			
		||||
                        header.column.columnDef.header,
 | 
			
		||||
                        header.getContext()
 | 
			
		||||
                      )}
 | 
			
		||||
                    </TableHead>
 | 
			
		||||
                  ))}
 | 
			
		||||
                </TableRow>
 | 
			
		||||
              ))}
 | 
			
		||||
            </TableHeader>
 | 
			
		||||
            <TableBody>
 | 
			
		||||
              {table.getRowModel().rows.map((row) => (
 | 
			
		||||
                <TableRow key={row.id}>
 | 
			
		||||
                  {row.getVisibleCells().map((cell) => (
 | 
			
		||||
                    <TableCell key={cell.id}>
 | 
			
		||||
                      {flexRender(
 | 
			
		||||
                        cell.column.columnDef.cell,
 | 
			
		||||
                        cell.getContext()
 | 
			
		||||
                      )}
 | 
			
		||||
                    </TableCell>
 | 
			
		||||
                  ))}
 | 
			
		||||
                </TableRow>
 | 
			
		||||
              ))}
 | 
			
		||||
            </TableBody>
 | 
			
		||||
          </Table>
 | 
			
		||||
        <CardContent className="p-0">
 | 
			
		||||
          {devices.length === 0 ? (
 | 
			
		||||
            <div className="flex flex-col items-center justify-center py-12">
 | 
			
		||||
              <Monitor className="h-12 w-12 text-muted-foreground mb-4" />
 | 
			
		||||
              <h3 className="text-lg font-semibold mb-2">Không có thiết bị</h3>
 | 
			
		||||
              <p className="text-muted-foreground text-center max-w-sm">
 | 
			
		||||
                Phòng này chưa có thiết bị nào được kết nối.
 | 
			
		||||
              </p>
 | 
			
		||||
            </div>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <>
 | 
			
		||||
              <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>
 | 
			
		||||
      </Card>
 | 
			
		||||
    </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,7 +19,19 @@ import {
 | 
			
		|||
  TableHeader,
 | 
			
		||||
  TableRow,
 | 
			
		||||
} 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 { Badge } from "@/components/ui/badge";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
 | 
			
		||||
export const Route = createFileRoute("/_authenticated/room/")({
 | 
			
		||||
  head: () => ({
 | 
			
		||||
| 
						 | 
				
			
			@ -43,19 +55,60 @@ function RoomComponent() {
 | 
			
		|||
  const columns: ColumnDef<any>[] = [
 | 
			
		||||
    {
 | 
			
		||||
      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",
 | 
			
		||||
      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ị",
 | 
			
		||||
      accessorKey: "numberOfDevices",
 | 
			
		||||
      cell: ({ row }) => (
 | 
			
		||||
        <div className="flex items-center gap-2">
 | 
			
		||||
          <Wifi className="h-4 w-4 text-green-600" />
 | 
			
		||||
          <Badge variant="secondary" className="font-medium">
 | 
			
		||||
            {row.original.numberOfDevices} thiết bị
 | 
			
		||||
          </Badge>
 | 
			
		||||
        </div>
 | 
			
		||||
      ),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      header: "Thiết bị offline",
 | 
			
		||||
      accessorKey: "numberOfOfflineDevices",
 | 
			
		||||
      cell: ({ row }) => {
 | 
			
		||||
        const 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,
 | 
			
		||||
    getCoreRowModel: getCoreRowModel(),
 | 
			
		||||
    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 (
 | 
			
		||||
    <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>
 | 
			
		||||
          <h1 className="text-3xl font-bold">Quản lý phòng</h1>
 | 
			
		||||
          <p className="text-muted-foreground mt-2">
 | 
			
		||||
            Danh sách các phòng hiện có trong hệ thống
 | 
			
		||||
        <div className="space-y-1">
 | 
			
		||||
          <h1 className="text-3xl font-bold tracking-tight">Quản lý phòng</h1>
 | 
			
		||||
          <p className="text-muted-foreground">
 | 
			
		||||
            Danh sách các phòng hiện có trong hệ thống ({roomData.length} phòng)
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <Card>
 | 
			
		||||
        <CardHeader>
 | 
			
		||||
          <CardTitle>Danh sách phòng</CardTitle>
 | 
			
		||||
 | 
			
		||||
      <Card className="shadow-sm">
 | 
			
		||||
        <CardHeader className="pb-4">
 | 
			
		||||
          <CardTitle className="flex items-center gap-2">
 | 
			
		||||
            <Building2 className="h-5 w-5" />
 | 
			
		||||
            Danh sách phòng
 | 
			
		||||
          </CardTitle>
 | 
			
		||||
        </CardHeader>
 | 
			
		||||
        <CardContent>
 | 
			
		||||
          <Table>
 | 
			
		||||
            <TableHeader>
 | 
			
		||||
              {table.getHeaderGroups().map((headerGroup) => (
 | 
			
		||||
                <TableRow key={headerGroup.id}>
 | 
			
		||||
                  {headerGroup.headers.map((header) => {
 | 
			
		||||
                    const isSorted = header.column.getIsSorted();
 | 
			
		||||
                    return (
 | 
			
		||||
                      <TableHead
 | 
			
		||||
                        key={header.id}
 | 
			
		||||
                        className="cursor-pointer select-none"
 | 
			
		||||
                        onClick={header.column.getToggleSortingHandler()}
 | 
			
		||||
                      >
 | 
			
		||||
                        {flexRender(
 | 
			
		||||
                          header.column.columnDef.header,
 | 
			
		||||
                          header.getContext()
 | 
			
		||||
                        )}
 | 
			
		||||
                        {isSorted ? (isSorted === "asc" ? " ▲" : " ▼") : ""}
 | 
			
		||||
                      </TableHead>
 | 
			
		||||
                    );
 | 
			
		||||
                  })}
 | 
			
		||||
                </TableRow>
 | 
			
		||||
              ))}
 | 
			
		||||
            </TableHeader>
 | 
			
		||||
            <TableBody>
 | 
			
		||||
              {table.getRowModel().rows.map((row) => (
 | 
			
		||||
                <TableRow
 | 
			
		||||
                  key={row.id}
 | 
			
		||||
                  className="cursor-pointer hover:bg-gray-100"
 | 
			
		||||
                  onClick={() =>
 | 
			
		||||
                    navigate({
 | 
			
		||||
                      to: "/room/$roomName",
 | 
			
		||||
                      params: { roomName: row.original.name },
 | 
			
		||||
                    })
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  {row.getVisibleCells().map((cell) => (
 | 
			
		||||
                    <TableCell key={cell.id}>
 | 
			
		||||
                      {flexRender(
 | 
			
		||||
                        cell.column.columnDef.cell,
 | 
			
		||||
                        cell.getContext()
 | 
			
		||||
                      )}
 | 
			
		||||
        <CardContent className="p-0">
 | 
			
		||||
          <div className="border-t">
 | 
			
		||||
            <Table>
 | 
			
		||||
              <TableHeader>
 | 
			
		||||
                {table.getHeaderGroups().map((headerGroup) => (
 | 
			
		||||
                  <TableRow
 | 
			
		||||
                    key={headerGroup.id}
 | 
			
		||||
                    className="hover:bg-transparent"
 | 
			
		||||
                  >
 | 
			
		||||
                    {headerGroup.headers.map((header) => {
 | 
			
		||||
                      const isSorted = header.column.getIsSorted();
 | 
			
		||||
                      return (
 | 
			
		||||
                        <TableHead
 | 
			
		||||
                          key={header.id}
 | 
			
		||||
                          className="cursor-pointer select-none font-semibold hover:bg-muted/50 transition-colors"
 | 
			
		||||
                          onClick={header.column.getToggleSortingHandler()}
 | 
			
		||||
                        >
 | 
			
		||||
                          <div className="flex items-center gap-2">
 | 
			
		||||
                            {flexRender(
 | 
			
		||||
                              header.column.columnDef.header,
 | 
			
		||||
                              header.getContext()
 | 
			
		||||
                            )}
 | 
			
		||||
                            {isSorted && (
 | 
			
		||||
                              <span className="text-primary">
 | 
			
		||||
                                {isSorted === "asc" ? "↑" : "↓"}
 | 
			
		||||
                              </span>
 | 
			
		||||
                            )}
 | 
			
		||||
                          </div>
 | 
			
		||||
                        </TableHead>
 | 
			
		||||
                      );
 | 
			
		||||
                    })}
 | 
			
		||||
                  </TableRow>
 | 
			
		||||
                ))}
 | 
			
		||||
              </TableHeader>
 | 
			
		||||
              <TableBody>
 | 
			
		||||
                {table.getRowModel().rows.length === 0 ? (
 | 
			
		||||
                  <TableRow>
 | 
			
		||||
                    <TableCell
 | 
			
		||||
                      colSpan={columns.length}
 | 
			
		||||
                      className="h-24 text-center"
 | 
			
		||||
                    >
 | 
			
		||||
                      <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>
 | 
			
		||||
                  ))}
 | 
			
		||||
                </TableRow>
 | 
			
		||||
              ))}
 | 
			
		||||
            </TableBody>
 | 
			
		||||
          </Table>
 | 
			
		||||
                  </TableRow>
 | 
			
		||||
                ) : (
 | 
			
		||||
                  table.getRowModel().rows.map((row) => (
 | 
			
		||||
                    <TableRow
 | 
			
		||||
                      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>
 | 
			
		||||
      </Card>
 | 
			
		||||
    </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,7 +11,9 @@ import { FileText } from "lucide-react";
 | 
			
		|||
import { UploadDialog } from "@/components/upload-dialog";
 | 
			
		||||
import { VersionTable } from "@/components/version-table";
 | 
			
		||||
import { UpdateButton } from "@/components/update-button";
 | 
			
		||||
import { RoomSelectDialog } from "@/components/room-select-dialog";
 | 
			
		||||
import type { AxiosProgressEvent } from "axios";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
 | 
			
		||||
interface AppManagerTemplateProps<TData> {
 | 
			
		||||
  title: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -19,10 +21,14 @@ interface AppManagerTemplateProps<TData> {
 | 
			
		|||
  data: TData[];
 | 
			
		||||
  isLoading: boolean;
 | 
			
		||||
  columns: ColumnDef<TData, any>[];
 | 
			
		||||
  onUpload: (fd: FormData, config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }) => Promise<void>;
 | 
			
		||||
  onUpdate?: () => void;
 | 
			
		||||
  onUpload: (
 | 
			
		||||
    fd: FormData,
 | 
			
		||||
    config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
 | 
			
		||||
  ) => Promise<void>;
 | 
			
		||||
  onUpdate?: (roomName: string) => void;
 | 
			
		||||
  updateLoading?: boolean;
 | 
			
		||||
  onTableInit?: (table: any) => void;
 | 
			
		||||
  rooms: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function AppManagerTemplate<TData>({
 | 
			
		||||
| 
						 | 
				
			
			@ -35,7 +41,14 @@ export function AppManagerTemplate<TData>({
 | 
			
		|||
  onUpdate,
 | 
			
		||||
  updateLoading,
 | 
			
		||||
  onTableInit,
 | 
			
		||||
  rooms,
 | 
			
		||||
}: AppManagerTemplateProps<TData>) {
 | 
			
		||||
  const [dialogOpen, setDialogOpen] = useState(false);
 | 
			
		||||
  const handleUpdateClick = () => {
 | 
			
		||||
    if (rooms && onUpdate) {
 | 
			
		||||
      setDialogOpen(true);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="w-full px-6 space-y-4">
 | 
			
		||||
      <div className="flex items-center justify-between">
 | 
			
		||||
| 
						 | 
				
			
			@ -63,10 +76,26 @@ export function AppManagerTemplate<TData>({
 | 
			
		|||
        </CardContent>
 | 
			
		||||
        {onUpdate && (
 | 
			
		||||
          <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>
 | 
			
		||||
        )}
 | 
			
		||||
        {rooms && onUpdate && (
 | 
			
		||||
          <RoomSelectDialog
 | 
			
		||||
            open={dialogOpen}
 | 
			
		||||
            onClose={() => setDialogOpen(false)}
 | 
			
		||||
            rooms={rooms}
 | 
			
		||||
            onConfirm={(roomName) => {
 | 
			
		||||
              onUpdate(roomName);
 | 
			
		||||
              setDialogOpen(false);
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </Card>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,6 @@
 | 
			
		|||
import { ShellCommandForm } from "@/components/command-form";
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { useState } from "react"
 | 
			
		||||
import {
 | 
			
		||||
  Card,
 | 
			
		||||
  CardContent,
 | 
			
		||||
| 
						 | 
				
			
			@ -6,17 +8,22 @@ import {
 | 
			
		|||
  CardFooter,
 | 
			
		||||
  CardHeader,
 | 
			
		||||
  CardTitle,
 | 
			
		||||
} from "@/components/ui/card";
 | 
			
		||||
import { UpdateButton } from "@/components/update-button";
 | 
			
		||||
import { FileText, Terminal } from "lucide-react";
 | 
			
		||||
} from "@/components/ui/card"
 | 
			
		||||
import { UpdateButton } from "@/components/update-button"
 | 
			
		||||
import { Terminal } from "lucide-react"
 | 
			
		||||
import { RoomSelectDialog } from "@/components/room-select-dialog"
 | 
			
		||||
 | 
			
		||||
interface FormSubmitTemplateProps {
 | 
			
		||||
  title: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  isLoading?: boolean;
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
  onSubmit?: () => void;
 | 
			
		||||
  submitLoading?: boolean;
 | 
			
		||||
  title: string
 | 
			
		||||
  description: string
 | 
			
		||||
  isLoading?: boolean
 | 
			
		||||
  children: (props: {
 | 
			
		||||
    command: string
 | 
			
		||||
    setCommand: (val: string) => void
 | 
			
		||||
  }) => React.ReactNode
 | 
			
		||||
  onSubmit?: (roomName: string, command: string) => void
 | 
			
		||||
  submitLoading?: boolean
 | 
			
		||||
  rooms?: string[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function FormSubmitTemplate({
 | 
			
		||||
| 
						 | 
				
			
			@ -26,7 +33,17 @@ export function FormSubmitTemplate({
 | 
			
		|||
  children,
 | 
			
		||||
  onSubmit,
 | 
			
		||||
  submitLoading,
 | 
			
		||||
  rooms = [],
 | 
			
		||||
}: FormSubmitTemplateProps) {
 | 
			
		||||
  const [dialogOpen, setDialogOpen] = useState(false)
 | 
			
		||||
  const [command, setCommand] = useState("")
 | 
			
		||||
 | 
			
		||||
  const handleClick = () => {
 | 
			
		||||
    if (rooms.length > 0 && onSubmit) {
 | 
			
		||||
      setDialogOpen(true)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="w-full px-6 space-y-4">
 | 
			
		||||
      <div>
 | 
			
		||||
| 
						 | 
				
			
			@ -41,13 +58,37 @@ export function FormSubmitTemplate({
 | 
			
		|||
          </CardTitle>
 | 
			
		||||
          <CardDescription>Nhập và gửi lệnh xuống thiết bị</CardDescription>
 | 
			
		||||
        </CardHeader>
 | 
			
		||||
        <CardContent>{children}</CardContent>
 | 
			
		||||
        <CardContent>
 | 
			
		||||
          {children({ command, setCommand })}
 | 
			
		||||
        </CardContent>
 | 
			
		||||
 | 
			
		||||
        {onSubmit && (
 | 
			
		||||
          <CardFooter>
 | 
			
		||||
            <UpdateButton onClick={onSubmit} loading={submitLoading} />
 | 
			
		||||
          <CardFooter className="flex gap-2">
 | 
			
		||||
            <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>
 | 
			
		||||
        )}
 | 
			
		||||
      </Card>
 | 
			
		||||
 | 
			
		||||
      {onSubmit && rooms.length > 0 && (
 | 
			
		||||
        <RoomSelectDialog
 | 
			
		||||
          open={dialogOpen}
 | 
			
		||||
          onClose={() => setDialogOpen(false)}
 | 
			
		||||
          rooms={rooms}
 | 
			
		||||
          onConfirm={(roomName) => {
 | 
			
		||||
            onSubmit(roomName, command)
 | 
			
		||||
            setDialogOpen(false)
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </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