From 3d5de1ca9375495f0d259704c9dc298a6a1cd992 Mon Sep 17 00:00:00 2001 From: aken-you Date: Sun, 5 Oct 2025 20:09:17 +0900 Subject: [PATCH 1/8] =?UTF-8?q?refactor:=20AdminPage=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=ED=85=8C=EC=9D=B4=EB=B8=94=EC=9D=84=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/admin/ui/member-list-table.tsx | 202 ++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 src/features/admin/ui/member-list-table.tsx 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..73f29a91 --- /dev/null +++ b/src/features/admin/ui/member-list-table.tsx @@ -0,0 +1,202 @@ +'use client'; + +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'; + +// todo: API 연결 +const memberList = [ + { + 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: '관리자', + }, + }, +]; + +const ROLE_OPTIONS = [ + { + value: '일반', + label: '일반', + }, + { + value: '멘토', + label: '멘토', + }, +]; + +const MEMBER_STATUS_OPTIONS = [ + { + value: '활성', + label: '활성', + }, + { + value: '일시정지', + label: '일시정지', + }, + { + value: '영구정지', + label: '영구정지', + }, + { + value: '휴면', + label: '휴면', + }, +]; + +export default function MemberListTable() { + 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 ( + <> +
+ +
+ +
+
+ + + + + + + + + + + + + {memberList.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' && ( + + 휴면 + + )} +
+
+
+ + ); +} + +function MemberListFilter() { + return ( +
+ + +
+ ); +} From 276a878f493bccef857d7833c1cf8d7aad4223b6 Mon Sep 17 00:00:00 2001 From: aken-you Date: Mon, 6 Oct 2025 16:02:37 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(admin)/admin/page.tsx | 297 ++++-------------- src/features/admin/api/member-list.server.ts | 26 ++ src/features/admin/api/member-list.ts | 26 ++ src/features/admin/api/types.ts | 30 ++ .../admin/model/use-member-list-query.ts | 21 ++ 5 files changed, 171 insertions(+), 229 deletions(-) create mode 100644 src/features/admin/api/member-list.server.ts create mode 100644 src/features/admin/api/member-list.ts create mode 100644 src/features/admin/api/types.ts create mode 100644 src/features/admin/model/use-member-list-query.ts diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx index dc147daf..1b651e0c 100644 --- a/app/(admin)/admin/page.tsx +++ b/app/(admin)/admin/page.tsx @@ -1,243 +1,82 @@ -'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 { GetMemberListResponse } from '@/features/admin/api/types'; +import MemberListTable from '@/features/admin/ui/member-list-table'; + +export default async function AdminPage() { + const queryClient = new QueryClient(); + + // 서버 side에서 첫 페이지 데이터 미리 가져오기 + await queryClient.prefetchQuery({ + queryKey: ['memberList', null, null, null, 1], // memberList, roleId, memberStatus, searchKeyword, page + queryFn: () => getMemberListInServer({}), + }); + + const { totalElements: totalUserCount } = + queryClient.getQueryData([ + 'memberList', + null, + null, + null, + 1, + ]); -export default function AdminPage() { return ( -
- + +
+
+
+

사용자 관리

