diff --git a/src/app/dashboard/core-applications/page.tsx b/src/app/dashboard/core-applications/page.tsx new file mode 100644 index 0000000..3356439 --- /dev/null +++ b/src/app/dashboard/core-applications/page.tsx @@ -0,0 +1,509 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import Link from 'next/link' + +import Loader from '@/components/ui/common/Loader' +import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi' + +type RecruitCoreResultStatus = 'SUBMITTED' | 'IN_REVIEW' | 'ACCEPTED' | 'REJECTED' + +type RecruitCoreApplicantSummary = { + applicationId: number + name: string + studentId: string + major: string + team: string + resultStatus: RecruitCoreResultStatus + session: string + createdAt: string +} + +type RecruitCoreApplicantDetail = { + applicationId: number + session: string + snapshot: { + name: string + studentId: string + phone: string + major: string + email: string + } + team: string + motivation: string + wish: string + strengths: string + pledge: string + fileUrls: string[] + resultStatus: RecruitCoreResultStatus + review: { + reviewedAt: string | null + reviewedBy: number | null + resultNote: string | null + } | null + createdAt: string + updatedAt: string +} + +type RecruitCoreApplicationPageResponse = { + content?: RecruitCoreApplicantSummary[] + page?: number + size?: number + totalElements?: number + totalPages?: number + last?: boolean +} + +type RecruitCoreDecisionResponse = { + applicationId: number + resultStatus: RecruitCoreResultStatus + reviewedAt: string | null + reviewedBy: number | null + userUpdated?: { + userRole: string + team: string | null + } | null +} + +const STATUS_OPTIONS: Array<{ label: string; value: RecruitCoreResultStatus | 'ALL' }> = [ + { label: '전체', value: 'ALL' }, + { label: '제출', value: 'SUBMITTED' }, + { label: '검토 중', value: 'IN_REVIEW' }, + { label: '합격', value: 'ACCEPTED' }, + { label: '불합격', value: 'REJECTED' } +] + +const TEAM_OPTIONS = ['ALL', 'HQ', 'HR', 'PR_DESIGN', 'TECH', 'BD'] as const + +const formatDateTime = (value?: string | null) => { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) +} + +const currentRecruitSession = () => { + const now = new Date() + const semester = now.getMonth() + 1 <= 6 ? 1 : 2 + return `${now.getFullYear()}-${semester}` +} + +export default function DashboardCoreApplicationsPage() { + const { apiClient } = useAuthenticatedApi() + + const [session, setSession] = useState(currentRecruitSession()) + const [selectedStatus, setSelectedStatus] = useState('ALL') + const [selectedTeam, setSelectedTeam] = useState<(typeof TEAM_OPTIONS)[number]>('ALL') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [applications, setApplications] = useState([]) + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const [totalElements, setTotalElements] = useState(0) + + const [selectedApplicationId, setSelectedApplicationId] = useState(null) + const [detail, setDetail] = useState(null) + const [detailLoading, setDetailLoading] = useState(false) + const [detailError, setDetailError] = useState(null) + const [decisionNote, setDecisionNote] = useState('') + const [overwriteTeamIfExists, setOverwriteTeamIfExists] = useState(true) + const [decisionLoading, setDecisionLoading] = useState<'accept' | 'reject' | null>(null) + + const fetchApplications = useCallback(async () => { + if (!session.trim()) { + setApplications([]) + setTotalPages(1) + setTotalElements(0) + setError('세션 값을 입력해 주세요.') + return + } + + setLoading(true) + setError(null) + try { + const response = await apiClient.get('/admin/recruit/core/applications', { + params: { + session: session.trim(), + page: page - 1, + size: 20, + ...(selectedStatus !== 'ALL' ? { status: selectedStatus } : {}), + ...(selectedTeam !== 'ALL' ? { team: selectedTeam } : {}) + } + }) + + setApplications(response.data?.content ?? []) + setTotalPages(response.data?.totalPages || 1) + setTotalElements(response.data?.totalElements || 0) + } catch (e: any) { + setApplications([]) + setTotalPages(1) + setTotalElements(0) + setError(e?.response?.data?.message || '코어 지원서 목록을 불러오지 못했습니다.') + } finally { + setLoading(false) + } + }, [apiClient, page, selectedStatus, selectedTeam, session]) + + useEffect(() => { + void fetchApplications() + }, [fetchApplications]) + + const openDetail = useCallback( + async (applicationId: number) => { + setSelectedApplicationId(applicationId) + setDetailLoading(true) + setDetailError(null) + setDecisionNote('') + setOverwriteTeamIfExists(true) + + try { + const response = await apiClient.get( + `/admin/recruit/core/applications/${applicationId}` + ) + setDetail(response.data) + } catch (e: any) { + setDetail(null) + setDetailError(e?.response?.data?.message || '지원서 상세를 불러오지 못했습니다.') + } finally { + setDetailLoading(false) + } + }, + [apiClient] + ) + + const closeDetail = () => { + setSelectedApplicationId(null) + setDetail(null) + setDetailError(null) + setDecisionNote('') + setDecisionLoading(null) + } + + const handleDecision = useCallback( + async (action: 'accept' | 'reject') => { + if (!selectedApplicationId || !detail) return + if (!decisionNote.trim()) { + alert('처리 메모를 입력해 주세요.') + return + } + + try { + setDecisionLoading(action) + const response = await apiClient.post( + `/admin/recruit/core/applications/${selectedApplicationId}/${action}`, + action === 'accept' + ? { + resultNote: decisionNote.trim(), + overwriteTeamIfExists + } + : { + resultNote: decisionNote.trim() + } + ) + + const nextStatus = response.data?.resultStatus ?? (action === 'accept' ? 'ACCEPTED' : 'REJECTED') + setApplications((prev) => + prev.map((item) => + item.applicationId === selectedApplicationId ? { ...item, resultStatus: nextStatus } : item + ) + ) + setDetail((prev) => + prev + ? { + ...prev, + resultStatus: nextStatus, + review: { + reviewedAt: response.data?.reviewedAt ?? new Date().toISOString(), + reviewedBy: response.data?.reviewedBy ?? null, + resultNote: decisionNote.trim() + } + } + : prev + ) + + if (action === 'accept') { + const updatedRole = response.data?.userUpdated?.userRole + alert(updatedRole ? `합격 처리되었습니다. 사용자 권한이 ${updatedRole}로 변경되었습니다.` : '합격 처리되었습니다.') + } else { + alert('불합격 처리되었습니다.') + } + } catch (e: any) { + alert(e?.response?.data?.message || '지원서 처리에 실패했습니다.') + } finally { + setDecisionLoading(null) + } + }, + [apiClient, decisionNote, detail, overwriteTeamIfExists, selectedApplicationId] + ) + + const pageNumbers = useMemo(() => { + const maxVisible = 7 + const pages: number[] = [] + const start = Math.max(1, page - Math.floor(maxVisible / 2)) + const end = Math.min(totalPages, start + maxVisible - 1) + for (let p = start; p <= end; p += 1) pages.push(p) + return pages + }, [page, totalPages]) + + return ( +
+ + +
+
+
+

