fix update button completed

This commit is contained in:
Do Manh Phuong 2025-11-19 14:55:14 +07:00
parent cfc6ea9796
commit 28c7bfc09e
41 changed files with 1222 additions and 701 deletions

34
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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<DeviceHealthCheck[]>; // API fetch
}
export function DeviceSearchDialog({
open,
onClose,
rooms,
onSelect,
fetchDevices,
}: DeviceSearchDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const [expandedRoom, setExpandedRoom] = useState<string | null>(null);
const [roomDevices, setRoomDevices] = useState<Record<string, DeviceHealthCheck[]>>({});
const [loadingRoom, setLoadingRoom] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Monitor className="w-6 h-6 text-primary" />
Chọn thiết bị
</DialogTitle>
</DialogHeader>
{/* Search bar */}
<Input
placeholder="Tìm kiếm phòng..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="my-2"
/>
{/* Room list */}
<ScrollArea className="max-h-[500px] rounded-lg border p-2">
<div className="space-y-1">
{filteredRooms.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
Không tìm thấy phòng
</p>
)}
{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 (
<div key={room.name} className="border rounded-lg overflow-hidden">
{/* Room header - clickable */}
<div
className="flex items-center gap-2 p-3 hover:bg-muted/50 cursor-pointer"
onClick={() => handleRoomClick(room.name)}
>
{/* Expand icon or loading */}
{isLoading ? (
<Loader2 className="w-4 h-4 text-muted-foreground flex-shrink-0 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
{/* Select all checkbox - chỉ hiện khi đã load devices */}
{devices.length > 0 && (
<Checkbox
checked={allSelected}
onCheckedChange={() => {
toggleAllInRoom(room.name);
}}
onClick={(e) => e.stopPropagation()}
className={someSelected && !allSelected ? "opacity-50" : ""}
/>
)}
<Building2 className="w-4 h-4 text-primary flex-shrink-0" />
<span className="font-semibold flex-1">{room.name}</span>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{selectedCount > 0 && (
<span className="text-primary font-medium">
{selectedCount}/
</span>
)}
<span>{room.numberOfDevices} thiết bị</span>
{room.numberOfOfflineDevices > 0 && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700">
{room.numberOfOfflineDevices} offline
</span>
)}
</div>
</div>
{/* Device table - collapsible */}
{isExpanded && devices.length > 0 && (
<div className="border-t bg-muted/20">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="w-12 p-2"></th>
<th className="text-left p-2 font-medium">Thiết bị</th>
<th className="text-left p-2 font-medium">IP Address</th>
<th className="text-left p-2 font-medium">MAC Address</th>
<th className="text-left p-2 font-medium">Version</th>
<th className="text-left p-2 font-medium">Trạng thái</th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr
key={device.id}
className="border-b last:border-b-0 hover:bg-muted/50"
>
<td className="p-2">
<Checkbox
checked={selected.includes(device.id)}
onCheckedChange={() => toggleDevice(device.id)}
/>
</td>
<td className="p-2">
<div className="flex items-center gap-2">
<Monitor className="w-3.5 h-3.5 text-muted-foreground" />
<span className="font-mono">{device.id}</span>
</div>
</td>
<td className="p-2 font-mono text-xs">
{device.networkInfos[0]?.ipAddress || "-"}
</td>
<td className="p-2 font-mono text-xs">
{device.networkInfos[0]?.macAddress || "-"}
</td>
<td className="p-2">
{device.version ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-muted">
v{device.version}
</span>
) : (
"-"
)}
</td>
<td className="p-2">
{device.isOffline ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
Offline
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
Online
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
})}
</div>
</ScrollArea>
{/* Selected count */}
{selected.length > 0 && (
<div className="text-sm text-muted-foreground bg-muted/50 px-3 py-2 rounded">
Đã chọn: <span className="font-semibold text-foreground">{selected.length}</span> thiết bị
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleClose}>
Hủy
</Button>
<Button onClick={handleConfirm} disabled={selected.length === 0}>
Xác nhận ({selected.length})
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>{triggerLabel}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{children(closeDialog)}
</DialogContent>
</Dialog>
);
}

View File

@ -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> | void;
}
export function SelectDialog({
open,
onClose,
title,
description,
icon,
items,
onConfirm,
}: SelectDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{icon}
{title}
</DialogTitle>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</DialogHeader>
<Input
placeholder="Tìm kiếm..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="my-2"
/>
<div className="max-h-64 overflow-y-auto space-y-2 mt-2 border rounded p-2">
{filteredItems.map((item) => (
<div key={item.value} className="flex items-center gap-2">
<Checkbox
checked={selected.includes(item.value)}
onCheckedChange={() => toggleItem(item.value)}
/>
<span>{item.label}</span>
</div>
))}
{filteredItems.length === 0 && (
<p className="text-sm text-muted-foreground text-center">Không kết quả</p>
)}
</div>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" onClick={onClose}>
Hủy
</Button>
<Button onClick={handleConfirm} disabled={selected.length === 0}>
Xác nhận
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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<void>;
closeDialog: () => void;
initialData?: Partial<BlacklistFormData>;
}
export function BlacklistForm({
onSubmit,
closeDialog,
initialData,
}: BlacklistFormProps) {
return (
<FormBuilder<BlacklistFormData>
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) => (
<>
<FormField<BlacklistFormData, "appName">
form={form}
name="appName"
label="Tên ứng dụng"
placeholder="VD: Google Chrome"
required
/>
<FormField<BlacklistFormData, "processName">
form={form}
name="processName"
label="Tên tiến trình"
placeholder="VD: chrome.exe"
required
/>
</>
)}
</FormBuilder>
);
}

