Add app management page

This commit is contained in:
Do Manh Phuong 2025-09-10 09:59:17 +07:00
parent 1f9336271c
commit 328d499dca
12 changed files with 479 additions and 248 deletions

View File

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

View File

@ -1,5 +0,0 @@
import { Button } from "./ui/button";
export function SubmitButton(){
}

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

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

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

View File

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

View File

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

View File

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

View File

@ -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>()({
</>
),
})

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

View File

@ -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 Agent</h1>
<p className="text-muted-foreground mt-2">
Quản 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 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 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}
/>
);
}

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