Core Applications Dashboard

+ Users + Members + MBTI +
+
+ +
+ { + setPage(1) + setSession(e.target.value) + }} + placeholder="예: 2026-1" + className="h-11 rounded-lg border border-gray-300 bg-gray-100 px-3 text-white outline-none focus:border-white" + /> + + +
+ 총 {totalElements}건 +
+
+ + {error ? ( +
{error}
+ ) : null} + +
+
+ + + + + + + + + + + + + {applications.length === 0 ? ( + + + + ) : ( + applications.map((application) => ( + void openDetail(application.applicationId)} + className="cursor-pointer border-t border-white/10 hover:bg-white/5" + > + + + + + + + + )) + )} + +
이름학번전공지원 팀상태제출일
+ 조회된 지원서가 없습니다. +
{application.name}{application.studentId}{application.major}{application.team}{application.resultStatus}{formatDateTime(application.createdAt)}
+
+
+ +
+ + {pageNumbers.map((pageNumber) => ( + + ))} + +
+
+ + {selectedApplicationId !== null ? ( +
+
+
+
+

코어 지원서 상세

+

+ {detail ? `${detail.snapshot.name} / ${detail.team} / ${detail.resultStatus}` : '상세 로딩 중'} +

+
+ +
+ + {detailError ?
{detailError}
: null} + + {detail ? ( +
+
+
+

기본 정보

+
+

이름: {detail.snapshot.name}

+

학번: {detail.snapshot.studentId}

+

전공: {detail.snapshot.major}

+

전화번호: {detail.snapshot.phone}

+

이메일: {detail.snapshot.email}

+

지원 팀: {detail.team}

+

제출 시각: {formatDateTime(detail.createdAt)}

+
+
+
+

검토 정보

+
+

상태: {detail.resultStatus}

+

검토 시각: {formatDateTime(detail.review?.reviewedAt)}

+

검토자 ID: {detail.review?.reviewedBy ?? '-'}

+

기존 메모: {detail.review?.resultNote || '-'}

+
+
+
+ + {[ + ['지원 동기', detail.motivation], + ['하고 싶은 일', detail.wish], + ['강점', detail.strengths], + ['다짐', detail.pledge] + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} + +
+

첨부 파일

+
+ {detail.fileUrls.length > 0 ? ( + detail.fileUrls.map((url) => ( + + {url} + + )) + ) : ( +

첨부 파일이 없습니다.

+ )} +
+
+ + {detail.resultStatus === 'SUBMITTED' || detail.resultStatus === 'IN_REVIEW' ? ( +
+

처리 메모

+