diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx index dc147daf..2428c1be 100644 --- a/app/(admin)/admin/page.tsx +++ b/app/(admin)/admin/page.tsx @@ -1,243 +1,59 @@ -'use client'; - +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from '@tanstack/react-query'; import Image from 'next/image'; import Link from 'next/link'; -import { useEffect, useRef, useState } from 'react'; -import { formatYYYYMMDD } from '@/shared/lib/time'; -import Badge from '@/shared/ui/badge'; -import Checkbox from '@/shared/ui/checkbox'; -import { SingleDropdown } from '@/shared/ui/dropdown'; +import { getMemberListInServer } from '@/features/admin/api/member-list.server'; +import MemberListTable from '@/features/admin/ui/member-list-table'; -export default function AdminPage() { - return ( -
- -
- +
+
-
- ); -} - -// todo: API 연결 -const users = [ - { - memberId: 1, - memberStatus: 'ACTIVE', - memberName: '안유진', - joinedAt: '2025-09-23T16:42:01.155Z', - loginMostRecentlyAt: '2025-09-23T16:42:01.155Z', - role: { - roleId: 'ROLE_MEMBER', - roleName: '일반', - }, - }, - { - memberId: 2, - memberStatus: 'ACTIVE', - memberName: '이현서', - joinedAt: '2025-09-23T16:42:01.155Z', - loginMostRecentlyAt: '2025-09-23T16:42:01.155Z', - role: { - roleId: 'ROLE_ADMIN', - roleName: '관리자', - }, - }, -]; - -function UserTable() { - const [selectedIds, setSelectedIds] = useState(() => new Set()); - const headerCheckboxRef = useRef(null); - - const allSelected = users.length > 0 && selectedIds.size === users.length; // 모든 행을 선택했는지 - const someSelected = selectedIds.size > 0 && selectedIds.size < users.length; // 한개 이상 행을 선택했는지 - - useEffect(() => { - if (headerCheckboxRef.current) { - headerCheckboxRef.current.indeterminate = someSelected; - } - }, [someSelected]); - - const toggleAll = () => { - if (allSelected) { - setSelectedIds(new Set()); - } else { - setSelectedIds(new Set(users.map((u) => u.memberId))); - } - }; - - const toggleRow = (id: number) => { - setSelectedIds((prev) => { - const next = new Set(prev); - - if (next.has(id)) next.delete(id); - else next.add(id); - - return next; - }); - }; - - return ( -
- - - - - - - - - - - - - {users.map((user, idx) => ( - - - - - - - - - ))} - -
- - - 이름 - - 가입일 - - 최근 로그인 - - 권한 - - 상태 -
- toggleRow(user.memberId)} - checked={selectedIds.has(user.memberId)} - /> - - {user.memberName} - - {formatYYYYMMDD(user.joinedAt)} - - {formatYYYYMMDD(user.loginMostRecentlyAt)} - - {user.role.roleId === 'ROLE_ADMIN' ? '멘토' : '일반'} - - {user.memberStatus === 'ACTIVE' && ( - - 활성 - - )} - {user.memberStatus === 'PAUSED' && ( - - 영구정지 - - )} - {user.memberStatus === 'PERM_BAN' && ( - - 일시정지 - - )} - {user.memberStatus === 'DORMANT' && ( - - 휴면 - - )} -
-
+ ); } diff --git a/public/icons/filled-x.svg b/public/icons/filled-x.svg new file mode 100644 index 00000000..dcad8aa1 --- /dev/null +++ b/public/icons/filled-x.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/keyboard-arrow-left.svg b/public/icons/keyboard-arrow-left.svg new file mode 100644 index 00000000..7cae4f94 --- /dev/null +++ b/public/icons/keyboard-arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/keyboard-arrow-right.svg b/public/icons/keyboard-arrow-right.svg new file mode 100644 index 00000000..63932aaf --- /dev/null +++ b/public/icons/keyboard-arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/more.svg b/public/icons/more.svg new file mode 100644 index 00000000..b467ef1c --- /dev/null +++ b/public/icons/more.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/search.svg b/public/icons/search.svg new file mode 100644 index 00000000..1fe51ca0 --- /dev/null +++ b/public/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/features/admin/api/member-list.server.ts b/src/features/admin/api/member-list.server.ts new file mode 100644 index 00000000..3ac9c020 --- /dev/null +++ b/src/features/admin/api/member-list.server.ts @@ -0,0 +1,26 @@ +import { axiosServerInstance } from '@/shared/tanstack-query/axios.server'; +import { GetMemberListRequest, GetMemberListResponse } from './types'; + +export const getMemberListInServer = async ({ + roleId, + memberStatus, + searchKeyword, + page = 1, +}: GetMemberListRequest): Promise => { + // size는 10으로 고정 + let queryString = `page=${page}&size=10`; + + if (roleId) { + queryString += `&role-id=${roleId}`; + } + if (memberStatus) { + queryString += `&member-status=${memberStatus}`; + } + if (searchKeyword) { + queryString += `&search-keyword=${searchKeyword}`; + } + + const res = await axiosServerInstance.get(`/admin/members?${queryString}`); + + return res.data.content; +}; diff --git a/src/features/admin/api/member-list.ts b/src/features/admin/api/member-list.ts new file mode 100644 index 00000000..064ffedd --- /dev/null +++ b/src/features/admin/api/member-list.ts @@ -0,0 +1,26 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { GetMemberListRequest, GetMemberListResponse } from './types'; + +export const getMemberList = async ({ + roleId, + memberStatus, + searchKeyword, + page = 1, +}: GetMemberListRequest): Promise => { + // size는 10으로 고정 + let queryString = `page=${page}&size=10`; + + if (roleId) { + queryString += `&role-id=${roleId}`; + } + if (memberStatus) { + queryString += `&member-status=${memberStatus}`; + } + if (searchKeyword) { + queryString += `&search-keyword=${searchKeyword}`; + } + + const res = await axiosInstance.get(`/admin/members?${queryString}`); + + return res.data.content; +}; diff --git a/src/features/admin/api/types.ts b/src/features/admin/api/types.ts new file mode 100644 index 00000000..9f4d17a9 --- /dev/null +++ b/src/features/admin/api/types.ts @@ -0,0 +1,30 @@ +export type RoleId = 'ROLE_MEMBER' | 'ROLE_ADMIN' | 'ROLE_MENTOR'; +type RoleName = '일반' | '관리자' | '멘토'; +export type MemberStatus = 'ACTIVE' | 'PAUSED' | 'PERM_BAN' | 'DORMANT'; + +export interface GetMemberListRequest { + roleId?: RoleId; + memberStatus?: MemberStatus; + searchKeyword?: string; + page?: number; +} + +export interface GetMemberListResponse { + page: number; + size: number; + totalElements: number; + totalPages: number; + hasNext: boolean; + hasPrevious: boolean; + content: { + memberId: number; + memberStatus: MemberStatus; + memberName: string; + joinedAt: string; + loginMostRecentlyAt: string; + role: { + roleId: RoleId; + roleName: RoleName; + }; + }[]; +} diff --git a/src/features/admin/model/use-member-list-query.ts b/src/features/admin/model/use-member-list-query.ts new file mode 100644 index 00000000..762ce211 --- /dev/null +++ b/src/features/admin/model/use-member-list-query.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { getMemberList } from '../api/member-list'; +import { GetMemberListRequest } from '../api/types'; + +export const useGetMemberListQuery = ({ + roleId, + memberStatus, + searchKeyword, + page = 1, +}: GetMemberListRequest) => { + return useQuery({ + queryKey: ['memberList', roleId, memberStatus, searchKeyword, page], + queryFn: () => + getMemberList({ + roleId, + memberStatus, + searchKeyword, + page, + }), + }); +}; diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx new file mode 100644 index 00000000..d244a28f --- /dev/null +++ b/src/features/admin/ui/member-list-table.tsx @@ -0,0 +1,268 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { formatYYYYMMDD } from '@/shared/lib/time'; +import Badge from '@/shared/ui/badge'; +import Button from '@/shared/ui/button'; +import Checkbox from '@/shared/ui/checkbox'; +import { SingleDropdown } from '@/shared/ui/dropdown'; +import Pagination from '@/shared/ui/pagination'; +import FilledX from 'public/icons/filled-x.svg'; +import SearchIcon from 'public/icons/search.svg'; +import { MemberStatus, RoleId } from '../api/types'; +import { useGetMemberListQuery } from '../model/use-member-list-query'; + +const ROLE_MAP = { + ROLE_MEMBER: '일반', + ROLE_MENTOR: '멘토', + ROLE_ADMIN: '관리자', +}; +const ROLE_OPTIONS = Object.entries(ROLE_MAP).map(([key, label]) => ({ + value: key, + label, +})); + +const MEMBER_STATUS_MAP = { + ACTIVE: '활성', + PERM_BAN: '일시정지', + PAUSED: '영구정지', + DORMANT: '휴면', +}; +const MEMBER_STATUS_OPTIONS = Object.entries(MEMBER_STATUS_MAP).map( + ([key, label]) => ({ + value: key, + label, + }), +); + +export default function MemberListTable() { + const [roleId, setRoleId] = useState(null); + const [memberStatus, setMemberStatus] = useState(null); + const [searchKeyword, setSearchKeyword] = useState(''); + const [page, setPage] = useState(1); + + const { data } = useGetMemberListQuery({ + roleId, + memberStatus, + searchKeyword, + page, + }); + + const memberList = data?.content || []; + + const [selectedIds, setSelectedIds] = useState(() => new Set()); + const headerCheckboxRef = useRef(null); + + const allSelected = + memberList.length > 0 && selectedIds.size === memberList.length; // 모든 행을 선택했는지 + const someSelected = + selectedIds.size > 0 && selectedIds.size < memberList.length; // 한개 이상 행을 선택했는지 + + useEffect(() => { + if (headerCheckboxRef.current) { + headerCheckboxRef.current.indeterminate = someSelected; + } + }, [someSelected]); + + const toggleAll = () => { + if (allSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(memberList.map((u) => u.memberId))); + } + }; + + const toggleRow = (id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev); + + if (next.has(id)) next.delete(id); + else next.add(id); + + return next; + }); + }; + + return ( + <> +
+
+

사용자 관리

+ + + {data?.totalElements} + + + 명의 사용자 + +
+ + +
+
+ +
+ +
+
+ + + + + + + + + + + + + {memberList.map((user, idx) => ( + + + + + + + + + ))} + +
+ + + 이름 + + 가입일 + + 최근 로그인 + + 권한 + + 상태 +
+ toggleRow(user.memberId)} + checked={selectedIds.has(user.memberId)} + /> + + {user.memberName} + + {formatYYYYMMDD(user.joinedAt)} + + {formatYYYYMMDD(user.loginMostRecentlyAt)} + + {ROLE_MAP[user.role.roleId]} + + + {MEMBER_STATUS_MAP[user.memberStatus]} + +
+
+ + +
+ + ); +} + +function MemberListFilter({ + roleId, + memberStatus, + onSelectRoleId, + onSelectMemberStatus, +}: { + roleId: RoleId | null; + memberStatus: MemberStatus | null; + onSelectRoleId: (roleId: RoleId | null) => void; + onSelectMemberStatus: (memberStatus: MemberStatus | null) => void; +}) { + return ( + <> + {(roleId || memberStatus) && ( + + )} +
+ + +
+ + ); +} + +function MemberListSearchInput({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + return ( +
+
+ + onChange(e.target.value)} + className="outline-0" + placeholder="이름으로 검색" + /> +
+ + {value.length > 0 && ( + + )} +
+ ); +} diff --git a/src/shared/ui/pagination/index.tsx b/src/shared/ui/pagination/index.tsx new file mode 100644 index 00000000..c502dba4 --- /dev/null +++ b/src/shared/ui/pagination/index.tsx @@ -0,0 +1,150 @@ +import { cva } from 'class-variance-authority'; +import ArrowBackIcon from 'public/icons/keyboard-arrow-left.svg'; +import ArrowForwardIcon from 'public/icons/keyboard-arrow-right.svg'; +import MoreIcon from 'public/icons/more.svg'; + +// 현재 페이지를 중심으로 표시할 중간 페이지 범위 계산 (첫 페이지와 마지막 페이지 제외) +const getVisibleMiddlePages = ({ + totalPages, + page, + middleButtonCount, +}: { + totalPages: number; + page: number; + middleButtonCount: number; +}) => { + // 전체 페이지가 적으면 모든 페이지 표시 + // 예를들면 totalPages가 5이고 middleButtonCount가 3이면, 모든 페이지 표시 + if (totalPages <= middleButtonCount + 2) { + return Array.from({ length: totalPages }, (_, i) => i + 1).filter( + (page) => page !== 1 && page !== totalPages, + ); + } + + // 현재 페이지를 중심으로 middleButtonCount개의 페이지 계산 + let start = Math.max(2, page - Math.floor(middleButtonCount / 2)); + const end = Math.min(totalPages - 1, start + middleButtonCount - 1); + + // 끝 부분에 맞춰서 시작점 조정 + // 예를들면 totalPages 10 / middleButtonCount 3 / page 9이면, start가 8 end가 9이므로 start를 7로 조정 + if (end - start + 1 < middleButtonCount) { + start = Math.max(2, end - middleButtonCount + 1); + } + + const pages = []; + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + return pages.filter((page) => page !== 1 && page !== totalPages); +}; + +interface PaginationProps { + page: number; + totalPages: number; + onChangePage: (page: number) => void; + middleButtonCount: number; + className?: string; +} + +const pageButtonVariants = cva( + 'w-[32px] h-[32px] flex items-center justify-center rounded-50', + { + variants: { + color: { + default: + 'hover:bg-fill-neutral-subtle-hover active:bg-fill-neutral-subtle-pressed text-text-default', + active: 'bg-background-accent-blue-strong text-text-inverse', + disabled: 'cursor-not-allowed text-text-disabled', + }, + }, + defaultVariants: { + color: 'default', + }, + }, +); + +export default function Pagination({ + page: currentPage, + totalPages, + onChangePage, + middleButtonCount, + className, +}: PaginationProps) { + const visibleMiddlePages = getVisibleMiddlePages({ + totalPages, + page: currentPage, + middleButtonCount, + }); + const showFirstEllipsis = visibleMiddlePages[0] > 2; // 첫 페이지 다음에 생략 표시 + const showLastEllipsis = + visibleMiddlePages[visibleMiddlePages.length - 1] < totalPages - 1; // 마지막 페이지 이전에 생략 표시 + + return ( +
+ {/* 이전 페이지 버튼 */} + + + {/* 첫 번째 페이지 */} + + + {/* 첫 번째 생략 표시 */} + {showFirstEllipsis && } + + {/* 중간 페이지들 */} + {visibleMiddlePages.map((page) => ( + + ))} + + {/* 마지막 생략 표시 */} + {showLastEllipsis && } + + {/* 마지막 페이지 */} + {totalPages > 1 && ( + + )} + + {/* 다음 페이지 버튼 */} + +
+ ); +}