add setup folder scan
This commit is contained in:
parent
451eed4c65
commit
eb4243ee5b
|
|
@ -23,9 +23,9 @@ export function DeleteButton({
|
||||||
onClick,
|
onClick,
|
||||||
loading = false,
|
loading = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
label = "Xóa",
|
label = "Xóa khỏi server",
|
||||||
title = "Xác nhận xóa",
|
title = "Xóa khỏi server",
|
||||||
description = "Bạn có chắc chắn muốn xóa các mục này không?",
|
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) {
|
}: DeleteButtonProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [isConfirming, setIsConfirming] = useState(false);
|
const [isConfirming, setIsConfirming] = useState(false);
|
||||||
|
|
@ -46,23 +46,28 @@ export function DeleteButton({
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
disabled={loading || disabled}
|
disabled={loading || disabled}
|
||||||
className="gap-2"
|
className="gap-2 px-4"
|
||||||
>
|
>
|
||||||
|
{loading || isConfirming ? (
|
||||||
|
<span className="animate-spin">⏳</span>
|
||||||
|
) : (
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
{label}
|
)}
|
||||||
|
{loading || isConfirming ? "Đang xóa..." : label}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle className="text-lg">{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription className="text-base">{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
disabled={isConfirming}
|
disabled={isConfirming}
|
||||||
|
className="flex-1"
|
||||||
>
|
>
|
||||||
Hủy
|
Hủy
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -70,8 +75,19 @@ export function DeleteButton({
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={isConfirming || loading}
|
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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,19 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Monitor, Wifi, WifiOff } from "lucide-react";
|
import { Monitor, Wifi, WifiOff } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { FolderStatusPopover } from "../folder-status-popover";
|
||||||
|
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
||||||
|
|
||||||
export function ComputerCard({
|
export function ComputerCard({
|
||||||
device,
|
device,
|
||||||
position,
|
position,
|
||||||
|
folderStatus,
|
||||||
|
isCheckingFolder,
|
||||||
}: {
|
}: {
|
||||||
device: any | undefined;
|
device: any | undefined;
|
||||||
position: number;
|
position: number;
|
||||||
|
folderStatus?: ClientFolderStatus;
|
||||||
|
isCheckingFolder?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (!device) {
|
if (!device) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -98,6 +104,17 @@ export function ComputerCard({
|
||||||
{position}
|
{position}
|
||||||
</div>
|
</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")} />
|
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
|
||||||
{firstNetworkInfo?.ipAddress && (
|
{firstNetworkInfo?.ipAddress && (
|
||||||
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
<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);
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
|
||||||
// Match server allowed extensions
|
// 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 isFileValid = (file: File) => {
|
||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
import { Monitor, DoorOpen } from "lucide-react";
|
import { Monitor, DoorOpen } from "lucide-react";
|
||||||
import { ComputerCard } from "../cards/computer-card";
|
import { ComputerCard } from "../cards/computer-card";
|
||||||
import { useMachineNumber } from "../../hooks/useMachineNumber";
|
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 getMachineNumber = useMachineNumber();
|
||||||
const deviceMap = new Map<number, any>();
|
const deviceMap = new Map<number, any>();
|
||||||
|
|
||||||
|
|
@ -23,11 +32,17 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
|
||||||
{/* Bên trái (21–40) */}
|
{/* Bên trái (21–40) */}
|
||||||
{Array.from({ length: 4 }).map((_, i) => {
|
{Array.from({ length: 4 }).map((_, i) => {
|
||||||
const pos = leftStart + (3 - 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 (
|
return (
|
||||||
<ComputerCard
|
<ComputerCard
|
||||||
key={pos}
|
key={pos}
|
||||||
device={deviceMap.get(pos)}
|
device={device}
|
||||||
position={pos}
|
position={pos}
|
||||||
|
folderStatus={folderStatus}
|
||||||
|
isCheckingFolder={isCheckingFolder}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -40,11 +55,17 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
|
||||||
{/* Bên phải (1–20) */}
|
{/* Bên phải (1–20) */}
|
||||||
{Array.from({ length: 4 }).map((_, i) => {
|
{Array.from({ length: 4 }).map((_, i) => {
|
||||||
const pos = rightStart + (3 - 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 (
|
return (
|
||||||
<ComputerCard
|
<ComputerCard
|
||||||
key={pos}
|
key={pos}
|
||||||
device={deviceMap.get(pos)}
|
device={device}
|
||||||
position={pos}
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||||
|
import { FolderStatusPopover } from "../folder-status-popover";
|
||||||
|
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
||||||
|
|
||||||
interface DeviceTableProps {
|
interface DeviceTableProps {
|
||||||
devices: any[];
|
devices: any[];
|
||||||
|
folderStatuses?: Map<string, ClientFolderStatus>;
|
||||||
|
isCheckingFolder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component hiển thị danh sách thiết bị ở dạng bảng
|
* 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 getMachineNumber = useMachineNumber();
|
||||||
|
|
||||||
const columns: ColumnDef<any>[] = [
|
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({
|
const table = useReactTable({
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,23 @@ export const BASE_URL = isDev
|
||||||
|
|
||||||
export const API_ENDPOINTS = {
|
export const API_ENDPOINTS = {
|
||||||
APP_VERSION: {
|
APP_VERSION: {
|
||||||
|
//agent and app api
|
||||||
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
||||||
UPLOAD: `${BASE_URL}/AppVersion/upload`,
|
UPLOAD: `${BASE_URL}/AppVersion/upload`,
|
||||||
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
||||||
|
|
||||||
|
//blacklist api
|
||||||
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
|
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
|
||||||
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
|
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
|
||||||
DELETE_BLACKLIST: (appId: number) => `${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}`,
|
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`,
|
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: {
|
DEVICE_COMM: {
|
||||||
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
|
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}`,
|
UPDATE_BLACKLIST: (roomName: string) => `${BASE_URL}/DeviceComm/updateblacklist/${roomName}`,
|
||||||
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
|
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
|
||||||
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
|
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
|
||||||
|
REQUEST_GET_CLIENT_FOLDER_STATUS: (roomName: string) =>
|
||||||
|
`${BASE_URL}/DeviceComm/clientfolderstatus/${roomName}`,
|
||||||
},
|
},
|
||||||
SSE_EVENTS: {
|
SSE_EVENTS: {
|
||||||
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
||||||
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
|
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
|
||||||
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: async (data) => {
|
||||||
invalidate.forEach((key) =>
|
// Invalidate queries trước
|
||||||
|
await Promise.all(
|
||||||
|
invalidate.map((key) =>
|
||||||
queryClient.invalidateQueries({ queryKey: key })
|
queryClient.invalidateQueries({ queryKey: key })
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
// Sau đó gọi callback
|
||||||
onSuccess?.(data);
|
onSuccess?.(data);
|
||||||
},
|
},
|
||||||
onError,
|
onError,
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,7 @@ import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
|
import type { Version } from "@/types/file";
|
||||||
type Version = {
|
|
||||||
id?: string;
|
|
||||||
version: string;
|
|
||||||
fileName: string;
|
|
||||||
folderPath: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
requestUpdateAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/agent/")({
|
export const Route = createFileRoute("/_authenticated/agent/")({
|
||||||
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
|
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,14 @@ import { toast } from "sonner";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
|
import type { Version } from "@/types/file";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/apps/")({
|
export const Route = createFileRoute("/_authenticated/apps/")({
|
||||||
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
|
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
|
||||||
component: AppsComponent,
|
component: AppsComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
type Version = {
|
|
||||||
id: number;
|
|
||||||
version: string;
|
|
||||||
fileName: string;
|
|
||||||
folderPath: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
requestUpdateAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function AppsComponent() {
|
function AppsComponent() {
|
||||||
const { data, isLoading } = useQueryData({
|
const { data, isLoading } = useQueryData({
|
||||||
queryKey: ["software-version"],
|
queryKey: ["software-version"],
|
||||||
|
|
@ -74,7 +67,7 @@ function AppsComponent() {
|
||||||
|
|
||||||
const deleteMutation = useMutationData<{ MsiFileIds: number[] }>({
|
const deleteMutation = useMutationData<{ MsiFileIds: number[] }>({
|
||||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.DELETE_FILES,
|
url: BASE_URL + API_ENDPOINTS.APP_VERSION.DELETE_FILES,
|
||||||
method: "DELETE",
|
method: "POST",
|
||||||
invalidate: [["software-version"]],
|
invalidate: [["software-version"]],
|
||||||
onSuccess: () => toast.success("Xóa phần mềm thành công!"),
|
onSuccess: () => toast.success("Xóa phần mềm thành công!"),
|
||||||
onError: (error) => {
|
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
|
// Cột bảng
|
||||||
const columns: ColumnDef<Version>[] = [
|
const columns: ColumnDef<Version>[] = [
|
||||||
{ accessorKey: "version", header: "Phiên bản" },
|
{ accessorKey: "version", header: "Phiên bản" },
|
||||||
|
|
@ -90,7 +108,7 @@ function AppsComponent() {
|
||||||
{ accessorKey: "folderPath", header: "Đường dẫn" },
|
{ accessorKey: "folderPath", header: "Đường dẫn" },
|
||||||
{
|
{
|
||||||
accessorKey: "updatedAt",
|
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 }) =>
|
cell: ({ getValue }) =>
|
||||||
getValue()
|
getValue()
|
||||||
? new Date(getValue() as string).toLocaleString("vi-VN")
|
? new Date(getValue() as string).toLocaleString("vi-VN")
|
||||||
|
|
@ -98,11 +116,35 @@ function AppsComponent() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "requestUpdateAt",
|
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",
|
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 }) => (
|
cell: ({ row }) => (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -193,22 +235,83 @@ function AppsComponent() {
|
||||||
toast.error("Vui lòng chọn ít nhất một file để xóa!");
|
toast.error("Vui lòng chọn ít nhất một file để xóa!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
|
const handleDeleteFromRequiredList = async () => {
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
const selectedRows = table.getSelectedRowModel().rows;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteMutation.mutateAsync({
|
for (const row of selectedRows) {
|
||||||
data: { MsiFileIds },
|
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) {
|
} catch (e) {
|
||||||
console.error("Delete error:", e);
|
console.error("Delete error:", e);
|
||||||
toast.error("Có lỗi xảy ra khi xóa!");
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<AppManagerTemplate<Version>
|
<AppManagerTemplate<Version>
|
||||||
title="Quản lý phần mềm"
|
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"
|
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}
|
data={versionList}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
@ -217,11 +320,16 @@ function AppsComponent() {
|
||||||
onUpdate={handleInstall}
|
onUpdate={handleInstall}
|
||||||
onDownload={handleDonwload}
|
onDownload={handleDonwload}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onDeleteFromServer={handleDeleteFromServer}
|
||||||
|
onDeleteFromRequired={handleDeleteFromRequiredList}
|
||||||
|
onAddToRequired={handleAddToRequired}
|
||||||
updateLoading={installMutation.isPending}
|
updateLoading={installMutation.isPending}
|
||||||
downloadLoading={downloadMutation.isPending}
|
downloadLoading={downloadMutation.isPending}
|
||||||
deleteLoading={deleteMutation.isPending}
|
deleteLoading={deleteMutation.isPending || deleteRequiredFileMutation.isPending}
|
||||||
|
addToRequiredLoading={addRequiredFileMutation.isPending}
|
||||||
onTableInit={setTable}
|
onTableInit={setTable}
|
||||||
rooms={roomData}
|
rooms={roomData}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import { createFileRoute, useParams } from "@tanstack/react-router";
|
import { createFileRoute, useParams } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { useQueryData } from "@/hooks/useQueryData";
|
import { useQueryData } from "@/hooks/useQueryData";
|
||||||
|
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
||||||
|
import { useClientFolderStatus } from "@/hooks/useClientFolderStatus";
|
||||||
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
||||||
import { DeviceGrid } from "@/components/grids/device-grid";
|
import { DeviceGrid } from "@/components/grids/device-grid";
|
||||||
import { DeviceTable } from "@/components/tables/device-table";
|
import { DeviceTable } from "@/components/tables/device-table";
|
||||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/room/$roomName/")({
|
export const Route = createFileRoute("/_authenticated/room/$roomName/")({
|
||||||
head: ({ params }) => ({
|
head: ({ params }) => ({
|
||||||
|
|
@ -19,6 +22,14 @@ export const Route = createFileRoute("/_authenticated/room/$roomName/")({
|
||||||
function RoomDetailPage() {
|
function RoomDetailPage() {
|
||||||
const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
|
const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
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({
|
const { data: devices = [] } = useQueryData({
|
||||||
queryKey: ["devices", roomName],
|
queryKey: ["devices", roomName],
|
||||||
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
|
||||||
|
|
@ -26,6 +37,28 @@ function RoomDetailPage() {
|
||||||
|
|
||||||
const parseMachineNumber = useMachineNumber();
|
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) => {
|
const sortedDevices = [...devices].sort((a, b) => {
|
||||||
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
|
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
|
||||||
});
|
});
|
||||||
|
|
@ -39,6 +72,22 @@ function RoomDetailPage() {
|
||||||
Danh sách thiết bị phòng {roomName}
|
Danh sách thiết bị phòng {roomName}
|
||||||
</CardTitle>
|
</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">
|
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border">
|
||||||
<Button
|
<Button
|
||||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||||
|
|
@ -59,6 +108,7 @@ function RoomDetailPage() {
|
||||||
Bảng
|
Bảng
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
|
|
@ -71,9 +121,17 @@ function RoomDetailPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "grid" ? (
|
) : viewMode === "grid" ? (
|
||||||
<DeviceGrid devices={sortedDevices} />
|
<DeviceGrid
|
||||||
|
devices={sortedDevices}
|
||||||
|
folderStatuses={folderStatuses}
|
||||||
|
isCheckingFolder={isCheckingFolder}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DeviceTable devices={sortedDevices} />
|
<DeviceTable
|
||||||
|
devices={sortedDevices}
|
||||||
|
folderStatuses={folderStatuses}
|
||||||
|
isCheckingFolder={isCheckingFolder}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Loader2,
|
Loader2,
|
||||||
Wifi,
|
Wifi,
|
||||||
WifiOff,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
@ -77,34 +76,16 @@ function RoomComponent() {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Số lượng thiết bị",
|
header: "Số lượng thiết bị online",
|
||||||
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",
|
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const offlineCount = row.original.numberOfOfflineDevices;
|
const onlineCount = row.original.numberOfDevices - row.original.numberOfOfflineDevices;
|
||||||
const isOffline = offlineCount > 0;
|
const totalCount = row.original.numberOfDevices;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<WifiOff
|
<Wifi className="h-4 w-4 text-green-600" />
|
||||||
className={`h-4 w-4 ${isOffline ? "text-red-500" : "text-muted-foreground"}`}
|
<Badge variant="secondary" className="font-medium">
|
||||||
/>
|
{onlineCount} / {totalCount}
|
||||||
<Badge
|
|
||||||
variant={isOffline ? "destructive" : "outline"}
|
|
||||||
className="font-medium"
|
|
||||||
>
|
|
||||||
{offlineCount} offline
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import { FileText, Building2, Download } from "lucide-react";
|
||||||
import { FormDialog } from "@/components/dialogs/form-dialog";
|
import { FormDialog } from "@/components/dialogs/form-dialog";
|
||||||
import { VersionTable } from "@/components/tables/version-table";
|
import { VersionTable } from "@/components/tables/version-table";
|
||||||
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
|
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 type { AxiosProgressEvent } from "axios";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
||||||
|
|
@ -23,6 +24,7 @@ import { fetchDevicesFromRoom } from "@/services/device.service";
|
||||||
|
|
||||||
interface AppManagerTemplateProps<TData> {
|
interface AppManagerTemplateProps<TData> {
|
||||||
title: string;
|
title: string;
|
||||||
|
uploadFormTitle?: string;
|
||||||
description: string;
|
description: string;
|
||||||
data: TData[];
|
data: TData[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
@ -36,7 +38,11 @@ interface AppManagerTemplateProps<TData> {
|
||||||
onDownload?: (targetNames: string[]) => Promise<void> | void;
|
onDownload?: (targetNames: string[]) => Promise<void> | void;
|
||||||
downloadLoading?: boolean;
|
downloadLoading?: boolean;
|
||||||
onDelete?: () => Promise<void> | void;
|
onDelete?: () => Promise<void> | void;
|
||||||
|
onDeleteFromServer?: () => Promise<void> | void;
|
||||||
|
onDeleteFromRequired?: () => Promise<void> | void;
|
||||||
deleteLoading?: boolean;
|
deleteLoading?: boolean;
|
||||||
|
onAddToRequired?: () => Promise<void> | void;
|
||||||
|
addToRequiredLoading?: boolean;
|
||||||
onTableInit?: (table: any) => void;
|
onTableInit?: (table: any) => void;
|
||||||
rooms?: Room[];
|
rooms?: Room[];
|
||||||
devices?: string[];
|
devices?: string[];
|
||||||
|
|
@ -44,6 +50,7 @@ interface AppManagerTemplateProps<TData> {
|
||||||
|
|
||||||
export function AppManagerTemplate<TData>({
|
export function AppManagerTemplate<TData>({
|
||||||
title,
|
title,
|
||||||
|
uploadFormTitle,
|
||||||
description,
|
description,
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -54,7 +61,11 @@ export function AppManagerTemplate<TData>({
|
||||||
onDownload,
|
onDownload,
|
||||||
downloadLoading,
|
downloadLoading,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onDeleteFromServer,
|
||||||
|
onDeleteFromRequired,
|
||||||
deleteLoading,
|
deleteLoading,
|
||||||
|
onAddToRequired,
|
||||||
|
addToRequiredLoading,
|
||||||
onTableInit,
|
onTableInit,
|
||||||
rooms = [],
|
rooms = [],
|
||||||
devices = [],
|
devices = [],
|
||||||
|
|
@ -112,8 +123,8 @@ export function AppManagerTemplate<TData>({
|
||||||
<p className="text-muted-foreground mt-2">{description}</p>
|
<p className="text-muted-foreground mt-2">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<FormDialog
|
<FormDialog
|
||||||
triggerLabel="Tải lên phiên bản mới"
|
triggerLabel={uploadFormTitle || "Tải phiên bản mới"}
|
||||||
title="Cập nhật phiên bản"
|
title={uploadFormTitle || "Cập nhật phiên bản"}
|
||||||
>
|
>
|
||||||
{(closeDialog) => (
|
{(closeDialog) => (
|
||||||
<UploadVersionForm onSubmit={onUpload} closeDialog={closeDialog} />
|
<UploadVersionForm onSubmit={onUpload} closeDialog={closeDialog} />
|
||||||
|
|
@ -138,7 +149,7 @@ export function AppManagerTemplate<TData>({
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{(onUpdate || onDelete) && (
|
{(onUpdate || onDelete || onAddToRequired) && (
|
||||||
<CardFooter className="flex items-center justify-between gap-4">
|
<CardFooter className="flex items-center justify-between gap-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<RequestUpdateMenu
|
<RequestUpdateMenu
|
||||||
|
|
@ -171,12 +182,21 @@ export function AppManagerTemplate<TData>({
|
||||||
icon={<Download className="h-4 w-4" />}
|
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>
|
</div>
|
||||||
{onDelete && (
|
{onDeleteFromServer && onDeleteFromRequired && (
|
||||||
<DeleteButton
|
<DeleteMenu
|
||||||
onClick={onDelete}
|
onDeleteFromServer={onDeleteFromServer}
|
||||||
|
onDeleteFromRequired={onDeleteFromRequired}
|
||||||
loading={deleteLoading}
|
loading={deleteLoading}
|
||||||
disabled={false}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardFooter>
|
</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