diff --git a/package-lock.json b/package-lock.json index e8db8ff..c22c2f2 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", @@ -25,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", @@ -1579,6 +1581,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 +1697,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", @@ -3325,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", @@ -7861,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 2fc1438..d419b76 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", @@ -29,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..ed02a93 --- /dev/null +++ b/src/components/bars/device-searchbar.tsx @@ -0,0 +1,325 @@ +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 | Promise; + 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< + Record + >({}); + 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 = async () => { + try { + await onSelect(selected); + } catch (e) { + console.error("Error on select:", e); + } finally { + 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} + {room.numberOfOfflineDevices > 0 && ( + + {room.numberOfOfflineDevices} + + )} +
+
+ + {/* Device table - collapsible */} + {isExpanded && devices.length > 0 && ( +
+ + + + + + + + + + + + + {devices.map((device) => ( + + + + + + + + + ))} + +
+ Thiết bị + + IP + + MAC + + Ver + + Trạ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} + +
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +} 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/dialogs/add-new-dialog.tsx b/src/components/dialogs/add-new-dialog.tsx new file mode 100644 index 0000000..c83e554 --- /dev/null +++ b/src/components/dialogs/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/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..3da79c1 --- /dev/null +++ b/src/components/dialogs/select-dialog.tsx @@ -0,0 +1,98 @@ +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([]); + setSearch(""); + onClose(); + }; + + 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) => ( +
+ +