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) => ( +
+ +