From 95266fab50bbb34399068981f3eb326be6f57260 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=ED=95=9C=EB=8F=99=ED=98=84?= <hando1220@ajou.ac.kr>
Date: Thu, 27 Mar 2025 22:59:02 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=20=EC=A1=B0=ED=9A=8C?=
 =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 package.json                     |   1 +
 pnpm-lock.yaml                   |  39 +++++
 src/components/ui/pagination.tsx | 101 +++++++++++
 src/components/ui/popover.tsx    |  46 +++++
 src/pages/log/List.tsx           | 284 +++++++++++++++++++++++++++++++
 src/routes.tsx                   |   4 +
 src/types/log.ts                 |  23 +++
 7 files changed, 498 insertions(+)
 create mode 100644 src/components/ui/pagination.tsx
 create mode 100644 src/components/ui/popover.tsx
 create mode 100644 src/pages/log/List.tsx
 create mode 100644 src/types/log.ts

diff --git a/package.json b/package.json
index 9c3c323..f4194fd 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
     "@radix-ui/react-dropdown-menu": "^2.1.6",
     "@radix-ui/react-hover-card": "^1.1.6",
     "@radix-ui/react-label": "^2.1.2",
+    "@radix-ui/react-popover": "^1.1.6",
     "@radix-ui/react-select": "^2.1.6",
     "@radix-ui/react-separator": "^1.1.2",
     "@radix-ui/react-slot": "^1.1.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 810a9ae..cd012c6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers:
       '@radix-ui/react-label':
         specifier: ^2.1.2
         version: 2.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-popover':
+        specifier: ^1.1.6
+        version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
       '@radix-ui/react-select':
         specifier: ^2.1.6
         version: 2.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -575,6 +578,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-popover@1.1.6':
+    resolution: {integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-popper@1.2.2':
     resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==}
     peerDependencies:
@@ -2169,6 +2185,29 @@ snapshots:
       '@types/react': 19.0.10
       '@types/react-dom': 19.0.4(@types/react@19.0.10)
 
+  '@radix-ui/react-popover@1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.1
+      '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0)
+      '@radix-ui/react-context': 1.1.1(@types/react@19.0.10)(react@19.0.0)
+      '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-focus-guards': 1.1.1(@types/react@19.0.10)(react@19.0.0)
+      '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-id': 1.1.0(@types/react@19.0.10)(react@19.0.0)
+      '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-slot': 1.1.2(@types/react@19.0.10)(react@19.0.0)
+      '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.10)(react@19.0.0)
+      aria-hidden: 1.2.4
+      react: 19.0.0
+      react-dom: 19.0.0(react@19.0.0)
+      react-remove-scroll: 2.6.3(@types/react@19.0.10)(react@19.0.0)
+    optionalDependencies:
+      '@types/react': 19.0.10
+      '@types/react-dom': 19.0.4(@types/react@19.0.10)
+
   '@radix-ui/react-popper@1.2.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..5eea7b2
