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