Compare commits
	
		
			1 Commits
		
	
	
		
			main
			...
			feature_up
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| cfc6ea9796 | 
							
								
								
									
										180
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										180
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -9,6 +9,7 @@
 | 
			
		|||
        "@radix-ui/react-avatar": "^1.1.10",
 | 
			
		||||
        "@radix-ui/react-checkbox": "^1.3.3",
 | 
			
		||||
        "@radix-ui/react-dialog": "^1.1.14",
 | 
			
		||||
        "@radix-ui/react-dropdown-menu": "^2.1.16",
 | 
			
		||||
        "@radix-ui/react-label": "^2.1.7",
 | 
			
		||||
        "@radix-ui/react-popover": "^1.1.15",
 | 
			
		||||
        "@radix-ui/react-progress": "^1.1.7",
 | 
			
		||||
| 
						 | 
				
			
			@ -1579,6 +1580,41 @@
 | 
			
		|||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@radix-ui/react-dropdown-menu": {
 | 
			
		||||
      "version": "2.1.16",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
 | 
			
		||||
      "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
 | 
			
		||||
      "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-id": "1.1.1",
 | 
			
		||||
        "@radix-ui/react-menu": "2.1.16",
 | 
			
		||||
        "@radix-ui/react-primitive": "2.1.3",
 | 
			
		||||
        "@radix-ui/react-use-controllable-state": "1.2.2"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "@types/react": "*",
 | 
			
		||||
        "@types/react-dom": "*",
 | 
			
		||||
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
 | 
			
		||||
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependenciesMeta": {
 | 
			
		||||
        "@types/react": {
 | 
			
		||||
          "optional": true
 | 
			
		||||
        },
 | 
			
		||||
        "@types/react-dom": {
 | 
			
		||||
          "optional": true
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@radix-ui/react-dropdown-menu/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-focus-guards": {
 | 
			
		||||
      "version": "1.1.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -1660,6 +1696,150 @@
 | 
			
		|||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@radix-ui/react-menu": {
 | 
			
		||||
      "version": "2.1.16",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
 | 
			
		||||
      "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@radix-ui/primitive": "1.1.3",
 | 
			
		||||
        "@radix-ui/react-collection": "1.1.7",
 | 
			
		||||
        "@radix-ui/react-compose-refs": "1.1.2",
 | 
			
		||||
        "@radix-ui/react-context": "1.1.2",
 | 
			
		||||
        "@radix-ui/react-direction": "1.1.1",
 | 
			
		||||
        "@radix-ui/react-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-roving-focus": "1.1.11",
 | 
			
		||||
        "@radix-ui/react-slot": "1.2.3",
 | 
			
		||||
        "@radix-ui/react-use-callback-ref": "1.1.1",
 | 
			
		||||
        "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-menu/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-menu/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-menu/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-menu/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-menu/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-popover": {
 | 
			
		||||
      "version": "1.1.15",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,6 +13,7 @@
 | 
			
		|||
    "@radix-ui/react-avatar": "^1.1.10",
 | 
			
		||||
    "@radix-ui/react-checkbox": "^1.3.3",
 | 
			
		||||
    "@radix-ui/react-dialog": "^1.1.14",
 | 
			
		||||
    "@radix-ui/react-dropdown-menu": "^2.1.16",
 | 
			
		||||
    "@radix-ui/react-label": "^2.1.7",
 | 
			
		||||
    "@radix-ui/react-popover": "^1.1.15",
 | 
			
		||||
    "@radix-ui/react-progress": "^1.1.7",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										140
									
								
								src/components/add-new-dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								src/components/add-new-dialog.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,140 @@
 | 
			
		|||
import { useState } from "react";
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
  DialogFooter,
 | 
			
		||||
  DialogHeader,
 | 
			
		||||
  DialogTitle,
 | 
			
		||||
  DialogTrigger,
 | 
			
		||||
} from "@/components/ui/dialog";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Label } from "@/components/ui/label";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import { useForm, formOptions } from "@tanstack/react-form";
 | 
			
		||||
import axios from "axios";
 | 
			
		||||
 | 
			
		||||
interface AddBlacklistDialogProps {
 | 
			