View File

@ -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<T extends Record<string, any>> {
defaultValues: T;
onSubmit: (values: T) => Promise<void> | void;
submitLabel?: string;
cancelLabel?: string;
onCancel?: () => void;
showCancel?: boolean;
children: (form: any) => ReactNode;
}
export function FormBuilder<T extends Record<string, any>>({
defaultValues,
onSubmit,
submitLabel = "Submit",
cancelLabel = "Hủy",
onCancel,
showCancel = false,
children,
}: FormBuilderProps<T>) {
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 (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{children(form)}
<div className="flex justify-end gap-2">
{showCancel && onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
{cancelLabel}
</Button>
)}
<Button type="submit">{submitLabel}</Button>
</div>
</form>
);
}
interface FormFieldProps<T, K extends keyof T> {
form: any;
name: K;
label: string;
type?: string;
placeholder?: string;
required?: boolean;
}
export function FormField<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
type = "text",
placeholder,
required,
}: FormFieldProps<T, K>) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<Input
type={type}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder={placeholder}
/>
</div>
)}
</form.Field>
);
}
export function FormTextarea<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
placeholder,
required,
}: Omit<FormFieldProps<T, K>, "type">) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder={placeholder}
/>
</div>
)}
</form.Field>
);
}
export function FormSelect<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
options,
required,
}: {
form: any;
name: K;
label: string;
options: { value: string; label: string }[];
required?: boolean;
}) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</form.Field>
);
}

View File

