diff --git a/server/service/admin-club.service.ts b/server/service/admin-club.service.ts index 3a09a4e..fa6c38a 100644 --- a/server/service/admin-club.service.ts +++ b/server/service/admin-club.service.ts @@ -1,6 +1,11 @@ import { FindOptionsWhere, In, IsNull, Repository } from 'typeorm' import { InjectRepository, Service } from '../provider' -import { ClubEntity, ClubHistoryEntity, ServiceUserEntity, UserEntity } from '../infra/database/entities' +import { + ClubEntity, + ClubHistoryEntity, + ServiceUserEntity, + UserEntity, +} from '../infra/database/entities' import { ClubManagerEntity } from '../infra/database/entities/club-manager.entity' import { ClubManagerRegisterRequestEntity } from '../infra/database/entities/club-manager-register-request.entity' import { ClubVerificationRequestEntity } from '../infra/database/entities/club-verification-request.entity' @@ -53,6 +58,7 @@ export type AdminClubDetail = { sns: string introduction: string | null created_at: string + reject_reason: string | null } manager_data: { name: string @@ -87,6 +93,7 @@ export type AdminClubManagerRequestItem = { student_id: string } status: ClubStatus + reject_reason: string | null created_at: string } @@ -96,6 +103,7 @@ export type AdminClubVerificationRequestItem = { club_name: string category: string status: ClubStatus + reject_reason: string | null created_at: string } @@ -126,7 +134,7 @@ export class AdminClubService { const clubs = await this.clubRepository.find({ where, - order: { createdAt: 'ASC' }, + order: { createdAt: 'DESC' }, }) if (clubs.length === 0) return [] @@ -193,6 +201,7 @@ export class AdminClubService { sns: club.sns, introduction: club.introduction, created_at: club.createdAt, + reject_reason: club.rejectReason, }, manager_data: { name: manager?.name ?? '', @@ -334,6 +343,7 @@ export class AdminClubService { 'manager_request.phone AS applicant_phone', 'manager_request.student_id AS applicant_student_id', 'manager_request.status AS status', + 'manager_request.reject_reason AS reject_reason', 'manager_request.created_at AS created_at', ]) .orderBy('manager_request.created_at', 'DESC') @@ -351,6 +361,7 @@ export class AdminClubService { applicant_phone: string applicant_student_id: string status: ClubStatus + reject_reason: string | null created_at: string }>() @@ -365,6 +376,7 @@ export class AdminClubService { student_id: request.applicant_student_id, }, status: request.status, + reject_reason: request.reject_reason, created_at: request.created_at, })) } @@ -381,6 +393,7 @@ export class AdminClubService { "COALESCE(club.name, '') AS club_name", "COALESCE(club.category, '') AS category", 'verification_request.status AS status', + 'verification_request.reject_reason AS reject_reason', 'verification_request.created_at AS created_at', ]) .orderBy('verification_request.created_at', 'DESC') @@ -395,6 +408,7 @@ export class AdminClubService { club_name: string category: string status: ClubStatus + reject_reason: string | null created_at: string }>() @@ -404,6 +418,7 @@ export class AdminClubService { club_name: request.club_name, category: request.category, status: request.status, + reject_reason: request.reject_reason, created_at: request.created_at, })) } @@ -426,15 +441,15 @@ export class AdminClubService { throw new NotFoundError('manager request not found') } - if (request.status !== PENDING_CLUB_STATUS) { - throw new ConflictError('manager request already processed') - } - const processedAt = new Date().toISOString() const isApproved = decision.status === PUBLIC_CLUB_STATUS const isRejected = decision.status === REJECTED_CLUB_STATUS + const isPending = decision.status === PENDING_CLUB_STATUS if (isApproved) { + if (request.status !== PENDING_CLUB_STATUS) { + throw new ConflictError('can only approve from PENDING status') + } const existingManager = await clubManagerRepository.findOneBy({ clubId: request.clubId }) if (existingManager) { throw new ConflictError('club already has a manager') @@ -449,6 +464,13 @@ export class AdminClubService { }) } + if (isPending && request.status === PUBLIC_CLUB_STATUS) { + await clubManagerRepository.delete({ + clubId: request.clubId, + serviceUserId: request.serviceUserId, + }) + } + await managerRequestRepository.update( { id: request.id }, { @@ -485,15 +507,15 @@ export class AdminClubService { throw new NotFoundError('verification request not found') } - if (request.status !== PENDING_CLUB_STATUS) { - throw new ConflictError('verification request already processed') - } - const processedAt = new Date().toISOString() const isApproved = decision.status === PUBLIC_CLUB_STATUS const isRejected = decision.status === REJECTED_CLUB_STATUS + const isPending = decision.status === PENDING_CLUB_STATUS if (isApproved) { + if (request.status !== PENDING_CLUB_STATUS) { + throw new ConflictError('can only approve from PENDING status') + } await clubRepository.update( { uuid: request.clubId }, { @@ -503,6 +525,16 @@ export class AdminClubService { ) } + if (isPending && request.status === PUBLIC_CLUB_STATUS) { + await clubRepository.update( + { uuid: request.clubId }, + { + isOfficialVerified: false, + verifiedAt: null, + }, + ) + } + await verificationRequestRepository.update( { id: request.id }, { diff --git a/src/admin/api.ts b/src/admin/api.ts index 3ea08cb..3208062 100644 --- a/src/admin/api.ts +++ b/src/admin/api.ts @@ -6,7 +6,7 @@ import type { AdminClubHistoriesResponse, } from 'src/lib/schemas/admin' import { ADMIN_AUTH_TOKEN_KEY, buildQuery } from 'src/admin/constants' -import type { DecisionStatus, StatusFilter } from 'src/admin/types' +import type { ClubStatus, StatusFilter } from 'src/admin/types' type FetchOptions = NonNullable[1]> @@ -59,7 +59,7 @@ export const fetchHistories = (query: string) => export const updateClubStatus = (payload: { uuid: string - status: DecisionStatus + status: ClubStatus reject_reason?: string is_official_verified: boolean }) => @@ -74,7 +74,7 @@ export const updateClubStatus = (payload: { export const updateManagerRequestStatus = (payload: { id: number - status: DecisionStatus + status: ClubStatus reject_reason?: string }) => request(`/api/v2/admin/clubs/manager-requests/${payload.id}/status`, { @@ -87,7 +87,7 @@ export const updateManagerRequestStatus = (payload: { export const updateVerificationStatus = (payload: { id: number - status: DecisionStatus + status: ClubStatus reject_reason?: string }) => request(`/api/v2/admin/clubs/verifications/${payload.id}/status`, { diff --git a/src/admin/components/ClubsTab.tsx b/src/admin/components/ClubsTab.tsx index 2356a58..8d026ea 100644 --- a/src/admin/components/ClubsTab.tsx +++ b/src/admin/components/ClubsTab.tsx @@ -1,9 +1,16 @@ import React, { useState } from 'react' import { useClubDetail } from 'src/admin/hooks' import { formatDate, statusLabels } from 'src/admin/constants' -import type { AdminClub, AdminClubDetail, DecisionStatus } from 'src/admin/types' +import type { AdminClub, AdminClubDetail, ClubStatus } from 'src/admin/types' import { DetailItem, ErrorState, LoadingRows, EmptyState, StatusBadge } from './ui' +type DecidePayload = { + uuid: string + status: ClubStatus + reject_reason?: string + is_official_verified: boolean +} + export const ClubsTab = ({ clubs, isLoading, @@ -15,12 +22,7 @@ export const ClubsTab = ({ isLoading: boolean error: unknown isMutating: boolean - onDecide: (payload: { - uuid: string - status: DecisionStatus - reject_reason?: string - is_official_verified: boolean - }) => void + onDecide: (payload: DecidePayload) => void }) => { const [selectedUuid, setSelectedUuid] = useState(null) const detailQuery = useClubDetail(selectedUuid) @@ -111,142 +113,239 @@ const ClubDetailDialog = ({ error: unknown isMutating: boolean onClose: () => void - onDecide: (payload: { - uuid: string - status: DecisionStatus - reject_reason?: string - is_official_verified: boolean - }) => void + onDecide: (payload: DecidePayload) => void }) => { const [officialVerified, setOfficialVerified] = useState(true) const [rejectReason, setRejectReason] = useState('') + const [rejectReasonError, setRejectReasonError] = useState(false) + const [confirmAction, setConfirmAction] = useState<{ + payload: DecidePayload + message: string + } | null>(null) - return ( -
-
-
-
-

동아리 상세 검토

-

{detail?.club_data.name ?? '불러오는 중'}

-
- -
+ const requestConfirm = (payload: DecidePayload, message: string) => { + if (payload.status === 'REJECTED' && !rejectReason.trim()) { + setRejectReasonError(true) + return + } + setRejectReasonError(false) + setConfirmAction({ payload, message }) + } -
- {isLoading && } - {Boolean(error) && } - {detail && ( -
-
- {`${detail.club_data.name} -
- - - - - - - - - - -
-
+ const handleConfirm = () => { + if (confirmAction) { + onDecide(confirmAction.payload) + setConfirmAction(null) + } + } -