		||||
  onAdded?: () => void; // callback để refresh danh sách sau khi thêm
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const formOpts = formOptions({
 | 
			
		||||
  defaultValues: { appName: "", processName: "" },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export function AddBlacklistDialog({ onAdded }: AddBlacklistDialogProps) {
 | 
			
		||||
  const [isOpen, setIsOpen] = useState(false);
 | 
			
		||||
  const [isSubmitting, setIsSubmitting] = useState(false);
 | 
			
		||||
  const [isDone, setIsDone] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const form = useForm({
 | 
			
		||||
    ...formOpts,
 | 
			
		||||
    onSubmit: async ({ value }) => {
 | 
			
		||||
      if (!value.appName || !value.processName) {
 | 
			
		||||
        toast.error("Vui lòng nhập đầy đủ tên ứng dụng và tiến trình");
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        setIsSubmitting(true);
 | 
			
		||||
 | 
			
		||||
        await axios.post("/api/appversions/add-blacklist", {
 | 
			
		||||
          appName: value.appName,
 | 
			
		||||
          processName: value.processName,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        toast.success("Đã thêm vào blacklist!");
 | 
			
		||||
        setIsDone(true);
 | 
			
		||||
 | 
			
		||||
        if (onAdded) onAdded();
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error(error);
 | 
			
		||||
        toast.error("Không thể thêm vào blacklist");
 | 
			
		||||
      } finally {
 | 
			
		||||
        setIsSubmitting(false);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const handleDialogClose = (open: boolean) => {
 | 
			
		||||
    if (isSubmitting) return;
 | 
			
		||||
    setIsOpen(open);
 | 
			
		||||
    if (!open) {
 | 
			
		||||
      setIsDone(false);
 | 
			
		||||
      form.reset();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog open={isOpen} onOpenChange={handleDialogClose}>
 | 
			
		||||
      <DialogTrigger asChild>
 | 
			
		||||
        <Button>Thêm vào Blacklist</Button>
 | 
			
		||||
      </DialogTrigger>
 | 
			
		||||
 | 
			
		||||
      <DialogContent>
 | 
			
		||||
        <DialogHeader>
 | 
			
		||||
          <DialogTitle>Thêm ứng dụng vào danh sách cấm</DialogTitle>
 | 
			
		||||
        </DialogHeader>
 | 
			
		||||
 | 
			
		||||
        <form
 | 
			
		||||
          className="space-y-4"
 | 
			
		||||
          onSubmit={(e) => {
 | 
			
		||||
            e.preventDefault();
 | 
			
		||||
            e.stopPropagation();
 | 
			
		||||
            form.handleSubmit();
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <form.Field name="appName">
 | 
			
		||||
            {(field) => (
 | 
			
		||||
              <div>
 | 
			
		||||
                <Label>Tên ứng dụng</Label>
 | 
			
		||||
                <Input
 | 
			
		||||
                  value={field.state.value}
 | 
			
		||||
                  onChange={(e) => field.handleChange(e.target.value)}
 | 
			
		||||
                  placeholder="Ví dụ: Google Chrome"
 | 
			
		||||
                  disabled={isSubmitting || isDone}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
          </form.Field>
 | 
			
		||||
 | 
			
		||||
          <form.Field name="processName">
 | 
			
		||||
            {(field) => (
 | 
			
		||||
              <div>
 | 
			
		||||
                <Label>Tên tiến trình</Label>
 | 
			
		||||
                <Input
 | 
			
		||||
                  value={field.state.value}
 | 
			
		||||
                  onChange={(e) => field.handleChange(e.target.value)}
 | 
			
		||||
                  placeholder="chrome.exe"
 | 
			
		||||
                  disabled={isSubmitting || isDone}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
          </form.Field>
 | 
			
		||||
 | 
			
		||||
          <DialogFooter>
 | 
			
		||||
            {!isDone ? (
 | 
			
		||||
              <>
 | 
			
		||||
                <Button
 | 
			
		||||
                  type="button"
 | 
			
		||||
                  variant="outline"
 | 
			
		||||
                  onClick={() => handleDialogClose(false)}
 | 
			
		||||
                  disabled={isSubmitting}
 | 
			
		||||
                >
 | 
			
		||||
                  Hủy
 | 
			
		||||
                </Button>
 | 
			
		||||
                <Button type="submit" disabled={isSubmitting}>
 | 
			
		||||
                  {isSubmitting ? "Đang thêm..." : "Thêm"}
 | 
			
		||||
                </Button>
 | 
			
		||||
              </>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <Button type="button" onClick={() => handleDialogClose(false)}>
 | 
			
		||||
                Hoàn tất
 | 
			
		||||
              </Button>
 | 
			
		||||
            )}
 | 
			
		||||
          </DialogFooter>
 | 
			
		||||
        </form>
 | 
			
		||||
      </DialogContent>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -11,23 +11,35 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
 | 
			
		|||
    if (number > 0 && number <= 40) deviceMap.set(number, device);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const computersPerRow = 8;
 | 
			
		||||
  const totalRows = 5;
 | 
			
		||||
 | 
			
		||||
  const renderRow = (rowIndex: number) => {
 | 
			
		||||
    const start = rowIndex * computersPerRow + 1;
 | 
			
		||||
    // Trái: 1–20
 | 
			
		||||
    const leftStart = rowIndex * 4 + 1;
 | 
			
		||||
    // Phải: 21–40
 | 
			
		||||
    const rightStart = 21 + rowIndex * 4;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div key={rowIndex} className="flex items-center justify-center gap-3">
 | 
			
		||||
        {/* Bên trái (1–20) */}
 | 
			
		||||
        {Array.from({ length: 4 }).map((_, i) => {
 | 
			
		||||
          const pos = start + i;
 | 
			
		||||
          return <ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />;
 | 
			
		||||
          const pos = leftStart + i;
 | 
			
		||||
          return (
 | 
			
		||||
            <ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
 | 
			
		||||
        {/* Đường chia giữa */}
 | 
			
		||||
        <div className="w-32 flex items-center justify-center">
 | 
			
		||||
          <div className="h-px w-full bg-border border-t-2 border-dashed" />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {/* Bên phải (21–40) */}
 | 
			
		||||
        {Array.from({ length: 4 }).map((_, i) => {
 | 
			
		||||
          const pos = start + i + 4;
 | 
			
		||||
          return <ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />;
 | 
			
		||||
          const pos = rightStart + i;
 | 
			
		||||
          return (
 | 
			
		||||
            <ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										65
									
								
								src/components/request-update-menu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/components/request-update-menu.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,65 @@
 | 
			
		|||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import {
 | 
			
		||||
  DropdownMenu,
 | 
			
		||||
  DropdownMenuContent,
 | 
			
		||||
  DropdownMenuItem,
 | 
			
		||||
  DropdownMenuTrigger,
 | 
			
		||||
  DropdownMenuSeparator,
 | 
			
		||||
} from "@/components/ui/dropdown-menu";
 | 
			
		||||
import { Loader2, RefreshCw, ChevronDown } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
interface RequestUpdateMenuProps {
 | 
			
		||||
  onUpdateDevice: () => void;
 | 
			
		||||
  onUpdateRoom: () => void;
 | 
			
		||||
  onUpdateAll: () => void;
 | 
			
		||||
  loading?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function RequestUpdateMenu({
 | 
			
		||||
  onUpdateDevice,
 | 
			
		||||
  onUpdateRoom,
 | 
			
		||||
  onUpdateAll,
 | 
			
		||||
  loading,
 | 
			
		||||
}: RequestUpdateMenuProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenu>
 | 
			
		||||
      <DropdownMenuTrigger asChild>
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="outline"
 | 
			
		||||
          disabled={loading}
 | 
			
		||||
          className="group relative overflow-hidden border-2 border-gray-300 bg-white text-gray-800 font-medium px-6 py-2.5 rounded-lg transition-all duration-300 hover:border-gray-400 hover:bg-gray-50 hover:shadow-lg hover:shadow-gray-200/50 hover:-translate-y-0.5 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
 | 
			
		||||
        >
 | 
			
		||||
          <div className="flex items-center gap-2">
 | 
			
		||||
            {loading ? (
 | 
			
		||||
              <Loader2 className="h-4 w-4 animate-spin text-gray-600" />
 | 
			
		||||
            ) : (
 | 
			
		||||
              <RefreshCw className="h-4 w-4 text-gray-600 transition-transform duration-300 group-hover:rotate-180" />
 | 
			
		||||
            )}
 | 
			
		||||
            <span className="text-sm font-semibold">
 | 
			
		||||
              {loading ? "Đang gửi..." : "Cập nhật"}
 | 
			
		||||
            </span>
 | 
			
		||||
            <ChevronDown className="h-4 w-4 text-gray-600 transition-transform duration-200 group-data-[state=open]:rotate-180" />
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-gray-100/30 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      </DropdownMenuTrigger>
 | 
			
		||||
      <DropdownMenuContent align="start" className="w-56">
 | 
			
		||||
        <DropdownMenuItem onClick={onUpdateDevice} disabled={loading}>
 | 
			
		||||
          <RefreshCw className="h-4 w-4 mr-2" />
 | 
			
		||||
          <span>Cập nhật thiết bị cụ thể</span>
 | 
			
		||||
        </DropdownMenuItem>
 | 
			
		||||
        <DropdownMenuSeparator />
 | 
			
		||||
        <DropdownMenuItem onClick={onUpdateRoom} disabled={loading}>
 | 
			
		||||
          <RefreshCw className="h-4 w-4 mr-2" />
 | 
			
		||||
          <span>Cập nhật theo phòng</span>
 | 
			
		||||
        </DropdownMenuItem>
 | 
			
		||||
        <DropdownMenuSeparator />
 | 
			
		||||
        <DropdownMenuItem onClick={onUpdateAll} disabled={loading}>
 | 
			
		||||
          <RefreshCw className="h-4 w-4 mr-2" />
 | 
			
		||||
          <span>Cập nhật tất cả thiết bị</span>
 | 
			
		||||
        </DropdownMenuItem>
 | 
			
		||||
      </DropdownMenuContent>
 | 
			
		||||
    </DropdownMenu>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,98 +0,0 @@
 | 
			
		|||
"use client"
 | 
			
		||||
 | 
			
		||||
import { useState } from "react"
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
  DialogHeader,
 | 
			
		||||
  DialogTitle,
 | 
			
		||||
  DialogFooter,
 | 
			
		||||
} from "@/components/ui/dialog"
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
 | 
			
		||||
import { Label } from "@/components/ui/label"
 | 
			
		||||
import { Check, Home } from "lucide-react"
 | 
			
		||||
 | 
			
		||||
interface RoomSelectDialogProps {
 | 
			
		||||
  open: boolean
 | 
			
		||||
  onClose: () => void
 | 
			
		||||
  rooms: string[]
 | 
			
		||||
  onConfirm: (roomName: string) => void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function RoomSelectDialog({
 | 
			
		||||
  open,
 | 
			
		||||
  onClose,
 | 
			
		||||
  rooms,
 | 
			
		||||
  onConfirm,
 | 
			
		||||
}: RoomSelectDialogProps) {
 | 
			
		||||
  const [selectedRoom, setSelectedRoom] = useState<string>("")
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog open={open} onOpenChange={onClose}>
 | 
			
		||||
      <DialogContent className="sm:max-w-md">
 | 
			
		||||
        <DialogHeader className="text-center pb-4">
 | 
			
		||||
          <div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-3">
 | 
			
		||||
            <Home className="w-6 h-6 text-primary" />
 | 
			
		||||
          </div>
 | 
			
		||||
          <DialogTitle className="text-xl font-semibold">
 | 
			
		||||
            Chọn phòng để cập nhật
 | 
			
		||||
          </DialogTitle>
 | 
			
		||||
          <p className="text-sm text-muted-foreground mt-1">
 | 
			
		||||
            Vui lòng chọn phòng để gửi lệnh cập nhật
 | 
			
		||||
          </p>
 | 
			
		||||
        </DialogHeader>
 | 
			
		||||
 | 
			
		||||
        <div className="py-3">
 | 
			
		||||
          <RadioGroup
 | 
			
		||||
            value={selectedRoom}
 | 
			
		||||
            onValueChange={setSelectedRoom}
 | 
			
		||||
            className="space-y-3"
 | 
			
		||||
          >
 | 
			
		||||
            {rooms.map((room) => (
 | 
			
		||||
              <div
 | 
			
		||||
                key={room}
 | 
			
		||||
                className="flex items-center justify-between p-3 rounded-lg border border-border hover:border-primary/60 hover:bg-accent/50 transition-all duration-200 cursor-pointer"
 | 
			
		||||
              >
 | 
			
		||||
                <div className="flex items-center gap-3">
 | 
			
		||||
                  <RadioGroupItem value={room} id={room} />
 | 
			
		||||
                  <Label
 | 
			
		||||
                    htmlFor={room}
 | 
			
		||||
                    className="font-medium cursor-pointer hover:text-primary"
 | 
			
		||||
                  >
 | 
			
		||||
                    {room}
 | 
			
		||||
                  </Label>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                {selectedRoom === room && (
 | 
			
		||||
                  <div className="w-5 h-5 bg-primary rounded-full flex items-center justify-center">
 | 
			
		||||
                    <Check className="w-3 h-3 text-primary-foreground" />
 | 
			
		||||
                  </div>
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
            ))}
 | 
			
		||||
          </RadioGroup>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <DialogFooter className="gap-2 pt-4">
 | 
			
		||||
          <Button variant="outline" onClick={onClose} className="flex-1 sm:flex-none">
 | 
			
		||||
            Hủy
 | 
			
		||||
          </Button>
 | 
			
		||||
          <Button
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              if (selectedRoom) {
 | 
			
		||||
                onConfirm(selectedRoom)
 | 
			
		||||
                onClose()
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
            disabled={!selectedRoom}
 | 
			
		||||
            className="flex-1 sm:flex-none"
 | 
			
		||||
          >
 | 
			
		||||
            <Check className="w-4 h-4 mr-2" />
 | 
			
		||||
            Xác nhận
 | 
			
		||||
          </Button>
 | 
			
		||||
        </DialogFooter>
 | 
			
		||||
      </DialogContent>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										135
									
								
								src/components/select-dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								src/components/select-dialog.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,135 @@
 | 
			
		|||
import { useEffect, useState, useMemo } from "react"
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
  DialogHeader,
 | 
			
		||||
  DialogTitle,
 | 
			
		||||
  DialogFooter,
 | 
			
		||||
} from "@/components/ui/dialog"
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
import { Checkbox } from "@/components/ui/checkbox"
 | 
			
		||||
import { Label } from "@/components/ui/label"
 | 
			
		||||
import { Check, Search } from "lucide-react"
 | 
			
		||||
import { Input } from "@/components/ui/input"
 | 
			
		||||
 | 
			
		||||
interface SelectDialogProps {
 | 
			
		||||
  open: boolean
 | 
			
		||||
  onClose: () => void
 | 
			
		||||
  items: string[]           // danh sách chung: có thể là devices hoặc rooms
 | 
			
		||||
  title?: string            // tiêu đề động
 | 
			
		||||
  description?: string      // mô tả ngắn
 | 
			
		||||
  icon?: React.ReactNode    // icon thay đổi tùy loại
 | 
			
		||||
  onConfirm: (selected: string[]) => void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function SelectDialog({
 | 
			
		||||
  open,
 | 
			
		||||
  onClose,
 | 
			
		||||
  items,
 | 
			
		||||
  title = "Chọn mục",
 | 
			
		||||
  description = "Bạn có thể chọn nhiều mục để thao tác",
 | 
			
		||||
  icon,
 | 
			
		||||
  onConfirm,
 | 
			
		||||
}: SelectDialogProps) {
 | 
			
		||||
  const [selectedItems, setSelectedItems] = useState<string[]>([])
 | 
			
		||||
  const [search, setSearch] = useState("")
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!open) {
 | 
			
		||||
      setSelectedItems([])
 | 
			
		||||
      setSearch("")
 | 
			
		||||
    }
 | 
			
		||||
  }, [open])
 | 
			
		||||
 | 
			
		||||
  const toggleItem = (item: string) => {
 | 
			
		||||
    setSelectedItems((prev) =>
 | 
			
		||||
      prev.includes(item)
 | 
			
		||||
        ? prev.filter((i) => i !== item)
 | 
			
		||||
        : [...prev, item]
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Lọc danh sách theo từ khóa
 | 
			
		||||
  const filteredItems = useMemo(() => {
 | 
			
		||||
    return items.filter((item) =>
 | 
			
		||||
      item.toLowerCase().includes(search.toLowerCase())
 | 
			
		||||
    )
 | 
			
		||||
  }, [items, search])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog open={open} onOpenChange={onClose}>
 | 
			
		||||
      <DialogContent className="sm:max-w-md">
 | 
			
		||||
        <DialogHeader className="text-center pb-4">
 | 
			
		||||
          <div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-3">
 | 
			
		||||
            {icon ?? <Search className="w-6 h-6 text-primary" />}
 | 
			
		||||
          </div>
 | 
			
		||||
          <DialogTitle className="text-xl font-semibold">{title}</DialogTitle>
 | 
			
		||||
          <p className="text-sm text-muted-foreground mt-1">{description}</p>
 | 
			
		||||
        </DialogHeader>
 | 
			
		||||
 | 
			
		||||
        {/* 🔍 Thanh tìm kiếm */}
 | 
			
		||||
        <div className="relative mb-3">
 | 
			
		||||
          <Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
 | 
			
		||||
          <Input
 | 
			
		||||
            placeholder="Tìm kiếm..."
 | 
			
		||||
            value={search}
 | 
			
		||||
            onChange={(e) => setSearch(e.target.value)}
 | 
			
		||||
            className="pl-9"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {/* Danh sách các item */}
 | 
			
		||||
        <div className="py-3 space-y-3 max-h-64 overflow-y-auto">
 | 
			
		||||
          {filteredItems.length > 0 ? (
 | 
			
		||||
            filteredItems.map((item) => (
 | 
			
		||||
              <div
 | 
			
		||||
                key={item}
 | 
			
		||||
                className="flex items-center justify-between p-3 rounded-lg border border-border hover:border-primary/60 hover:bg-accent/50 transition-all duration-200 cursor-pointer"
 | 
			
		||||
                onClick={() => toggleItem(item)}
 | 
			
		||||
              >
 | 
			
		||||
                <div className="flex items-center gap-3">
 | 
			
		||||
                  <Checkbox
 | 
			
		||||
                    checked={selectedItems.includes(item)}
 | 
			
		||||
                    onCheckedChange={() => toggleItem(item)}
 | 
			
		||||
                  />
 | 
			
		||||
                  <Label className="font-medium cursor-pointer hover:text-primary">
 | 
			
		||||
                    {item}
 | 
			
		||||
                  </Label>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                {selectedItems.includes(item) && (
 | 
			
		||||
                  <div className="w-5 h-5 bg-primary rounded-full flex items-center justify-center">
 | 
			
		||||
                    <Check className="w-3 h-3 text-primary-foreground" />
 | 
			
		||||
                  </div>
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
            ))
 | 
			
		||||
          ) : (
 | 
			
		||||
            <p className="text-center text-sm text-muted-foreground py-4">
 | 
			
		||||
              Không tìm thấy kết quả
 | 
			
		||||
            </p>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <DialogFooter className="gap-2 pt-4">
 | 
			
		||||
          <Button variant="outline" onClick={onClose} className="flex-1 sm:flex-none">
 | 
			
		||||
            Hủy
 | 
			
		||||
          </Button>
 | 
			
		||||
          <Button
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              if (selectedItems.length > 0) {
 | 
			
		||||
                onConfirm(selectedItems)
 | 
			
		||||
                onClose()
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
            disabled={selectedItems.length === 0}
 | 
			
		||||
            className="flex-1 sm:flex-none"
 | 
			
		||||
          >
 | 
			
		||||
            <Check className="w-4 h-4 mr-2" />
 | 
			
		||||
            Xác nhận ({selectedItems.length})
 | 
			
		||||
          </Button>
 | 
			
		||||
        </DialogFooter>
 | 
			
		||||
      </DialogContent>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										255
									
								
								src/components/ui/dropdown-menu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								src/components/ui/dropdown-menu.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,255 @@
 | 
			
		|||
import * as React from "react"
 | 
			
		||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
 | 
			
		||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
function DropdownMenu({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
 | 
			
		||||
  return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuPortal({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuTrigger({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Trigger
 | 
			
		||||
      data-slot="dropdown-menu-trigger"
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuContent({
 | 
			
		||||
  className,
 | 
			
		||||
  sideOffset = 4,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Portal>
 | 
			
		||||
      <DropdownMenuPrimitive.Content
 | 
			
		||||
        data-slot="dropdown-menu-content"
 | 
			
		||||
        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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
 | 
			
		||||
          className
 | 
			
		||||
        )}
 | 
			
		||||
        {...props}
 | 
			
		||||
      />
 | 
			
		||||
    </DropdownMenuPrimitive.Portal>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuGroup({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuItem({
 | 
			
		||||
  className,
 | 
			
		||||
  inset,
 | 
			
		||||
  variant = "default",
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
 | 
			
		||||
  inset?: boolean
 | 
			
		||||
  variant?: "default" | "destructive"
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Item
 | 
			
		||||
      data-slot="dropdown-menu-item"
 | 
			
		||||
      data-inset={inset}
 | 
			
		||||
      data-variant={variant}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuCheckboxItem({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  checked,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.CheckboxItem
 | 
			
		||||
      data-slot="dropdown-menu-checkbox-item"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      checked={checked}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
 | 
			
		||||
        <DropdownMenuPrimitive.ItemIndicator>
 | 
			
		||||
          <CheckIcon className="size-4" />
 | 
			
		||||
        </DropdownMenuPrimitive.ItemIndicator>
 | 
			
		||||
      </span>
 | 
			
		||||
      {children}
 | 
			
		||||
    </DropdownMenuPrimitive.CheckboxItem>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuRadioGroup({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.RadioGroup
 | 
			
		||||
      data-slot="dropdown-menu-radio-group"
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuRadioItem({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.RadioItem
 | 
			
		||||
      data-slot="dropdown-menu-radio-item"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
 | 
			
		||||
        <DropdownMenuPrimitive.ItemIndicator>
 | 
			
		||||
          <CircleIcon className="size-2 fill-current" />
 | 
			
		||||
        </DropdownMenuPrimitive.ItemIndicator>
 | 
			
		||||
      </span>
 | 
			
		||||
      {children}
 | 
			
		||||
    </DropdownMenuPrimitive.RadioItem>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuLabel({
 | 
			
		||||
  className,
 | 
			
		||||
  inset,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
 | 
			
		||||
  inset?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Label
 | 
			
		||||
      data-slot="dropdown-menu-label"
 | 
			
		||||
      data-inset={inset}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuSeparator({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Separator
 | 
			
		||||
      data-slot="dropdown-menu-separator"
 | 
			
		||||
      className={cn("bg-border -mx-1 my-1 h-px", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuShortcut({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<"span">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <span
 | 
			
		||||
      data-slot="dropdown-menu-shortcut"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "text-muted-foreground ml-auto text-xs tracking-widest",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuSub({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
 | 
			
		||||
  return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuSubTrigger({
 | 
			
		||||
  className,
 | 
			
		||||
  inset,
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
 | 
			
		||||
  inset?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.SubTrigger
 | 
			
		||||
      data-slot="dropdown-menu-sub-trigger"
 | 
			
		||||
      data-inset={inset}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
      <ChevronRightIcon className="ml-auto size-4" />
 | 
			
		||||
    </DropdownMenuPrimitive.SubTrigger>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuSubContent({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.SubContent
 | 
			
		||||
      data-slot="dropdown-menu-sub-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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  DropdownMenu,
 | 
			
		||||
  DropdownMenuPortal,
 | 
			
		||||
  DropdownMenuTrigger,
 | 
			
		||||
  DropdownMenuContent,
 | 
			
		||||
  DropdownMenuGroup,
 | 
			
		||||
  DropdownMenuLabel,
 | 
			
		||||
  DropdownMenuItem,
 | 
			
		||||
  DropdownMenuCheckboxItem,
 | 
			
		||||
  DropdownMenuRadioGroup,
 | 
			
		||||
  DropdownMenuRadioItem,
 | 
			
		||||
  DropdownMenuSeparator,
 | 
			
		||||
  DropdownMenuShortcut,
 | 
			
		||||
  DropdownMenuSub,
 | 
			
		||||
  DropdownMenuSubTrigger,
 | 
			
		||||
  DropdownMenuSubContent,
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +29,5 @@ export const API_ENDPOINTS = {
 | 
			
		|||
    DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
 | 
			
		||||
    DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
 | 
			
		||||
    GET_PROCESSES_LISTS:  `${BASE_URL}/Sse/events/processLists`,
 | 
			
		||||
    
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -59,7 +59,7 @@ function AgentsPage() {
 | 
			
		|||
  });
 | 
			
		||||
 | 
			
		||||
  const updateMutation = useMutationData<void>({
 | 
			
		||||
    url: "", 
 | 
			
		||||
    url: "",
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
 | 
			
		||||
    onError: () => toast.error("Gửi yêu cầu thất bại!"),
 | 
			
		||||
| 
						 | 
				
			
			@ -75,13 +75,19 @@ function AgentsPage() {
 | 
			
		|||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Callback khi chọn phòng update
 | 
			
		||||
  const handleUpdate = async (roomName: string) => {
 | 
			
		||||
    return updateMutation.mutateAsync({
 | 
			
		||||
      url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT(roomName),
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      data: undefined,
 | 
			
		||||
    });
 | 
			
		||||
  const handleUpdate = async (roomNames: string[]) => {
 | 
			
		||||
    for (const roomName of roomNames) {
 | 
			
		||||
      try {
 | 
			
		||||
        await updateMutation.mutateAsync({
 | 
			
		||||
          url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT(roomName),
 | 
			
		||||
          method: "POST",
 | 
			
		||||
          data: undefined
 | 
			
		||||
        });
 | 
			
		||||
      } catch {
 | 
			
		||||
        toast.error(`Gửi yêu cầu thất bại cho ${roomName}`);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    toast.success("Đã gửi yêu cầu update cho các phòng đã chọn!");
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Cột bảng
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -113,7 +113,7 @@ function AppsComponent() {
 | 
			
		|||
  };
 | 
			
		||||
 | 
			
		||||
  // Callback khi chọn phòng
 | 
			
		||||
  const handleInstall = async (roomName: string) => {
 | 
			
		||||
  const handleInstall = async (roomNames: string[]) => {
 | 
			
		||||
    if (!table) {
 | 
			
		||||
      toast.error("Không thể lấy thông tin bảng!");
 | 
			
		||||
      return;
 | 
			
		||||
| 
						 | 
				
			
			@ -127,10 +127,14 @@ function AppsComponent() {
 | 
			
		|||
 | 
			
		||||
    const MsiFileIds = selectedRows.map((row: any) => row.original.id);
 | 
			
		||||
 | 
			
		||||
    return installMutation.mutateAsync({
 | 
			
		||||
      url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI(roomName), // set url động
 | 
			
		||||
      data: { MsiFileIds },
 | 
			
		||||
    });
 | 
			
		||||
    for (const roomName of roomNames) {
 | 
			
		||||
      await installMutation.mutateAsync({
 | 
			
		||||
        url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI(roomName),
 | 
			
		||||
        data: { MsiFileIds },
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    toast.success("Đã gửi yêu cầu cài đặt phần mềm cho các phòng đã chọn!");
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,7 +17,7 @@ export const Route = createFileRoute("/_authenticated/room/$roomName/")({
 | 
			
		|||
 | 
			
		||||
function RoomDetailPage() {
 | 
			
		||||
  const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
 | 
			
		||||
  const [viewMode, setViewMode] = useState<"table" | "grid">("table");
 | 
			
		||||
  const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
 | 
			
		||||
  const { data: devices = [] } = useQueryData({
 | 
			
		||||
    queryKey: ["devices", roomName],
 | 
			
		||||
    url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
 | 
			
		||||
| 
						 | 
				
			
			@ -33,15 +33,6 @@ function RoomDetailPage() {
 | 
			
		|||
          </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"
 | 
			
		||||
| 
						 | 
				
			
			@ -51,6 +42,15 @@ function RoomDetailPage() {
 | 
			
		|||
              <LayoutGrid className="h-4 w-4" />
 | 
			
		||||
              Sơ đồ
 | 
			
		||||
            </Button>
 | 
			
		||||
            <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>
 | 
			
		||||
          </div>
 | 
			
		||||
        </CardHeader>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,13 +7,13 @@ import {
 | 
			
		|||
  CardHeader,
 | 
			
		||||
  CardTitle,
 | 
			
		||||
} from "@/components/ui/card";
 | 
			
		||||
import { FileText } from "lucide-react";
 | 
			
		||||
import { FileText, Building2, Monitor } from "lucide-react";
 | 
			
		||||
import { UploadDialog } from "@/components/upload-dialog";
 | 
			
		||||
import { VersionTable } from "@/components/version-table";
 | 
			
		||||
import { UpdateButton } from "@/components/update-button";
 | 
			
		||||
import { RoomSelectDialog } from "@/components/room-select-dialog";
 | 
			
		||||
import { RequestUpdateMenu } from "@/components/request-update-menu";
 | 
			
		||||
import type { AxiosProgressEvent } from "axios";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { SelectDialog } from "@/components/select-dialog"; // <-- dùng dialog chung
 | 
			
		||||
 | 
			
		||||
interface AppManagerTemplateProps<TData> {
 | 
			
		||||
  title: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -25,10 +25,11 @@ interface AppManagerTemplateProps<TData> {
 | 
			
		|||
    fd: FormData,
 | 
			
		||||
    config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
 | 
			
		||||
  ) => Promise<void>;
 | 
			
		||||
  onUpdate?: (roomName: string) => void;
 | 
			
		||||
  onUpdate?: (targetNames: string[]) => Promise<void> | void;
 | 
			
		||||
  updateLoading?: boolean;
 | 
			
		||||
  onTableInit?: (table: any) => void;
 | 
			
		||||
  rooms: string[];
 | 
			
		||||
  rooms?: string[];
 | 
			
		||||
  devices?: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function AppManagerTemplate<TData>({
 | 
			
		||||
| 
						 | 
				
			
			@ -41,16 +42,57 @@ export function AppManagerTemplate<TData>({
 | 
			
		|||
  onUpdate,
 | 
			
		||||
  updateLoading,
 | 
			
		||||
  onTableInit,
 | 
			
		||||
  rooms,
 | 
			
		||||
  rooms = [],
 | 
			
		||||
  devices = [],
 | 
			
		||||
}: AppManagerTemplateProps<TData>) {
 | 
			
		||||
  const [dialogOpen, setDialogOpen] = useState(false);
 | 
			
		||||
  const handleUpdateClick = () => {
 | 
			
		||||
    if (rooms && onUpdate) {
 | 
			
		||||
  const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
 | 
			
		||||
 | 
			
		||||
  const openRoomDialog = () => {
 | 
			
		||||
    if (rooms.length > 0 && onUpdate) {
 | 
			
		||||
      setDialogType("room");
 | 
			
		||||
      setDialogOpen(true);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const openDeviceDialog = () => {
 | 
			
		||||
    if (devices.length > 0 && onUpdate) {
 | 
			
		||||
      setDialogType("device");
 | 
			
		||||
      setDialogOpen(true);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleUpdateAll = async () => {
 | 
			
		||||
    if (!onUpdate) return;
 | 
			
		||||
    const allTargets = [...rooms, ...devices];
 | 
			
		||||
    await onUpdate(allTargets); 
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getDialogProps = () => {
 | 
			
		||||
    if (dialogType === "room") {
 | 
			
		||||
      return {
 | 
			
		||||
        title: "Chọn phòng",
 | 
			
		||||
        description: "Chọn các phòng cần cập nhật",
 | 
			
		||||
        icon: <Building2 className="w-6 h-6 text-primary" />,
 | 
			
		||||
        items: rooms,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    if (dialogType === "device") {
 | 
			
		||||
      return {
 | 
			
		||||
        title: "Chọn thiết bị",
 | 
			
		||||
        description: "Chọn các thiết bị cần cập nhật",
 | 
			
		||||
        icon: <Monitor className="w-6 h-6 text-primary" />,
 | 
			
		||||
        items: devices,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const dialogProps = getDialogProps();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="w-full px-6 space-y-4">
 | 
			
		||||
      {/* Header */}
 | 
			
		||||
      <div className="flex items-center justify-between">
 | 
			
		||||
        <div>
 | 
			
		||||
          <h1 className="text-3xl font-bold">{title}</h1>
 | 
			
		||||
| 
						 | 
				
			
			@ -59,6 +101,7 @@ export function AppManagerTemplate<TData>({
 | 
			
		|||
        <UploadDialog onSubmit={onUpload} />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* Table */}
 | 
			
		||||
      <Card className="w-full">
 | 
			
		||||
        <CardHeader>
 | 
			
		||||
          <CardTitle className="flex items-center gap-2">
 | 
			
		||||
| 
						 | 
				
			
			@ -66,6 +109,7 @@ export function AppManagerTemplate<TData>({
 | 
			
		|||
          </CardTitle>
 | 
			
		||||
          <CardDescription>Tất cả các phiên bản đã tải lên</CardDescription>
 | 
			
		||||
        </CardHeader>
 | 
			
		||||
 | 
			
		||||
        <CardContent>
 | 
			
		||||
          <VersionTable
 | 
			
		||||
            data={data}
 | 
			
		||||
| 
						 | 
				
			
			@ -74,28 +118,35 @@ export function AppManagerTemplate<TData>({
 | 
			
		|||
            onTableInit={onTableInit}
 | 
			
		||||
          />
 | 
			
		||||
        </CardContent>
 | 
			
		||||
 | 
			
		||||
        {onUpdate && (
 | 
			
		||||
          <CardFooter>
 | 
			
		||||
            <UpdateButton onClick={handleUpdateClick} loading={updateLoading} />
 | 
			
		||||
            <UpdateButton
 | 
			
		||||
              onClick={() => onUpdate("All")}
 | 
			
		||||
            <RequestUpdateMenu
 | 
			
		||||
              onUpdateDevice={openDeviceDialog}
 | 
			
		||||
              onUpdateRoom={openRoomDialog}
 | 
			
		||||
              onUpdateAll={handleUpdateAll}
 | 
			
		||||
              loading={updateLoading}
 | 
			
		||||
              label="Cập nhật tất cả thiết bị"
 | 
			
		||||
            />
 | 
			
		||||
          </CardFooter>
 | 
			
		||||
        )}
 | 
			
		||||
        {rooms && onUpdate && (
 | 
			
		||||
          <RoomSelectDialog
 | 
			
		||||
            open={dialogOpen}
 | 
			
		||||
            onClose={() => setDialogOpen(false)}
 | 
			
		||||
            rooms={rooms}
 | 
			
		||||
            onConfirm={(roomName) => {
 | 
			
		||||
              onUpdate(roomName);
 | 
			
		||||
              setDialogOpen(false);
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </Card>
 | 
			
		||||
 | 
			
		||||
      {/* 🧩 SelectDialog tái sử dụng */}
 | 
			
		||||
      {dialogProps && (
 | 
			
		||||
        <SelectDialog
 | 
			
		||||
          open={dialogOpen}
 | 
			
		||||
          onClose={() => setDialogOpen(false)}
 | 
			
		||||
          title={dialogProps.title}
 | 
			
		||||
          description={dialogProps.description}
 | 
			
		||||
          icon={dialogProps.icon}
 | 
			
		||||
          items={dialogProps.items}
 | 
			
		||||
          onConfirm={async (selectedItems) => {
 | 
			
		||||
            if (!onUpdate) return;
 | 
			
		||||
            await onUpdate(selectedItems);
 | 
			
		||||
            setDialogOpen(false);
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,3 @@
 | 
			
		|||
"use client"
 | 
			
		||||
 | 
			
		||||
import { useState } from "react"
 | 
			
		||||
import {
 | 
			
		||||
  Card,
 | 
			
		||||
| 
						 | 
				
			
			@ -9,9 +7,9 @@ import {
 | 
			
		|||
  CardHeader,
 | 
			
		||||
  CardTitle,
 | 
			
		||||
} from "@/components/ui/card"
 | 
			
		||||
import { UpdateButton } from "@/components/update-button"
 | 
			
		||||
import { Terminal } from "lucide-react"
 | 
			
		||||
import { RoomSelectDialog } from "@/components/room-select-dialog"
 | 
			
		||||
import { Terminal, Building2, Monitor } from "lucide-react"
 | 
			
		||||
import { RequestUpdateMenu } from "@/components/request-update-menu"
 | 
			
		||||
import { SelectDialog } from "@/components/select-dialog"
 | 
			
		||||
 | 
			
		||||
interface FormSubmitTemplateProps {
 | 
			
		||||
  title: string
 | 
			
		||||
| 
						 | 
				
			
			@ -21,9 +19,10 @@ interface FormSubmitTemplateProps {
 | 
			
		|||
    command: string
 | 
			
		||||
    setCommand: (val: string) => void
 | 
			
		||||
  }) => React.ReactNode
 | 
			
		||||
  onSubmit?: (roomName: string, command: string) => void
 | 
			
		||||
  onSubmit?: (target: string, command: string) => void | Promise<void>
 | 
			
		||||
  submitLoading?: boolean
 | 
			
		||||
  rooms?: string[]
 | 
			
		||||
  devices?: string[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function FormSubmitTemplate({
 | 
			
		||||
| 
						 | 
				
			
			@ -34,16 +33,56 @@ export function FormSubmitTemplate({
 | 
			
		|||
  onSubmit,
 | 
			
		||||
  submitLoading,
 | 
			
		||||
  rooms = [],
 | 
			
		||||
  devices = [],
 | 
			
		||||
}: FormSubmitTemplateProps) {
 | 
			
		||||
  const [dialogOpen, setDialogOpen] = useState(false)
 | 
			
		||||
  const [command, setCommand] = useState("")
 | 
			
		||||
  const [dialogOpen, setDialogOpen] = useState(false)
 | 
			
		||||
  const [dialogType, setDialogType] = useState<"room" | "device" | null>(null)
 | 
			
		||||
 | 
			
		||||
  const handleClick = () => {
 | 
			
		||||
  const openRoomDialog = () => {
 | 
			
		||||
    if (rooms.length > 0 && onSubmit) {
 | 
			
		||||
      setDialogType("room")
 | 
			
		||||
      setDialogOpen(true)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const openDeviceDialog = () => {
 | 
			
		||||
    if (devices.length > 0 && onSubmit) {
 | 
			
		||||
      setDialogType("device")
 | 
			
		||||
      setDialogOpen(true)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleSubmitAll = () => {
 | 
			
		||||
    if (!onSubmit) return
 | 
			
		||||
    const allTargets = [...rooms, ...devices]
 | 
			
		||||
    for (const target of allTargets) {
 | 
			
		||||
      onSubmit(target, command)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const getDialogProps = () => {
 | 
			
		||||
    if (dialogType === "room") {
 | 
			
		||||
      return {
 | 
			
		||||
        title: "Chọn phòng để gửi lệnh",
 | 
			
		||||
        description: "Chọn các phòng muốn gửi lệnh CMD tới",
 | 
			
		||||
        icon: <Building2 className="w-6 h-6 text-primary" />,
 | 
			
		||||
        items: rooms,
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (dialogType === "device") {
 | 
			
		||||
      return {
 | 
			
		||||
        title: "Chọn thiết bị để gửi lệnh",
 | 
			
		||||
        description: "Chọn các thiết bị muốn gửi lệnh CMD tới",
 | 
			
		||||
        icon: <Monitor className="w-6 h-6 text-primary" />,
 | 
			
		||||
        items: devices,
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const dialogProps = getDialogProps()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="w-full px-6 space-y-4">
 | 
			
		||||
      <div>
 | 
			
		||||
| 
						 | 
				
			
			@ -58,33 +97,35 @@ export function FormSubmitTemplate({
 | 
			
		|||
          </CardTitle>
 | 
			
		||||
          <CardDescription>Nhập và gửi lệnh xuống thiết bị</CardDescription>
 | 
			
		||||
        </CardHeader>
 | 
			
		||||
        <CardContent>
 | 
			
		||||
          {children({ command, setCommand })}
 | 
			
		||||
        </CardContent>
 | 
			
		||||
 | 
			
		||||
        <CardContent>{children({ command, setCommand })}</CardContent>
 | 
			
		||||
 | 
			
		||||
        {onSubmit && (
 | 
			
		||||
          <CardFooter className="flex gap-2">
 | 
			
		||||
            <UpdateButton
 | 
			
		||||
              onClick={handleClick}
 | 
			
		||||
          <CardFooter className="flex justify-end">
 | 
			
		||||
            <RequestUpdateMenu
 | 
			
		||||
              onUpdateDevice={openDeviceDialog}
 | 
			
		||||
              onUpdateRoom={openRoomDialog}
 | 
			
		||||
              onUpdateAll={handleSubmitAll}
 | 
			
		||||
              loading={submitLoading}
 | 
			
		||||
              label="Yêu cầu theo phòng"
 | 
			
		||||
            />
 | 
			
		||||
            <UpdateButton
 | 
			
		||||
              onClick={() => onSubmit("All", command)}
 | 
			
		||||
              loading={submitLoading}
 | 
			
		||||
              label="Cập nhật tất cả thiết bị"
 | 
			
		||||
            />
 | 
			
		||||
          </CardFooter>
 | 
			
		||||
        )}
 | 
			
		||||
      </Card>
 | 
			
		||||
 | 
			
		||||
      {onSubmit && rooms.length > 0 && (
 | 
			
		||||
        <RoomSelectDialog
 | 
			
		||||
      {/* 🧩 Dùng SelectDialog chung */}
 | 
			
		||||
      {dialogProps && (
 | 
			
		||||
        <SelectDialog
 | 
			
		||||
          open={dialogOpen}
 | 
			
		||||
          onClose={() => setDialogOpen(false)}
 | 
			
		||||
          rooms={rooms}
 | 
			
		||||
          onConfirm={(roomName) => {
 | 
			
		||||
            onSubmit(roomName, command)
 | 
			
		||||
          title={dialogProps.title}
 | 
			
		||||
          description={dialogProps.description}
 | 
			
		||||
          icon={dialogProps.icon}
 | 
			
		||||
          items={dialogProps.items}
 | 
			
		||||
          onConfirm={async (selectedItems) => {
 | 
			
		||||
            if (!onSubmit) return
 | 
			
		||||
            for (const item of selectedItems) {
 | 
			
		||||
              await onSubmit(item, command)
 | 
			
		||||
            }
 | 
			
		||||
            setDialogOpen(false)
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										153
									
								
								src/template/table-manager-template.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								src/template/table-manager-template.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,153 @@
 | 
			
		|||
import { RequestUpdateMenu } from "@/components/request-update-menu";
 | 
			
		||||
import { SelectDialog } from "@/components/select-dialog";
 | 
			
		||||
import {
 | 
			
		||||
  Card,
 | 
			
		||||
  CardContent,
 | 
			
		||||
  CardDescription,
 | 
			
		||||
  CardFooter,
 | 
			
		||||
  CardHeader,
 | 
			
		||||
  CardTitle,
 | 
			
		||||
} from "@/components/ui/card";
 | 
			
		||||
import { UploadDialog } from "@/components/upload-dialog";
 | 
			
		||||
import { VersionTable } from "@/components/version-table";
 | 
			
		||||
import type { ColumnDef } from "@tanstack/react-table";
 | 
			
		||||
import type { AxiosProgressEvent } from "axios";
 | 
			
		||||
import { FileText, Building2, Monitor } from "lucide-react";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
 | 
			
		||||
interface BlackListManagerTemplateProps<TData> {
 | 
			
		||||
  title: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  data: TData[];
 | 
			
		||||
  isLoading: boolean;
 | 
			
		||||
  columns: ColumnDef<TData, any>[];
 | 
			
		||||
  onUpload: (
 | 
			
		||||
    fd: FormData,
 | 
			
		||||
    config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
 | 
			
		||||
  ) => Promise<void>;
 | 
			
		||||
  onUpdate?: (roomName: string) => void;
 | 
			
		||||
  updateLoading?: boolean;
 | 
			
		||||
  onTableInit?: (table: any) => void;
 | 
			
		||||
  rooms: string[];
 | 
			
		||||
  devices?: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function BlackListManagerTemplate<TData>({
 | 
			
		||||
  title,
 | 
			
		||||
  description,
 | 
			
		||||
  data,
 | 
			
		||||
  isLoading,
 | 
			
		||||
  columns,
 | 
			
		||||
  onUpload,
 | 
			
		||||
  onUpdate,
 | 
			
		||||
  updateLoading,
 | 
			
		||||
  onTableInit,
 | 
			
		||||
  rooms = [],
 | 
			
		||||
  devices = [],
 | 
			
		||||
}: BlackListManagerTemplateProps<TData>) {
 | 
			
		||||
  const [dialogOpen, setDialogOpen] = useState(false);
 | 
			
		||||
  const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
 | 
			
		||||
 | 
			
		||||
  const handleUpdateAll = () => {
 | 
			
		||||
    if (onUpdate) onUpdate("All");
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const openRoomDialog = () => {
 | 
			
		||||
    if (rooms.length > 0 && onUpdate) {
 | 
			
		||||
      setDialogType("room");
 | 
			
		||||
      setDialogOpen(true);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const openDeviceDialog = () => {
 | 
			
		||||
    if (devices.length > 0 && onUpdate) {
 | 
			
		||||
      setDialogType("device");
 | 
			
		||||
      setDialogOpen(true);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getDialogProps = () => {
 | 
			
		||||
    if (dialogType === "room") {
 | 
			
		||||
      return {
 | 
			
		||||
        title: "Chọn phòng",
 | 
			
		||||
        description: "Chọn các phòng cần cập nhật",
 | 
			
		||||
        icon: <Building2 className="w-6 h-6 text-primary" />,
 | 
			
		||||
        items: rooms,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    if (dialogType === "device") {
 | 
			
		||||
      return {
 | 
			
		||||
        title: "Chọn thiết bị",
 | 
			
		||||
        description: "Chọn các thiết bị cần cập nhật",
 | 
			
		||||
        icon: <Monitor className="w-6 h-6 text-primary" />,
 | 
			
		||||
        items: devices,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  };
 | 
			
		||||
  const dialogProps = getDialogProps();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="w-full px-6 space-y-4">
 | 
			
		||||
      {/* Header */}
 | 
			
		||||
      <div className="flex items-center justify-between">
 | 
			
		||||
        <div>
 | 
			
		||||
          <h1 className="text-3xl font-bold">{title}</h1>
 | 
			
		||||
          <p className="text-muted-foreground mt-2">{description}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
        <UploadDialog onSubmit={onUpload} />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* Table */}
 | 
			
		||||
      <Card className="w-full">
 | 
			
		||||
        <CardHeader>
 | 
			
		||||
          <CardTitle className="flex items-center gap-2">
 | 
			
		||||
            <FileText className="h-5 w-5" /> Danh sách phần mềm bị chặn
 | 
			
		||||
          </CardTitle>
 | 
			
		||||
          <CardDescription>
 | 
			
		||||
            Các phần mềm không được cho phép trong hệ thống
 | 
			
		||||
          </CardDescription>
 | 
			
		||||
        </CardHeader>
 | 
			
		||||
 | 
			
		||||
        <CardContent>
 | 
			
		||||
          <VersionTable
 | 
			
		||||
            data={data}
 | 
			
		||||
            isLoading={isLoading}
 | 
			
		||||
            columns={columns}
 | 
			
		||||
            onTableInit={onTableInit}
 | 
			
		||||
          />
 | 
			
		||||
        </CardContent>
 | 
			
		||||
 | 
			
		||||
        {/* Footer */}
 | 
			
		||||
        {onUpdate && (
 | 
			
		||||
          <CardFooter className="flex flex-col sm:flex-row gap-2">
 | 
			
		||||
            <RequestUpdateMenu
 | 
			
		||||
              onUpdateDevice={openDeviceDialog}
 | 
			
		||||
              onUpdateRoom={openRoomDialog}
 | 
			
		||||
              onUpdateAll={handleUpdateAll}
 | 
			
		||||
              loading={updateLoading}
 | 
			
		||||
            />
 | 
			
		||||
          </CardFooter>
 | 
			
		||||
        )}
 | 
			
		||||
      </Card>
 | 
			
		||||
 | 
			
		||||
      {dialogProps && (
 | 
			
		||||
        <SelectDialog
 | 
			
		||||
          open={dialogOpen}
 | 
			
		||||
          onClose={() => setDialogOpen(false)}
 | 
			
		||||
          title={dialogProps.title}
 | 
			
		||||
          description={dialogProps.description}
 | 
			
		||||
          icon={dialogProps.icon}
 | 
			
		||||
          items={dialogProps.items}
 | 
			
		||||
          onConfirm={async (selectedItems) => {
 | 
			
		||||
            if (!onUpdate) return;
 | 
			
		||||
            for (const item of selectedItems) {
 | 
			
		||||
              onUpdate(item);
 | 
			
		||||
            }
 | 
			
		||||
            setDialogOpen(false);
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										13
									
								
								src/types/device.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/types/device.ts
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
export interface NetworkInfo {
 | 
			
		||||
  macAddress?: string;
 | 
			
		||||
  ipAddress?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface DeviceHealthCheck {
 | 
			
		||||
  id: string;
 | 
			
		||||
  deviceTime: string; 
 | 
			
		||||
  version?: string;
 | 
			
		||||
  room?: string;
 | 
			
		||||
  isOffline: boolean;
 | 
			
		||||
  networkInfos: NetworkInfo[];
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user