@ -0,0 +1,119 @@
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 { Progress } from "@/components/ui/progress";
import type { AxiosProgressEvent } from "axios";
import { useState } from "react";
import { toast } from "sonner";
interface UploadVersionFormProps {
onSubmit: (fd: FormData, config?: { onUploadProgress: (e: AxiosProgressEvent) => void }) => Promise<void>;
closeDialog: () => void;
}
export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormProps) {
const [uploadPercent, setUploadPercent] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [isDone, setIsDone] = useState(false);
const form = useForm({
defaultValues: { files: new DataTransfer().files, newVersion: "" },
onSubmit: async ({ value }) => {
if (!value.newVersion || value.files.length === 0) {
toast.error("Vui lòng điền đầy đủ thông tin");
return;
}
try {
setIsUploading(true);
setUploadPercent(0);
setIsDone(false);
const fd = new FormData();
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
fd.append("Version", value.newVersion);
await onSubmit(fd, {
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.total) {
const progress = Math.round((e.loaded * 100) / e.total);
setUploadPercent(progress);
}
},
});
setIsDone(true);
} catch (error) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
} finally {
setIsUploading(false);
}
},
});
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.Field name="newVersion">
{(field) => (
<div>
<Label>Phiên bản</Label>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="1.0.0"
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
<form.Field name="files">
{(field) => (
<div>
<Label>File</Label>
<Input
type="file"
onChange={(e) => e.target.files && field.handleChange(e.target.files)}
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
{(uploadPercent > 0 || isUploading || isDone) && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
<span>{uploadPercent}%</span>
</div>
<Progress value={uploadPercent} className="w-full" />
</div>
)}
<div className="flex justify-end gap-2">
{!isDone ? (
<>
<Button type="button" variant="outline" onClick={closeDialog} disabled={isUploading}>
Hủy
</Button>
<Button type="submit" disabled={isUploading}>
{isUploading ? "Đang tải..." : "Upload"}
</Button>
</>
) : (
<Button type="button" onClick={closeDialog}>
Hoàn tất
</Button>
)}
</div>
</form>
);
}

View File

@ -1,6 +1,6 @@
import { Monitor, DoorOpen } from "lucide-react";
import { ComputerCard } from "./computer-card";
import { useMachineNumber } from "../hooks/useMachineNumber";
import { ComputerCard } from "../cards/computer-card";
import { useMachineNumber } from "../../hooks/useMachineNumber";
export function DeviceGrid({ devices }: { devices: any[] }) {
const getMachineNumber = useMachineNumber();
@ -14,16 +14,15 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
const totalRows = 5;
const renderRow = (rowIndex: number) => {
// Trái: 120
const leftStart = rowIndex * 4 + 1;
// Phải: 2140
const rightStart = 21 + rowIndex * 4;
// Đảo ngược: 21-40 sang trái, 1-20 sang phải
const leftStart = 21 + (totalRows - 1 - rowIndex) * 4;
const rightStart = (totalRows - 1 - rowIndex) * 4 + 1;
return (
<div key={rowIndex} className="flex items-center justify-center gap-3">
{/* Bên trái (120) */}
{/* Bên trái (2140) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = leftStart + i;
const pos = leftStart + (3 - i);
return (
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
);
@ -34,9 +33,9 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
<div className="h-px w-full bg-border border-t-2 border-dashed" />
</div>
{/* Bên phải (2140) */}
{/* Bên phải (120) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = rightStart + i;
const pos = rightStart + (3 - i);
return (
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
);
@ -48,16 +47,15 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
return (
<div className="px-0.5 py-8 space-y-6">
<div className="flex items-center justify-between px-4 pb-6 border-b-2 border-dashed">
<div className="flex items-center gap-3 px-6 py-4 bg-primary/10 rounded-lg border-2 border-primary/20">
<Monitor className="h-6 w-6 text-primary" />
<span className="font-semibold text-lg">Bàn Giảng Viên</span>
</div>
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
<DoorOpen className="h-6 w-6 text-muted-foreground" />
<span className="font-semibold text-lg">Cửa Ra Vào</span>
</div>
<div className="flex items-center gap-3 px-6 py-4 bg-primary/10 rounded-lg border-2 border-primary/20">
<Monitor className="h-6 w-6 text-primary" />
<span className="font-semibold text-lg">Bàn Giảng Viên</span>
</div>
</div>
<div className="space-y-4">
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
</div>

View File

@ -1,151 +0,0 @@
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Checkbox } from "@/components/ui/checkbox"
import { Play, PlayCircle } from "lucide-react"
import { useState } from "react"
interface PresetCommand {
id: string
label: string
command: string
description?: string
}
interface PresetCommandsProps {
onSelectCommand: (command: string) => void
onExecuteMultiple?: (commands: string[]) => void
disabled?: boolean
}
// Danh sách các command có sẵn
const PRESET_COMMANDS: PresetCommand[] = [
{
id: "check-disk",
label: "Kiểm tra dung lượng ổ đĩa",
command: "df -h",
description: "Hiển thị thông tin dung lượng các ổ đĩa",
},
{
id: "check-memory",
label: "Kiểm tra RAM",
command: "free -h",
description: "Hiển thị thông tin bộ nhớ RAM",
},
{
id: "check-cpu",
label: "Kiểm tra CPU",
command: "top -bn1 | head -20",
description: "Hiển thị thông tin CPU và tiến trình",
},
{
id: "list-processes",
label: "Danh sách tiến trình",
command: "ps aux",
description: "Liệt kê tất cả tiến trình đang chạy",
},
{
id: "network-info",
label: "Thông tin mạng",
command: "ifconfig",
description: "Hiển thị cấu hình mạng",
},
{
id: "system-info",
label: "Thông tin hệ thống",
command: "uname -a",
description: "Hiển thị thông tin hệ điều hành",
},
{
id: "uptime",
label: "Thời gian hoạt động",
command: "uptime",
description: "Hiển thị thời gian hệ thống đã chạy",
},
{
id: "reboot",
label: "Khởi động lại",
command: "reboot",
description: "Khởi động lại thiết bị",
},
]
export function PresetCommands({ onSelectCommand, onExecuteMultiple, disabled }: PresetCommandsProps) {
const [selectedCommands, setSelectedCommands] = useState<Set<string>>(new Set())
const handleToggleCommand = (commandId: string) => {
setSelectedCommands((prev) => {
const newSet = new Set(prev)
if (newSet.has(commandId)) {
newSet.delete(commandId)
} else {
newSet.add(commandId)
}
return newSet
})
}
const handleExecuteSelected = () => {
const commands = PRESET_COMMANDS.filter((cmd) => selectedCommands.has(cmd.id)).map((cmd) => cmd.command)
if (commands.length > 0 && onExecuteMultiple) {
onExecuteMultiple(commands)
setSelectedCommands(new Set()) // Clear selection after execution
}
}
const handleSelectAll = () => {
if (selectedCommands.size === PRESET_COMMANDS.length) {
setSelectedCommands(new Set())
} else {
setSelectedCommands(new Set(PRESET_COMMANDS.map((cmd) => cmd.id)))
}
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" onClick={handleSelectAll} disabled={disabled}>
<Checkbox checked={selectedCommands.size === PRESET_COMMANDS.length} className="mr-2" />
{selectedCommands.size === PRESET_COMMANDS.length ? "Bỏ chọn tất cả" : "Chọn tất cả"}
</Button>
{selectedCommands.size > 0 && (
<Button size="sm" onClick={handleExecuteSelected} disabled={disabled}>
<PlayCircle className="h-4 w-4 mr-2" />
Thực thi {selectedCommands.size} lệnh
</Button>
)}
</div>
<ScrollArea className="h-[25vh] w-full rounded-md border p-4">
<div className="space-y-2">
{PRESET_COMMANDS.map((preset) => (
<div
key={preset.id}
className="flex items-start gap-3 rounded-lg border p-3 hover:bg-accent transition-colors"
>
<Checkbox
checked={selectedCommands.has(preset.id)}
onCheckedChange={() => handleToggleCommand(preset.id)}
disabled={disabled}
className="mt-1"
/>
<div className="flex-1 space-y-1">
<div className="font-medium text-sm">{preset.label}</div>
{preset.description && <div className="text-xs text-muted-foreground">{preset.description}</div>}
<code className="text-xs bg-muted px-2 py-1 rounded block mt-1">{preset.command}</code>
</div>
<Button
size="sm"
variant="outline"
onClick={() => onSelectCommand(preset.command)}
disabled={disabled}
className="shrink-0"
>
<Play className="h-4 w-4" />
</Button>
</div>
))}
</div>
</ScrollArea>
</div>
)
}

View File

@ -1,135 +0,0 @@
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>
)
}

View File

@ -17,7 +17,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
import { useMachineNumber } from "../hooks/useMachineNumber";
import { useMachineNumber } from "@/hooks/useMachineNumber";
interface DeviceTableProps {
devices: any[];

View File

@ -3,7 +3,7 @@ import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { useIsMobile } from "@/hooks/useMobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

View File

@ -1,164 +0,0 @@
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 { Progress } from "@/components/ui/progress";
import { useForm, formOptions } from "@tanstack/react-form";
import { toast } from "sonner";
import type { AxiosProgressEvent } from "axios";
interface UploadDialogProps {
onSubmit: (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => Promise<void>;
}
const formOpts = formOptions({
defaultValues: { files: new DataTransfer().files, newVersion: "" },
});
export function UploadDialog({ onSubmit }: UploadDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [isDone, setIsDone] = useState(false);
const form = useForm({
...formOpts,
onSubmit: async ({ value }) => {
if (!value.newVersion || value.files.length === 0) {
toast.error("Vui lòng điền đầy đủ thông tin");
return;
}
try {
setIsUploading(true);
setUploadPercent(0);
setIsDone(false);
const fd = new FormData();
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
fd.append("Version", value.newVersion);
await onSubmit(fd, {
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.total) {
const progress = Math.round((e.loaded * 100) / e.total);
setUploadPercent(progress);
}
},
});
setIsDone(true);
} catch (error) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
} finally {
setIsUploading(false);
}
},
});
const handleDialogClose = (open: boolean) => {
if (isUploading) return;
setIsOpen(open);
if (!open) {
setUploadPercent(0);
setIsDone(false);
form.reset();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleDialogClose}>
<DialogTrigger asChild>
<Button>Tải lên phiên bản mới</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Cập nhật phiên bản</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field name="newVersion">
{(field) => (
<div>
<Label>Phiên bản</Label>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="1.0.0"
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
<form.Field name="files">
{(field) => (
<div>
<Label>File</Label>
<Input
type="file"
accept=".exe,.msi,.apk"
onChange={(e) =>
e.target.files && field.handleChange(e.target.files)
}
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
{(uploadPercent > 0 || isUploading || isDone) && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
<span>{uploadPercent}%</span>
</div>
<Progress value={uploadPercent} className="w-full" />
</div>
)}
<DialogFooter>
{!isDone ? (
<>
<Button
type="button"
variant="outline"
onClick={() => handleDialogClose(false)}
disabled={isUploading}
>
Hủy
</Button>
<Button type="submit" disabled={isUploading}>
{isUploading ? "Đang tải..." : "Upload"}
</Button>
</>
) : (
<Button type="button" onClick={() => handleDialogClose(false)}>
Hoàn tất
</Button>
)}
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -11,7 +11,7 @@ export const API_ENDPOINTS = {
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
DELETE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
DELETE_BLACKLIST: (appId: number) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
UPDATE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/update/${appId}`,
REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
},
@ -22,6 +22,7 @@ export const API_ENDPOINTS = {
GET_DEVICE_FROM_ROOM: (roomName: string) =>
`${BASE_URL}/DeviceComm/room/${roomName}`,
UPDATE_AGENT: (roomName: string) => `${BASE_URL}/DeviceComm/updateagent/${roomName}`,
UPDATE_BLACKLIST: (roomName: string) => `${BASE_URL}/DeviceComm/updateblacklist/${roomName}`,
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
},

View File

@ -0,0 +1,9 @@
import type { Room } from "@/types/room";
import type { SelectItem } from "@/components/dialogs/select-dialog";
export function mapRoomsToSelectItems(rooms: Room[]): SelectItem[] {
return rooms.map((room) => ({
label: `${room.name} (${room.numberOfDevices} máy, ${room.numberOfOfflineDevices} offline)`,
value: room.name,
}));
}

View File

@ -0,0 +1,37 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
type DeleteDataOptions<TOutput> = {
onSuccess?: (data: TOutput) => void;
onError?: (error: any) => void;
invalidate?: string[][];
};
export function useDeleteData<TOutput = any>({
onSuccess,
onError,
invalidate = [],
}: DeleteDataOptions<TOutput> = {}) {
const queryClient = useQueryClient();
return useMutation<
TOutput,
any,
{
url: string;
config?: any;
}
>({
mutationFn: async ({ url, config }) => {
const response = await axios.delete(url, config);
return response.data;
},
onSuccess: (data) => {
invalidate.forEach((key) =>
queryClient.invalidateQueries({ queryKey: key })
);
onSuccess?.(data);
},
onError,
});
}

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
type QueryDataOptions<T> = {
queryKey: string[];
@ -7,7 +7,7 @@ type QueryDataOptions<T> = {
params?: Record<string, any>;
select?: (data: any) => T;
enabled?: boolean;
}
};
export function useQueryData<T = any>({
queryKey,
@ -21,6 +21,5 @@ export function useQueryData<T = any>({
queryFn: () => axios.get(url, { params }).then((res) => res.data),
select,
enabled,
})
});
}

View File

@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { AppSidebar } from "@/components/sidebars/app-sidebar";
import {
SidebarProvider,
SidebarInset,

View File

@ -10,7 +10,7 @@ import "./styles.css";
const auth = useAuthToken.getState();
const queryClient = new QueryClient();
export const queryClient = new QueryClient();
// Create a new router instance
const router = createRouter({

View File

@ -6,7 +6,6 @@ import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import type { AxiosProgressEvent } from "axios";
import { type Room } from "@/types/room";
type Version = {
id?: string;
@ -35,11 +34,6 @@ function AgentsPage() {
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
const versionList: Version[] = Array.isArray(data)
? data
: data
@ -76,18 +70,18 @@ function AgentsPage() {
};
const handleUpdate = async (roomNames: string[]) => {
for (const roomName of roomNames) {
try {
for (const roomName of roomNames) {
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!");
} catch (e) {
toast.error("Có lỗi xảy ra khi cập nhật!");
}
};
// Cột bảng
@ -123,7 +117,7 @@ function AgentsPage() {
onUpload={handleUpload}
onUpdate={handleUpdate}
updateLoading={updateMutation.isPending}
rooms={rooms}
rooms={roomData}
/>
);
}

View File

@ -7,7 +7,6 @@ import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import { useState } from "react";
import type { AxiosProgressEvent } from "axios";
import type { Room } from "@/types/room";
export const Route = createFileRoute("/_authenticated/apps/")({
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
@ -34,11 +33,6 @@ function AppsComponent() {
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
const versionList: Version[] = Array.isArray(data)
? data
: data
@ -127,14 +121,17 @@ function AppsComponent() {
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
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!");
} catch (e) {
toast.error("Có lỗi xảy ra khi cài đặt!");
}
};
return (
@ -148,7 +145,7 @@ function AppsComponent() {
onUpdate={handleInstall}
updateLoading={installMutation.isPending}
onTableInit={setTable}
rooms={rooms}
rooms={roomData}
/>
);
}

View File

@ -1,66 +1,181 @@
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { useMutationData } from "@/hooks/useMutationData";
import { useDeleteData } from "@/hooks/useDeleteData";
import { useQueryData } from "@/hooks/useQueryData";
import { createFileRoute } from "@tanstack/react-router";
import type { ColumnDef } from "@tanstack/react-table";
import type { Blacklist } from "@/types/black-list";
import { BlackListManagerTemplate } from "@/template/table-manager-template";
import { toast } from "sonner";
import { useState } from "react";
type Blacklist = {
id: number;
appName: string;
processName: string;
createdAt?: string;
updatedAt?: string;
createdBy?: string;
};
export const Route = createFileRoute("/_authenticated/blacklist/")({
head: () => ({ meta: [{ title: "Danh sách các ứng dụng bị chặn" }] }),
component: BlacklistComponent,
});
function BlacklistComponent() {
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
// Lấy danh sách blacklist
const { data, isLoading } = useQueryData({
queryKey: ["blacklist"],
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
});
// Lấy danh sách phòng
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
const blacklist: Blacklist[] = Array.isArray(data)
? (data as Blacklist[])
: [];
const columns : ColumnDef<Blacklist>[] =
[
const columns: ColumnDef<Blacklist>[] = [
{
accessorKey: "id",
header: "ID",
cell: info => info.getValue(),
header: "STT",
cell: (info) => info.getValue(),
},
{
accessorKey: "appName",
header: "Tên ứng dụng",
cell: info => info.getValue(),
cell: (info) => info.getValue(),
},
{
accessorKey: "processName",
header: "Tên tiến trình",
cell: info => info.getValue(),
cell: (info) => info.getValue(),
},
{
accessorKey: "createdAt",
header: "Ngày tạo",
cell: info => info.getValue(),
cell: (info) => info.getValue(),
},
{
accessorKey: "updatedAt",
header: "Ngày cập nhật",
cell: info => info.getValue(),
cell: (info) => info.getValue(),
},
{
accessorKey: "createdBy",
header: "Người tạo",
cell: info => info.getValue(),
cell: (info) => info.getValue(),
},
]
return <div>Hello "/_authenticated/blacklist/"!</div>;
{
id: "select",
header: () => (
<input
type="checkbox"
onChange={(e) => {
if (e.target.checked) {
const allIds = data.map((item: { id: number }) => item.id);
setSelectedRows(new Set(allIds));
} else {
setSelectedRows(new Set());
}
}}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={selectedRows.has(row.original.id)}
onChange={(e) => {
const newSelected = new Set(selectedRows);
if (e.target.checked) {
newSelected.add(row.original.id);
} else {
newSelected.delete(row.original.id);
}
setSelectedRows(newSelected);
}}
/>
),
},
];
// API thêm blacklist
const addNewBlacklistMutation = useMutationData<void>({
url: "",
method: "POST",
onSuccess: () => toast.success("Thêm mới thành công!"),
onError: () => toast.error("Thêm mới thất bại!"),
});
// API cập nhật thiết bị
const updateDeviceMutation = useMutationData<void>({
url: "",
method: "POST",
onSuccess: () => toast.success("Cập nhật thành công!"),
onError: () => toast.error("Cập nhật thất bại!"),
});
// API xoá
const deleteBlacklistMutation = useDeleteData<void>({
invalidate: [["blacklist"]],
onSuccess: () => toast.success("Xóa thành công!"),
onError: () => toast.error("Xóa thất bại!"),
});
// Thêm blacklist
const handleAddNewBlacklist = async (blacklist: {
appName: string;
processName: string;
}) => {
try {
await addNewBlacklistMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.APP_VERSION.ADD_BLACKLIST,
method: "POST",
config: { headers: { "Content-Type": "application/json" } },
data: undefined,
});
} catch {
toast.error("Thêm mới thất bại!");
}
};
// Xoá blacklist
const handleDeleteBlacklist = async () => {
try {
for (const blacklistId of selectedRows) {
await deleteBlacklistMutation.mutateAsync({
url:
BASE_URL + API_ENDPOINTS.APP_VERSION.DELETE_BLACKLIST(blacklistId),
config: { headers: { "Content-Type": "application/json" } },
});
}
setSelectedRows(new Set());
} catch {}
};
const handleUpdateDevice = async (target: string | string[]) => {
const targets = Array.isArray(target) ? target : [target];
try {
for (const deviceId of targets) {
await updateDeviceMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_BLACKLIST(deviceId),
data: undefined,
});
toast.success(`Đã gửi cập nhật cho ${deviceId}`);
}
} catch (e) {
toast.error("Có lỗi xảy ra khi cập nhật!");
}
};
return (
<BlackListManagerTemplate<Blacklist>
title="Danh sách các ứng dụng bị chặn"
description="Quản lý các ứng dụng và tiến trình bị chặn trên thiết bị"
data={blacklist}
columns={columns}
isLoading={isLoading}
rooms={roomData}
onAdd={handleAddNewBlacklist}
onUpdate={handleUpdateDevice}
/>
);
}

View File

@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { FormSubmitTemplate } from "@/template/form-submit-template";
import { ShellCommandForm } from "@/components/command-form";
import { ShellCommandForm } from "@/components/forms/command-form";
import { useMutationData } from "@/hooks/useMutationData";
import { useQueryData } from "@/hooks/useQueryData";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
@ -22,11 +22,6 @@ function CommandPage() {
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
// Mutation gửi lệnh
const sendCommandMutation = useMutationData<
SendCommandRequest,
@ -52,7 +47,7 @@ function CommandPage() {
title="CMD Command"
description="Gửi lệnh shell xuống thiết bị để thực thi"
isLoading={sendCommandMutation.isPending}
rooms={rooms}
rooms={roomData}
onSubmit={(roomName, command) => {
sendCommandMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(roomName),

View File

@ -5,8 +5,9 @@ import { LayoutGrid, TableIcon, Monitor } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useQueryData } from "@/hooks/useQueryData";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { DeviceGrid } from "@/components/device-grid";
import { DeviceTable } from "@/components/device-table";
import { DeviceGrid } from "@/components/grids/device-grid";
import { DeviceTable } from "@/components/tables/device-table";
import { useMachineNumber } from "@/hooks/useMachineNumber";
export const Route = createFileRoute("/_authenticated/room/$roomName/")({
head: ({ params }) => ({
@ -23,6 +24,12 @@ function RoomDetailPage() {
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
});
const parseMachineNumber = useMachineNumber();
const sortedDevices = [...devices].sort((a, b) => {
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
});
return (
<div className="w-full px-6 space-y-6">
<Card className="shadow-sm">
@ -64,9 +71,9 @@ function RoomDetailPage() {
</p>
</div>
) : viewMode === "grid" ? (
<DeviceGrid devices={devices} />
<DeviceGrid devices={sortedDevices} />
) : (
<DeviceTable devices={devices} />
<DeviceTable devices={sortedDevices} />
)}
</CardContent>
</Card>

View File

@ -0,0 +1,21 @@
import axios from "axios";
import { queryClient } from "@/main";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import type { DeviceHealthCheck } from "@/types/device";
export async function fetchDevicesFromRoom(
roomName: string
): Promise<DeviceHealthCheck[]> {
const data = await queryClient.ensureQueryData({
queryKey: ["devices-from-room", roomName],
queryFn: async () => {
const response = await axios.get<DeviceHealthCheck[]>(
BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName)
);
return response.data ?? [];
},
staleTime: 1000 * 60 * 3,
});
return data;
}

View File

@ -7,13 +7,18 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { FileText, Building2, Monitor } from "lucide-react";
import { UploadDialog } from "@/components/upload-dialog";
import { VersionTable } from "@/components/version-table";
import { RequestUpdateMenu } from "@/components/request-update-menu";
import { FileText, Building2 } from "lucide-react";
import { FormDialog } from "@/components/dialogs/form-dialog";
import { VersionTable } from "@/components/tables/version-table";
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import type { AxiosProgressEvent } from "axios";
import { useState } from "react";
import { SelectDialog } from "@/components/select-dialog"; // <-- dùng dialog chung
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import { UploadVersionForm } from "@/components/forms/upload-file-form";
import type { Room } from "@/types/room";
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
import { fetchDevicesFromRoom } from "@/services/device.service";
interface AppManagerTemplateProps<TData> {
title: string;
@ -28,7 +33,7 @@ interface AppManagerTemplateProps<TData> {
onUpdate?: (targetNames: string[]) => Promise<void> | void;
updateLoading?: boolean;
onTableInit?: (table: any) => void;
rooms?: string[];
rooms?: Room[];
devices?: string[];
}
@ -56,7 +61,7 @@ export function AppManagerTemplate<TData>({
};
const openDeviceDialog = () => {
if (devices.length > 0 && onUpdate) {
if (onUpdate) {
setDialogType("device");
setDialogOpen(true);
}
@ -64,32 +69,14 @@ export function AppManagerTemplate<TData>({
const handleUpdateAll = async () => {
if (!onUpdate) return;
const allTargets = [...rooms, ...devices];
// Assuming Room has an 'id' property; adjust if needed
const roomIds = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomIds, ...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 */}
@ -98,10 +85,16 @@ export function AppManagerTemplate<TData>({
<h1 className="text-3xl font-bold">{title}</h1>
<p className="text-muted-foreground mt-2">{description}</p>
</div>
<UploadDialog onSubmit={onUpload} />
<FormDialog
triggerLabel="Tải lên phiên bản mới"
title="Cập nhật phiên bản"
>
{(closeDialog) => (
<UploadVersionForm onSubmit={onUpload} closeDialog={closeDialog} />
)}
</FormDialog>
</div>
{/* Table */}
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@ -131,15 +124,15 @@ export function AppManagerTemplate<TData>({
)}
</Card>
{/* 🧩 SelectDialog tái sử dụng */}
{dialogProps && (
{/* Dialog chọn phòng */}
{dialogType === "room" && (
<SelectDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title={dialogProps.title}
description={dialogProps.description}
icon={dialogProps.icon}
items={dialogProps.items}
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={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => {
if (!onUpdate) return;
await onUpdate(selectedItems);
@ -147,6 +140,17 @@ export function AppManagerTemplate<TData>({
}}
/>
)}
{/* Dialog tìm thiết bị */}
{dialogType === "device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "device"}
onClose={() => setDialogOpen(false)}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom} // ⬅ thêm vào đây
onSelect={(deviceIds) => onUpdate && onUpdate(deviceIds)}
/>
)}
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useState } from "react"
import { useState } from "react";
import {
Card,
CardContent,
@ -6,82 +6,69 @@ import {
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Terminal, Building2, Monitor } from "lucide-react"
import { RequestUpdateMenu } from "@/components/request-update-menu"
import { SelectDialog } from "@/components/select-dialog"
} from "@/components/ui/card";
import { Terminal, Building2 } from "lucide-react";
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import type { Room } from "@/types/room";
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
import { fetchDevicesFromRoom } from "@/services/device.service";
interface FormSubmitTemplateProps {
title: string
description: string
isLoading?: boolean
title: string;
description: string;
isLoading?: boolean;
children: (props: {
command: string
setCommand: (val: string) => void
}) => React.ReactNode
onSubmit?: (target: string, command: string) => void | Promise<void>
submitLoading?: boolean
rooms?: string[]
devices?: string[]
command: string;
setCommand: (val: string) => void;
}) => React.ReactNode;
onSubmit?: (target: string, command: string) => void | Promise<void>;
submitLoading?: boolean;
rooms?: Room[];
devices?: string[];
}
export function FormSubmitTemplate({
title,
description,
isLoading,
children,
onSubmit,
submitLoading,
rooms = [],
devices = [],
}: FormSubmitTemplateProps) {
const [command, setCommand] = useState("")
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null)
const [command, setCommand] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null);
// Mở dialog chọn phòng
const openRoomDialog = () => {
if (rooms.length > 0 && onSubmit) {
setDialogType("room")
setDialogOpen(true)
}
setDialogType("room");
setDialogOpen(true);
}
};
// Mở dialog tìm thiết bị (search bar)
const openDeviceDialog = () => {
if (devices.length > 0 && onSubmit) {
setDialogType("device")
setDialogOpen(true)
}
if (onSubmit) {
setDialogType("device");
setDialogOpen(true);
}
};
// Gửi cho tất cả
const handleSubmitAll = () => {
if (!onSubmit) return
const allTargets = [...rooms, ...devices]
if (!onSubmit) return;
const roomIds = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomIds, ...devices];
for (const target of allTargets) {
onSubmit(target, command)
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">
@ -112,24 +99,38 @@ export function FormSubmitTemplate({
)}
</Card>
{/* 🧩 Dùng SelectDialog chung */}
{dialogProps && (
{/* Dialog chọn phòng */}
{dialogType === "room" && (
<SelectDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title={dialogProps.title}
description={dialogProps.description}
icon={dialogProps.icon}
items={dialogProps.items}
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={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => {
if (!onSubmit) return
if (!onSubmit) return;
for (const item of selectedItems) {
await onSubmit(item, command)
await onSubmit(item, command);
}
setDialogOpen(false)
setDialogOpen(false);
}}
/>
)}
</div>
)
{/* Dialog tìm thiết bị */}
{dialogType === "device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "device"}
onClose={() => setDialogOpen(false)}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom} // ⬅ thêm vào đây
onSelect={(deviceIds) =>
onSubmit &&
deviceIds.forEach((deviceId) => onSubmit(deviceId, command))
}
/>
)}
</div>
);
}

View File

@ -1,5 +1,6 @@
import { RequestUpdateMenu } from "@/components/request-update-menu";
import { SelectDialog } from "@/components/select-dialog";
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import {
Card,
CardContent,
@ -8,12 +9,16 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { UploadDialog } from "@/components/upload-dialog";
import { VersionTable } from "@/components/version-table";
import { FormDialog } from "@/components/dialogs/form-dialog";
import { VersionTable } from "@/components/tables/version-table";
import type { ColumnDef } from "@tanstack/react-table";
import type { AxiosProgressEvent } from "axios";
import { FileText, Building2, Monitor } from "lucide-react";
import { FileText, Building2 } from "lucide-react";
import { useState } from "react";
import { BlacklistForm } from "@/components/forms/black-list-form";
import type { BlacklistFormData } from "@/types/black-list";
import type { Room } from "@/types/room";
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
import { fetchDevicesFromRoom } from "@/services/device.service";
interface BlackListManagerTemplateProps<TData> {
title: string;
@ -21,15 +26,12 @@ interface BlackListManagerTemplateProps<TData> {
data: TData[];
isLoading: boolean;
columns: ColumnDef<TData, any>[];
onUpload: (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => Promise<void>;
onUpdate?: (roomName: string) => void;
onAdd: (data: BlacklistFormData) => Promise<void>;
onDelete?: (id: number) => Promise<void>;
onUpdate?: (target: string | string[]) => void | Promise<void>;
updateLoading?: boolean;
onTableInit?: (table: any) => void;
rooms: string[];
devices?: string[];
rooms: Room[];
}
export function BlackListManagerTemplate<TData>({
@ -38,18 +40,17 @@ export function BlackListManagerTemplate<TData>({
data,
isLoading,
columns,
onUpload,
onAdd,
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 handleUpdateAll = async () => {
if (onUpdate) await onUpdate("All");
};
const openRoomDialog = () => {
@ -60,33 +61,12 @@ export function BlackListManagerTemplate<TData>({
};
const openDeviceDialog = () => {
if (devices.length > 0 && onUpdate) {
if (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 */}
@ -95,7 +75,14 @@ export function BlackListManagerTemplate<TData>({
<h1 className="text-3xl font-bold">{title}</h1>
<p className="text-muted-foreground mt-2">{description}</p>
</div>
<UploadDialog onSubmit={onUpload} />
<FormDialog
triggerLabel="Thêm phần mềm bị chặn"
title="Thêm phần mềm bị chặn"
>
{(closeDialog) => (
<BlacklistForm onSubmit={onAdd} closeDialog={closeDialog} />
)}
</FormDialog>
</div>
{/* Table */}
@ -131,23 +118,33 @@ export function BlackListManagerTemplate<TData>({
)}
</Card>
{dialogProps && (
{/* Dialog chọn phòng */}
{dialogType === "room" && (
<SelectDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title={dialogProps.title}
description={dialogProps.description}
icon={dialogProps.icon}
items={dialogProps.items}
onConfirm={async (selectedItems) => {
title="Chọn phòng"
description="Chọn các phòng cần cập nhật danh sách đen"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedRooms) => {
if (!onUpdate) return;
for (const item of selectedItems) {
onUpdate(item);
}
await onUpdate(selectedRooms);
setDialogOpen(false);
}}
/>
)}
{/* Dialog tìm thiết bị */}
{dialogType === "device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "device"}
onClose={() => setDialogOpen(false)}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom} // ⬅ thêm vào đây
onSelect={(deviceIds) => onUpdate && onUpdate(deviceIds)}
/>
)}
</div>
);
}

10
src/types/black-list.ts Normal file
View File

@ -0,0 +1,10 @@
export type Blacklist = {
id: number;
appName: string;
processName: string;
createdAt?: string;
updatedAt?: string;
createdBy?: string;
};
export type BlacklistFormData = Pick<Blacklist, "appName" | "processName">;

View File

@ -0,0 +1,3 @@
export type InstallHistory = {
}