From c7d5a23ac4040ce96b36fafd8a094141211e40dc Mon Sep 17 00:00:00 2001 From: yhaeul Date: Mon, 10 Aug 2026 05:05:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=EC=A7=84=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=EC=97=90=20=EC=A4=91=EC=95=99?= =?UTF-8?q?=EB=8F=99=EC=95=84=EB=A6=AC=20=EC=86=8C=EC=86=8D=20=ED=95=84?= =?UTF-8?q?=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 --- apps/web/pages/admin/index.tsx | 4 ++ apps/web/server/service/admin-club.service.ts | 17 ++++++ apps/web/src/admin/api.ts | 33 +++++++++-- apps/web/src/admin/components/AdminLayout.tsx | 30 +++++++++- apps/web/src/admin/constants.ts | 7 ++- apps/web/src/admin/hooks.ts | 56 ++++++++++++++----- apps/web/src/admin/types.ts | 1 + apps/web/src/lib/openapi/register-paths.ts | 8 +-- apps/web/src/lib/schemas/admin.ts | 6 ++ 9 files changed, 135 insertions(+), 27 deletions(-) diff --git a/apps/web/pages/admin/index.tsx b/apps/web/pages/admin/index.tsx index f226d7d6..fe465c4f 100644 --- a/apps/web/pages/admin/index.tsx +++ b/apps/web/pages/admin/index.tsx @@ -17,6 +17,8 @@ const AdminDashboardPage = () => { setActiveTab, statusFilter, setStatusFilter, + affiliationFilter, + setAffiliationFilter, totalCount, pagination, pendingCounts, @@ -57,6 +59,8 @@ const AdminDashboardPage = () => { totalCount={totalCount} pendingCounts={pendingCounts} statusFilter={statusFilter} + affiliationFilter={affiliationFilter} + onAffiliationFilterChange={setAffiliationFilter} onLogout={handleLogout} > > { @@ -147,6 +148,9 @@ export class AdminClubService { if (status) { where.status = status } + if (affiliationType) { + where.affiliationType = affiliationType + } const [clubs, totalCount] = await this.clubRepository.findAndCount({ where, @@ -315,6 +319,7 @@ export class AdminClubService { async getAdminClubHistories({ club_uuid: clubUuid, query, + affiliation_type: affiliationType, offset, limit, }: AdminClubHistoriesQuery): Promise<{ total_count: number; histories: AdminClubHistoryItem[] }> { @@ -334,6 +339,10 @@ export class AdminClubService { baseQuery.andWhere('history.club_id = :clubUuid', { clubUuid }) } + if (affiliationType) { + baseQuery.andWhere('club.affiliation_type = :affiliationType', { affiliationType }) + } + if (trimmedQuery) { baseQuery.andWhere( '(club.name ILIKE :query OR manager.name ILIKE :query OR app_user.name ILIKE :query)', @@ -393,6 +402,7 @@ export class AdminClubService { async getAdminClubManagerRequests({ status, + affiliation_type: affiliationType, offset, limit, }: AdminClubManagerRequestsQuery): Promise< @@ -405,6 +415,9 @@ export class AdminClubService { if (status) { baseQuery.andWhere('manager_request.status = :status', { status }) } + if (affiliationType) { + baseQuery.andWhere('club.affiliation_type = :affiliationType', { affiliationType }) + } const totalCount = await baseQuery.getCount() const requests = await baseQuery @@ -462,6 +475,7 @@ export class AdminClubService { async getAdminClubVerificationRequests({ status, + affiliation_type: affiliationType, offset, limit, }: AdminClubVerificationRequestsQuery): Promise< @@ -474,6 +488,9 @@ export class AdminClubService { if (status) { baseQuery.andWhere('verification_request.status = :status', { status }) } + if (affiliationType) { + baseQuery.andWhere('club.affiliation_type = :affiliationType', { affiliationType }) + } const totalCount = await baseQuery.getCount() const requests = await baseQuery diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 1ed002a3..c3161a43 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -1,5 +1,5 @@ import { ADMIN_AUTH_TOKEN_KEY, buildQuery } from 'src/admin/constants' -import type { ClubStatus, StatusFilter } from 'src/admin/types' +import type { AffiliationFilter, ClubStatus, StatusFilter } from 'src/admin/types' import type { ClubCollegeMajor } from 'src/entities/club' import type { AdminClubDetailResponse, @@ -46,10 +46,18 @@ export const verifyAdminRole = async (token: string): Promise => { if (!res.ok) throw new Error('admin role required') } -export const fetchClubs = (status: StatusFilter, pagination: PaginationParams = {}) => +const getAffiliationType = (affiliationFilter: AffiliationFilter) => + affiliationFilter === 'CENTRAL' ? '중앙동아리' : undefined + +export const fetchClubs = ( + status: StatusFilter, + affiliationFilter: AffiliationFilter, + pagination: PaginationParams = {}, +) => request( `/api/v2/admin/clubs${buildQuery({ status: status === 'ALL' ? undefined : status, + affiliation_type: getAffiliationType(affiliationFilter), ...pagination, })}`, ) @@ -57,28 +65,43 @@ export const fetchClubs = (status: StatusFilter, pagination: PaginationParams = export const fetchClubDetail = (uuid: string) => request(`/api/v2/admin/clubs/${uuid}`) -export const fetchManagerRequests = (status: StatusFilter, pagination: PaginationParams = {}) => +export const fetchManagerRequests = ( + status: StatusFilter, + affiliationFilter: AffiliationFilter, + pagination: PaginationParams = {}, +) => request( `/api/v2/admin/clubs/manager-requests${buildQuery({ status: status === 'ALL' ? undefined : status, + affiliation_type: getAffiliationType(affiliationFilter), ...pagination, })}`, ) export const fetchVerificationRequests = ( status: StatusFilter, + affiliationFilter: AffiliationFilter, pagination: PaginationParams = {}, ) => request( `/api/v2/admin/clubs/verifications${buildQuery({ status: status === 'ALL' ? undefined : status, + affiliation_type: getAffiliationType(affiliationFilter), ...pagination, })}`, ) -export const fetchHistories = (query: string, pagination: PaginationParams = {}) => +export const fetchHistories = ( + query: string, + affiliationFilter: AffiliationFilter, + pagination: PaginationParams = {}, +) => request( - `/api/v2/admin/clubs/histories${buildQuery({ query, ...pagination })}`, + `/api/v2/admin/clubs/histories${buildQuery({ + query, + affiliation_type: getAffiliationType(affiliationFilter), + ...pagination, + })}`, ) export const fetchCollegeMajors = async () => { diff --git a/apps/web/src/admin/components/AdminLayout.tsx b/apps/web/src/admin/components/AdminLayout.tsx index 24cd11da..efbf0229 100644 --- a/apps/web/src/admin/components/AdminLayout.tsx +++ b/apps/web/src/admin/components/AdminLayout.tsx @@ -1,6 +1,6 @@ import type React from 'react' -import { TABS } from 'src/admin/constants' -import type { AdminTab, StatusFilter } from 'src/admin/types' +import { AFFILIATION_FILTERS, TABS } from 'src/admin/constants' +import type { AdminTab, AffiliationFilter, StatusFilter } from 'src/admin/types' import { StatusBadge, StatusFilterBar } from './ui' type PendingCounts = { @@ -15,6 +15,8 @@ export const AdminLayout = ({ totalCount, pendingCounts, statusFilter, + affiliationFilter, + onAffiliationFilterChange, onLogout, children, }: { @@ -23,6 +25,8 @@ export const AdminLayout = ({ totalCount: number pendingCounts: PendingCounts statusFilter: StatusFilter + affiliationFilter: AffiliationFilter + onAffiliationFilterChange: (filter: AffiliationFilter) => void onLogout: () => void children: React.ReactNode }) => ( @@ -119,6 +123,28 @@ export const AdminLayout = ({ +
+ 소속 + {AFFILIATION_FILTERS.map((filter) => ( + + ))} +
+ {children} diff --git a/apps/web/src/admin/constants.ts b/apps/web/src/admin/constants.ts index a2d06b00..55383373 100644 --- a/apps/web/src/admin/constants.ts +++ b/apps/web/src/admin/constants.ts @@ -1,4 +1,4 @@ -import type { AdminTab, ClubStatus, StatusFilter } from 'src/admin/types' +import type { AdminTab, AffiliationFilter, ClubStatus, StatusFilter } from 'src/admin/types' import type { ClubCollegeMajor } from 'src/entities/club' export const ADMIN_AUTH_TOKEN_KEY = 'allclear:admin-auth-token' @@ -11,6 +11,11 @@ export const STATUS_FILTERS: { label: string; value: StatusFilter }[] = [ { label: '반려', value: 'REJECTED' }, ] +export const AFFILIATION_FILTERS: { label: string; value: AffiliationFilter }[] = [ + { label: '전체', value: 'ALL' }, + { label: '중앙동아리', value: 'CENTRAL' }, +] + export const TABS: { label: string; value: AdminTab }[] = [ { label: '동아리 신청', value: 'clubs' }, { label: '관리자 매핑', value: 'managerRequests' }, diff --git a/apps/web/src/admin/hooks.ts b/apps/web/src/admin/hooks.ts index e7b38f61..aaa4e336 100644 --- a/apps/web/src/admin/hooks.ts +++ b/apps/web/src/admin/hooks.ts @@ -13,7 +13,7 @@ import { verifyAdminRole, } from 'src/admin/api' import { ADMIN_AUTH_TOKEN_KEY, ADMIN_PAGE_SIZE, formatCollegeMajorLabel } from 'src/admin/constants' -import type { AdminTab, ClubStatus, StatusFilter } from 'src/admin/types' +import type { AdminTab, AffiliationFilter, ClubStatus, StatusFilter } from 'src/admin/types' const INITIAL_PAGE_BY_TAB: Record = { clubs: 1, @@ -35,6 +35,7 @@ export const useAdminDashboard = () => { const [authToken, setAuthToken] = useState(null) const [activeTab, setActiveTab] = useState('clubs') const [statusFilter, setStatusFilter] = useState('PENDING') + const [affiliationFilter, setAffiliationFilter] = useState('ALL') const [pageByTab, setPageByTab] = useState>(INITIAL_PAGE_BY_TAB) const [submittedHistoryQuery, setSubmittedHistoryQuery] = useState('') const [toasts, setToasts] = useState< @@ -70,9 +71,9 @@ export const useAdminDashboard = () => { const historiesPage = pageByTab.histories const clubsQuery = useQuery( - ['admin-clubs', statusFilter, clubsPage, ADMIN_PAGE_SIZE], + ['admin-clubs', statusFilter, affiliationFilter, clubsPage, ADMIN_PAGE_SIZE], () => - fetchClubs(statusFilter, { + fetchClubs(statusFilter, affiliationFilter, { offset: getPageOffset(clubsPage), limit: ADMIN_PAGE_SIZE, }), @@ -81,18 +82,30 @@ export const useAdminDashboard = () => { }, ) const managerRequestsQuery = useQuery( - ['admin-manager-requests', statusFilter, managerRequestsPage, ADMIN_PAGE_SIZE], + [ + 'admin-manager-requests', + statusFilter, + affiliationFilter, + managerRequestsPage, + ADMIN_PAGE_SIZE, + ], () => - fetchManagerRequests(statusFilter, { + fetchManagerRequests(statusFilter, affiliationFilter, { offset: getPageOffset(managerRequestsPage), limit: ADMIN_PAGE_SIZE, }), { enabled: activeTab === 'managerRequests' && !!authToken }, ) const verificationRequestsQuery = useQuery( - ['admin-verification-requests', statusFilter, verificationRequestsPage, ADMIN_PAGE_SIZE], + [ + 'admin-verification-requests', + statusFilter, + affiliationFilter, + verificationRequestsPage, + ADMIN_PAGE_SIZE, + ], () => - fetchVerificationRequests(statusFilter, { + fetchVerificationRequests(statusFilter, affiliationFilter, { offset: getPageOffset(verificationRequestsPage), limit: ADMIN_PAGE_SIZE, }), @@ -100,27 +113,33 @@ export const useAdminDashboard = () => { ) const clubsPendingQuery = useQuery( - ['admin-clubs-pending-count'], - () => fetchClubs('PENDING', PENDING_COUNT_PAGINATION), + ['admin-clubs-pending-count', affiliationFilter], + () => fetchClubs('PENDING', affiliationFilter, PENDING_COUNT_PAGINATION), { enabled: !!authToken, refetchInterval: 30_000, }, ) const managerRequestsPendingQuery = useQuery( - ['admin-manager-requests-pending-count'], - () => fetchManagerRequests('PENDING', PENDING_COUNT_PAGINATION), + ['admin-manager-requests-pending-count', affiliationFilter], + () => fetchManagerRequests('PENDING', affiliationFilter, PENDING_COUNT_PAGINATION), { enabled: !!authToken, refetchInterval: 30_000 }, ) const verificationsPendingQuery = useQuery( - ['admin-verifications-pending-count'], - () => fetchVerificationRequests('PENDING', PENDING_COUNT_PAGINATION), + ['admin-verifications-pending-count', affiliationFilter], + () => fetchVerificationRequests('PENDING', affiliationFilter, PENDING_COUNT_PAGINATION), { enabled: !!authToken, refetchInterval: 30_000 }, ) const historiesQuery = useQuery( - ['admin-club-histories', submittedHistoryQuery, historiesPage, ADMIN_PAGE_SIZE], + [ + 'admin-club-histories', + submittedHistoryQuery, + affiliationFilter, + historiesPage, + ADMIN_PAGE_SIZE, + ], () => - fetchHistories(submittedHistoryQuery, { + fetchHistories(submittedHistoryQuery, affiliationFilter, { offset: getPageOffset(historiesPage), limit: ADMIN_PAGE_SIZE, }), @@ -232,6 +251,11 @@ export const useAdminDashboard = () => { })) } + const handleAffiliationFilterChange = (filter: AffiliationFilter) => { + setAffiliationFilter(filter) + setPageByTab(INITIAL_PAGE_BY_TAB) + } + const handleHistorySearch = (query: string) => { setSubmittedHistoryQuery(query) setTabPage('histories', 1) @@ -263,6 +287,8 @@ export const useAdminDashboard = () => { setActiveTab: handleTabChange, statusFilter, setStatusFilter: handleStatusFilterChange, + affiliationFilter, + setAffiliationFilter: handleAffiliationFilterChange, totalCount, pagination: { page: pageByTab[activeTab], diff --git a/apps/web/src/admin/types.ts b/apps/web/src/admin/types.ts index 1ce93987..cc6b3eb1 100644 --- a/apps/web/src/admin/types.ts +++ b/apps/web/src/admin/types.ts @@ -10,6 +10,7 @@ import type { export type { ClubDecisionStatus as DecisionStatus, ClubStatus } export type StatusFilter = ClubStatus | 'ALL' +export type AffiliationFilter = 'ALL' | 'CENTRAL' export type AdminTab = 'clubs' | 'managerRequests' | 'verificationRequests' | 'histories' export type AdminClub = AdminClubsResponse['data']['clubs'][number] diff --git a/apps/web/src/lib/openapi/register-paths.ts b/apps/web/src/lib/openapi/register-paths.ts index 0a8da62b..ad4565ba 100644 --- a/apps/web/src/lib/openapi/register-paths.ts +++ b/apps/web/src/lib/openapi/register-paths.ts @@ -2217,7 +2217,7 @@ registry.registerPath({ tags: ['Admin'], summary: '운영진 전용 동아리 목록 조회', description: - '운영진 대시보드에서 동아리 목록을 조회합니다. `status` query로 PENDING, APPROVED, REJECTED 중 하나를 필터링할 수 있으며, query를 생략하면 전체 상태를 조회합니다. `offset`/`limit`으로 페이지네이션할 수 있습니다.', + '운영진 대시보드에서 동아리 목록을 조회합니다. `status` query로 PENDING, APPROVED, REJECTED 중 하나를, `affiliation_type` query로 소속 유형을 필터링할 수 있으며, query를 생략하면 전체를 조회합니다. `offset`/`limit`으로 페이지네이션할 수 있습니다.', security: [{ bearerAuth: [] }], request: { query: AdminClubsQuerySchema, @@ -2244,7 +2244,7 @@ registry.registerPath({ tags: ['Admin'], summary: '운영진 전용 동아리 수정 이력 조회', description: - '이미 승인된 동아리의 정보가 수정되었을 때 기록된 before_data와 after_data를 조회합니다. club_uuid로 특정 동아리의 이력만 필터링할 수 있으며, query로 동아리명 또는 수정한 관리자 이름을 검색할 수 있고, offset/limit으로 페이지네이션할 수 있습니다.', + '이미 승인된 동아리의 정보가 수정되었을 때 기록된 before_data와 after_data를 조회합니다. club_uuid로 특정 동아리의 이력만, `affiliation_type`으로 소속 유형만 필터링할 수 있으며, query로 동아리명 또는 수정한 관리자 이름을 검색할 수 있고, offset/limit으로 페이지네이션할 수 있습니다.', security: [{ bearerAuth: [] }], request: { query: AdminClubHistoriesQuerySchema, @@ -2272,7 +2272,7 @@ registry.registerPath({ tags: ['Admin'], summary: '운영진 전용 매핑 신청 목록 조회', description: - '기존 동아리에 대해 관리자 권한을 요청한 유저들의 리스트를 조회합니다. status query로 PENDING, APPROVED, REJECTED 중 하나를 필터링할 수 있으며, query를 생략하면 전체 상태를 조회합니다. `offset`/`limit`으로 페이지네이션할 수 있습니다.', + '기존 동아리에 대해 관리자 권한을 요청한 유저들의 리스트를 조회합니다. status query로 PENDING, APPROVED, REJECTED 중 하나를, `affiliation_type` query로 소속 유형을 필터링할 수 있으며, query를 생략하면 전체를 조회합니다. `offset`/`limit`으로 페이지네이션할 수 있습니다.', security: [{ bearerAuth: [] }], request: { query: AdminClubManagerRequestsQuerySchema, @@ -2300,7 +2300,7 @@ registry.registerPath({ tags: ['Admin'], summary: '운영진 전용 공식 인증 요청 목록 조회', description: - '동아리 관리자들이 신청한 공식 인증 요청 리스트를 조회합니다. status query로 PENDING, APPROVED, REJECTED 중 하나를 필터링할 수 있으며, query를 생략하면 전체 상태를 조회합니다. `offset`/`limit`으로 페이지네이션할 수 있습니다.', + '동아리 관리자들이 신청한 공식 인증 요청 리스트를 조회합니다. status query로 PENDING, APPROVED, REJECTED 중 하나를, `affiliation_type` query로 소속 유형을 필터링할 수 있으며, query를 생략하면 전체를 조회합니다. `offset`/`limit`으로 페이지네이션할 수 있습니다.', security: [{ bearerAuth: [] }], request: { query: AdminClubVerificationRequestsQuerySchema, diff --git a/apps/web/src/lib/schemas/admin.ts b/apps/web/src/lib/schemas/admin.ts index 8d4ca09d..7f935557 100644 --- a/apps/web/src/lib/schemas/admin.ts +++ b/apps/web/src/lib/schemas/admin.ts @@ -25,9 +25,12 @@ const AdminPaginationQueryFields = { .default(20), } +const ADMIN_CLUB_AFFILIATION_TYPES = ['중앙동아리', '소속동아리', '연합동아리', '기타'] as const + export const AdminClubsQuerySchema = z .object({ status: z.enum(CLUB_STATUSES).optional(), + affiliation_type: z.enum(ADMIN_CLUB_AFFILIATION_TYPES).optional(), ...AdminPaginationQueryFields, }) .openapi('AdminClubsQuery') @@ -136,6 +139,7 @@ export const AdminClubHistoriesQuerySchema = z .object({ club_uuid: z.string().uuid().optional(), query: z.string().trim().optional(), + affiliation_type: z.enum(ADMIN_CLUB_AFFILIATION_TYPES).optional(), ...AdminPaginationQueryFields, }) .openapi('AdminClubHistoriesQuery') @@ -176,6 +180,7 @@ export type AdminClubHistoriesResponse = z.infer