--- /dev/null
+++ b/src/components/ui/pagination.tsx
@@ -0,0 +1,101 @@
+import * as React from 'react';
+import { Link } from 'react-router';
+import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+import { Button, buttonVariants } from '@/components/ui/button';
+
+function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
+  return (
+    <nav
+      role="navigation"
+      aria-label="pagination"
+      data-slot="pagination"
+      className={cn('mx-auto flex w-full justify-center', className)}
+      {...props}
+    />
+  );
+}
+
+function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
+  return <ul data-slot="pagination-content" className={cn('flex flex-row items-center gap-1', className)} {...props} />;
+}
+
+function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
+  return <li data-slot="pagination-item" {...props} />;
+}
+
+type PaginationLinkProps = {
+  isActive?: boolean;
+} & Pick<React.ComponentProps<typeof Button>, 'size'> &
+  React.ComponentProps<typeof Link>;
+
+function PaginationLink({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) {
+  return (
+    <Link
+      aria-current={isActive ? 'page' : undefined}
+      data-slot="pagination-link"
+      data-active={isActive}
+      className={cn(
+        buttonVariants({
+          variant: isActive ? 'outline' : 'ghost',
+          size,
+        }),
+        className
+      )}
+      {...props}
+    />
+  );
+}
+
+function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
+  return (
+    <PaginationLink
+      aria-label="이전 페이지"
+      size="default"
+      className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
+      {...props}
+    >
+      <ChevronLeftIcon />
+      <span className="hidden sm:block">이전</span>
+    </PaginationLink>
+  );
+}
+
+function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
+  return (
+    <PaginationLink
+      aria-label="다음 페이지"
+      size="default"
+      className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
+      {...props}
+    >
+      <span className="hidden sm:block">다음</span>
+      <ChevronRightIcon />
+    </PaginationLink>
+  );
+}
+
+function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
+  return (
+    <span
+      aria-hidden
+      data-slot="pagination-ellipsis"
+      className={cn('flex size-9 items-center justify-center', className)}
+      {...props}
+    >
+      <MoreHorizontalIcon className="size-4" />
+      <span className="sr-only">More pages</span>
+    </span>
+  );
+}
+
+export {
+  Pagination,
+  PaginationContent,
+  PaginationLink,
+  PaginationItem,
+  PaginationPrevious,
+  PaginationNext,
+  PaginationEllipsis,
+};
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
new file mode 100644
index 0000000..6d51b6c
--- /dev/null
+++ b/src/components/ui/popover.tsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+
+import { cn } from "@/lib/utils"
+
+function Popover({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
+  return <PopoverPrimitive.Root data-slot="popover" {...props} />
+}
+
+function PopoverTrigger({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
+  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
+}
+
+function PopoverContent({
+  className,
+  align = "center",
+  sideOffset = 4,
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
+  return (
+    <PopoverPrimitive.Portal>
+      <PopoverPrimitive.Content
+        data-slot="popover-content"
+        align={align}
+        sideOffset={sideOffset}
+        className={cn(
+          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
+          className
+        )}
+        {...props}
+      />
+    </PopoverPrimitive.Portal>
+  )
+}
+
+function PopoverAnchor({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
+  return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
+}
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
diff --git a/src/pages/log/List.tsx b/src/pages/log/List.tsx
new file mode 100644
index 0000000..152ef04
--- /dev/null
+++ b/src/pages/log/List.tsx
@@ -0,0 +1,284 @@
+import { useEffect, useRef, useState } from 'react';
+import { useSearchParams } from 'react-router';
+import { Filter, X } from 'lucide-react';
+import { toast } from 'sonner';
+import { useAuthStore } from '@/stores/authStore';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardFooter } from '@/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Input } from '@/components/ui/input';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Badge } from '@/components/ui/badge';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import {
+  Pagination,
+  PaginationContent,
+  PaginationItem,
+  PaginationLink,
+  PaginationNext,
+  PaginationPrevious,
+} from '@/components/ui/pagination';
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuLabel,
+  DropdownMenuRadioGroup,
+  DropdownMenuRadioItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { cn } from '@/lib/utils';
+import { Log, LogListResponse, Action, Type } from '@/types/log';
+
+interface PageInfo {
+  first: boolean;
+  last: boolean;
+  totalElements: number;
+  totalPages: number;
+}
+
+const ACTION = {
+  CREATE: '생성',
+  UPDATE: '수정',
+  DELETE: '삭제',
+};
+
+const TYPE = {
+  ROUTING: '라우팅',
+  CERTIFICATE: '인증서',
+  FORWARDING: '포워딩',
+};
+
+export default function LogList() {
+  const { authFetch, selectedProject } = useAuthStore();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [logs, setLogs] = useState<Log[] | null>(null);
+  const [pageInfo, setPageInfo] = useState<PageInfo | null>(null);
+  const searchInputRef = useRef<HTMLInputElement>(null);
+  const page = parseInt(searchParams.get('page') || '1');
+  const action = searchParams.get('action') as Action | null;
+  const username = searchParams.get('username');
+  const type = searchParams.get('type') as Type | null;
+
+  useEffect(() => {
+    setLogs(null);
+
+    authFetch(`/api/logs?projectId=${selectedProject?.id}&page=${page - 1}&${searchParams.toString().toLowerCase()}`)
+      .then((response): Promise<LogListResponse> => {
+        if (!response.ok) throw new Error(`로그 목록 조회 실패: (${response.status})`);
+
+        return response.json();
+      })
+      .then(({ totalPages, totalElements, first, last, contents }) => {
+        setLogs(contents);
+        setPageInfo({ totalPages, totalElements, first, last });
+      })
+      .catch((error) => {
+        console.error(error);
+        toast.error('로그 정보를 조회할 수 없습니다.');
+      });
+  }, [authFetch, selectedProject, page, searchParams]);
+
+  return (
+    <div className="flex flex-1 flex-col gap-4 p-6">
+      <div className="flex flex-col sm:flex-row gap-4 justify-between mb-2">
+        <div>
+          <h1 className="scroll-m-20 text-3xl font-semibold first:mt-0">설정 변경 로그 조회</h1>
+          {pageInfo === null ? (
+            <Skeleton className="w-[18rem] h-[1rem] mt-2 rounded-full" />
+          ) : (
+            <p className="mt-1 text-base text-gray-500">총 {pageInfo.totalElements}개의 로그가 조회되었습니다.</p>
+          )}
+        </div>
+      </div>
+      <Card>
+        <CardContent>
+          <div className="flex w-full items-center space-x-2 mb-4">
+            <DropdownMenu>
+              <DropdownMenuTrigger asChild>
+                <Button variant="secondary">
+                  <Filter />
+                  필터
+                </Button>
+              </DropdownMenuTrigger>
+              <DropdownMenuContent>
+                <DropdownMenuLabel>설정 타입</DropdownMenuLabel>
+                <DropdownMenuSeparator />
+                <DropdownMenuRadioGroup
+                  value={type || undefined}
+                  onValueChange={(value) =>
+                    setSearchParams((prev) => {
+                      prev.delete('page');
+                      prev.set('type', value);
+                      return prev;
+                    })
+                  }
+                >
+                  <DropdownMenuRadioItem value="ROUTING">라우팅</DropdownMenuRadioItem>
+                  <DropdownMenuRadioItem value="FORWARDING">포워딩</DropdownMenuRadioItem>
+                  <DropdownMenuRadioItem value="CERTIFICATE">인증서</DropdownMenuRadioItem>
+                </DropdownMenuRadioGroup>
+                <DropdownMenuSeparator />
+                <DropdownMenuLabel>설정 내용</DropdownMenuLabel>
+                <DropdownMenuSeparator />
+                <DropdownMenuRadioGroup
+                  value={action || undefined}
+                  onValueChange={(value) =>
+                    setSearchParams((prev) => {
+                      prev.delete('page');
+                      prev.set('action', value);
+                      return prev;
+                    })
+                  }
+                >
+                  <DropdownMenuRadioItem value="CREATE">생성</DropdownMenuRadioItem>
+                  <DropdownMenuRadioItem value="UPDATE">수정</DropdownMenuRadioItem>
+                  <DropdownMenuRadioItem value="DELETE">삭제</DropdownMenuRadioItem>
+                </DropdownMenuRadioGroup>
+              </DropdownMenuContent>
+            </DropdownMenu>
+            <form
+              className="flex flex-1 items-center gap-2"
+              onSubmit={(e) => {
+                e.preventDefault();
+                const value = searchInputRef.current?.value;
+                setSearchParams((prev) => {
+                  prev.delete('page');
+                  if (value) prev.set('username', value);
+                  else prev.delete('username');
+                  return prev;
+                });
+              }}
+            >
+              <div className="flex flex-1 items-center border rounded-md px-2 gap-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
+                {type && (
+                  <Badge
+                    variant="secondary"
+                    className="cursor-pointer"
+                    onClick={() =>
+                      setSearchParams((prev) => {
+                        prev.delete('page');
+                        prev.delete('type');
+                        return prev;
+                      })
+                    }
+                  >
+                    {TYPE[type]}
+                    <X className="cursor-pointer" />
+                  </Badge>
+                )}
+                {action && (
+                  <Badge
+                    variant="secondary"
+                    className="cursor-pointer"
+                    onClick={() =>
+                      setSearchParams((prev) => {
+                        prev.delete('page');
+                        prev.delete('action');
+                        return prev;
+                      })
+                    }
+                  >
+                    {ACTION[action]}
+                    <X className="cursor-pointer" />
+                  </Badge>
+                )}
+                <Input
+                  placeholder="사용자명으로 검색..."
+                  className="pl-2 border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
+                  ref={searchInputRef}
+                  defaultValue={username || ''}
+                />
+              </div>
+              <Button type="submit" variant="secondary">
+                검색
+              </Button>
+            </form>
+          </div>
+          <Table>
+            <TableHeader>
+              <TableRow>
+                <TableHead className="w-40">시간</TableHead>
+                <TableHead>설정 내용</TableHead>
+                <TableHead className="w-32 text-center">사용자명</TableHead>
+              </TableRow>
+            </TableHeader>
+            <TableBody>
+              {logs === null ? (
+                <>
+                  <TableRow>
+                    <TableCell colSpan={3}>
+                      <Skeleton className="w-full h-[1rem] my-2 rounded-full" />
+                    </TableCell>
+                  </TableRow>
+                  <TableRow>
+                    <TableCell colSpan={3}>
+                      <Skeleton className="w-full h-[1rem] my-2 rounded-full" />
+                    </TableCell>
+                  </TableRow>
+                  <TableRow>
+                    <TableCell colSpan={3}>
+                      <Skeleton className="w-full h-[1rem] my-2 rounded-full" />
+                    </TableCell>
+                  </TableRow>
+                </>
+              ) : logs.length === 0 ? (
+                <TableRow>
+                  <TableCell colSpan={3} className="text-center text-muted-foreground">
+                    조회된 로그가 없습니다.
+                  </TableCell>
+                </TableRow>
+              ) : (
+                logs.map((log) => (
+                  <TableRow key={log.id}>
+                    <TableCell>{log.createdAt}</TableCell>
+                    <TableCell>
+                      <div className="flex items-center gap-2">
+                        <Badge variant="default">{TYPE[log.type]}</Badge>
+                        <Badge variant="secondary">{ACTION[log.action]}</Badge>
+                        <div>{log.description.split('\n').join(' / ')}</div>
+                      </div>
+                    </TableCell>
+                    <TableCell className="truncate max-w-32 text-center">{log.user.name}</TableCell>
+                  </TableRow>
+                ))
+              )}
+            </TableBody>
+          </Table>
+        </CardContent>
+        {pageInfo && pageInfo.totalPages !== 0 && (
+          <CardFooter>
+            <div className="mx-auto flex w-full justify-center gap-1">
+              <PaginationPrevious
+                to={`?page=${page - 1}`}
+                className={cn({ 'pointer-events-none opacity-50': pageInfo.first })}
+              />
+              <Popover>
+                <PopoverTrigger asChild>
+                  <Button variant="outline">{`${page} / ${pageInfo.totalPages} 페이지`}</Button>
+                </PopoverTrigger>
+                <PopoverContent className="p-1">
+                  <Pagination>
+                    <PaginationContent className="grid gap-1 grid-cols-10">
+                      {Array.from({ length: pageInfo.totalPages }).map((_, idx) => (
+                        <PaginationItem key={idx}>
+                          <PaginationLink to={`?page=${idx + 1}`} isActive={page === idx + 1} className="w-8 h-8">
+                            {idx + 1}
+                          </PaginationLink>
+                        </PaginationItem>
+                      ))}
+                    </PaginationContent>
+                  </Pagination>
+                </PopoverContent>
+              </Popover>
+              <PaginationNext
+                to={`?page=${page + 1}`}
+                className={cn({ 'pointer-events-none opacity-50': pageInfo.last })}
+              />
+            </div>
+          </CardFooter>
+        )}
+      </Card>
+    </div>
+  );
+}
diff --git a/src/routes.tsx b/src/routes.tsx
index 1880f94..87f2197 100644
--- a/src/routes.tsx
+++ b/src/routes.tsx
@@ -11,6 +11,7 @@ import CertificateCreate from './pages/certificate/Create';
 import ForwardingList from '@/pages/forwarding/List';
 import ForwardingCreate from './pages/forwarding/Create';
 import ForwardingEdit from './pages/forwarding/Edit';
+import LogList from './pages/log/List';
 
 export default function AppRoutes() {
   return (
@@ -32,6 +33,9 @@ export default function AppRoutes() {
           <Route path="create" element={<ForwardingCreate />} />
           <Route path="edit/:id" element={<ForwardingEdit />} />
         </Route>
+        <Route path="log">
+          <Route index element={<LogList />} />
+        </Route>
         <Route path="*" element={<NotFound />} />
       </Route>
     </Routes>
diff --git a/src/types/log.ts b/src/types/log.ts
new file mode 100644
index 0000000..1139ea8
--- /dev/null
+++ b/src/types/log.ts
@@ -0,0 +1,23 @@
+export type Action = 'CREATE' | 'UPDATE' | 'DELETE';
+export type Type = 'ROUTING' | 'CERTIFICATE' | 'FORWARDING';
+
+export interface Log {
+  id: number;
+  user: {
+    id: string;
+    name: string;
+  };
+  action: Action;
+  type: Type;
+  description: string;
+  createdAt: string;
+}
+
+export interface LogListResponse {
+  totalPages: number;
+  totalElements: number;
+  size: number;
+  contents: Log[];
+  first: boolean;
+  last: boolean;
+}
-- 
GitLab