refactor route
This commit is contained in:
		
							parent
							
								
									1781b7cd2e
								
							
						
					
					
						commit
						ff6a5f6741
					
				| 
						 | 
					@ -3,7 +3,7 @@
 | 
				
			||||||
  <head>
 | 
					  <head>
 | 
				
			||||||
    <meta charset="UTF-8" />
 | 
					    <meta charset="UTF-8" />
 | 
				
			||||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
				
			||||||
    <link rel="icon" href="/favicon.ico" />
 | 
					    <link rel="icon" href="/public/computer-956.svg" />
 | 
				
			||||||
    <meta name="theme-color" content="#000000" />
 | 
					    <meta name="theme-color" content="#000000" />
 | 
				
			||||||
    <meta
 | 
					    <meta
 | 
				
			||||||
      name="description"
 | 
					      name="description"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										266
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										266
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| 
						 | 
					@ -10,9 +10,11 @@
 | 
				
			||||||
        "@radix-ui/react-checkbox": "^1.3.3",
 | 
					        "@radix-ui/react-checkbox": "^1.3.3",
 | 
				
			||||||
        "@radix-ui/react-dialog": "^1.1.14",
 | 
					        "@radix-ui/react-dialog": "^1.1.14",
 | 
				
			||||||
        "@radix-ui/react-label": "^2.1.7",
 | 
					        "@radix-ui/react-label": "^2.1.7",
 | 
				
			||||||
 | 
					        "@radix-ui/react-popover": "^1.1.15",
 | 
				
			||||||
        "@radix-ui/react-progress": "^1.1.7",
 | 
					        "@radix-ui/react-progress": "^1.1.7",
 | 
				
			||||||
        "@radix-ui/react-radio-group": "^1.3.8",
 | 
					        "@radix-ui/react-radio-group": "^1.3.8",
 | 
				
			||||||
        "@radix-ui/react-scroll-area": "^1.2.10",
 | 
					        "@radix-ui/react-scroll-area": "^1.2.10",
 | 
				
			||||||
 | 
					        "@radix-ui/react-select": "^2.2.6",
 | 
				
			||||||
        "@radix-ui/react-separator": "^1.1.7",
 | 
					        "@radix-ui/react-separator": "^1.1.7",
 | 
				
			||||||
        "@radix-ui/react-slot": "^1.2.3",
 | 
					        "@radix-ui/react-slot": "^1.2.3",
 | 
				
			||||||
        "@radix-ui/react-tooltip": "^1.2.7",
 | 
					        "@radix-ui/react-tooltip": "^1.2.7",
 | 
				
			||||||
