add setup folder scan

This commit is contained in:
Do Manh Phuong 2025-12-03 18:26:36 +07:00
parent 451eed4c65
commit eb4243ee5b
16 changed files with 747 additions and 113 deletions

View File

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

View File

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

View 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 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>
);
}

View File

@ -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));

View File

@ -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 (2140) */}
{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 (120) */}
{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}
/>
);
})}

View 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> 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>
)}
</>
);
}

View File

@ -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({

View File

@ -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`,
},
};

View 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;
}

View File

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

View File

@ -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" }] }),

View File

@ -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"></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}
/>
</>
);
}

View File

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

View File

@ -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>
);

View File

@ -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
View File

@ -0,0 +1,9 @@
export type Version = {
id: number;
version: string;
fileName: string;
folderPath: string;
updatedAt?: string;
requestUpdateAt?: string;
isRequired: boolean;
};