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 ( + + + + + + + Cập nhật phiên bản mới + Chọn tệp và nhập số phiên bản + + +
+ + {(field) => ( +
+ + field.handleChange(e.target.value)} + placeholder="e.g., 1.0.0" + /> +
+ )} +
+ + + {(field) => ( +
+ + e.target.files && field.handleChange(e.target.files)} + /> +
+ )} +
+ + {uploadPercent > 0 && ( +
+ + + {uploadPercent}% +
+ )} + + + + + +
+
+
+ ); +} 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); - - - - - - - Cập nhật phiên bản mới - - Chọn tệp và nhập số phiên bản - - - -
- - {(field) => ( -
- - field.handleChange(e.target.value)} - placeholder="e.g., 1.0.0" - /> -
- )} -
- - - {(field) => ( -
- - { - if (e.target.files) { - field.handleChange(e.target.files); - } - }} - /> -
- )} -
- - {uploadPercent > 0 && ( -
- - - {uploadPercent}% -
- )} - - - - - -
-
-
-
- - - - - - 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 && ( + + + + )} + +
+ ); +}