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