+
-
-
+
+
관리자
+
+ kimkim@gmail.com
+
+
-
-
-
-
사용자 관리
- 총
- 50
-
- 명의 사용자
-
+
-
-
+
+
-
-
- );
-}
-
-// 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 && (
+
+ )}
+
+ {/* 다음 페이지 버튼 */}
+
+
+ );
+}