add setup folder scan
This commit is contained in:
parent
451eed4c65
commit
eb4243ee5b
|
|
@ -23,9 +23,9 @@ 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?",
|
||||
label = "Xóa khỏi server",
|
||||
title = "Xóa khỏi server",
|
||||
description = "Bạn có chắc chắn muốn xóa các phần mềm này khỏi server không? Hành động này không thể hoàn tác.",
|
||||
}: DeleteButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
|
@ -46,23 +46,28 @@ export function DeleteButton({
|
|||
variant="destructive"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={loading || disabled}
|
||||
className="gap-2"
|
||||
className="gap-2 px-4"
|
||||
>
|
||||
{loading || isConfirming ? (
|
||||
<span className="animate-spin">⏳</span>
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{label}
|
||||
)}
|
||||
{loading || isConfirming ? "Đang xóa..." : label}
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
<DialogTitle className="text-lg">{title}</DialogTitle>
|
||||
<DialogDescription className="text-base">{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isConfirming}
|
||||
className="flex-1"
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
|
|
@ -70,8 +75,19 @@ export function DeleteButton({
|
|||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
disabled={isConfirming || loading}
|
||||
className="flex-1 gap-2"
|
||||
>
|
||||
{isConfirming ? "Đang xóa..." : "Xóa"}
|
||||
{isConfirming ? (
|
||||
<>
|
||||
<span className="animate-spin">⏳</span>
|
||||
Đang xóa...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,19 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Monitor, Wifi, WifiOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FolderStatusPopover } from "../folder-status-popover";
|
||||
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
||||
|
||||
export function ComputerCard({
|
||||
device,
|
||||
position,
|
||||
folderStatus,
|
||||
isCheckingFolder,
|
||||
}: {
|
||||
device: any | undefined;
|
||||
position: number;
|
||||
folderStatus?: ClientFolderStatus;
|
||||
isCheckingFolder?: boolean;
|
||||
}) {
|
||||
if (!device) {
|
||||
return (
|
||||
|
|
@ -98,6 +104,17 @@ export function ComputerCard({
|
|||
{position}
|
||||
</div>
|
||||
|
||||
{/* Folder Status Icon */}
|
||||
{device && !isOffline && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<FolderStatusPopover
|
||||
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
|
||||
status={folderStatus}
|
||||
isLoading={isCheckingFolder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
|
||||
{firstNetworkInfo?.ipAddress && (
|
||||
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
||||
|
|
|
|||
145
src/components/folder-status-popover.tsx
Normal file
145
src/components/folder-status-popover.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CheckCircle2, AlertCircle, Loader2, AlertTriangle } from "lucide-react";
|
||||
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface FolderStatusPopoverProps {
|
||||
deviceId: string;
|
||||
status?: ClientFolderStatus;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function FolderStatusPopover({
|
||||
deviceId,
|
||||
status,
|
||||
isLoading,
|
||||
}: FolderStatusPopoverProps) {
|
||||
const hasMissing = status && status.missingFiles.length > 0;
|
||||
const hasExtra = status && status.extraFiles.length > 0;
|
||||
const hasIssues = hasMissing || hasExtra;
|
||||
|
||||
// Xác định màu sắc và icon dựa trên trạng thái
|
||||
let statusColor = "text-green-500";
|
||||
let statusIcon = (
|
||||
<CheckCircle2 className={`h-5 w-5 ${statusColor}`} />
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
statusColor = "text-blue-500";
|
||||
statusIcon = <Loader2 className={`h-5 w-5 animate-spin ${statusColor}`} />;
|
||||
} else if (hasMissing && hasExtra) {
|
||||
// Vừa thiếu vừa thừa -> Đỏ + Alert
|
||||
statusColor = "text-red-600";
|
||||
statusIcon = <AlertTriangle className={`h-5 w-5 ${statusColor}`} />;
|
||||
} else if (hasMissing) {
|
||||
// Chỉ thiếu -> Đỏ
|
||||
statusColor = "text-red-500";
|
||||
statusIcon = <AlertCircle className={`h-5 w-5 ${statusColor}`} />;
|
||||
} else if (hasExtra) {
|
||||
// Chỉ thừa -> Cam
|
||||
statusColor = "text-orange-500";
|
||||
statusIcon = <AlertCircle className={`h-5 w-5 ${statusColor}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="p-2 hover:bg-muted rounded-md transition-colors">
|
||||
{statusIcon}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96 p-4" side="right">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-semibold">Thư mục Setup: {deviceId}</div>
|
||||
{hasIssues && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{hasMissing && hasExtra
|
||||
? "Không đồng bộ"
|
||||
: hasMissing
|
||||
? "Thiếu file"
|
||||
: "Thừa file"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Đang kiểm tra...
|
||||
</div>
|
||||
) : !status ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Chưa có dữ liệu
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* File thiếu */}
|
||||
{hasMissing && (
|
||||
<div className="border-l-4 border-red-500 pl-3">
|
||||
<h4 className="text-sm font-semibold text-red-600 mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
File thiếu ({status.missingFiles.length})
|
||||
</h4>
|
||||
<ScrollArea className="h-32 rounded-md border bg-red-50/30 p-2">
|
||||
<div className="space-y-2">
|
||||
{status.missingFiles.map((file, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs bg-white rounded p-2 border border-red-200"
|
||||
>
|
||||
<div className="font-mono font-semibold text-red-700">
|
||||
{file.fileName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{file.folderPath}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File thừa */}
|
||||
{hasExtra && (
|
||||
<div className="border-l-4 border-orange-500 pl-3">
|
||||
<h4 className="text-sm font-semibold text-orange-600 mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
File thừa ({status.extraFiles.length})
|
||||
</h4>
|
||||
<ScrollArea className="h-32 rounded-md border bg-orange-50/30 p-2">
|
||||
<div className="space-y-2">
|
||||
{status.extraFiles.map((file, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs bg-white rounded p-2 border border-orange-200"
|
||||
>
|
||||
<div className="font-mono font-semibold text-orange-700">
|
||||
{file.fileName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{file.folderPath}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trạng thái OK */}
|
||||
{!hasIssues && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 bg-green-50/30 rounded p-3 border border-green-200">
|
||||
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="font-medium">Thư mục đạt yêu cầu</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
|
|||
const [isDone, setIsDone] = useState(false);
|
||||
|
||||
// Match server allowed extensions
|
||||
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb"];
|
||||
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1"];
|
||||
const isFileValid = (file: File) => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import { Monitor, DoorOpen } from "lucide-react";
|
||||
import { ComputerCard } from "../cards/computer-card";
|
||||
import { useMachineNumber } from "../../hooks/useMachineNumber";
|
||||
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
||||
|
||||
export function DeviceGrid({ devices }: { devices: any[] }) {
|
||||
export function DeviceGrid({
|
||||
devices,
|
||||
folderStatuses,
|
||||
isCheckingFolder,
|
||||
}: {
|
||||
devices: any[];
|
||||
folderStatuses?: Map<string, ClientFolderStatus>;
|
||||
isCheckingFolder?: boolean;
|
||||
}) {
|
||||
const getMachineNumber = useMachineNumber();
|
||||
const deviceMap = new Map<number, any>();
|
||||
|
||||
|
|
@ -23,11 +32,17 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
|
|||
{/* Bên trái (21–40) */}
|
||||
{Array.from({ length: 4 }).map((_, i) => {
|
||||
const pos = leftStart + (3 - i);
|
||||
const device = deviceMap.get(pos);
|
||||
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
||||
const folderStatus = folderStatuses?.get(macAddress);
|
||||
|
||||
return (
|
||||
<ComputerCard
|
||||
key={pos}
|
||||
device={deviceMap.get(pos)}
|
||||
device={device}
|
||||
position={pos}
|
||||
folderStatus={folderStatus}
|
||||
isCheckingFolder={isCheckingFolder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -40,11 +55,17 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
|
|||
{/* Bên phải (1–20) */}
|
||||
{Array.from({ length: 4 }).map((_, i) => {
|
||||
const pos = rightStart + (3 - i);
|
||||
const device = deviceMap.get(pos);
|
||||
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
||||
const folderStatus = folderStatuses?.get(macAddress);
|
||||
|
||||
return (
|
||||
<ComputerCard
|
||||
key={pos}
|
||||
device={deviceMap.get(pos)}
|
||||
device={device}
|
||||
position={pos}
|
||||
folderStatus={folderStatus}
|
||||
isCheckingFolder={isCheckingFolder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
140
src/components/menu/delete-menu.tsx
Normal file
140
src/components/menu/delete-menu.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Loader2, Trash2, ChevronDown, AlertTriangle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface DeleteMenuProps {
|
||||
onDeleteFromServer: () => void;
|
||||
onDeleteFromRequired: () => void;
|
||||
loading?: boolean;
|
||||
label?: string;
|
||||
serverLabel?: string;
|
||||
requiredLabel?: string;
|
||||
}
|
||||
|
||||
export function DeleteMenu({
|
||||
onDeleteFromServer,
|
||||
onDeleteFromRequired,
|
||||
loading,
|
||||
label = "Xóa",
|
||||
serverLabel = "Xóa khỏi server",
|
||||
requiredLabel = "Xóa khỏi danh sách yêu cầu",
|
||||
}: DeleteMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
|
||||
|
||||
const handleDeleteFromServer = async () => {
|
||||
try {
|
||||
await onDeleteFromServer();
|
||||
} finally {
|
||||
setOpen(false);
|
||||
setShowConfirmDelete(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFromRequired = async () => {
|
||||
try {
|
||||
await onDeleteFromRequired();
|
||||
} finally {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={loading}
|
||||
className="group relative overflow-hidden font-medium px-6 py-2.5 rounded-lg transition-all duration-300 hover:shadow-lg hover:shadow-red-200/50 hover:-translate-y-0.5 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm font-semibold">
|
||||
{loading ? "Đang xóa..." : label}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={handleDeleteFromRequired}
|
||||
disabled={loading}
|
||||
className="focus:bg-orange-50 focus:text-orange-900"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2 text-orange-600" />
|
||||
<span>{requiredLabel}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowConfirmDelete(true)}
|
||||
disabled={loading}
|
||||
className="focus:bg-red-50 focus:text-red-900"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2 text-red-600" />
|
||||
<span>{serverLabel}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Confirmation Dialog for Delete from Server */}
|
||||
{showConfirmDelete && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-sm mx-4 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
<h3 className="font-semibold text-lg">Cảnh báo: Xóa khỏi server</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Bạn đang chuẩn bị xóa các phần mềm này khỏi server. Hành động này <strong>không thể hoàn tác</strong> và sẽ xóa vĩnh viễn tất cả các tệp liên quan.
|
||||
</p>
|
||||
<p className="text-sm text-red-600 mb-6 font-medium">
|
||||
Vui lòng chắc chắn trước khi tiếp tục.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmDelete(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteFromServer}
|
||||
disabled={loading}
|
||||
className="gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Đang xóa...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Xóa khỏi server
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,15 +18,23 @@ 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 { FolderStatusPopover } from "../folder-status-popover";
|
||||
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
||||
|
||||
interface DeviceTableProps {
|
||||
devices: any[];
|
||||
folderStatuses?: Map<string, ClientFolderStatus>;
|
||||
isCheckingFolder?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component hiển thị danh sách thiết bị ở dạng bảng
|
||||
*/
|
||||
export function DeviceTable({ devices }: DeviceTableProps) {
|
||||
export function DeviceTable({
|
||||
devices,
|
||||
folderStatuses,
|
||||
isCheckingFolder,
|
||||
}: DeviceTableProps) {
|
||||
const getMachineNumber = useMachineNumber();
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
|
|
@ -137,6 +145,27 @@ export function DeviceTable({ devices }: DeviceTableProps) {
|
|||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Thư mục Setup",
|
||||
cell: ({ row }) => {
|
||||
const device = row.original;
|
||||
const isOffline = device.isOffline;
|
||||
const macAddress = device.networkInfos?.[0]?.macAddress || device.id;
|
||||
const folderStatus = folderStatuses?.get(macAddress);
|
||||
|
||||
if (isOffline) {
|
||||
return <span className="text-muted-foreground text-sm">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<FolderStatusPopover
|
||||
deviceId={macAddress}
|
||||
status={folderStatus}
|
||||
isLoading={isCheckingFolder}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
|
|
|
|||
|
|
@ -6,15 +6,23 @@ export const BASE_URL = isDev
|
|||
|
||||
export const API_ENDPOINTS = {
|
||||
APP_VERSION: {
|
||||
//agent and app api
|
||||
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
||||
UPLOAD: `${BASE_URL}/AppVersion/upload`,
|
||||
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
||||
|
||||
//blacklist api
|
||||
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
|
||||
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`,
|
||||
|
||||
//require file api
|
||||
GET_REQUIRED_FILES: `${BASE_URL}/AppVersion/requirefiles`,
|
||||
ADD_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/add`,
|
||||
DELETE_REQUIRED_FILE: (fileId: number) => `${BASE_URL}/AppVersion/requirefile/delete/${fileId}`,
|
||||
DELETE_FILES: (fileId: number) => `${BASE_URL}/AppVersion/delete/${fileId}`,
|
||||
},
|
||||
DEVICE_COMM: {
|
||||
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
|
||||
|
|
@ -27,10 +35,13 @@ export const API_ENDPOINTS = {
|
|||
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`,
|
||||
REQUEST_GET_CLIENT_FOLDER_STATUS: (roomName: string) =>
|
||||
`${BASE_URL}/DeviceComm/clientfolderstatus/${roomName}`,
|
||||
},
|
||||
SSE_EVENTS: {
|
||||
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
||||
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
|
||||
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
|
||||
GET_CLIENT_FOLDER_STATUS: `${BASE_URL}/Sse/events/clientFolderStatuses`,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
83
src/hooks/useClientFolderStatus.ts
Normal file
83
src/hooks/useClientFolderStatus.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { API_ENDPOINTS } from "@/config/api";
|
||||
|
||||
export interface MissingFiles {
|
||||
fileName: string;
|
||||
folderPath: string;
|
||||
}
|
||||
|
||||
export interface ExtraFiles {
|
||||
fileName: string;
|
||||
folderPath: string;
|
||||
}
|
||||
|
||||
export interface ClientFolderStatus {
|
||||
id: number;
|
||||
deviceId: string;
|
||||
missingFiles: MissingFiles[];
|
||||
extraFiles: ExtraFiles[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function useClientFolderStatus(roomName?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [folderStatuses, setFolderStatuses] = useState<
|
||||
Map<string, ClientFolderStatus>
|
||||
>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
const connect = () => {
|
||||
eventSource = new EventSource(
|
||||
API_ENDPOINTS.SSE_EVENTS.GET_CLIENT_FOLDER_STATUS
|
||||
);
|
||||
|
||||
eventSource.addEventListener("clientFolderStatus", (event) => {
|
||||
try {
|
||||
const data: ClientFolderStatus = JSON.parse(event.data);
|
||||
|
||||
if (roomName && data.deviceId) {
|
||||
setFolderStatuses((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(data.deviceId, data);
|
||||
return newMap;
|
||||
});
|
||||
|
||||
// Also cache in React Query for persistence
|
||||
queryClient.setQueryData(
|
||||
["folderStatus", data.deviceId],
|
||||
data
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error parsing clientFolderStatus event:", err);
|
||||
}
|
||||
});
|
||||
|
||||
const onError = (err: any) => {
|
||||
console.error("SSE connection error:", err);
|
||||
cleanup();
|
||||
reconnectTimeout.current = setTimeout(connect, 5000);
|
||||
};
|
||||
|
||||
eventSource.onerror = onError;
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (eventSource) eventSource.close();
|
||||
if (reconnectTimeout.current) {
|
||||
clearTimeout(reconnectTimeout.current);
|
||||
reconnectTimeout.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
return cleanup;
|
||||
}, [roomName, queryClient]);
|
||||
|
||||
return folderStatuses;
|
||||
}
|
||||
|
|
@ -42,10 +42,14 @@ export function useMutationData<TInput = any, TOutput = any>({
|
|||
});
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
invalidate.forEach((key) =>
|
||||
onSuccess: async (data) => {
|
||||
// Invalidate queries trước
|
||||
await Promise.all(
|
||||
invalidate.map((key) =>
|
||||
queryClient.invalidateQueries({ queryKey: key })
|
||||
)
|
||||
);
|
||||
// Sau đó gọi callback
|
||||
onSuccess?.(data);
|
||||
},
|
||||
onError,
|
||||
|
|
|
|||
|
|
@ -6,15 +6,7 @@ import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
|||
import { toast } from "sonner";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
|
||||
type Version = {
|
||||
id?: string;
|
||||
version: string;
|
||||
fileName: string;
|
||||
folderPath: string;
|
||||
updatedAt?: string;
|
||||
requestUpdateAt?: string;
|
||||
};
|
||||
import type { Version } from "@/types/file";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/agent/")({
|
||||
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
|
||||
|
|
|
|||
|
|
@ -7,21 +7,14 @@ import { toast } from "sonner";
|
|||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type { Version } from "@/types/file";
|
||||
import { Check, X } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/apps/")({
|
||||
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
|
||||
component: AppsComponent,
|
||||
});
|
||||
|
||||
type Version = {
|
||||
id: number;
|
||||
version: string;
|
||||
fileName: string;
|
||||
folderPath: string;
|
||||
updatedAt?: string;
|
||||
requestUpdateAt?: string;
|
||||
};
|
||||
|
||||
function AppsComponent() {
|
||||
const { data, isLoading } = useQueryData({
|
||||
queryKey: ["software-version"],
|
||||
|
|
@ -74,7 +67,7 @@ function AppsComponent() {
|
|||
|
||||
const deleteMutation = useMutationData<{ MsiFileIds: number[] }>({
|
||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.DELETE_FILES,
|
||||
method: "DELETE",
|
||||
method: "POST",
|
||||
invalidate: [["software-version"]],
|
||||
onSuccess: () => toast.success("Xóa phần mềm thành công!"),
|
||||
onError: (error) => {
|
||||
|
|
@ -83,6 +76,31 @@ function AppsComponent() {
|
|||
},
|
||||
});
|
||||
|
||||
const addRequiredFileMutation = useMutationData<{
|
||||
fileName: string;
|
||||
version: string;
|
||||
}>({
|
||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.ADD_REQUIRED_FILE,
|
||||
method: "POST",
|
||||
invalidate: [["software-version"]],
|
||||
onSuccess: () => toast.success("Thêm file vào danh sách thành công!"),
|
||||
onError: (error) => {
|
||||
console.error("Add required file error:", error);
|
||||
toast.error("Thêm file vào danh sách thất bại!");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteRequiredFileMutation = useMutationData<{ id: number }>({
|
||||
url: "",
|
||||
method: "POST",
|
||||
invalidate: [["software-version"]],
|
||||
onSuccess: () => toast.success("Xóa file khỏi danh sách thành công!"),
|
||||
onError: (error) => {
|
||||
console.error("Delete required file error:", error);
|
||||
toast.error("Xóa file khỏi danh sách thất bại!");
|
||||
},
|
||||
});
|
||||
|
||||
// Cột bảng
|
||||
const columns: ColumnDef<Version>[] = [
|
||||
{ accessorKey: "version", header: "Phiên bản" },
|
||||
|
|
@ -90,7 +108,7 @@ function AppsComponent() {
|
|||
{ accessorKey: "folderPath", header: "Đường dẫn" },
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: "Thời gian cập nhật",
|
||||
header: () => <div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>,
|
||||
cell: ({ getValue }) =>
|
||||
getValue()
|
||||
? new Date(getValue() as string).toLocaleString("vi-VN")
|
||||
|
|
@ -98,11 +116,35 @@ function AppsComponent() {
|
|||
},
|
||||
{
|
||||
accessorKey: "requestUpdateAt",
|
||||
header: "Thời gian yêu cầu cài đặt",
|
||||
header: () => <div className="whitespace-normal max-w-xs">Thời gian yêu cầu cài đặt/tải xuống</div>,
|
||||
cell: ({ getValue }) =>
|
||||
getValue()
|
||||
? new Date(getValue() as string).toLocaleString("vi-VN")
|
||||
: "N/A",
|
||||
},
|
||||
{
|
||||
id: "required",
|
||||
header: () => <div className="whitespace-normal max-w-xs">Đã thêm vào danh sách</div>,
|
||||
cell: ({ row }) => {
|
||||
const isRequired = row.original.isRequired;
|
||||
return isRequired ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm text-green-600">Có</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<X className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-400">Không</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "select",
|
||||
header: () => <span>Thêm vào danh sách yêu cầu</span>,
|
||||
header: () => <div className="whitespace-normal max-w-xs">Chọn</div>,
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -193,22 +235,83 @@ function AppsComponent() {
|
|||
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);
|
||||
const handleDeleteFromRequiredList = async () => {
|
||||
if (!table) return;
|
||||
|
||||
const selectedRows = table.getSelectedRowModel().rows;
|
||||
|
||||
try {
|
||||
await deleteMutation.mutateAsync({
|
||||
data: { MsiFileIds },
|
||||
for (const row of selectedRows) {
|
||||
const { id } = row.original;
|
||||
await deleteRequiredFileMutation.mutateAsync({
|
||||
data: { id },
|
||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.DELETE_REQUIRED_FILE(id),
|
||||
});
|
||||
}
|
||||
if (table) {
|
||||
table.setRowSelection({});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Delete from required list error:", e);
|
||||
toast.error("Có lỗi xảy ra khi xóa!");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFromServer = async () => {
|
||||
if (!table) return;
|
||||
|
||||
const selectedRows = table.getSelectedRowModel().rows;
|
||||
|
||||
try {
|
||||
for (const row of selectedRows) {
|
||||
const { id } = row.original;
|
||||
await deleteMutation.mutateAsync({
|
||||
data: { MsiFileIds: [id] },
|
||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.DELETE_FILES(id),
|
||||
});
|
||||
}
|
||||
if (table) {
|
||||
table.setRowSelection({});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Delete error:", e);
|
||||
toast.error("Có lỗi xảy ra khi xóa!");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToRequired = 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!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const row of selectedRows) {
|
||||
const { fileName, version } = row.original;
|
||||
await addRequiredFileMutation.mutateAsync({
|
||||
data: { fileName, version },
|
||||
});
|
||||
}
|
||||
table.setRowSelection({});
|
||||
} catch (e) {
|
||||
console.error("Add required file error:", e);
|
||||
toast.error("Có lỗi xảy ra!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppManagerTemplate<Version>
|
||||
title="Quản lý phần mềm"
|
||||
uploadFormTitle="Tải lên || Cập nhật file phần mềm"
|
||||
description="Quản lý và gửi yêu cầu cài đặt phần mềm hoặc file cấu hình"
|
||||
data={versionList}
|
||||
isLoading={isLoading}
|
||||
|
|
@ -217,11 +320,16 @@ function AppsComponent() {
|
|||
onUpdate={handleInstall}
|
||||
onDownload={handleDonwload}
|
||||
onDelete={handleDelete}
|
||||
onDeleteFromServer={handleDeleteFromServer}
|
||||
onDeleteFromRequired={handleDeleteFromRequiredList}
|
||||
onAddToRequired={handleAddToRequired}
|
||||
updateLoading={installMutation.isPending}
|
||||
downloadLoading={downloadMutation.isPending}
|
||||
deleteLoading={deleteMutation.isPending}
|
||||
deleteLoading={deleteMutation.isPending || deleteRequiredFileMutation.isPending}
|
||||
addToRequiredLoading={addRequiredFileMutation.isPending}
|
||||
onTableInit={setTable}
|
||||
rooms={roomData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import { createFileRoute, useParams } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LayoutGrid, TableIcon, Monitor } from "lucide-react";
|
||||
import { LayoutGrid, TableIcon, Monitor, FolderCheck, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useQueryData } from "@/hooks/useQueryData";
|
||||
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
||||
import { useClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
||||
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
||||
import { DeviceGrid } from "@/components/grids/device-grid";
|
||||
import { DeviceTable } from "@/components/tables/device-table";
|
||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/room/$roomName/")({
|
||||
head: ({ params }) => ({
|
||||
|
|
@ -19,6 +22,14 @@ export const Route = createFileRoute("/_authenticated/room/$roomName/")({
|
|||
function RoomDetailPage() {
|
||||
const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
|
||||
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
||||
const [isCheckingFolder, setIsCheckingFolder] = useState(false);
|
||||
|
||||
// SSE real-time updates
|
||||
useDeviceEvents(roomName);
|
||||
|
||||
// Folder status from SSE
|
||||
const folderStatuses = useClientFolderStatus(roomName);
|
||||
|
||||
const { data: devices = [] } = useQueryData({
|
||||
queryKey: ["devices", roomName],
|
||||
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
|
||||
|
|
@ -26,6 +37,28 @@ function RoomDetailPage() {
|
|||
|
||||
const parseMachineNumber = useMachineNumber();
|
||||
|
||||
const handleCheckFolderStatus = async () => {
|
||||
try {
|
||||
setIsCheckingFolder(true);
|
||||
const response = await fetch(
|
||||
BASE_URL + API_ENDPOINTS.DEVICE_COMM.REQUEST_GET_CLIENT_FOLDER_STATUS(roomName),
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to request folder status");
|
||||
}
|
||||
|
||||
toast.success("Đang kiểm tra thư mục Setup...");
|
||||
} catch (error) {
|
||||
console.error("Check folder error:", error);
|
||||
toast.error("Lỗi khi kiểm tra thư mục!");
|
||||
setIsCheckingFolder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedDevices = [...devices].sort((a, b) => {
|
||||
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
|
||||
});
|
||||
|
|
@ -39,6 +72,22 @@ function RoomDetailPage() {
|
|||
Danh sách thiết bị phòng {roomName}
|
||||
</CardTitle>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleCheckFolderStatus}
|
||||
disabled={isCheckingFolder}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isCheckingFolder ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FolderCheck className="h-4 w-4" />
|
||||
)}
|
||||
{isCheckingFolder ? "Đang kiểm tra..." : "Kiểm tra thư mục Setup"}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||
|
|
@ -59,6 +108,7 @@ function RoomDetailPage() {
|
|||
Bảng
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
|
|
@ -71,9 +121,17 @@ function RoomDetailPage() {
|
|||
</p>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<DeviceGrid devices={sortedDevices} />
|
||||
<DeviceGrid
|
||||
devices={sortedDevices}
|
||||
folderStatuses={folderStatuses}
|
||||
isCheckingFolder={isCheckingFolder}
|
||||
/>
|
||||
) : (
|
||||
<DeviceTable devices={sortedDevices} />
|
||||
<DeviceTable
|
||||
devices={sortedDevices}
|
||||
folderStatuses={folderStatuses}
|
||||
isCheckingFolder={isCheckingFolder}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import {
|
|||
ChevronRight,
|
||||
Loader2,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
|
||||
import React from "react";
|
||||
|
|
@ -77,34 +76,16 @@ function RoomComponent() {
|
|||
),
|
||||
},
|
||||
{
|
||||
header: "Số lượng thiết bị",
|
||||
accessorKey: "numberOfDevices",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi className="h-4 w-4 text-green-600" />
|
||||
<Badge variant="secondary" className="font-medium">
|
||||
{row.original.numberOfDevices} thiết bị
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Thiết bị offline",
|
||||
accessorKey: "numberOfOfflineDevices",
|
||||
header: "Số lượng thiết bị online",
|
||||
cell: ({ row }) => {
|
||||
const offlineCount = row.original.numberOfOfflineDevices;
|
||||
const isOffline = offlineCount > 0;
|
||||
const onlineCount = row.original.numberOfDevices - row.original.numberOfOfflineDevices;
|
||||
const totalCount = row.original.numberOfDevices;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<WifiOff
|
||||
className={`h-4 w-4 ${isOffline ? "text-red-500" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<Badge
|
||||
variant={isOffline ? "destructive" : "outline"}
|
||||
className="font-medium"
|
||||
>
|
||||
{offlineCount} offline
|
||||
<Wifi className="h-4 w-4 text-green-600" />
|
||||
<Badge variant="secondary" className="font-medium">
|
||||
{onlineCount} / {totalCount}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ 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 { DeleteMenu } from "@/components/menu/delete-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import { useState } from "react";
|
||||
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
||||
|
|
@ -23,6 +24,7 @@ import { fetchDevicesFromRoom } from "@/services/device.service";
|
|||
|
||||
interface AppManagerTemplateProps<TData> {
|
||||
title: string;
|
||||
uploadFormTitle?: string;
|
||||
description: string;
|
||||
data: TData[];
|
||||
isLoading: boolean;
|
||||
|
|
@ -36,7 +38,11 @@ interface AppManagerTemplateProps<TData> {
|
|||
onDownload?: (targetNames: string[]) => Promise<void> | void;
|
||||
downloadLoading?: boolean;
|
||||
onDelete?: () => Promise<void> | void;
|
||||
onDeleteFromServer?: () => Promise<void> | void;
|
||||
onDeleteFromRequired?: () => Promise<void> | void;
|
||||
deleteLoading?: boolean;
|
||||
onAddToRequired?: () => Promise<void> | void;
|
||||
addToRequiredLoading?: boolean;
|
||||
onTableInit?: (table: any) => void;
|
||||
rooms?: Room[];
|
||||
devices?: string[];
|
||||
|
|
@ -44,6 +50,7 @@ interface AppManagerTemplateProps<TData> {
|
|||
|
||||
export function AppManagerTemplate<TData>({
|
||||
title,
|
||||
uploadFormTitle,
|
||||
description,
|
||||
data,
|
||||
isLoading,
|
||||
|
|
@ -54,7 +61,11 @@ export function AppManagerTemplate<TData>({
|
|||
onDownload,
|
||||
downloadLoading,
|
||||
onDelete,
|
||||
onDeleteFromServer,
|
||||
onDeleteFromRequired,
|
||||
deleteLoading,
|
||||
onAddToRequired,
|
||||
addToRequiredLoading,
|
||||
onTableInit,
|
||||
rooms = [],
|
||||
devices = [],
|
||||
|
|
@ -112,8 +123,8 @@ export function AppManagerTemplate<TData>({
|
|||
<p className="text-muted-foreground mt-2">{description}</p>
|
||||
</div>
|
||||
<FormDialog
|
||||
triggerLabel="Tải lên phiên bản mới"
|
||||
title="Cập nhật phiên bản"
|
||||
triggerLabel={uploadFormTitle || "Tải phiên bản mới"}
|
||||
title={uploadFormTitle || "Cập nhật phiên bản"}
|
||||
>
|
||||
{(closeDialog) => (
|
||||
<UploadVersionForm onSubmit={onUpload} closeDialog={closeDialog} />
|
||||
|
|
@ -138,7 +149,7 @@ export function AppManagerTemplate<TData>({
|
|||
/>
|
||||
</CardContent>
|
||||
|
||||
{(onUpdate || onDelete) && (
|
||||
{(onUpdate || onDelete || onAddToRequired) && (
|
||||
<CardFooter className="flex items-center justify-between gap-4">
|
||||
<div className="flex gap-2">
|
||||
<RequestUpdateMenu
|
||||
|
|
@ -171,12 +182,21 @@ export function AppManagerTemplate<TData>({
|
|||
icon={<Download className="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
{onAddToRequired && (
|
||||
<Button
|
||||
onClick={onAddToRequired}
|
||||
disabled={addToRequiredLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
{addToRequiredLoading ? "Đang thêm..." : "Thêm vào danh sách"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{onDelete && (
|
||||
<DeleteButton
|
||||
onClick={onDelete}
|
||||
{onDeleteFromServer && onDeleteFromRequired && (
|
||||
<DeleteMenu
|
||||
onDeleteFromServer={onDeleteFromServer}
|
||||
onDeleteFromRequired={onDeleteFromRequired}
|
||||
loading={deleteLoading}
|
||||
disabled={false}
|
||||
/>
|
||||
)}
|
||||
</CardFooter>
|
||||
|
|
|
|||
9
src/types/file.ts
Normal file
9
src/types/file.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export type Version = {
|
||||
id: number;
|
||||
version: string;
|
||||
fileName: string;
|
||||
folderPath: string;
|
||||
updatedAt?: string;
|
||||
requestUpdateAt?: string;
|
||||
isRequired: boolean;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user