From cfc6ea97964fd5ad7bc63642f6ce878cfb598aaf Mon Sep 17 00:00:00 2001 From: phuongdm Date: Fri, 31 Oct 2025 16:52:56 +0700 Subject: [PATCH 1/3] change button to dropdown menu --- package-lock.json | 180 +++++++++++++ package.json | 1 + src/components/add-new-dialog.tsx | 140 ++++++++++ src/components/device-grid.tsx | 24 +- src/components/request-update-menu.tsx | 65 +++++ src/components/room-select-dialog.tsx | 98 ------- src/components/select-dialog.tsx | 135 ++++++++++ src/components/ui/dropdown-menu.tsx | 255 ++++++++++++++++++ src/config/api.ts | 1 - src/routes/_authenticated/agent/index.tsx | 22 +- src/routes/_authenticated/apps/index.tsx | 14 +- .../_authenticated/room/$roomName/index.tsx | 20 +- src/template/app-manager-template.tsx | 97 +++++-- src/template/form-submit-template.tsx | 91 +++++-- src/template/table-manager-template.tsx | 153 +++++++++++ src/types/device.ts | 13 + 16 files changed, 1133 insertions(+), 176 deletions(-) create mode 100644 src/components/add-new-dialog.tsx create mode 100644 src/components/request-update-menu.tsx delete mode 100644 src/components/room-select-dialog.tsx create mode 100644 src/components/select-dialog.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/template/table-manager-template.tsx create mode 100644 src/types/device.ts diff --git a/package-lock.json b/package-lock.json index e8db8ff..60b65a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2fc1438..9d68cd9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/add-new-dialog.tsx b/src/components/add-new-dialog.tsx new file mode 100644 index 0000000..c83e554 --- /dev/null +++ b/src/components/add-new-dialog.tsx @@ -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 ( + + + + + + + + Thêm ứng dụng vào danh sách cấm + + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + {(field) => ( +
+ + field.handleChange(e.target.value)} + placeholder="Ví dụ: Google Chrome" + disabled={isSubmitting || isDone} + /> +
+ )} +
+ + + {(field) => ( +
+ + field.handleChange(e.target.value)} + placeholder="chrome.exe" + disabled={isSubmitting || isDone} + /> +
+ )} +
+ + + {!isDone ? ( + <> + + + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/src/components/device-grid.tsx b/src/components/device-grid.tsx index 254143c..75b3bdc 100644 --- a/src/components/device-grid.tsx +++ b/src/components/device-grid.tsx @@ -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 (
+ {/* Bên trái (1–20) */} {Array.from({ length: 4 }).map((_, i) => { - const pos = start + i; - return ; + const pos = leftStart + i; + return ( + + ); })} + + {/* Đường chia giữa */}
+ + {/* Bên phải (21–40) */} {Array.from({ length: 4 }).map((_, i) => { - const pos = start + i + 4; - return ; + const pos = rightStart + i; + return ( + + ); })}
); diff --git a/src/components/request-update-menu.tsx b/src/components/request-update-menu.tsx new file mode 100644 index 0000000..ef51d71 --- /dev/null +++ b/src/components/request-update-menu.tsx @@ -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 ( + + + + + + + + Cập nhật thiết bị cụ thể + + + + + Cập nhật theo phòng + + + + + Cập nhật tất cả thiết bị + + + + ); +} diff --git a/src/components/room-select-dialog.tsx b/src/components/room-select-dialog.tsx deleted file mode 100644 index 937addf..0000000 --- a/src/components/room-select-dialog.tsx +++ /dev/null @@ -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("") - - return ( - - - -
- -
- - Chọn phòng để cập nhật - -

- Vui lòng chọn phòng để gửi lệnh cập nhật -

-
- -
- - {rooms.map((room) => ( -
-
- - -
- - {selectedRoom === room && ( -
- -
- )} -
- ))} -
-
- - - - - -
-
- ) -} diff --git a/src/components/select-dialog.tsx b/src/components/select-dialog.tsx new file mode 100644 index 0000000..b8fe53f --- /dev/null +++ b/src/components/select-dialog.tsx @@ -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([]) + 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 ( + + + +
+ {icon ?? } +
+ {title} +

{description}

+
+ + {/* 🔍 Thanh tìm kiếm */} +
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + {/* Danh sách các item */} +
+ {filteredItems.length > 0 ? ( + filteredItems.map((item) => ( +
toggleItem(item)} + > +
+ toggleItem(item)} + /> + +
+ + {selectedItems.includes(item) && ( +
+ +
+ )} +
+ )) + ) : ( +

+ Không tìm thấy kết quả +

+ )} +
+ + + + + +
+
+ ) +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..eaed9ba --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -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) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/config/api.ts b/src/config/api.ts index de4e355..c076a3e 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -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`, - }, }; diff --git a/src/routes/_authenticated/agent/index.tsx b/src/routes/_authenticated/agent/index.tsx index acaf9ae..5e52b7d 100644 --- a/src/routes/_authenticated/agent/index.tsx +++ b/src/routes/_authenticated/agent/index.tsx @@ -59,7 +59,7 @@ function AgentsPage() { }); const updateMutation = useMutationData({ - 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 diff --git a/src/routes/_authenticated/apps/index.tsx b/src/routes/_authenticated/apps/index.tsx index 9533523..f6053bb 100644 --- a/src/routes/_authenticated/apps/index.tsx +++ b/src/routes/_authenticated/apps/index.tsx @@ -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 ( diff --git a/src/routes/_authenticated/room/$roomName/index.tsx b/src/routes/_authenticated/room/$roomName/index.tsx index 00b2369..2cb9c7b 100644 --- a/src/routes/_authenticated/room/$roomName/index.tsx +++ b/src/routes/_authenticated/room/$roomName/index.tsx @@ -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() {
- +
diff --git a/src/template/app-manager-template.tsx b/src/template/app-manager-template.tsx index 907ef08..18a1205 100644 --- a/src/template/app-manager-template.tsx +++ b/src/template/app-manager-template.tsx @@ -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 { title: string; @@ -25,10 +25,11 @@ interface AppManagerTemplateProps { fd: FormData, config?: { onUploadProgress?: (e: AxiosProgressEvent) => void } ) => Promise; - onUpdate?: (roomName: string) => void; + onUpdate?: (targetNames: string[]) => Promise | void; updateLoading?: boolean; onTableInit?: (table: any) => void; - rooms: string[]; + rooms?: string[]; + devices?: string[]; } export function AppManagerTemplate({ @@ -41,16 +42,57 @@ export function AppManagerTemplate({ onUpdate, updateLoading, onTableInit, - rooms, + rooms = [], + devices = [], }: AppManagerTemplateProps) { 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: , + 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: , + items: devices, + }; + } + return null; + }; + + const dialogProps = getDialogProps(); + return (
+ {/* Header */}

{title}

@@ -59,6 +101,7 @@ export function AppManagerTemplate({
+ {/* Table */} @@ -66,6 +109,7 @@ export function AppManagerTemplate({ Tất cả các phiên bản đã tải lên + ({ onTableInit={onTableInit} /> + {onUpdate && ( - - onUpdate("All")} + )} - {rooms && onUpdate && ( - setDialogOpen(false)} - rooms={rooms} - onConfirm={(roomName) => { - onUpdate(roomName); - setDialogOpen(false); - }} - /> - )} + + {/* 🧩 SelectDialog tái sử dụng */} + {dialogProps && ( + 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); + }} + /> + )}
); } diff --git a/src/template/form-submit-template.tsx b/src/template/form-submit-template.tsx index e0c7817..885eecb 100644 --- a/src/template/form-submit-template.tsx +++ b/src/template/form-submit-template.tsx @@ -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 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: , + 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: , + items: devices, + } + } + return null + } + + const dialogProps = getDialogProps() + return (
@@ -58,33 +97,35 @@ export function FormSubmitTemplate({ Nhập và gửi lệnh xuống thiết bị - - {children({ command, setCommand })} - + + {children({ command, setCommand })} {onSubmit && ( - - + - onSubmit("All", command)} - loading={submitLoading} - label="Cập nhật tất cả thiết bị" /> )} - {onSubmit && rooms.length > 0 && ( - 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) }} /> diff --git a/src/template/table-manager-template.tsx b/src/template/table-manager-template.tsx new file mode 100644 index 0000000..f758338 --- /dev/null +++ b/src/template/table-manager-template.tsx @@ -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 { + title: string; + description: string; + data: TData[]; + isLoading: boolean; + columns: ColumnDef[]; + onUpload: ( + fd: FormData, + config?: { onUploadProgress?: (e: AxiosProgressEvent) => void } + ) => Promise; + onUpdate?: (roomName: string) => void; + updateLoading?: boolean; + onTableInit?: (table: any) => void; + rooms: string[]; + devices?: string[]; +} + +export function BlackListManagerTemplate({ + title, + description, + data, + isLoading, + columns, + onUpload, + onUpdate, + updateLoading, + onTableInit, + rooms = [], + devices = [], +}: BlackListManagerTemplateProps) { + 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: , + 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: , + items: devices, + }; + } + return null; + }; + const dialogProps = getDialogProps(); + + return ( +
+ {/* Header */} +
+
+

{title}

+

{description}

+
+ +
+ + {/* Table */} + + + + Danh sách phần mềm bị chặn + + + Các phần mềm không được cho phép trong hệ thống + + + + + + + + {/* Footer */} + {onUpdate && ( + + + + )} + + + {dialogProps && ( + 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); + }} + /> + )} +
+ ); +} diff --git a/src/types/device.ts b/src/types/device.ts new file mode 100644 index 0000000..53096da --- /dev/null +++ b/src/types/device.ts @@ -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[]; +} From 28c7bfc09e6b14e3596f087975c56cfb57f4a9f9 Mon Sep 17 00:00:00 2001 From: phuongdm Date: Wed, 19 Nov 2025 14:55:14 +0700 Subject: [PATCH 2/3] fix update button completed --- package-lock.json | 34 ++- package.json | 1 + src/components/bars/device-searchbar.tsx | 281 ++++++++++++++++++ .../{ => buttons}/update-button.tsx | 0 src/components/{ => cards}/computer-card.tsx | 0 .../{ => dialogs}/add-new-dialog.tsx | 0 src/components/dialogs/form-dialog.tsx | 30 ++ src/components/dialogs/select-dialog.tsx | 96 ++++++ src/components/forms/black-list-form.tsx | 67 +++++ src/components/{ => forms}/command-form.tsx | 0 src/components/forms/dynamic-submit-form.tsx | 161 ++++++++++ src/components/forms/upload-file-form.tsx | 119 ++++++++ src/components/{ => grids}/device-grid.tsx | 28 +- .../{ => menu}/request-update-menu.tsx | 0 src/components/preset-command.tsx | 151 ---------- src/components/select-dialog.tsx | 135 --------- src/components/{ => selects}/floor-select.tsx | 0 src/components/{ => selects}/room-select.tsx | 0 src/components/{ => sidebars}/app-sidebar.tsx | 0 src/components/{ => tables}/device-table.tsx | 2 +- src/components/{ => tables}/version-table.tsx | 0 src/components/ui/sidebar.tsx | 2 +- src/components/upload-dialog.tsx | 164 ---------- src/config/api.ts | 3 +- src/helpers/mapRoomToSelectItems.ts | 9 + src/hooks/useDeleteData.ts | 37 +++ src/hooks/{use-mobile.ts => useMobile.ts} | 0 src/hooks/useQueryData.ts | 19 +- src/layouts/app-layout.tsx | 2 +- src/main.tsx | 2 +- src/routes/_authenticated/agent/index.tsx | 18 +- src/routes/_authenticated/apps/index.tsx | 25 +- src/routes/_authenticated/blacklist/index.tsx | 177 +++++++++-- src/routes/_authenticated/command/index.tsx | 9 +- .../_authenticated/room/$roomName/index.tsx | 15 +- src/services/device.service.ts | 21 ++ src/template/app-manager-template.tsx | 82 ++--- src/template/form-submit-template.tsx | 125 ++++---- src/template/table-manager-template.tsx | 95 +++--- src/types/black-list.ts | 10 + src/types/install-history.ts | 3 + 41 files changed, 1222 insertions(+), 701 deletions(-) create mode 100644 src/components/bars/device-searchbar.tsx rename src/components/{ => buttons}/update-button.tsx (100%) rename src/components/{ => cards}/computer-card.tsx (100%) rename src/components/{ => dialogs}/add-new-dialog.tsx (100%) create mode 100644 src/components/dialogs/form-dialog.tsx create mode 100644 src/components/dialogs/select-dialog.tsx create mode 100644 src/components/forms/black-list-form.tsx rename src/components/{ => forms}/command-form.tsx (100%) create mode 100644 src/components/forms/dynamic-submit-form.tsx create mode 100644 src/components/forms/upload-file-form.tsx rename src/components/{ => grids}/device-grid.tsx (80%) rename src/components/{ => menu}/request-update-menu.tsx (100%) delete mode 100644 src/components/preset-command.tsx delete mode 100644 src/components/select-dialog.tsx rename src/components/{ => selects}/floor-select.tsx (100%) rename src/components/{ => selects}/room-select.tsx (100%) rename src/components/{ => sidebars}/app-sidebar.tsx (100%) rename src/components/{ => tables}/device-table.tsx (99%) rename src/components/{ => tables}/version-table.tsx (100%) delete mode 100644 src/components/upload-dialog.tsx create mode 100644 src/helpers/mapRoomToSelectItems.ts create mode 100644 src/hooks/useDeleteData.ts rename src/hooks/{use-mobile.ts => useMobile.ts} (100%) create mode 100644 src/services/device.service.ts create mode 100644 src/types/black-list.ts create mode 100644 src/types/install-history.ts diff --git a/package-lock.json b/package-lock.json index 60b65a3..c22c2f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@tanstack/react-router-devtools": "^1.121.2", "@tanstack/react-table": "^8.21.3", "@tanstack/router-plugin": "^1.121.2", + "@tanstack/zod-form-adapter": "^0.42.1", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -3505,6 +3506,33 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/zod-form-adapter": { + "version": "0.42.1", + "resolved": "https://registry.npmjs.org/@tanstack/zod-form-adapter/-/zod-form-adapter-0.42.1.tgz", + "integrity": "sha512-hPRM0lawVKP64yurW4c6KHZH6altMo2MQN14hfi+GMBTKjO9S7bW1x5LPZ5cayoJE3mBvdlahpSGT5rYZtSbXQ==", + "dependencies": { + "@tanstack/form-core": "0.42.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "zod": "^3.x" + } + }, + "node_modules/@tanstack/zod-form-adapter/node_modules/@tanstack/form-core": { + "version": "0.42.1", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-0.42.1.tgz", + "integrity": "sha512-jTU0jyHqFceujdtPNv3jPVej1dTqBwa8TYdIyWB5BCwRVUBZEp1PiYEBkC9r92xu5fMpBiKc+JKud3eeVjuMiA==", + "dependencies": { + "@tanstack/store": "^0.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -8041,9 +8069,9 @@ } }, "node_modules/vite": { - "version": "6.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", - "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/package.json b/package.json index 9d68cd9..d419b76 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@tanstack/react-router-devtools": "^1.121.2", "@tanstack/react-table": "^8.21.3", "@tanstack/router-plugin": "^1.121.2", + "@tanstack/zod-form-adapter": "^0.42.1", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/components/bars/device-searchbar.tsx b/src/components/bars/device-searchbar.tsx new file mode 100644 index 0000000..79dd3ef --- /dev/null +++ b/src/components/bars/device-searchbar.tsx @@ -0,0 +1,281 @@ +import { useState, useMemo } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Building2, Monitor, ChevronDown, ChevronRight, Loader2 } from "lucide-react"; +import type { Room } from "@/types/room"; +import type { DeviceHealthCheck } from "@/types/device"; + +interface DeviceSearchDialogProps { + open: boolean; + onClose: () => void; + rooms: Room[]; + onSelect: (deviceIds: string[]) => void; + fetchDevices: (roomName: string) => Promise; // API fetch +} + +export function DeviceSearchDialog({ + open, + onClose, + rooms, + onSelect, + fetchDevices, +}: DeviceSearchDialogProps) { + const [selected, setSelected] = useState([]); + const [expandedRoom, setExpandedRoom] = useState(null); + const [roomDevices, setRoomDevices] = useState>({}); + const [loadingRoom, setLoadingRoom] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + + const sortedRooms = useMemo(() => { + return [...rooms].sort((a, b) => { + const nameA = typeof a.name === "string" ? a.name : ""; + const nameB = typeof b.name === "string" ? b.name : ""; + return nameA.localeCompare(nameB); + }); + }, [rooms]); + + const filteredRooms = useMemo(() => { + if (!searchQuery) return sortedRooms; + return sortedRooms.filter(room => + room.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [sortedRooms, searchQuery]); + + const handleRoomClick = async (roomName: string) => { + // Nếu đang mở thì đóng lại + if (expandedRoom === roomName) { + setExpandedRoom(null); + return; + } + + // Nếu chưa fetch devices của room này thì gọi API + if (!roomDevices[roomName]) { + setLoadingRoom(roomName); + try { + const devices = await fetchDevices(roomName); + setRoomDevices(prev => ({ ...prev, [roomName]: devices })); + setExpandedRoom(roomName); + } catch (error) { + console.error("Failed to fetch devices:", error); + // Có thể thêm toast notification ở đây + } finally { + setLoadingRoom(null); + } + } else { + // Đã có data rồi thì chỉ toggle + setExpandedRoom(roomName); + } + }; + + const toggleDevice = (deviceId: string) => { + setSelected((prev) => + prev.includes(deviceId) ? prev.filter((id) => id !== deviceId) : [...prev, deviceId] + ); + }; + + const toggleAllInRoom = (roomName: string) => { + const devices = roomDevices[roomName] || []; + const deviceIds = devices.map(d => d.id); + const allSelected = deviceIds.every(id => selected.includes(id)); + + if (allSelected) { + setSelected(prev => prev.filter(id => !deviceIds.includes(id))); + } else { + setSelected(prev => [...new Set([...prev, ...deviceIds])]); + } + }; + + const handleConfirm = () => { + onSelect(selected); + setSelected([]); + setExpandedRoom(null); + setRoomDevices({}); + setSearchQuery(""); + onClose(); + }; + + const handleClose = () => { + setSelected([]); + setExpandedRoom(null); + setRoomDevices({}); + setSearchQuery(""); + onClose(); + }; + + return ( + + + + + + Chọn thiết bị + + + + {/* Search bar */} + setSearchQuery(e.target.value)} + className="my-2" + /> + + {/* Room list */} + +
+ {filteredRooms.length === 0 && ( +

+ Không tìm thấy phòng +

+ )} + + {filteredRooms.map((room) => { + const isExpanded = expandedRoom === room.name; + const isLoading = loadingRoom === room.name; + const devices = roomDevices[room.name] || []; + const allSelected = devices.length > 0 && devices.every(d => selected.includes(d.id)); + const someSelected = devices.some(d => selected.includes(d.id)); + const selectedCount = devices.filter(d => selected.includes(d.id)).length; + + return ( +
+ {/* Room header - clickable */} +
handleRoomClick(room.name)} + > + {/* Expand icon or loading */} + {isLoading ? ( + + ) : isExpanded ? ( + + ) : ( + + )} + + {/* Select all checkbox - chỉ hiện khi đã load devices */} + {devices.length > 0 && ( + { + toggleAllInRoom(room.name); + }} + onClick={(e) => e.stopPropagation()} + className={someSelected && !allSelected ? "opacity-50" : ""} + /> + )} + + + + {room.name} + +
+ {selectedCount > 0 && ( + + {selectedCount}/ + + )} + {room.numberOfDevices} thiết bị + {room.numberOfOfflineDevices > 0 && ( + + {room.numberOfOfflineDevices} offline + + )} +
+
+ + {/* Device table - collapsible */} + {isExpanded && devices.length > 0 && ( +
+
+ + + + + + + + + + + + + {devices.map((device) => ( + + + + + + + + + ))} + +
Thiết bịIP AddressMAC AddressVersionTrạng thái
+ toggleDevice(device.id)} + /> + +
+ + {device.id} +
+
+ {device.networkInfos[0]?.ipAddress || "-"} + + {device.networkInfos[0]?.macAddress || "-"} + + {device.version ? ( + + v{device.version} + + ) : ( + "-" + )} + + {device.isOffline ? ( + + Offline + + ) : ( + + Online + + )} +
+
+
+ )} +
+ ); + })} +
+
+ + {/* Selected count */} + {selected.length > 0 && ( +
+ Đã chọn: {selected.length} thiết bị +
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/update-button.tsx b/src/components/buttons/update-button.tsx similarity index 100% rename from src/components/update-button.tsx rename to src/components/buttons/update-button.tsx diff --git a/src/components/computer-card.tsx b/src/components/cards/computer-card.tsx similarity index 100% rename from src/components/computer-card.tsx rename to src/components/cards/computer-card.tsx diff --git a/src/components/add-new-dialog.tsx b/src/components/dialogs/add-new-dialog.tsx similarity index 100% rename from src/components/add-new-dialog.tsx rename to src/components/dialogs/add-new-dialog.tsx diff --git a/src/components/dialogs/form-dialog.tsx b/src/components/dialogs/form-dialog.tsx new file mode 100644 index 0000000..1a209ee --- /dev/null +++ b/src/components/dialogs/form-dialog.tsx @@ -0,0 +1,30 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { type ReactNode, useState } from "react"; + +interface FormDialogProps { + triggerLabel: string; + title: string; + children: (closeDialog: () => void) => ReactNode; +} + +export function FormDialog({ triggerLabel, title, children }: FormDialogProps) { + const [isOpen, setIsOpen] = useState(false); + + const closeDialog = () => setIsOpen(false); + + return ( + + + + + + + {title} + + + {children(closeDialog)} + + + ); +} diff --git a/src/components/dialogs/select-dialog.tsx b/src/components/dialogs/select-dialog.tsx new file mode 100644 index 0000000..235be70 --- /dev/null +++ b/src/components/dialogs/select-dialog.tsx @@ -0,0 +1,96 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useState, useMemo } from "react"; + +export interface SelectItem { + label: string; + value: string; +} + +interface SelectDialogProps { + open: boolean; + onClose: () => void; + title: string; + description?: string; + icon?: React.ReactNode; + items: SelectItem[]; + onConfirm: (values: string[]) => Promise | void; +} + +export function SelectDialog({ + open, + onClose, + title, + description, + icon, + items, + onConfirm, +}: SelectDialogProps) { + const [selected, setSelected] = useState([]); + const [search, setSearch] = useState(""); + + const filteredItems = useMemo(() => { + return items.filter((item) => + item.label.toLowerCase().includes(search.toLowerCase()) + ); + }, [items, search]); + + const toggleItem = (value: string) => { + setSelected((prev) => + prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value] + ); + }; + + const handleConfirm = async () => { + await onConfirm(selected); + setSelected([]); + }; + + return ( + + + + + {icon} + {title} + + {description &&

{description}

} +
+ + setSearch(e.target.value)} + className="my-2" + /> + +
+ {filteredItems.map((item) => ( +
+ toggleItem(item.value)} + /> + {item.label} +
+ ))} + + {filteredItems.length === 0 && ( +

Không có kết quả

+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/forms/black-list-form.tsx b/src/components/forms/black-list-form.tsx new file mode 100644 index 0000000..f34cc6a --- /dev/null +++ b/src/components/forms/black-list-form.tsx @@ -0,0 +1,67 @@ +import { FormBuilder, FormField } from "@/components/forms/dynamic-submit-form"; +import { type BlacklistFormData } from "@/types/black-list"; +import { toast } from "sonner"; + +interface BlacklistFormProps { + onSubmit: (data: BlacklistFormData) => Promise; + closeDialog: () => void; + initialData?: Partial; +} + +export function BlacklistForm({ + onSubmit, + closeDialog, + initialData, +}: BlacklistFormProps) { + return ( + + defaultValues={{ + appName: initialData?.appName || "", + processName: initialData?.processName || "", + }} + onSubmit={async (values: BlacklistFormData) => { + if (!values.appName.trim()) { + toast.error("Vui lòng nhập tên ứng dụng"); + return; + } + if (!values.processName.trim()) { + toast.error("Vui lòng nhập tên tiến trình"); + return; + } + + try { + await onSubmit(values); + toast.success("Thêm phần mềm bị chặn thành công!"); + closeDialog(); + } catch (error) { + console.error("Error:", error); + toast.error("Có lỗi xảy ra!"); + } + }} + submitLabel="Thêm" + cancelLabel="Hủy" + onCancel={closeDialog} + showCancel={true} + > + {(form: any) => ( + <> + + form={form} + name="appName" + label="Tên ứng dụng" + placeholder="VD: Google Chrome" + required + /> + + + form={form} + name="processName" + label="Tên tiến trình" + placeholder="VD: chrome.exe" + required + /> + + )} + + ); +} diff --git a/src/components/command-form.tsx b/src/components/forms/command-form.tsx similarity index 100% rename from src/components/command-form.tsx rename to src/components/forms/command-form.tsx diff --git a/src/components/forms/dynamic-submit-form.tsx b/src/components/forms/dynamic-submit-form.tsx new file mode 100644 index 0000000..5406b99 --- /dev/null +++ b/src/components/forms/dynamic-submit-form.tsx @@ -0,0 +1,161 @@ +import { useForm } from "@tanstack/react-form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { type ReactNode } from "react"; + +interface FormBuilderProps> { + defaultValues: T; + onSubmit: (values: T) => Promise | void; + submitLabel?: string; + cancelLabel?: string; + onCancel?: () => void; + showCancel?: boolean; + children: (form: any) => ReactNode; +} + +export function FormBuilder>({ + defaultValues, + onSubmit, + submitLabel = "Submit", + cancelLabel = "Hủy", + onCancel, + showCancel = false, + children, +}: FormBuilderProps) { + const form = useForm({ + defaultValues, + onSubmit: async ({ value }) => { + try { + await onSubmit(value as T); + } catch (error) { + console.error("Submit error:", error); + toast.error("Có lỗi xảy ra!"); + } + }, + }); + + return ( +
{ + e.preventDefault(); + form.handleSubmit(); + }} + > + {children(form)} + +
+ {showCancel && onCancel && ( + + )} + +
+
+ ); +} + +interface FormFieldProps { + form: any; + name: K; + label: string; + type?: string; + placeholder?: string; + required?: boolean; +} + +export function FormField, K extends keyof T>({ + form, + name, + label, + type = "text", + placeholder, + required, +}: FormFieldProps) { + return ( + + {(field: any) => ( +
+ + field.handleChange(e.target.value)} + placeholder={placeholder} + /> +
+ )} +
+ ); +} + +export function FormTextarea, K extends keyof T>({ + form, + name, + label, + placeholder, + required, +}: Omit, "type">) { + return ( + + {(field: any) => ( +
+ +