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="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<title>Create TanStack App - .</title> <title>Quản lý phòng máy</title>
</head> </head>
<body> <body>
<div id="app"></div> <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: { APP_VERSION: {
GET_VERSION: `${BASE_URL}/AppVersion/version`, GET_VERSION: `${BASE_URL}/AppVersion/version`,
UPLOAD: `${BASE_URL}/AppVersion/upload`, UPLOAD: `${BASE_URL}/AppVersion/upload`,
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
}, },
DEVICE_COMM: { DEVICE_COMM: {
UPDATE_AGENT: `${BASE_URL}/DeviceComm/updateagent`, DOWNLOAD_MSI: `${BASE_URL}/DeviceComm/installmsi`,
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`, GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
GET_DEVICE_FROM_ROOM: (roomName: string) => GET_DEVICE_FROM_ROOM: (roomName: string) =>
`${BASE_URL}/DeviceComm/room/${roomName}`, `${BASE_URL}/DeviceComm/room/${roomName}`,
UPDATE_AGENT: `${BASE_URL}/DeviceComm/updateagent`,
}, },
SSE_EVENTS: { SSE_EVENTS: {
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`, DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,

View File

@ -18,9 +18,9 @@ type AppLayoutProps = {
export default function AppLayout({ children }: AppLayoutProps) { export default function AppLayout({ children }: AppLayoutProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const handlePrefetchApps = () => { const handlePrefetchAgents = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["app-version"], queryKey: ["agent-version"],
queryFn: () => queryFn: () =>
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION).then((res) => fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION).then((res) =>
res.json() res.json()
@ -28,6 +28,16 @@ export default function AppLayout({ children }: AppLayoutProps) {
staleTime: 60 * 1000, 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 = () => { const handlePrefetchRooms = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
@ -50,10 +60,13 @@ export default function AppLayout({ children }: AppLayoutProps) {
}, },
{ {
title: "Quản lý Agent", title: "Quản lý Agent",
to: "/apps", to: "/agent",
icon: AppWindow, icon: AppWindow,
onPointerEnter: handlePrefetchApps, onPointerEnter: handlePrefetchAgents,
}, },
{ title: "Quản lý phần mềm", to: "/apps", icon: AppWindow,
onPointerEnter: handlePrefetchSofware,
},
]; ];
return ( return (

View File

@ -14,6 +14,7 @@ import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index' import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/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 AuthLoginIndexRouteImport } from './routes/_auth/login/index'
import { Route as AuthenticatedRoomRoomNameIndexRouteImport } from './routes/_authenticated/room/$roomName/index' import { Route as AuthenticatedRoomRoomNameIndexRouteImport } from './routes/_authenticated/room/$roomName/index'
@ -40,6 +41,11 @@ const AuthenticatedAppsIndexRoute = AuthenticatedAppsIndexRouteImport.update({
path: '/apps/', path: '/apps/',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedAgentIndexRoute = AuthenticatedAgentIndexRouteImport.update({
id: '/agent/',
path: '/agent/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthLoginIndexRoute = AuthLoginIndexRouteImport.update({ const AuthLoginIndexRoute = AuthLoginIndexRouteImport.update({
id: '/login/', id: '/login/',
path: '/login/', path: '/login/',
@ -55,6 +61,7 @@ const AuthenticatedRoomRoomNameIndexRoute =
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof AuthLoginIndexRoute '/login': typeof AuthLoginIndexRoute
'/agent': typeof AuthenticatedAgentIndexRoute
'/apps': typeof AuthenticatedAppsIndexRoute '/apps': typeof AuthenticatedAppsIndexRoute
'/room': typeof AuthenticatedRoomIndexRoute '/room': typeof AuthenticatedRoomIndexRoute
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute '/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
@ -62,6 +69,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof AuthLoginIndexRoute '/login': typeof AuthLoginIndexRoute
'/agent': typeof AuthenticatedAgentIndexRoute
'/apps': typeof AuthenticatedAppsIndexRoute '/apps': typeof AuthenticatedAppsIndexRoute
'/room': typeof AuthenticatedRoomIndexRoute '/room': typeof AuthenticatedRoomIndexRoute
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute '/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
@ -72,21 +80,23 @@ export interface FileRoutesById {
'/_auth': typeof AuthRouteWithChildren '/_auth': typeof AuthRouteWithChildren
'/_authenticated': typeof AuthenticatedRouteWithChildren '/_authenticated': typeof AuthenticatedRouteWithChildren
'/_auth/login/': typeof AuthLoginIndexRoute '/_auth/login/': typeof AuthLoginIndexRoute
'/_authenticated/agent/': typeof AuthenticatedAgentIndexRoute
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute '/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
'/_authenticated/room/': typeof AuthenticatedRoomIndexRoute '/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
'/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute '/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/login' | '/apps' | '/room' | '/room/$roomName' fullPaths: '/' | '/login' | '/agent' | '/apps' | '/room' | '/room/$roomName'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/login' | '/apps' | '/room' | '/room/$roomName' to: '/' | '/login' | '/agent' | '/apps' | '/room' | '/room/$roomName'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/_auth' | '/_auth'
| '/_authenticated' | '/_authenticated'
| '/_auth/login/' | '/_auth/login/'
| '/_authenticated/agent/'
| '/_authenticated/apps/' | '/_authenticated/apps/'
| '/_authenticated/room/' | '/_authenticated/room/'
| '/_authenticated/room/$roomName/' | '/_authenticated/room/$roomName/'
@ -135,6 +145,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedAppsIndexRouteImport preLoaderRoute: typeof AuthenticatedAppsIndexRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/agent/': {
id: '/_authenticated/agent/'
path: '/agent'
fullPath: '/agent'
preLoaderRoute: typeof AuthenticatedAgentIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/login/': { '/_auth/login/': {
id: '/_auth/login/' id: '/_auth/login/'
path: '/login' path: '/login'
@ -163,12 +180,14 @@ const AuthRouteChildren: AuthRouteChildren = {
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface AuthenticatedRouteChildren { interface AuthenticatedRouteChildren {
AuthenticatedAgentIndexRoute: typeof AuthenticatedAgentIndexRoute
AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
} }
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedAgentIndexRoute: AuthenticatedAgentIndexRoute,
AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute, AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute, AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute, AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,

View File

@ -6,6 +6,12 @@ export interface RouterContext {
} }
export const Route = createRootRouteWithContext<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: () => ( component: () => (
<> <>
<HeadContent /> <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 { createFileRoute } from "@tanstack/react-router";
import { import { AppManagerTemplate } from "@/template/app-manager-template";
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useQueryData } from "@/hooks/useQueryData"; import { useQueryData } from "@/hooks/useQueryData";
import { useMutationData } from "@/hooks/useMutationData"; 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"; import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
interface UploadAppFormProps { import type { ColumnDef } from "@tanstack/react-table";
files: FileList; import { useState } from "react";
newVersion: string;
}
const defaultInput: UploadAppFormProps = {
files: new DataTransfer().files,
newVersion: "",
};
const formOpts = formOptions({
defaultValues: defaultInput,
});
export const Route = createFileRoute("/_authenticated/apps/")({ export const Route = createFileRoute("/_authenticated/apps/")({
head: () => ({ head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
meta: [{ title: "Quản lý Agent" }],
}),
component: AppsComponent, component: AppsComponent,
}); });
type Version = {
id: number;
version: string;
fileName: string;
folderPath: string;
updatedAt?: string;
};
function AppsComponent() { function AppsComponent() {
const { data: versionData, isLoading } = useQueryData({ const { data, isLoading } = useQueryData({
queryKey: ["app-version"], queryKey: ["software-version"],
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION, url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE, // API lấy danh sách file MSI
}); });
const versionList = Array.isArray(versionData) const versionList: Version[] = Array.isArray(data)
? versionData ? data
: versionData : data
? [versionData] ? [data]
: []; : [];
const [table, setTable] = useState<any>();
const [isUploadOpen, setIsUploadOpen] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
const uploadMutation = useMutationData<FormData>({ const uploadMutation = useMutationData<FormData>({
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD, url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
method: "POST", method: "POST",
config: { onSuccess: () => toast.success("Upload thành công!"),
onUploadProgress: (e: ProgressEvent) => { onError: () => toast.error("Upload thất bại!"),
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!"),
}); });
const updateAgentMutation = useMutationData<void>({ const installMutation = useMutationData<{ msiFileIds: number[] }>({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT, url: BASE_URL+ API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI,
method: "POST", onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
onSuccess: () => toast.success("Đã gửi yêu cầu cập nhật đến thiết bị!"),
onError: () => toast.error("Gửi yêu cầu thất bại!"), onError: () => toast.error("Gửi yêu cầu thất bại!"),
}); });
const form = useForm({ const columns: ColumnDef<Version>[] = [
...formOpts, {
onSubmit: async ({ value }) => { id: "select",
const typedValue = value as UploadAppFormProps; header: () => <span>Thêm vào danh sách yêu cầu</span>,
if (!typedValue.newVersion || typedValue.files.length === 0) { cell: ({ row }) => (
toast.error("Vui lòng điền đầy đủ thông tin"); <input
return; type="checkbox"
} checked={row.getIsSelected?.() ?? false}
const formData = new FormData(); onChange={row.getToggleSelectedHandler?.()}
Array.from(typedValue.files).forEach((file) => />
formData.append("files", file) ),
); enableSorting: false,
formData.append("newVersion", typedValue.newVersion); enableHiding: false,
await uploadMutation.mutateAsync(formData);
}, },
}); { 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 ( return (
<div className="w-full px-6 space-y-4"> <AppManagerTemplate<Version>
<div className="flex items-center justify-between"> title="Quản lý phần mềm"
<div> description="Quản lý và gửi yêu cầu cài đặt phần mềm MSI"
<h1 className="text-3xl font-bold">Quản Agent</h1> data={versionList}
<p className="text-muted-foreground mt-2"> isLoading={isLoading}
Quản theo dõi các phiên bản Agent columns={columns}
</p> onUpload={(fd) => uploadMutation.mutateAsync(fd)}
</div> onTableInit={setTable}
onUpdate={() => {
const selectedIds = table
?.getSelectedRowModel()
.rows.map((row: any) => (row.original as Version).id);
<Dialog open={isUploadOpen} onOpenChange={setIsUploadOpen}> installMutation.mutateAsync({ msiFileIds: selectedIds });
<DialogTrigger asChild> }}
<Button className="gap-2"> updateLoading={installMutation.isPending}
<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>
); );
} }

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