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