+ + + {totalUserCount} + + + 명의 사용자 + +
-
-
- +
- - ); -} - -// 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/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, + }), + }); +}; From b83470335b1fc96d356ab9e069c8dd5df46088cf Mon Sep 17 00:00:00 2001 From: aken-you Date: Mon, 6 Oct 2025 16:46:05 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/icons/keyboard-arrow-left.svg | 3 + public/icons/keyboard-arrow-right.svg | 3 + public/icons/more.svg | 3 + src/features/admin/ui/member-list-table.tsx | 51 ++++--- src/shared/ui/pagination/index.tsx | 150 ++++++++++++++++++++ 5 files changed, 184 insertions(+), 26 deletions(-) create mode 100644 public/icons/keyboard-arrow-left.svg create mode 100644 public/icons/keyboard-arrow-right.svg create mode 100644 public/icons/more.svg create mode 100644 src/shared/ui/pagination/index.tsx 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/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 73f29a91..3670308c 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -5,32 +5,9 @@ import { formatYYYYMMDD } from '@/shared/lib/time'; import Badge from '@/shared/ui/badge'; import Checkbox from '@/shared/ui/checkbox'; import { SingleDropdown } from '@/shared/ui/dropdown'; - -// todo: API 연결 -const memberList = [ - { - 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: '관리자', - }, - }, -]; +import Pagination from '@/shared/ui/pagination'; +import { MemberStatus, RoleId } from '../api/types'; +import { useGetMemberListQuery } from '../model/use-member-list-query'; const ROLE_OPTIONS = [ { @@ -63,6 +40,20 @@ const MEMBER_STATUS_OPTIONS = [ ]; export default function MemberListTable() { + const [roleId, setRoleId] = useState(); + const [memberStatus, setMemberStatus] = useState(); + 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); @@ -187,6 +178,14 @@ export default function MemberListTable() { + + ); 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 && ( + + )} + + {/* 다음 페이지 버튼 */} + +
+ ); +} From 470f8ec6d127e06701ecad39beac4ed268685a40 Mon Sep 17 00:00:00 2001 From: aken-you Date: Mon, 6 Oct 2025 17:02:50 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EA=B6=8C=ED=95=9C,=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=EC=83=81=ED=83=9C=20=ED=95=84=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/admin/ui/member-list-table.tsx | 139 +++++++++++--------- 1 file changed, 79 insertions(+), 60 deletions(-) diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 3670308c..9c263fa8 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -3,46 +3,40 @@ 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 { MemberStatus, RoleId } from '../api/types'; import { useGetMemberListQuery } from '../model/use-member-list-query'; -const ROLE_OPTIONS = [ - { - value: '일반', - label: '일반', - }, - { - value: '멘토', - label: '멘토', - }, -]; - -const MEMBER_STATUS_OPTIONS = [ - { - value: '활성', - label: '활성', - }, - { - value: '일시정지', - label: '일시정지', - }, - { - value: '영구정지', - label: '영구정지', - }, - { - value: '휴면', - label: '휴면', - }, -]; +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(); - const [memberStatus, setMemberStatus] = useState(); - const [searchKeyword, setSearchKeyword] = useState(); + const [roleId, setRoleId] = useState(null); + const [memberStatus, setMemberStatus] = useState(null); + const [searchKeyword, setSearchKeyword] = useState(null); const [page, setPage] = useState(1); const { data } = useGetMemberListQuery({ @@ -89,8 +83,13 @@ export default function MemberListTable() { return ( <> -
- +
+
@@ -149,29 +148,15 @@ export default function MemberListTable() { {formatYYYYMMDD(user.loginMostRecentlyAt)} - {user.role.roleId === 'ROLE_ADMIN' ? '멘토' : '일반'} + {ROLE_MAP[user.role.roleId]} - {user.memberStatus === 'ACTIVE' && ( - - 활성 - - )} - {user.memberStatus === 'PAUSED' && ( - - 영구정지 - - )} - {user.memberStatus === 'PERM_BAN' && ( - - 일시정지 - - )} - {user.memberStatus === 'DORMANT' && ( - - 휴면 - - )} + + {MEMBER_STATUS_MAP[user.memberStatus]} + ))} @@ -191,11 +176,45 @@ export default function MemberListTable() { ); } -function MemberListFilter() { +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) && ( + + )} +
+ + +
+ ); } From e30989a209406991ae4505bec354c37f22b31ad1 Mon Sep 17 00:00:00 2001 From: aken-you Date: Mon, 6 Oct 2025 17:19:33 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20=EC=B4=9D=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20UI=EB=A5=BC=20MemberListTable=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(admin)/admin/page.tsx | 23 ------------- src/features/admin/ui/member-list-table.tsx | 36 ++++++++++++++++++++- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx index 1b651e0c..939636e0 100644 --- a/app/(admin)/admin/page.tsx +++ b/app/(admin)/admin/page.tsx @@ -6,7 +6,6 @@ import { import Image from 'next/image'; import Link from 'next/link'; import { getMemberListInServer } from '@/features/admin/api/member-list.server'; -import { GetMemberListResponse } from '@/features/admin/api/types'; import MemberListTable from '@/features/admin/ui/member-list-table'; export default async function AdminPage() { @@ -18,15 +17,6 @@ export default async function AdminPage() { queryFn: () => getMemberListInServer({}), }); - const { totalElements: totalUserCount } = - queryClient.getQueryData([ - 'memberList', - null, - null, - null, - 1, - ]); - return (
@@ -61,19 +51,6 @@ export default async function AdminPage() {
-
-
-

사용자 관리

- - - {totalUserCount} - - - 명의 사용자 - -
-
-
diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 9c263fa8..0e044210 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -36,7 +36,7 @@ const MEMBER_STATUS_OPTIONS = Object.entries(MEMBER_STATUS_MAP).map( export default function MemberListTable() { const [roleId, setRoleId] = useState(null); const [memberStatus, setMemberStatus] = useState(null); - const [searchKeyword, setSearchKeyword] = useState(null); + const [searchKeyword, setSearchKeyword] = useState(''); const [page, setPage] = useState(1); const { data } = useGetMemberListQuery({ @@ -83,6 +83,23 @@ export default function MemberListTable() { return ( <> +
+
+

사용자 관리

+ + + {data?.totalElements} + + + 명의 사용자 + +
+ + +
); } + +function MemberListSearchInput({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + return ( + onChange(e.target.value)} + placeholder="이름, 이메일 입력" + /> + ); +} From 012ddbec7358840b42326209035fe147a6c26699 Mon Sep 17 00:00:00 2001 From: aken-you Date: Mon, 6 Oct 2025 17:30:44 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=EC=96=B4=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(admin)/admin/page.tsx | 2 +- public/icons/search.svg | 3 +++ src/features/admin/ui/member-list-table.tsx | 17 +++++++++++------ 3 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 public/icons/search.svg diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx index 939636e0..2428c1be 100644 --- a/app/(admin)/admin/page.tsx +++ b/app/(admin)/admin/page.tsx @@ -13,7 +13,7 @@ export default async function AdminPage() { // 서버 side에서 첫 페이지 데이터 미리 가져오기 await queryClient.prefetchQuery({ - queryKey: ['memberList', null, null, null, 1], // memberList, roleId, memberStatus, searchKeyword, page + queryKey: ['memberList', null, null, '', 1], // memberList, roleId, memberStatus, searchKeyword, page queryFn: () => getMemberListInServer({}), }); 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/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 0e044210..6067dcaa 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -7,6 +7,7 @@ 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 SearchIcon from 'public/icons/search.svg'; import { MemberStatus, RoleId } from '../api/types'; import { useGetMemberListQuery } from '../model/use-member-list-query'; @@ -244,11 +245,15 @@ function MemberListSearchInput({ onChange: (v: string) => void; }) { return ( - onChange(e.target.value)} - placeholder="이름, 이메일 입력" - /> +
+ + onChange(e.target.value)} + className="outline-0" + placeholder="이름, 이메일 입력" + /> +
); } From 007a24b34d1711f06544973c38a84dbef04462ad Mon Sep 17 00:00:00 2001 From: aken-you Date: Mon, 6 Oct 2025 19:33:16 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=EC=96=B4?= =?UTF-8?q?=EC=9D=84=20=EC=A7=80=EC=9A=B0=EB=8A=94=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/icons/filled-x.svg | 3 +++ src/features/admin/ui/member-list-table.tsx | 27 ++++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 public/icons/filled-x.svg 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/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 6067dcaa..6915bbb8 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -7,6 +7,7 @@ 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'; @@ -245,15 +246,23 @@ function MemberListSearchInput({ onChange: (v: string) => void; }) { return ( -
- - onChange(e.target.value)} - className="outline-0" - placeholder="이름, 이메일 입력" - /> +
+
+ + onChange(e.target.value)} + className="outline-0" + placeholder="이름 입력" + /> +
+ + {value.length > 0 && ( + + )}
); } From e4cf3166e6e8443d9e54fe3c12a8ea019887e892 Mon Sep 17 00:00:00 2001 From: aken-you Date: Mon, 6 Oct 2025 22:12:22 +0900 Subject: [PATCH 8/8] =?UTF-8?q?style:=20=EC=9D=B4=EB=A6=84=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=96=B4=20input=20placeholder=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/admin/ui/member-list-table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index 6915bbb8..d244a28f 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -254,7 +254,7 @@ function MemberListSearchInput({ value={value} onChange={(e) => onChange(e.target.value)} className="outline-0" - placeholder="이름 입력" + placeholder="이름으로 검색" />