diff --git a/index.html b/index.html
index e913164..02b0d51 100644
--- a/index.html
+++ b/index.html
@@ -11,7 +11,7 @@
/>
-
Create TanStack App - .
+ Quản lý phòng máy
diff --git a/src/components/submit-button.tsx b/src/components/submit-button.tsx
deleted file mode 100644
index e353917..0000000
--- a/src/components/submit-button.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { Button } from "./ui/button";
-
-export function SubmitButton(){
-
-}
\ No newline at end of file
diff --git a/src/components/update-button.tsx b/src/components/update-button.tsx
new file mode 100644
index 0000000..ef77a8b
--- /dev/null
+++ b/src/components/update-button.tsx
@@ -0,0 +1,14 @@
+import { Button } from "@/components/ui/button";
+
+interface UpdateButtonProps {
+ onClick: () => void;
+ loading?: boolean;
+}
+
+export function UpdateButton({ onClick, loading }: UpdateButtonProps) {
+ return (
+
+ );
+}
diff --git a/src/components/upload-dialog.tsx b/src/components/upload-dialog.tsx
new file mode 100644
index 0000000..579e21f
--- /dev/null
+++ b/src/components/upload-dialog.tsx
@@ -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;
+ 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 (
+
+ );
+}
diff --git a/src/components/version-table.tsx b/src/components/version-table.tsx
new file mode 100644
index 0000000..c1f2525
--- /dev/null
+++ b/src/components/version-table.tsx
@@ -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 {
+ data: TData[];
+ columns: ColumnDef[];
+ isLoading: boolean;
+ onTableInit?: (table: any) => void; // <-- thêm
+}
+
+export function VersionTable({
+ data,
+ columns,
+ isLoading,
+ onTableInit,
+}: VersionTableProps) {
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getRowId: (row: any) => row.id?.toString(),
+ enableRowSelection: true,
+ });
+
+ useEffect(() => {
+ onTableInit?.(table);
+ }, [table, onTableInit]);
+
+ return (
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ ))}
+
+ ))}
+
+
+
+ {isLoading ? (
+
+ Đang tải dữ liệu...
+
+ ) : table.getRowModel().rows.length === 0 ? (
+
+ Không có dữ liệu.
+
+ ) : (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/src/config/api.ts b/src/config/api.ts
index 4fa3675..56457b3 100644
--- a/src/config/api.ts
+++ b/src/config/api.ts
@@ -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`,
diff --git a/src/layouts/app-layout.tsx b/src/layouts/app-layout.tsx
index 15ffa51..4507587 100644
--- a/src/layouts/app-layout.tsx
+++ b/src/layouts/app-layout.tsx
@@ -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 (
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 1f5b1a9..8a84e00 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -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,
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index e8345e3..8f65101 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -6,6 +6,12 @@ export interface RouterContext {
}
export const Route = createRootRouteWithContext()({
+ 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: () => (
<>
@@ -13,3 +19,4 @@ export const Route = createRootRouteWithContext()({
>
),
})
+
diff --git a/src/routes/_authenticated/agent/index.tsx b/src/routes/_authenticated/agent/index.tsx
new file mode 100644
index 0000000..d8fb3b7
--- /dev/null
+++ b/src/routes/_authenticated/agent/index.tsx
@@ -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({
+ 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({
+ 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[] = [
+ {
+ 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 (
+
+ 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}
+ />
+ );
+}
diff --git a/src/routes/_authenticated/apps/index.tsx b/src/routes/_authenticated/apps/index.tsx
index 011a0d8..49866b6 100644
--- a/src/routes/_authenticated/apps/index.tsx
+++ b/src/routes/_authenticated/apps/index.tsx
@@ -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();
const uploadMutation = useMutationData({
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({
- 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[] = [
+ {
+ id: "select",
+ header: () => Thêm vào danh sách yêu cầu,
+ cell: ({ row }) => (
+
+ ),
+ 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 (
-
-
-
-
Quản lý Agent
-
- Quản lý và theo dõi các phiên bản Agent
-
-
+
+ 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);
-
-
-
-
-
-
-
- Lịch sử phiên bản
-
-
- Tất cả các phiên bản đã tải lên của Agent
-
-
-
-
-
-
- Phiên bản
- Tên tệp
- Đường dẫn thư mục
- Thời gian cập nhật
-
-
-
- {isLoading ? (
-
- Đang tải dữ liệu...
-
- ) : versionList.length === 0 ? (
-
- Không có dữ liệu phiên bản.
-
- ) : (
- versionList.map((v: any) => (
-
- {v.version}
- {v.fileName}
- {v.folderPath}
-
- {v.updatedAt
- ? new Date(v.updatedAt).toLocaleString("vi-VN")
- : "N/A"}
-
-
- ))
- )}
-
-
-
-
-
-
-
-
+ installMutation.mutateAsync({ msiFileIds: selectedIds });
+ }}
+ updateLoading={installMutation.isPending}
+ />
);
}
diff --git a/src/template/app-manager-template.tsx b/src/template/app-manager-template.tsx
new file mode 100644
index 0000000..ecda957
--- /dev/null
+++ b/src/template/app-manager-template.tsx
@@ -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 {
+ title: string;
+ description: string;
+ data: TData[];
+ isLoading: boolean;
+ columns: ColumnDef[];
+ onUpload: (fd: FormData) => Promise;
+ onUpdate?: () => void;
+ updateLoading?: boolean;
+ onTableInit?: (table: any) => void;
+}
+
+export function AppManagerTemplate({
+ title,
+ description,
+ data,
+ isLoading,
+ columns,
+ onUpload,
+ onUpdate,
+ updateLoading,
+ onTableInit,
+}: AppManagerTemplateProps) {
+ return (
+
+
+
+
{title}
+
{description}
+
+
+
+
+
+
+
+ Lịch sử phiên bản
+
+ Tất cả các phiên bản đã tải lên
+
+
+
+
+ {onUpdate && (
+
+
+
+ )}
+
+
+ );
+}