fix UI freeze problem

This commit is contained in:
Do Manh Phuong 2025-12-02 11:03:15 +07:00
parent 3b1865c21d
commit 451eed4c65
7 changed files with 204 additions and 26 deletions

View File

@ -0,0 +1,81 @@
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface DeleteButtonProps {
onClick: () => void | Promise<void>;
loading?: boolean;
disabled?: boolean;
label?: string;
title?: string;
description?: string;
}
export function DeleteButton({
onClick,
loading = false,
disabled = false,
label = "Xóa",
title = "Xác nhận xóa",
description = "Bạn có chắc chắn muốn xóa các mục này không?",
}: DeleteButtonProps) {
const [open, setOpen] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const handleConfirm = async () => {
setIsConfirming(true);
try {
await onClick();
} finally {
setIsConfirming(false);
setOpen(false);
}
};
return (
<>
<Button
variant="destructive"
onClick={() => setOpen(true)}
disabled={loading || disabled}
className="gap-2"
>
<Trash2 className="h-4 w-4" />
{label}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isConfirming}
>
Hủy
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={isConfirming || loading}
>
{isConfirming ? "Đang xóa..." : "Xóa"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -17,6 +17,13 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
const [isUploading, setIsUploading] = useState(false);
const [isDone, setIsDone] = useState(false);
// Match server allowed extensions
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb"];
const isFileValid = (file: File) => {
const fileName = file.name.toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
};
const form = useForm({
defaultValues: { files: new DataTransfer().files, newVersion: "" },
onSubmit: async ({ value }) => {
@ -25,6 +32,15 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
return;
}
// Validate file types
const invalidFiles = Array.from(value.files).filter((f) => !isFileValid(f));
if (invalidFiles.length > 0) {
toast.error(
`File không hợp lệ: ${invalidFiles.map((f) => f.name).join(", ")}. Chỉ chấp nhận ${ALLOWED_EXTENSIONS.join(", ")}`
);
return;
}
try {
setIsUploading(true);
setUploadPercent(0);
@ -81,9 +97,13 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
<Label>File</Label>
<Input
type="file"
accept=".exe,.apk,.conf,.json,.xml,.setting,.lnk,.url,.seb"
onChange={(e) => e.target.files && field.handleChange(e.target.files)}
disabled={isUploading || isDone}
/>
<p className="text-xs text-muted-foreground mt-1">
Chỉ chấp nhận file: .exe, .apk, .conf, .json, .xml, .setting, .lnk, .url, .seb
</p>
</div>
)}
</form.Field>

View File

@ -14,6 +14,11 @@ interface RequestUpdateMenuProps {
onUpdateRoom: () => void;
onUpdateAll: () => void;
loading?: boolean;
label?: string;
deviceLabel?: string;
roomLabel?: string;
allLabel?: string;
icon?: React.ReactNode;
}
export function RequestUpdateMenu({
@ -21,6 +26,11 @@ export function RequestUpdateMenu({
onUpdateRoom,
onUpdateAll,
loading,
label = "Cập nhật",
deviceLabel = "Thiết bị cụ thể",
roomLabel = "Theo phòng",
allLabel = "Tất cả thiết bị",
icon,
}: RequestUpdateMenuProps) {
const [open, setOpen] = useState(false);
@ -58,11 +68,13 @@ export function RequestUpdateMenu({
<div className="flex items-center gap-2">
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-600" />
) : icon ? (
<div className="h-4 w-4 text-gray-600">{icon}</div>
) : (
<RefreshCw className="h-4 w-4 text-gray-600 transition-transform duration-300 group-hover:rotate-180" />
)}
<span className="text-sm font-semibold">
{loading ? "Đang gửi..." : "Cập nhật"}
{loading ? "Đang gửi..." : label}
</span>
<ChevronDown className="h-4 w-4 text-gray-600 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
@ -73,17 +85,17 @@ export function RequestUpdateMenu({
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem onClick={handleUpdateDevice} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>Cập nhật thiết bị cụ thể</span>
<span>{deviceLabel}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleUpdateRoom} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>Cập nhật theo phòng</span>
<span>{roomLabel}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleUpdateAll} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>Cập nhật tất cả thiết bị</span>
<span>{allLabel}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -18,7 +18,7 @@ interface VersionTableProps<TData> {
data: TData[];
columns: ColumnDef<TData, any>[];
isLoading: boolean;
onTableInit?: (table: any) => void; // <-- thêm
onTableInit?: (table: any) => void;
}
export function VersionTable<TData>({

View File

@ -13,6 +13,7 @@ export const API_ENDPOINTS = {
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
DELETE_BLACKLIST: (appId: number) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
UPDATE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/update/${appId}`,
DELETE_FILES: `${BASE_URL}/AppVersion/delete`,
REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
},
DEVICE_COMM: {

View File

@ -72,6 +72,17 @@ function AppsComponent() {
},
});
const deleteMutation = useMutationData<{ MsiFileIds: number[] }>({
url: BASE_URL + API_ENDPOINTS.APP_VERSION.DELETE_FILES,
method: "DELETE",
invalidate: [["software-version"]],
onSuccess: () => toast.success("Xóa phần mềm thành công!"),
onError: (error) => {
console.error("Delete error:", error);
toast.error("Xóa phần mềm thất bại!");
},
});
// Cột bảng
const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
@ -171,6 +182,30 @@ function AppsComponent() {
}
};
const handleDelete = async () => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để xóa!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
await deleteMutation.mutateAsync({
data: { MsiFileIds },
});
} catch (e) {
console.error("Delete error:", e);
toast.error("Có lỗi xảy ra khi xóa!");
}
};
return (
<AppManagerTemplate<Version>
title="Quản lý phần mềm"
@ -181,8 +216,10 @@ function AppsComponent() {
onUpload={handleUpload}
onUpdate={handleInstall}
onDownload={handleDonwload}
onDelete={handleDelete}
updateLoading={installMutation.isPending}
downloadLoading={downloadMutation.isPending}
deleteLoading={deleteMutation.isPending}
onTableInit={setTable}
rooms={roomData}
/>

View File

@ -7,10 +7,11 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { FileText, Building2 } from "lucide-react";
import { FileText, Building2, Download } 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 { DeleteButton } from "@/components/buttons/delete-button";
import type { AxiosProgressEvent } from "axios";
import { useState } from "react";
import { SelectDialog } from "@/components/dialogs/select-dialog";
@ -34,6 +35,8 @@ interface AppManagerTemplateProps<TData> {
updateLoading?: boolean;
onDownload?: (targetNames: string[]) => Promise<void> | void;
downloadLoading?: boolean;
onDelete?: () => Promise<void> | void;
deleteLoading?: boolean;
onTableInit?: (table: any) => void;
rooms?: Room[];
devices?: string[];
@ -50,6 +53,8 @@ export function AppManagerTemplate<TData>({
updateLoading,
onDownload,
downloadLoading,
onDelete,
deleteLoading,
onTableInit,
rooms = [],
devices = [],
@ -133,27 +138,45 @@ export function AppManagerTemplate<TData>({
/>
</CardContent>
{onUpdate && (
<CardFooter className="gap-2">
<RequestUpdateMenu
onUpdateDevice={openDeviceDialog}
onUpdateRoom={openRoomDialog}
onUpdateAll={handleUpdateAll}
loading={updateLoading}
/>
{onDownload && (
{(onUpdate || onDelete) && (
<CardFooter className="flex items-center justify-between gap-4">
<div className="flex gap-2">
<RequestUpdateMenu
onUpdateDevice={openDownloadDeviceDialog}
onUpdateRoom={openDownloadRoomDialog}
onUpdateAll={() => {
if (!onDownload) return;
const roomIds = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomIds, ...devices];
onDownload(allTargets);
}}
loading={downloadLoading}
onUpdateDevice={openDeviceDialog}
onUpdateRoom={openRoomDialog}
onUpdateAll={handleUpdateAll}
loading={updateLoading}
label="Cài đặt"
deviceLabel="Cài đặt thiết bị cụ thể"
roomLabel="Cài đặt theo phòng"
allLabel="Cài đặt tất cả thiết bị"
/>
{onDownload && (
<RequestUpdateMenu
onUpdateDevice={openDownloadDeviceDialog}
onUpdateRoom={openDownloadRoomDialog}
onUpdateAll={() => {
if (!onDownload) return;
const roomIds = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomIds, ...devices];
onDownload(allTargets);
}}
loading={downloadLoading}
label="Tải xuống"
deviceLabel="Tải xuống thiết bị cụ thể"
roomLabel="Tải xuống theo phòng"
allLabel="Tải xuống tất cả thiết bị"
icon={<Download className="h-4 w-4" />}
/>
)}
</div>
{onDelete && (
<DeleteButton
onClick={onDelete}
loading={deleteLoading}
disabled={false}
/>
)}
</CardFooter>
@ -167,6 +190,7 @@ export function AppManagerTemplate<TData>({
onClose={() => {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng cần cập nhật"
@ -194,6 +218,7 @@ export function AppManagerTemplate<TData>({
onClose={() => {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom}
@ -223,6 +248,7 @@ export function AppManagerTemplate<TData>({
onClose={() => {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để tải file xuống"
@ -250,6 +276,7 @@ export function AppManagerTemplate<TData>({
onClose={() => {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom}