Add app management page
This commit is contained in:
parent
1f9336271c
commit
328d499dca
|
@ -11,7 +11,7 @@
|
|||
/>
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>Create TanStack App - .</title>
|
||||
<title>Quản lý phòng máy</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import { Button } from "./ui/button";
|
||||
|
||||
export function SubmitButton(){
|
||||
|
||||
}
|
14
src/components/update-button.tsx
Normal file
14
src/components/update-button.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface UpdateButtonProps {
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function UpdateButton({ onClick, loading }: UpdateButtonProps) {
|
||||
return (
|
||||
<Button variant="outline" onClick={onClick} disabled={loading}>
|
||||
{loading ? "Đang gửi..." : "Yêu cầu thiết bị cập nhật"}
|
||||
</Button>
|
||||
);
|
||||
}
|
110
src/components/upload-dialog.tsx
Normal file
110
src/components/upload-dialog.tsx
Normal file
|
@ -0,0 +1,110 @@
|
|||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter,
|
||||
DialogHeader, DialogTitle, DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Plus } from "lucide-react";
|
||||
import { formOptions, useForm } from "@tanstack/react-form";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UploadDialogProps {
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
interface UploadFormProps {
|
||||
files: FileList;
|
||||
newVersion: string;
|
||||
}
|
||||
|
||||
const defaultInput: UploadFormProps = {
|
||||
files: new DataTransfer().files,
|
||||
newVersion: "",
|
||||
};
|
||||
|
||||
const formOpts = formOptions({ defaultValues: defaultInput });
|
||||
|
||||
export function UploadDialog({ onSubmit, accept = ".exe,.msi,.apk" }: UploadDialogProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [uploadPercent, setUploadPercent] = useState(0);
|
||||
|
||||
const form = useForm({
|
||||
...formOpts,
|
||||
onSubmit: async ({ value }) => {
|
||||
if (!value.newVersion || value.files.length === 0) {
|
||||
toast.error("Vui lòng điền đầy đủ thông tin");
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
Array.from(value.files).forEach((file) => fd.append("files", file));
|
||||
fd.append("newVersion", value.newVersion);
|
||||
|
||||
setUploadPercent(0);
|
||||
await onSubmit(fd);
|
||||
setIsOpen(false);
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" /> Cập nhật phiên bản mới
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cập nhật phiên bản mới</DialogTitle>
|
||||
<DialogDescription>Chọn tệp và nhập số phiên bản</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit}>
|
||||
<form.Field name="newVersion">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label>Phiên bản</Label>
|
||||
<Input
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder="e.g., 1.0.0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="files">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label>File ứng dụng</Label>
|
||||
<Input
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={(e) => e.target.files && field.handleChange(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{uploadPercent > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Tiến trình upload</Label>
|
||||
<Progress value={uploadPercent} className="w-full" />
|
||||
<span>{uploadPercent}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}>Hủy</Button>
|
||||
<Button type="submit">Tải lên</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
87
src/components/version-table.tsx
Normal file
87
src/components/version-table.tsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface VersionTableProps<TData> {
|
||||
data: TData[];
|
||||
columns: ColumnDef<TData, any>[];
|
||||
isLoading: boolean;
|
||||
onTableInit?: (table: any) => void; // <-- thêm
|
||||
}
|
||||
|
||||
export function VersionTable<TData>({
|
||||
data,
|
||||
columns,
|
||||
isLoading,
|
||||
onTableInit,
|
||||
}: VersionTableProps<TData>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row: any) => row.id?.toString(),
|
||||
enableRowSelection: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onTableInit?.(table);
|
||||
}, [table, onTableInit]);
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length}>Đang tải dữ liệu...</TableCell>
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length}>Không có dữ liệu.</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
|
@ -8,12 +8,14 @@ export const API_ENDPOINTS = {
|
|||
APP_VERSION: {
|
||||
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
||||
UPLOAD: `${BASE_URL}/AppVersion/upload`,
|
||||
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
||||
},
|
||||
DEVICE_COMM: {
|
||||
UPDATE_AGENT: `${BASE_URL}/DeviceComm/updateagent`,
|
||||
DOWNLOAD_MSI: `${BASE_URL}/DeviceComm/installmsi`,
|
||||
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
|
||||
GET_DEVICE_FROM_ROOM: (roomName: string) =>
|
||||
`${BASE_URL}/DeviceComm/room/${roomName}`,
|
||||
UPDATE_AGENT: `${BASE_URL}/DeviceComm/updateagent`,
|
||||
},
|
||||
SSE_EVENTS: {
|
||||
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
||||
|
|
|
@ -18,9 +18,9 @@ type AppLayoutProps = {
|
|||
export default function AppLayout({ children }: AppLayoutProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handlePrefetchApps = () => {
|
||||
const handlePrefetchAgents = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["app-version"],
|
||||
queryKey: ["agent-version"],
|
||||
queryFn: () =>
|
||||
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION).then((res) =>
|
||||
res.json()
|
||||
|
@ -28,6 +28,16 @@ export default function AppLayout({ children }: AppLayoutProps) {
|
|||
staleTime: 60 * 1000,
|
||||
});
|
||||
};
|
||||
const handlePrefetchSofware = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["software-version"],
|
||||
queryFn: () =>
|
||||
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE).then((res) =>
|
||||
res.json()
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePrefetchRooms = () => {
|
||||
queryClient.prefetchQuery({
|
||||
|
@ -50,10 +60,13 @@ export default function AppLayout({ children }: AppLayoutProps) {
|
|||
},
|
||||
{
|
||||
title: "Quản lý Agent",
|
||||
to: "/apps",
|
||||
to: "/agent",
|
||||
icon: AppWindow,
|
||||
onPointerEnter: handlePrefetchApps,
|
||||
onPointerEnter: handlePrefetchAgents,
|
||||
},
|
||||
{ title: "Quản lý phần mềm", to: "/apps", icon: AppWindow,
|
||||
onPointerEnter: handlePrefetchSofware,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
@ -14,6 +14,7 @@ import { Route as AuthRouteImport } from './routes/_auth'
|
|||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
|
||||
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
|
||||
import { Route as AuthenticatedAgentIndexRouteImport } from './routes/_authenticated/agent/index'
|
||||
import { Route as AuthLoginIndexRouteImport } from './routes/_auth/login/index'
|
||||
import { Route as AuthenticatedRoomRoomNameIndexRouteImport } from './routes/_authenticated/room/$roomName/index'
|
||||
|
||||
|
@ -40,6 +41,11 @@ const AuthenticatedAppsIndexRoute = AuthenticatedAppsIndexRouteImport.update({
|
|||
path: '/apps/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedAgentIndexRoute = AuthenticatedAgentIndexRouteImport.update({
|
||||
id: '/agent/',
|
||||
path: '/agent/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthLoginIndexRoute = AuthLoginIndexRouteImport.update({
|
||||
id: '/login/',
|
||||
path: '/login/',
|
||||
|
@ -55,6 +61,7 @@ const AuthenticatedRoomRoomNameIndexRoute =
|
|||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof AuthLoginIndexRoute
|
||||
'/agent': typeof AuthenticatedAgentIndexRoute
|
||||
'/apps': typeof AuthenticatedAppsIndexRoute
|
||||
'/room': typeof AuthenticatedRoomIndexRoute
|
||||
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||
|
@ -62,6 +69,7 @@ export interface FileRoutesByFullPath {
|
|||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof AuthLoginIndexRoute
|
||||
'/agent': typeof AuthenticatedAgentIndexRoute
|
||||
'/apps': typeof AuthenticatedAppsIndexRoute
|
||||
'/room': typeof AuthenticatedRoomIndexRoute
|
||||
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||
|
@ -72,21 +80,23 @@ export interface FileRoutesById {
|
|||
'/_auth': typeof AuthRouteWithChildren
|
||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||
'/_auth/login/': typeof AuthLoginIndexRoute
|
||||
'/_authenticated/agent/': typeof AuthenticatedAgentIndexRoute
|
||||
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
|
||||
'/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
|
||||
'/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/login' | '/apps' | '/room' | '/room/$roomName'
|
||||
fullPaths: '/' | '/login' | '/agent' | '/apps' | '/room' | '/room/$roomName'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/login' | '/apps' | '/room' | '/room/$roomName'
|
||||
to: '/' | '/login' | '/agent' | '/apps' | '/room' | '/room/$roomName'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/_auth'
|
||||
| '/_authenticated'
|
||||
| '/_auth/login/'
|
||||
| '/_authenticated/agent/'
|
||||
| '/_authenticated/apps/'
|
||||
| '/_authenticated/room/'
|
||||
| '/_authenticated/room/$roomName/'
|
||||
|
@ -135,6 +145,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof AuthenticatedAppsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/agent/': {
|
||||
id: '/_authenticated/agent/'
|
||||
path: '/agent'
|
||||
fullPath: '/agent'
|
||||
preLoaderRoute: typeof AuthenticatedAgentIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_auth/login/': {
|
||||
id: '/_auth/login/'
|
||||
path: '/login'
|
||||
|
@ -163,12 +180,14 @@ const AuthRouteChildren: AuthRouteChildren = {
|
|||
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||
|
||||
interface AuthenticatedRouteChildren {
|
||||
AuthenticatedAgentIndexRoute: typeof AuthenticatedAgentIndexRoute
|
||||
AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
|
||||
AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
|
||||
AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
|
||||
}
|
||||
|
||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
AuthenticatedAgentIndexRoute: AuthenticatedAgentIndexRoute,
|
||||
AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
|
||||
AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
|
||||
AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,
|
||||
|
|
|
@ -6,6 +6,12 @@ export interface RouterContext {
|
|||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Quản lý phòng máy" },
|
||||
{ name: "description", content: "Ứng dụng quản lý thiết bị và phần mềm" },
|
||||
],
|
||||
}),
|
||||
component: () => (
|
||||
<>
|
||||
<HeadContent />
|
||||
|
@ -13,3 +19,4 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||
</>
|
||||
),
|
||||
})
|
||||
|
||||
|
|
81
src/routes/_authenticated/agent/index.tsx
Normal file
81
src/routes/_authenticated/agent/index.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AppManagerTemplate } from "@/template/app-manager-template";
|
||||
import { useQueryData } from "@/hooks/useQueryData";
|
||||
import { useMutationData } from "@/hooks/useMutationData";
|
||||
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
||||
import { toast } from "sonner";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
type Version = {
|
||||
id?: string;
|
||||
version: string;
|
||||
fileName: string;
|
||||
folderPath: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/agent/")({
|
||||
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
|
||||
component: AgentsPage,
|
||||
});
|
||||
|
||||
function AgentsPage() {
|
||||
// Lấy danh sách version
|
||||
const { data, isLoading } = useQueryData({
|
||||
queryKey: ["agent-version"],
|
||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
|
||||
});
|
||||
|
||||
const versionList: Version[] = Array.isArray(data) ? data : data ? [data] : [];
|
||||
|
||||
// Mutation upload
|
||||
const uploadMutation = useMutationData<FormData>({
|
||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
|
||||
method: "POST",
|
||||
onSuccess: () => toast.success("Upload thành công!"),
|
||||
onError: () => toast.error("Upload thất bại!"),
|
||||
});
|
||||
|
||||
const updateMutation = useMutationData<void>({
|
||||
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT,
|
||||
method: "POST",
|
||||
onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
|
||||
onError: () => toast.error("Gửi yêu cầu thất bại!"),
|
||||
});
|
||||
|
||||
const columns: ColumnDef<Version>[] = [
|
||||
{
|
||||
accessorKey: "version",
|
||||
header: "Phiên bản",
|
||||
},
|
||||
{
|
||||
accessorKey: "fileName",
|
||||
header: "Tên file",
|
||||
},
|
||||
{
|
||||
accessorKey: "folderPath",
|
||||
header: "Đường dẫn",
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: "Thời gian cập nhật",
|
||||
cell: ({ getValue }) =>
|
||||
getValue()
|
||||
? new Date(getValue() as string).toLocaleString("vi-VN")
|
||||
: "N/A",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AppManagerTemplate<Version>
|
||||
title="Quản lý Agent"
|
||||
description="Quản lý và theo dõi các phiên bản Agent"
|
||||
data={versionList}
|
||||
isLoading={isLoading}
|
||||
columns={columns}
|
||||
onUpload={(fd) => uploadMutation.mutateAsync(fd)}
|
||||
onUpdate={() => updateMutation.mutateAsync()}
|
||||
updateLoading={updateMutation.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,262 +1,94 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AppManagerTemplate } from "@/template/app-manager-template";
|
||||
import { useQueryData } from "@/hooks/useQueryData";
|
||||
import { useMutationData } from "@/hooks/useMutationData";
|
||||
import { formOptions, useForm } from "@tanstack/react-form";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FileText, Plus } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
||||
|
||||
interface UploadAppFormProps {
|
||||
files: FileList;
|
||||
newVersion: string;
|
||||
}
|
||||
|
||||
const defaultInput: UploadAppFormProps = {
|
||||
files: new DataTransfer().files,
|
||||
newVersion: "",
|
||||
};
|
||||
|
||||
const formOpts = formOptions({
|
||||
defaultValues: defaultInput,
|
||||
});
|
||||
import { toast } from "sonner";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/apps/")({
|
||||
head: () => ({
|
||||
meta: [{ title: "Quản lý Agent" }],
|
||||
}),
|
||||
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
|
||||
component: AppsComponent,
|
||||
});
|
||||
|
||||
type Version = {
|
||||
id: number;
|
||||
version: string;
|
||||
fileName: string;
|
||||
folderPath: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
function AppsComponent() {
|
||||
const { data: versionData, isLoading } = useQueryData({
|
||||
queryKey: ["app-version"],
|
||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
|
||||
const { data, isLoading } = useQueryData({
|
||||
queryKey: ["software-version"],
|
||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE, // API lấy danh sách file MSI
|
||||
});
|
||||
|
||||
const versionList = Array.isArray(versionData)
|
||||
? versionData
|
||||
: versionData
|
||||
? [versionData]
|
||||
const versionList: Version[] = Array.isArray(data)
|
||||
? data
|
||||
: data
|
||||
? [data]
|
||||
: [];
|
||||
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
const [uploadPercent, setUploadPercent] = useState(0);
|
||||
|
||||
const [table, setTable] = useState<any>();
|
||||
const uploadMutation = useMutationData<FormData>({
|
||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
|
||||
method: "POST",
|
||||
config: {
|
||||
onUploadProgress: (e: ProgressEvent) => {
|
||||
if (e.total) {
|
||||
const percent = Math.round((e.loaded * 100) / e.total);
|
||||
setUploadPercent(percent);
|
||||
}
|
||||
},
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Cập nhật thành công!");
|
||||
setUploadPercent(0);
|
||||
form.reset();
|
||||
setIsUploadOpen(false);
|
||||
},
|
||||
onError: () => toast.error("Lỗi khi cập nhật phiên bản!"),
|
||||
onSuccess: () => toast.success("Upload thành công!"),
|
||||
onError: () => toast.error("Upload thất bại!"),
|
||||
});
|
||||
|
||||
const updateAgentMutation = useMutationData<void>({
|
||||
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT,
|
||||
method: "POST",
|
||||
onSuccess: () => toast.success("Đã gửi yêu cầu cập nhật đến thiết bị!"),
|
||||
const installMutation = useMutationData<{ msiFileIds: number[] }>({
|
||||
url: BASE_URL+ API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI,
|
||||
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
|
||||
onError: () => toast.error("Gửi yêu cầu thất bại!"),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
...formOpts,
|
||||
onSubmit: async ({ value }) => {
|
||||
const typedValue = value as UploadAppFormProps;
|
||||
if (!typedValue.newVersion || typedValue.files.length === 0) {
|
||||
toast.error("Vui lòng điền đầy đủ thông tin");
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
Array.from(typedValue.files).forEach((file) =>
|
||||
formData.append("files", file)
|
||||
);
|
||||
formData.append("newVersion", typedValue.newVersion);
|
||||
|
||||
await uploadMutation.mutateAsync(formData);
|
||||
const columns: ColumnDef<Version>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: () => <span>Thêm vào danh sách yêu cầu</span>,
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.getIsSelected?.() ?? false}
|
||||
onChange={row.getToggleSelectedHandler?.()}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
});
|
||||
{ accessorKey: "version", header: "Phiên bản" },
|
||||
{ accessorKey: "fileName", header: "Tên file" },
|
||||
{ accessorKey: "folderPath", header: "Đường dẫn" },
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: "Thời gian cập nhật",
|
||||
cell: ({ getValue }) =>
|
||||
getValue()
|
||||
? new Date(getValue() as string).toLocaleString("vi-VN")
|
||||
: "N/A",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full px-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Quản lý Agent</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Quản lý và theo dõi các phiên bản Agent
|
||||
</p>
|
||||
</div>
|
||||
<AppManagerTemplate<Version>
|
||||
title="Quản lý phần mềm"
|
||||
description="Quản lý và gửi yêu cầu cài đặt phần mềm MSI"
|
||||
data={versionList}
|
||||
isLoading={isLoading}
|
||||
columns={columns}
|
||||
onUpload={(fd) => uploadMutation.mutateAsync(fd)}
|
||||
onTableInit={setTable}
|
||||
onUpdate={() => {
|
||||
const selectedIds = table
|
||||
?.getSelectedRowModel()
|
||||
.rows.map((row: any) => (row.original as Version).id);
|
||||
|
||||
<Dialog open={isUploadOpen} onOpenChange={setIsUploadOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Cập nhật phiên bản mới
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cập nhật phiên bản mới</DialogTitle>
|
||||
<DialogDescription>
|
||||
Chọn tệp và nhập số phiên bản
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit}>
|
||||
<form.Field name="newVersion">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label>Phiên bản</Label>
|
||||
<Input
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder="e.g., 1.0.0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="files">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label>File ứng dụng</Label>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".exe,.msi,.apk"
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
field.handleChange(e.target.files);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{uploadPercent > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Tiến trình upload</Label>
|
||||
<Progress value={uploadPercent} className="w-full" />
|
||||
<span>{uploadPercent}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsUploadOpen(false)}
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button type="submit" disabled={uploadMutation.isPending}>
|
||||
{uploadMutation.isPending ? "Đang tải..." : "Tải lên"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Lịch sử phiên bản
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Tất cả các phiên bản đã tải lên của Agent
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Phiên bản</TableHead>
|
||||
<TableHead>Tên tệp</TableHead>
|
||||
<TableHead>Đường dẫn thư mục</TableHead>
|
||||
<TableHead>Thời gian cập nhật</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>Đang tải dữ liệu...</TableCell>
|
||||
</TableRow>
|
||||
) : versionList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>Không có dữ liệu phiên bản.</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
versionList.map((v: any) => (
|
||||
<TableRow key={v.id || v.version}>
|
||||
<TableCell>{v.version}</TableCell>
|
||||
<TableCell>{v.fileName}</TableCell>
|
||||
<TableCell>{v.folderPath}</TableCell>
|
||||
<TableCell>
|
||||
{v.updatedAt
|
||||
? new Date(v.updatedAt).toLocaleString("vi-VN")
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => updateAgentMutation.mutateAsync()}
|
||||
disabled={updateAgentMutation.isPending}
|
||||
>
|
||||
{updateAgentMutation.isPending
|
||||
? "Đang gửi..."
|
||||
: "Yêu cầu thiết bị cập nhật"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
installMutation.mutateAsync({ msiFileIds: selectedIds });
|
||||
}}
|
||||
updateLoading={installMutation.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
71
src/template/app-manager-template.tsx
Normal file
71
src/template/app-manager-template.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { FileText } from "lucide-react";
|
||||
import { UploadDialog } from "@/components/upload-dialog";
|
||||
import { VersionTable } from "@/components/version-table";
|
||||
import { UpdateButton } from "@/components/update-button";
|
||||
|
||||
interface AppManagerTemplateProps<TData> {
|
||||
title: string;
|
||||
description: string;
|
||||
data: TData[];
|
||||
isLoading: boolean;
|
||||
columns: ColumnDef<TData, any>[];
|
||||
onUpload: (fd: FormData) => Promise<void>;
|
||||
onUpdate?: () => void;
|
||||
updateLoading?: boolean;
|
||||
onTableInit?: (table: any) => void;
|
||||
}
|
||||
|
||||
export function AppManagerTemplate<TData>({
|
||||
title,
|
||||
description,
|
||||
data,
|
||||
isLoading,
|
||||
columns,
|
||||
onUpload,
|
||||
onUpdate,
|
||||
updateLoading,
|
||||
onTableInit,
|
||||
}: AppManagerTemplateProps<TData>) {
|
||||
return (
|
||||
<div className="w-full px-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{title}</h1>
|
||||
<p className="text-muted-foreground mt-2">{description}</p>
|
||||
</div>
|
||||
<UploadDialog onSubmit={onUpload} />
|
||||
</div>
|
||||
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" /> Lịch sử phiên bản
|
||||
</CardTitle>
|
||||
<CardDescription>Tất cả các phiên bản đã tải lên</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VersionTable
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
columns={columns}
|
||||
onTableInit={onTableInit}
|
||||
/>
|
||||
</CardContent>
|
||||
{onUpdate && (
|
||||
<CardFooter>
|
||||
<UpdateButton onClick={onUpdate} loading={updateLoading} />
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user