| 
						 | 
					@ -1658,6 +1660,147 @@
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@radix-ui/react-popover": {
 | 
				
			||||||
 | 
					      "version": "1.1.15",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "@radix-ui/primitive": "1.1.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-compose-refs": "1.1.2",
 | 
				
			||||||
 | 
					        "@radix-ui/react-context": "1.1.2",
 | 
				
			||||||
 | 
					        "@radix-ui/react-dismissable-layer": "1.1.11",
 | 
				
			||||||
 | 
					        "@radix-ui/react-focus-guards": "1.1.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-focus-scope": "1.1.7",
 | 
				
			||||||
 | 
					        "@radix-ui/react-id": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-popper": "1.2.8",
 | 
				
			||||||
 | 
					        "@radix-ui/react-portal": "1.1.9",
 | 
				
			||||||
 | 
					        "@radix-ui/react-presence": "1.1.5",
 | 
				
			||||||
 | 
					        "@radix-ui/react-primitive": "2.1.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-slot": "1.2.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-controllable-state": "1.2.2",
 | 
				
			||||||
 | 
					        "aria-hidden": "^1.2.4",
 | 
				
			||||||
 | 
					        "react-remove-scroll": "^2.6.3"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "@types/react": "*",
 | 
				
			||||||
 | 
					        "@types/react-dom": "*",
 | 
				
			||||||
 | 
					        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
 | 
				
			||||||
 | 
					        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependenciesMeta": {
 | 
				
			||||||
 | 
					        "@types/react": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "@types/react-dom": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/primitive": {
 | 
				
			||||||
 | 
					      "version": "1.1.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
 | 
				
			||||||
 | 
					      "license": "MIT"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": {
 | 
				
			||||||
 | 
					      "version": "1.1.11",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "@radix-ui/primitive": "1.1.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-compose-refs": "1.1.2",
 | 
				
			||||||
 | 
					        "@radix-ui/react-primitive": "2.1.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-callback-ref": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-escape-keydown": "1.1.1"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "@types/react": "*",
 | 
				
			||||||
 | 
					        "@types/react-dom": "*",
 | 
				
			||||||
 | 
					        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
 | 
				
			||||||
 | 
					        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependenciesMeta": {
 | 
				
			||||||
 | 
					        "@types/react": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "@types/react-dom": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": {
 | 
				
			||||||
 | 
					      "version": "1.1.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "@types/react": "*",
 | 
				
			||||||
 | 
					        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependenciesMeta": {
 | 
				
			||||||
 | 
					        "@types/react": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": {
 | 
				
			||||||
 | 
					      "version": "1.2.8",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "@floating-ui/react-dom": "^2.0.0",
 | 
				
			||||||
 | 
					        "@radix-ui/react-arrow": "1.1.7",
 | 
				
			||||||
 | 
					        "@radix-ui/react-compose-refs": "1.1.2",
 | 
				
			||||||
 | 
					        "@radix-ui/react-context": "1.1.2",
 | 
				
			||||||
 | 
					        "@radix-ui/react-primitive": "2.1.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-callback-ref": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-layout-effect": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-rect": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-size": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/rect": "1.1.1"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "@types/react": "*",
 | 
				
			||||||
 | 
					        "@types/react-dom": "*",
 | 
				
			||||||
 | 
					        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
 | 
				
			||||||
 | 
					        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependenciesMeta": {
 | 
				
			||||||
 | 
					        "@types/react": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "@types/react-dom": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": {
 | 
				
			||||||
 | 
					      "version": "1.1.5",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "@radix-ui/react-compose-refs": "1.1.2",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-layout-effect": "1.1.1"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "@types/react": "*",
 | 
				
			||||||
 | 
					        "@types/react-dom": "*",
 | 
				
			||||||
 | 
					        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
 | 
				
			||||||
 | 
					        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependenciesMeta": {
 | 
				
			||||||
 | 
					        "@types/react": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "@types/react-dom": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/@radix-ui/react-popper": {
 | 
					    "node_modules/@radix-ui/react-popper": {
 | 
				
			||||||
      "version": "1.2.7",
 | 
					      "version": "1.2.7",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
 | 
				
			||||||
| 
						 | 
					@ -1945,6 +2088,129 @@
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@radix-ui/react-select": {
 | 
				
			||||||
 | 
					      "version": "2.2.6",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "@radix-ui/number": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/primitive": "1.1.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-collection": "1.1.7",
 | 
				
			||||||
 | 
					        "@radix-ui/react-compose-refs": "1.1.2",
 | 
				
			||||||
 | 
					        "@radix-ui/react-context": "1.1.2",
 | 
				
			||||||
 | 
					        "@radix-ui/react-direction": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-dismissable-layer": "1.1.11",
 | 
				
			||||||
 | 
					        "@radix-ui/react-focus-guards": "1.1.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-focus-scope": "1.1.7",
 | 
				
			||||||
 | 
					        "@radix-ui/react-id": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-popper": "1.2.8",
 | 
				
			||||||
 | 
					        "@radix-ui/react-portal": "1.1.9",
 | 
				
			||||||
 | 
					        "@radix-ui/react-primitive": "2.1.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-slot": "1.2.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-callback-ref": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-controllable-state": "1.2.2",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-layout-effect": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-previous": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-visually-hidden": "1.2.3",
 | 
				
			||||||
 | 
					        "aria-hidden": "^1.2.4",
 | 
				
			||||||
 | 
					        "react-remove-scroll": "^2.6.3"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "@types/react": "*",
 | 
				
			||||||
 | 
					        "@types/react-dom": "*",
 | 
				
			||||||
 | 
					        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
 | 
				
			||||||
 | 
					        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependenciesMeta": {
 | 
				
			||||||
 | 
					        "@types/react": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "@types/react-dom": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@radix-ui/react-select/node_modules/@radix-ui/primitive": {
 | 
				
			||||||
 | 
					      "version": "1.1.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
 | 
				
			||||||
 | 
					      "license": "MIT"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": {
 | 
				
			||||||
 | 
					      "version": "1.1.11",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "@radix-ui/primitive": "1.1.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-compose-refs": "1.1.2",
 | 
				
			||||||
 | 
					        "@radix-ui/react-primitive": "2.1.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-callback-ref": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-escape-keydown": "1.1.1"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "@types/react": "*",
 | 
				
			||||||
 | 
					        "@types/react-dom": "*",
 | 
				
			||||||
 | 
					        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
 | 
				
			||||||
 | 
					        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependenciesMeta": {
 | 
				
			||||||
 | 
					        "@types/react": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "@types/react-dom": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-guards": {
 | 
				
			||||||
 | 
					      "version": "1.1.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "@types/react": "*",
 | 
				
			||||||
 | 
					        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependenciesMeta": {
 | 
				
			||||||
 | 
					        "@types/react": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-popper": {
 | 
				
			||||||
 | 
					      "version": "1.2.8",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "@floating-ui/react-dom": "^2.0.0",
 | 
				
			||||||
 | 
					        "@radix-ui/react-arrow": "1.1.7",
 | 
				
			||||||
 | 
					        "@radix-ui/react-compose-refs": "1.1.2",
 | 
				
			||||||
 | 
					        "@radix-ui/react-context": "1.1.2",
 | 
				
			||||||
 | 
					        "@radix-ui/react-primitive": "2.1.3",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-callback-ref": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-layout-effect": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-rect": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/react-use-size": "1.1.1",
 | 
				
			||||||
 | 
					        "@radix-ui/rect": "1.1.1"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "@types/react": "*",
 | 
				
			||||||
 | 
					        "@types/react-dom": "*",
 | 
				
			||||||
 | 
					        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
 | 
				
			||||||
 | 
					        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependenciesMeta": {
 | 
				
			||||||
 | 
					        "@types/react": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "@types/react-dom": {
 | 
				
			||||||
 | 
					          "optional": true
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/@radix-ui/react-separator": {
 | 
					    "node_modules/@radix-ui/react-separator": {
 | 
				
			||||||
      "version": "1.1.7",
 | 
					      "version": "1.1.7",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,9 +14,11 @@
 | 
				
			||||||
    "@radix-ui/react-checkbox": "^1.3.3",
 | 
					    "@radix-ui/react-checkbox": "^1.3.3",
 | 
				
			||||||
    "@radix-ui/react-dialog": "^1.1.14",
 | 
					    "@radix-ui/react-dialog": "^1.1.14",
 | 
				
			||||||
    "@radix-ui/react-label": "^2.1.7",
 | 
					    "@radix-ui/react-label": "^2.1.7",
 | 
				
			||||||
 | 
					    "@radix-ui/react-popover": "^1.1.15",
 | 
				
			||||||
    "@radix-ui/react-progress": "^1.1.7",
 | 
					    "@radix-ui/react-progress": "^1.1.7",
 | 
				
			||||||
    "@radix-ui/react-radio-group": "^1.3.8",
 | 
					    "@radix-ui/react-radio-group": "^1.3.8",
 | 
				
			||||||
    "@radix-ui/react-scroll-area": "^1.2.10",
 | 
					    "@radix-ui/react-scroll-area": "^1.2.10",
 | 
				
			||||||
 | 
					    "@radix-ui/react-select": "^2.2.6",
 | 
				
			||||||
    "@radix-ui/react-separator": "^1.1.7",
 | 
					    "@radix-ui/react-separator": "^1.1.7",
 | 
				
			||||||
    "@radix-ui/react-slot": "^1.2.3",
 | 
					    "@radix-ui/react-slot": "^1.2.3",
 | 
				
			||||||
    "@radix-ui/react-tooltip": "^1.2.7",
 | 
					    "@radix-ui/react-tooltip": "^1.2.7",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										6
									
								
								public/computer-956.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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`,
 | 
					    GET_VERSION: `${BASE_URL}/AppVersion/version`,
 | 
				
			||||||
    UPLOAD: `${BASE_URL}/AppVersion/upload`,
 | 
					    UPLOAD: `${BASE_URL}/AppVersion/upload`,
 | 
				
			||||||
    GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
 | 
					    GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
 | 
				
			||||||
 | 
					    GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`, 
 | 
				
			||||||
 | 
					    ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
 | 
				
			||||||
 | 
					    DELETE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
 | 
				
			||||||
 | 
					    UPDATE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/update/${appId}`,
 | 
				
			||||||
 | 
					    REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  DEVICE_COMM: {
 | 
					  DEVICE_COMM: {
 | 
				
			||||||
    DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
 | 
					    DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
 | 
				
			||||||
 | 
					    GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
 | 
				
			||||||
    GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
 | 
					    GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
 | 
				
			||||||
    GET_DEVICE_FROM_ROOM: (roomName: string) =>
 | 
					    GET_DEVICE_FROM_ROOM: (roomName: string) =>
 | 
				
			||||||
      `${BASE_URL}/DeviceComm/room/${roomName}`,
 | 
					      `${BASE_URL}/DeviceComm/room/${roomName}`,
 | 
				
			||||||
| 
						 | 
					@ -22,5 +28,7 @@ export const API_ENDPOINTS = {
 | 
				
			||||||
  SSE_EVENTS: {
 | 
					  SSE_EVENTS: {
 | 
				
			||||||
    DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
 | 
					    DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
 | 
				
			||||||
    DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
 | 
					    DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
 | 
				
			||||||
 | 
					    GET_PROCESSES_LISTS:  `${BASE_URL}/Sse/events/processLists`,
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										24
									
								
								src/hooks/useFloor.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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,
 | 
					  SidebarInset,
 | 
				
			||||||
  SidebarTrigger,
 | 
					  SidebarTrigger,
 | 
				
			||||||
} from "@/components/ui/sidebar";
 | 
					} from "@/components/ui/sidebar";
 | 
				
			||||||
import { Home, Building, AppWindow, Terminal } from "lucide-react";
 | 
					import { Home, Building, AppWindow, Terminal, CircleX } from "lucide-react";
 | 
				
			||||||
import { Toaster } from "@/components/ui/sonner";
 | 
					import { Toaster } from "@/components/ui/sonner";
 | 
				
			||||||
import { useQueryClient } from "@tanstack/react-query";
 | 
					import { useQueryClient } from "@tanstack/react-query";
 | 
				
			||||||
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
 | 
					import { API_ENDPOINTS, BASE_URL } from "@/config/api";
 | 
				
			||||||
| 
						 | 
					@ -50,6 +50,17 @@ export default function AppLayout({ children }: AppLayoutProps) {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handlePrefetchBannedSoftware = () => {
 | 
				
			||||||
 | 
					    queryClient.prefetchQuery({
 | 
				
			||||||
 | 
					      queryKey: ["blacklist"],
 | 
				
			||||||
 | 
					      queryFn: () =>
 | 
				
			||||||
 | 
					        fetch(BASE_URL + API_ENDPOINTS.APP_VERSION).then((res) =>
 | 
				
			||||||
 | 
					          res.json()
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      staleTime: 60 * 1000,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const items = [
 | 
					  const items = [
 | 
				
			||||||
    { title: "Dashboard", to: "/", icon: Home },
 | 
					    { title: "Dashboard", to: "/", icon: Home },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
| 
						 | 
					@ -71,6 +82,12 @@ export default function AppLayout({ children }: AppLayoutProps) {
 | 
				
			||||||
      onPointerEnter: handlePrefetchSofware,
 | 
					      onPointerEnter: handlePrefetchSofware,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    { title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
 | 
					    { title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      title: "Danh sách đen",
 | 
				
			||||||
 | 
					      to: "/blacklist",
 | 
				
			||||||
 | 
					      icon: CircleX,
 | 
				
			||||||
 | 
					      onPointerEnter: handlePrefetchBannedSoftware,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
  ];
 | 
					  ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -13,6 +13,7 @@ import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
 | 
				
			||||||
import { Route as AuthRouteImport } from './routes/_auth'
 | 
					import { Route as AuthRouteImport } from './routes/_auth'
 | 
				
			||||||
import { Route as IndexRouteImport } from './routes/index'
 | 
					import { Route as IndexRouteImport } from './routes/index'
 | 
				
			||||||
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
 | 
					import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
 | 
				
			||||||
 | 
					import { Route as AuthenticatedDeviceIndexRouteImport } from './routes/_authenticated/device/index'
 | 
				
			||||||
import { Route as AuthenticatedCommandIndexRouteImport } from './routes/_authenticated/command/index'
 | 
					import { Route as AuthenticatedCommandIndexRouteImport } from './routes/_authenticated/command/index'
 | 
				
			||||||
import { Route as AuthenticatedBlacklistIndexRouteImport } from './routes/_authenticated/blacklist/index'
 | 
					import { Route as AuthenticatedBlacklistIndexRouteImport } from './routes/_authenticated/blacklist/index'
 | 
				
			||||||
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
 | 
					import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
 | 
				
			||||||
| 
						 | 
					@ -38,6 +39,12 @@ const AuthenticatedRoomIndexRoute = AuthenticatedRoomIndexRouteImport.update({
 | 
				
			||||||
  path: '/room/',
 | 
					  path: '/room/',
 | 
				
			||||||
  getParentRoute: () => AuthenticatedRoute,
 | 
					  getParentRoute: () => AuthenticatedRoute,
 | 
				
			||||||
} as any)
 | 
					} as any)
 | 
				
			||||||
 | 
					const AuthenticatedDeviceIndexRoute =
 | 
				
			||||||
 | 
					  AuthenticatedDeviceIndexRouteImport.update({
 | 
				
			||||||
 | 
					    id: '/device/',
 | 
				
			||||||
 | 
					    path: '/device/',
 | 
				
			||||||
 | 
					    getParentRoute: () => AuthenticatedRoute,
 | 
				
			||||||
 | 
					  } as any)
 | 
				
			||||||
const AuthenticatedCommandIndexRoute =
 | 
					const AuthenticatedCommandIndexRoute =
 | 
				
			||||||
  AuthenticatedCommandIndexRouteImport.update({
 | 
					  AuthenticatedCommandIndexRouteImport.update({
 | 
				
			||||||
    id: '/command/',
 | 
					    id: '/command/',
 | 
				
			||||||
| 
						 | 
					@ -79,6 +86,7 @@ export interface FileRoutesByFullPath {
 | 
				
			||||||
  '/apps': typeof AuthenticatedAppsIndexRoute
 | 
					  '/apps': typeof AuthenticatedAppsIndexRoute
 | 
				
			||||||
  '/blacklist': typeof AuthenticatedBlacklistIndexRoute
 | 
					  '/blacklist': typeof AuthenticatedBlacklistIndexRoute
 | 
				
			||||||
  '/command': typeof AuthenticatedCommandIndexRoute
 | 
					  '/command': typeof AuthenticatedCommandIndexRoute
 | 
				
			||||||
 | 
					  '/device': typeof AuthenticatedDeviceIndexRoute
 | 
				
			||||||
  '/room': typeof AuthenticatedRoomIndexRoute
 | 
					  '/room': typeof AuthenticatedRoomIndexRoute
 | 
				
			||||||
  '/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
 | 
					  '/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -89,6 +97,7 @@ export interface FileRoutesByTo {
 | 
				
			||||||
  '/apps': typeof AuthenticatedAppsIndexRoute
 | 
					  '/apps': typeof AuthenticatedAppsIndexRoute
 | 
				
			||||||
  '/blacklist': typeof AuthenticatedBlacklistIndexRoute
 | 
					  '/blacklist': typeof AuthenticatedBlacklistIndexRoute
 | 
				
			||||||
  '/command': typeof AuthenticatedCommandIndexRoute
 | 
					  '/command': typeof AuthenticatedCommandIndexRoute
 | 
				
			||||||
 | 
					  '/device': typeof AuthenticatedDeviceIndexRoute
 | 
				
			||||||
  '/room': typeof AuthenticatedRoomIndexRoute
 | 
					  '/room': typeof AuthenticatedRoomIndexRoute
 | 
				
			||||||
  '/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
 | 
					  '/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -102,6 +111,7 @@ export interface FileRoutesById {
 | 
				
			||||||
  '/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
 | 
					  '/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
 | 
				
			||||||
  '/_authenticated/blacklist/': typeof AuthenticatedBlacklistIndexRoute
 | 
					  '/_authenticated/blacklist/': typeof AuthenticatedBlacklistIndexRoute
 | 
				
			||||||
  '/_authenticated/command/': typeof AuthenticatedCommandIndexRoute
 | 
					  '/_authenticated/command/': typeof AuthenticatedCommandIndexRoute
 | 
				
			||||||
 | 
					  '/_authenticated/device/': typeof AuthenticatedDeviceIndexRoute
 | 
				
			||||||
  '/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
 | 
					  '/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
 | 
				
			||||||
  '/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
 | 
					  '/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -114,6 +124,7 @@ export interface FileRouteTypes {
 | 
				
			||||||
    | '/apps'
 | 
					    | '/apps'
 | 
				
			||||||
    | '/blacklist'
 | 
					    | '/blacklist'
 | 
				
			||||||
    | '/command'
 | 
					    | '/command'
 | 
				
			||||||
 | 
					    | '/device'
 | 
				
			||||||
    | '/room'
 | 
					    | '/room'
 | 
				
			||||||
    | '/room/$roomName'
 | 
					    | '/room/$roomName'
 | 
				
			||||||
  fileRoutesByTo: FileRoutesByTo
 | 
					  fileRoutesByTo: FileRoutesByTo
 | 
				
			||||||
| 
						 | 
					@ -124,6 +135,7 @@ export interface FileRouteTypes {
 | 
				
			||||||
    | '/apps'
 | 
					    | '/apps'
 | 
				
			||||||
    | '/blacklist'
 | 
					    | '/blacklist'
 | 
				
			||||||
    | '/command'
 | 
					    | '/command'
 | 
				
			||||||
 | 
					    | '/device'
 | 
				
			||||||
    | '/room'
 | 
					    | '/room'
 | 
				
			||||||
    | '/room/$roomName'
 | 
					    | '/room/$roomName'
 | 
				
			||||||
  id:
 | 
					  id:
 | 
				
			||||||
| 
						 | 
					@ -136,6 +148,7 @@ export interface FileRouteTypes {
 | 
				
			||||||
    | '/_authenticated/apps/'
 | 
					    | '/_authenticated/apps/'
 | 
				
			||||||
    | '/_authenticated/blacklist/'
 | 
					    | '/_authenticated/blacklist/'
 | 
				
			||||||
    | '/_authenticated/command/'
 | 
					    | '/_authenticated/command/'
 | 
				
			||||||
 | 
					    | '/_authenticated/device/'
 | 
				
			||||||
    | '/_authenticated/room/'
 | 
					    | '/_authenticated/room/'
 | 
				
			||||||
    | '/_authenticated/room/$roomName/'
 | 
					    | '/_authenticated/room/$roomName/'
 | 
				
			||||||
  fileRoutesById: FileRoutesById
 | 
					  fileRoutesById: FileRoutesById
 | 
				
			||||||
| 
						 | 
					@ -176,6 +189,13 @@ declare module '@tanstack/react-router' {
 | 
				
			||||||
      preLoaderRoute: typeof AuthenticatedRoomIndexRouteImport
 | 
					      preLoaderRoute: typeof AuthenticatedRoomIndexRouteImport
 | 
				
			||||||
      parentRoute: typeof AuthenticatedRoute
 | 
					      parentRoute: typeof AuthenticatedRoute
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    '/_authenticated/device/': {
 | 
				
			||||||
 | 
					      id: '/_authenticated/device/'
 | 
				
			||||||
 | 
					      path: '/device'
 | 
				
			||||||
 | 
					      fullPath: '/device'
 | 
				
			||||||
 | 
					      preLoaderRoute: typeof AuthenticatedDeviceIndexRouteImport
 | 
				
			||||||
 | 
					      parentRoute: typeof AuthenticatedRoute
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    '/_authenticated/command/': {
 | 
					    '/_authenticated/command/': {
 | 
				
			||||||
      id: '/_authenticated/command/'
 | 
					      id: '/_authenticated/command/'
 | 
				
			||||||
      path: '/command'
 | 
					      path: '/command'
 | 
				
			||||||
| 
						 | 
					@ -236,6 +256,7 @@ interface AuthenticatedRouteChildren {
 | 
				
			||||||
  AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
 | 
					  AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
 | 
				
			||||||
  AuthenticatedBlacklistIndexRoute: typeof AuthenticatedBlacklistIndexRoute
 | 
					  AuthenticatedBlacklistIndexRoute: typeof AuthenticatedBlacklistIndexRoute
 | 
				
			||||||
  AuthenticatedCommandIndexRoute: typeof AuthenticatedCommandIndexRoute
 | 
					  AuthenticatedCommandIndexRoute: typeof AuthenticatedCommandIndexRoute
 | 
				
			||||||
 | 
					  AuthenticatedDeviceIndexRoute: typeof AuthenticatedDeviceIndexRoute
 | 
				
			||||||
  AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
 | 
					  AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
 | 
				
			||||||
  AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
 | 
					  AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -245,6 +266,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
 | 
				
			||||||
  AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
 | 
					  AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
 | 
				
			||||||
  AuthenticatedBlacklistIndexRoute: AuthenticatedBlacklistIndexRoute,
 | 
					  AuthenticatedBlacklistIndexRoute: AuthenticatedBlacklistIndexRoute,
 | 
				
			||||||
  AuthenticatedCommandIndexRoute: AuthenticatedCommandIndexRoute,
 | 
					  AuthenticatedCommandIndexRoute: AuthenticatedCommandIndexRoute,
 | 
				
			||||||
 | 
					  AuthenticatedDeviceIndexRoute: AuthenticatedDeviceIndexRoute,
 | 
				
			||||||
  AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
 | 
					  AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
 | 
				
			||||||
  AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,
 | 
					  AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,9 +1,66 @@
 | 
				
			||||||
import { createFileRoute } from '@tanstack/react-router'
 | 
					import { API_ENDPOINTS, BASE_URL } from "@/config/api";
 | 
				
			||||||
 | 
					import { useQueryData } from "@/hooks/useQueryData";
 | 
				
			||||||
 | 
					import { createFileRoute } from "@tanstack/react-router";
 | 
				
			||||||
 | 
					import type { ColumnDef } from "@tanstack/react-table";
 | 
				
			||||||
 | 
					import { useState } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const Route = createFileRoute('/_authenticated/blacklist/')({
 | 
					type Blacklist = {
 | 
				
			||||||
  component: RouteComponent,
 | 
					  id: number;
 | 
				
			||||||
})
 | 
					  appName: string;
 | 
				
			||||||
 | 
					  processName: string;
 | 
				
			||||||
 | 
					  createdAt?: string;
 | 
				
			||||||
 | 
					  updatedAt?: string;
 | 
				
			||||||
 | 
					  createdBy?: string;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function RouteComponent() {
 | 
					export const Route = createFileRoute("/_authenticated/blacklist/")({
 | 
				
			||||||
  return <div>Hello "/_authenticated/blacklist/"!</div>
 | 
					  head: () => ({ meta: [{ title: "Danh sách các ứng dụng bị chặn" }] }),
 | 
				
			||||||
 | 
					  component: BlacklistComponent,
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function BlacklistComponent() {
 | 
				
			||||||
 | 
					  const { data, isLoading } = useQueryData({
 | 
				
			||||||
 | 
					    queryKey: ["blacklist"],
 | 
				
			||||||
 | 
					    url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const blacklist: Blacklist[] = Array.isArray(data)
 | 
				
			||||||
 | 
					    ? (data as Blacklist[])
 | 
				
			||||||
 | 
					    : [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const columns : ColumnDef<Blacklist>[] = 
 | 
				
			||||||
 | 
					  [
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        accessorKey: "id",
 | 
				
			||||||
 | 
					        header: "ID",
 | 
				
			||||||
 | 
					        cell: info => info.getValue(), 
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        accessorKey: "appName",
 | 
				
			||||||
 | 
					        header: "Tên ứng dụng",
 | 
				
			||||||
 | 
					        cell: info => info.getValue(), 
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        accessorKey: "processName",
 | 
				
			||||||
 | 
					        header: "Tên tiến trình",
 | 
				
			||||||
 | 
					        cell: info => info.getValue(), 
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        accessorKey: "createdAt",
 | 
				
			||||||
 | 
					        header: "Ngày tạo",
 | 
				
			||||||
 | 
					        cell: info => info.getValue(), 
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        accessorKey: "updatedAt",
 | 
				
			||||||
 | 
					        header: "Ngày cập nhật",
 | 
				
			||||||
 | 
					        cell: info => info.getValue(), 
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        accessorKey: "createdBy",
 | 
				
			||||||
 | 
					        header: "Người tạo",
 | 
				
			||||||
 | 
					        cell: info => info.getValue(), 
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return <div>Hello "/_authenticated/blacklist/"!</div>;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										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 { createFileRoute, useParams } from "@tanstack/react-router";
 | 
				
			||||||
 | 
					import { useState } from "react";
 | 
				
			||||||
 | 
					import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 | 
				
			||||||
 | 
					import { LayoutGrid, TableIcon, Monitor } from "lucide-react";
 | 
				
			||||||
 | 
					import { Button } from "@/components/ui/button";
 | 
				
			||||||
import { useQueryData } from "@/hooks/useQueryData";
 | 
					import { useQueryData } from "@/hooks/useQueryData";
 | 
				
			||||||
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
 | 
					import { API_ENDPOINTS, BASE_URL } from "@/config/api";
 | 
				
			||||||
import {
 | 
					import { DeviceGrid } from "@/components/device-grid";
 | 
				
			||||||
  flexRender,
 | 
					import { DeviceTable } from "@/components/device-table";
 | 
				
			||||||
  getCoreRowModel,
 | 
					 | 
				
			||||||
  getPaginationRowModel,
 | 
					 | 
				
			||||||
  useReactTable,
 | 
					 | 
				
			||||||
  type ColumnDef,
 | 
					 | 
				
			||||||
} from "@tanstack/react-table";
 | 
					 | 
				
			||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
 | 
					 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
  Table,
 | 
					 | 
				
			||||||
  TableBody,
 | 
					 | 
				
			||||||
  TableCell,
 | 
					 | 
				
			||||||
  TableHead,
 | 
					 | 
				
			||||||
  TableHeader,
 | 
					 | 
				
			||||||
  TableRow,
 | 
					 | 
				
			||||||
} from "@/components/ui/table";
 | 
					 | 
				
			||||||
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
 | 
					 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
  ChevronLeft,
 | 
					 | 
				
			||||||
  ChevronRight,
 | 
					 | 
				
			||||||
  Clock,
 | 
					 | 
				
			||||||
  Loader2,
 | 
					 | 
				
			||||||
  MapPin,
 | 
					 | 
				
			||||||
  Monitor,
 | 
					 | 
				
			||||||
  Wifi,
 | 
					 | 
				
			||||||
  WifiOff,
 | 
					 | 
				
			||||||
} from "lucide-react";
 | 
					 | 
				
			||||||
import { Badge } from "@/components/ui/badge";
 | 
					 | 
				
			||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
 | 
					 | 
				
			||||||
import { Button } from "@/components/ui/button";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const Route = createFileRoute("/_authenticated/room/$roomName/")({
 | 
					export const Route = createFileRoute("/_authenticated/room/$roomName/")({
 | 
				
			||||||
  head: ({ params }) => ({
 | 
					  head: ({ params }) => ({
 | 
				
			||||||
    meta: [{ title: `Danh sách thiết bị phòng ${params.roomName}` }],
 | 
					    meta: [{ title: `Danh sách thiết bị phòng ${params.roomName}` }],
 | 
				
			||||||
  }),
 | 
					  }),
 | 
				
			||||||
  component: RoomDetailComponent,
 | 
					  component: RoomDetailPage,
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function RoomDetailComponent() {
 | 
					function RoomDetailPage() {
 | 
				
			||||||
  const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
 | 
					  const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
 | 
				
			||||||
 | 
					  const [viewMode, setViewMode] = useState<"table" | "grid">("table");
 | 
				
			||||||
  const { data: devices = [], isLoading } = useQueryData({
 | 
					  const { data: devices = [] } = useQueryData({
 | 
				
			||||||
    queryKey: ["devices", roomName],
 | 
					    queryKey: ["devices", roomName],
 | 
				
			||||||
    url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
 | 
					    url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Lắng nghe SSE và update state
 | 
					 | 
				
			||||||
  useDeviceEvents(roomName);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const columns: ColumnDef<any>[] = [
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      header: "STT",
 | 
					 | 
				
			||||||
      cell: ({ row }) => (
 | 
					 | 
				
			||||||
        <div className="flex items-center gap-3">
 | 
					 | 
				
			||||||
          <Avatar className="h-8 w-8">
 | 
					 | 
				
			||||||
            <AvatarFallback className="bg-primary/10 text-primary">
 | 
					 | 
				
			||||||
              <Monitor className="h-4 w-4" />
 | 
					 | 
				
			||||||
            </AvatarFallback>
 | 
					 | 
				
			||||||
          </Avatar>
 | 
					 | 
				
			||||||
          <span className="font-medium text-sm">{row.index + 1}</span>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      header: () => (
 | 
					 | 
				
			||||||
        <div className="flex items-center gap-2">
 | 
					 | 
				
			||||||
          <Clock className="h-4 w-4" />
 | 
					 | 
				
			||||||
          Thời gian thiết bị
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
      accessorKey: "deviceTime",
 | 
					 | 
				
			||||||
      cell: ({ getValue }) => {
 | 
					 | 
				
			||||||
        const date = new Date(getValue() as string);
 | 
					 | 
				
			||||||
        return (
 | 
					 | 
				
			||||||
          <div className="text-sm">
 | 
					 | 
				
			||||||
            <div className="font-medium">
 | 
					 | 
				
			||||||
              {date.toLocaleDateString("vi-VN")}
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <div className="text-muted-foreground">
 | 
					 | 
				
			||||||
              {date.toLocaleTimeString("vi-VN")}
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      header: "Phiên bản",
 | 
					 | 
				
			||||||
      accessorKey: "version",
 | 
					 | 
				
			||||||
      cell: ({ getValue }) => (
 | 
					 | 
				
			||||||
        <Badge variant="secondary" className="font-mono">
 | 
					 | 
				
			||||||
          v{getValue() as string}
 | 
					 | 
				
			||||||
        </Badge>
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      header: () => (
 | 
					 | 
				
			||||||
        <div className="flex items-center gap-2">
 | 
					 | 
				
			||||||
          <MapPin className="h-4 w-4" />
 | 
					 | 
				
			||||||
          Phòng
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
      accessorKey: "room",
 | 
					 | 
				
			||||||
      cell: ({ getValue }) => (
 | 
					 | 
				
			||||||
        <span className="text-sm font-medium">{getValue() as string}</span>
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      header: () => (
 | 
					 | 
				
			||||||
        <div className="flex items-center gap-2">
 | 
					 | 
				
			||||||
          <Wifi className="h-4 w-4" />
 | 
					 | 
				
			||||||
          Thông tin mạng
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
      accessorKey: "networkInfos",
 | 
					 | 
				
			||||||
      cell: ({ getValue }) => {
 | 
					 | 
				
			||||||
        const networkInfos = getValue() as {
 | 
					 | 
				
			||||||
          macAddress?: string;
 | 
					 | 
				
			||||||
          ipAddress?: string;
 | 
					 | 
				
			||||||
        }[];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (!networkInfos || networkInfos.length === 0) {
 | 
					 | 
				
			||||||
          return (
 | 
					 | 
				
			||||||
            <span className="text-muted-foreground text-sm">
 | 
					 | 
				
			||||||
              Không 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 (
 | 
					  return (
 | 
				
			||||||
    <div className="w-full px-6 space-y-6">
 | 
					    <div className="w-full px-6 space-y-6">
 | 
				
			||||||
      <div className="flex items-center justify-between">
 | 
					 | 
				
			||||||
        <div className="space-y-1">
 | 
					 | 
				
			||||||
          <h1 className="text-3xl font-bold tracking-tight">
 | 
					 | 
				
			||||||
            Phòng: {roomName}
 | 
					 | 
				
			||||||
          </h1>
 | 
					 | 
				
			||||||
          <p className="text-muted-foreground">
 | 
					 | 
				
			||||||
            Quản 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">
 | 
					      <Card className="shadow-sm">
 | 
				
			||||||
        <CardHeader className="bg-muted/50">
 | 
					        <CardHeader className="bg-muted/50 flex items-center justify-between">
 | 
				
			||||||
          <CardTitle className="flex items-center gap-2">
 | 
					          <CardTitle className="flex items-center gap-2">
 | 
				
			||||||
            <Monitor className="h-5 w-5" />
 | 
					            <Monitor className="h-5 w-5" />
 | 
				
			||||||
            Danh sách thiết bị
 | 
					            Danh sách thiết bị phòng {roomName}
 | 
				
			||||||
          </CardTitle>
 | 
					          </CardTitle>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <div className="flex items-center gap-2 bg-background rounded-lg p-1 border">
 | 
				
			||||||
 | 
					            <Button
 | 
				
			||||||
 | 
					              variant={viewMode === "table" ? "default" : "ghost"}
 | 
				
			||||||
 | 
					              size="sm"
 | 
				
			||||||
 | 
					              onClick={() => setViewMode("table")}
 | 
				
			||||||
 | 
					              className="flex items-center gap-2"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <TableIcon className="h-4 w-4" />
 | 
				
			||||||
 | 
					              Bảng
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					            <Button
 | 
				
			||||||
 | 
					              variant={viewMode === "grid" ? "default" : "ghost"}
 | 
				
			||||||
 | 
					              size="sm"
 | 
				
			||||||
 | 
					              onClick={() => setViewMode("grid")}
 | 
				
			||||||
 | 
					              className="flex items-center gap-2"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <LayoutGrid className="h-4 w-4" />
 | 
				
			||||||
 | 
					              Sơ đồ
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
        </CardHeader>
 | 
					        </CardHeader>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <CardContent className="p-0">
 | 
					        <CardContent className="p-0">
 | 
				
			||||||
          {devices.length === 0 ? (
 | 
					          {devices.length === 0 ? (
 | 
				
			||||||
            <div className="flex flex-col items-center justify-center py-12">
 | 
					            <div className="flex flex-col items-center justify-center py-12">
 | 
				
			||||||
| 
						 | 
					@ -249,100 +63,10 @@ function RoomDetailComponent() {
 | 
				
			||||||
                Phòng này chưa có thiết bị nào được kết nối.
 | 
					                Phòng này chưa có thiết bị nào được kết nối.
 | 
				
			||||||
              </p>
 | 
					              </p>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					          ) : viewMode === "grid" ? (
 | 
				
			||||||
 | 
					            <DeviceGrid devices={devices} />
 | 
				
			||||||
          ) : (
 | 
					          ) : (
 | 
				
			||||||
            <>
 | 
					            <DeviceTable devices={devices} />
 | 
				
			||||||
              <div className="max-h-[600px] overflow-y-auto">
 | 
					 | 
				
			||||||
                <Table>
 | 
					 | 
				
			||||||
                  <TableHeader className="sticky top-0 bg-background z-10">
 | 
					 | 
				
			||||||
                    {table.getHeaderGroups().map((headerGroup) => (
 | 
					 | 
				
			||||||
                      <TableRow
 | 
					 | 
				
			||||||
                        key={headerGroup.id}
 | 
					 | 
				
			||||||
                        className="hover:bg-transparent border-b"
 | 
					 | 
				
			||||||
                      >
 | 
					 | 
				
			||||||
                        {headerGroup.headers.map((header) => (
 | 
					 | 
				
			||||||
                          <TableHead
 | 
					 | 
				
			||||||
                            key={header.id}
 | 
					 | 
				
			||||||
                            className="font-semibold text-foreground bg-muted/30"
 | 
					 | 
				
			||||||
                          >
 | 
					 | 
				
			||||||
                            {flexRender(
 | 
					 | 
				
			||||||
                              header.column.columnDef.header,
 | 
					 | 
				
			||||||
                              header.getContext()
 | 
					 | 
				
			||||||
                            )}
 | 
					 | 
				
			||||||
                          </TableHead>
 | 
					 | 
				
			||||||
                        ))}
 | 
					 | 
				
			||||||
                      </TableRow>
 | 
					 | 
				
			||||||
                    ))}
 | 
					 | 
				
			||||||
                  </TableHeader>
 | 
					 | 
				
			||||||
                  <TableBody>
 | 
					 | 
				
			||||||
                    {table.getRowModel().rows.map((row) => (
 | 
					 | 
				
			||||||
                      <TableRow
 | 
					 | 
				
			||||||
                        key={row.id}
 | 
					 | 
				
			||||||
                        className="hover:bg-muted/50 transition-colors"
 | 
					 | 
				
			||||||
                      >
 | 
					 | 
				
			||||||
                        {row.getVisibleCells().map((cell) => (
 | 
					 | 
				
			||||||
                          <TableCell key={cell.id} className="py-4">
 | 
					 | 
				
			||||||
                            {flexRender(
 | 
					 | 
				
			||||||
                              cell.column.columnDef.cell,
 | 
					 | 
				
			||||||
                              cell.getContext()
 | 
					 | 
				
			||||||
                            )}
 | 
					 | 
				
			||||||
                          </TableCell>
 | 
					 | 
				
			||||||
                        ))}
 | 
					 | 
				
			||||||
                      </TableRow>
 | 
					 | 
				
			||||||
                    ))}
 | 
					 | 
				
			||||||
                  </TableBody>
 | 
					 | 
				
			||||||
                </Table>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
              <div className="flex items-center justify-between p-4 border-t bg-muted/20">
 | 
					 | 
				
			||||||
                <div className="flex items-center gap-2 text-sm text-muted-foreground">
 | 
					 | 
				
			||||||
                  <span>
 | 
					 | 
				
			||||||
                    Hiển thị{" "}
 | 
					 | 
				
			||||||
                    {table.getState().pagination.pageIndex *
 | 
					 | 
				
			||||||
                      table.getState().pagination.pageSize +
 | 
					 | 
				
			||||||
                      1}{" "}
 | 
					 | 
				
			||||||
                    -{" "}
 | 
					 | 
				
			||||||
                    {Math.min(
 | 
					 | 
				
			||||||
                      (table.getState().pagination.pageIndex + 1) *
 | 
					 | 
				
			||||||
                        table.getState().pagination.pageSize,
 | 
					 | 
				
			||||||
                      devices.length
 | 
					 | 
				
			||||||
                    )}{" "}
 | 
					 | 
				
			||||||
                    trong tổng số {devices.length} thiết bị
 | 
					 | 
				
			||||||
                  </span>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                <div className="flex items-center gap-2">
 | 
					 | 
				
			||||||
                  <Button
 | 
					 | 
				
			||||||
                    variant="outline"
 | 
					 | 
				
			||||||
                    size="sm"
 | 
					 | 
				
			||||||
                    onClick={() => table.previousPage()}
 | 
					 | 
				
			||||||
                    disabled={!table.getCanPreviousPage()}
 | 
					 | 
				
			||||||
                    className="flex items-center gap-1"
 | 
					 | 
				
			||||||
                  >
 | 
					 | 
				
			||||||
                    <ChevronLeft className="h-4 w-4" />
 | 
					 | 
				
			||||||
                    Trước
 | 
					 | 
				
			||||||
                  </Button>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                  <div className="flex items-center gap-1 text-sm font-medium">
 | 
					 | 
				
			||||||
                    <span>Trang</span>
 | 
					 | 
				
			||||||
                    <span className="bg-primary text-primary-foreground px-2 py-1 rounded">
 | 
					 | 
				
			||||||
                      {table.getState().pagination.pageIndex + 1}
 | 
					 | 
				
			||||||
                    </span>
 | 
					 | 
				
			||||||
                    <span>của {table.getPageCount()}</span>
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                  <Button
 | 
					 | 
				
			||||||
                    variant="outline"
 | 
					 | 
				
			||||||
                    size="sm"
 | 
					 | 
				
			||||||
                    onClick={() => table.nextPage()}
 | 
					 | 
				
			||||||
                    disabled={!table.getCanNextPage()}
 | 
					 | 
				
			||||||
                    className="flex items-center gap-1"
 | 
					 | 
				
			||||||
                  >
 | 
					 | 
				
			||||||
                    Sau
 | 
					 | 
				
			||||||
                    <ChevronRight className="h-4 w-4" />
 | 
					 | 
				
			||||||
                  </Button>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </>
 | 
					 | 
				
			||||||
          )}
 | 
					          )}
 | 
				
			||||||
        </CardContent>
 | 
					        </CardContent>
 | 
				
			||||||
      </Card>
 | 
					      </Card>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue
	
	Block a user