diff --git a/src/app/dashboard/members/page.tsx b/src/app/dashboard/members/page.tsx new file mode 100644 index 0000000..0ffc4dd --- /dev/null +++ b/src/app/dashboard/members/page.tsx @@ -0,0 +1,334 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import Link from 'next/link' + +import Loader from '@/components/ui/common/Loader' +import UserDetailsModal from '@/components/admin/UserDetailsModal' +import { GdgSegmentedButton } from '@/components/ui/design-system' +import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi' +import { formatPhoneNumberDisplay } from '@/utils/phoneNumber' + +type RecruitMemberSummary = { + id: number + name: string + phoneNumber: string + major: string + studentId: string + admissionSemester: string | null + isPayed: boolean +} + +type RecruitMemberDetail = { + id?: number + name: string + enrolledClassification?: string + phoneNumber?: string + email?: string + gender?: string + birth?: string + major: string + studentId: string + admissionSemester?: string + isPayed: boolean + createdAt?: string + updatedAt?: string + answers?: { + answers?: Array<{ + id: number + inputType: string + responseValue: unknown + }> + } +} + +type MembersApiResponse = { + code: number + message: string + data: RecruitMemberSummary[] + meta?: { + page: number + size: number + totalElements: number + totalPages: number + hasNext: boolean + hasPrevious: boolean + sort: string + direction: string + } +} + +type MemberDetailApiResponse = { + code: number + message: string + data: RecruitMemberDetail +} + +const PAY_SEGMENTS = [ + { label: '미입금', value: false }, + { label: '입금 완료', value: true } +] as const + +const SEGMENT_WIDTH_PX = Math.max(...PAY_SEGMENTS.map((item) => item.label.length * 16 + 26)) + +export default function DashboardMembersPage() { + const { apiClient } = useAuthenticatedApi() + + const [members, setMembers] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const [searchInput, setSearchInput] = useState('') + const [question, setQuestion] = useState('') + const [page, setPage] = useState(1) + const [size] = useState(20) + const [totalPages, setTotalPages] = useState(1) + const [totalElements, setTotalElements] = useState(0) + + const [selectedMember, setSelectedMember] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + const [payUpdatingMemberId, setPayUpdatingMemberId] = useState(null) + + const fetchMembers = useCallback(async () => { + setLoading(true) + setError(null) + try { + const response = await apiClient.get('/recruit/member', { + params: { + page: page - 1, + size, + sort: 'createdAt', + dir: 'DESC', + ...(question.trim() ? { question: question.trim() } : {}) + } + }) + + setMembers(response.data?.data ?? []) + setTotalPages(response.data?.meta?.totalPages || 1) + setTotalElements(response.data?.meta?.totalElements || 0) + } catch (e: any) { + const message = + e?.response?.data?.message || + '지원자 목록을 불러오지 못했습니다. 권한 또는 네트워크 상태를 확인해 주세요.' + setError(message) + setMembers([]) + setTotalPages(1) + setTotalElements(0) + } finally { + setLoading(false) + } + }, [apiClient, page, question, size]) + + useEffect(() => { + void fetchMembers() + }, [fetchMembers]) + + const handleSearch = () => { + setPage(1) + setQuestion(searchInput) + } + + const handleTogglePay = async (memberId: number, nextState: boolean) => { + try { + setPayUpdatingMemberId(memberId) + await apiClient.patch(`/recruit/member/${memberId}/payment`, { isPayed: nextState }) + setMembers((prev) => + prev.map((member) => (member.id === memberId ? { ...member, isPayed: nextState } : member)) + ) + + if (selectedMember && selectedMember.studentId) { + setSelectedMember((prev) => (prev ? { ...prev, isPayed: nextState } : prev)) + } + } catch (e: any) { + const message = e?.response?.data?.message || '입금 상태 변경에 실패했습니다.' + alert(message) + } finally { + setPayUpdatingMemberId((prev) => (prev === memberId ? null : prev)) + } + } + + const openDetail = async (memberId: number) => { + try { + setLoading(true) + const response = await apiClient.get(`/recruit/member/${memberId}`) + setSelectedMember(response.data?.data ?? null) + setDetailOpen(true) + } catch (e: any) { + const message = e?.response?.data?.message || '상세 정보를 불러오지 못했습니다.' + alert(message) + } finally { + setLoading(false) + } + } + + 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 ( +
+ + +
+
+
+

Members Dashboard

+ + Memo 발송 + +
+
+ setSearchInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSearch() + }} + placeholder="이름 검색" + className="h-11 w-full rounded-lg border border-gray-300 bg-gray-100 px-3 text-white outline-none focus:border-white pc:w-[260px]" + /> + +
+
+ +
+

+ 전체 {totalElements}명 / 페이지 {page} of {totalPages} +

+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ + + + + + + + + + + + + {members.length === 0 ? ( + + + + ) : ( + members.map((member) => ( + + + + + + + + + )) + )} + +
이름학과학번전화번호회비상세
+ 조회된 지원자가 없습니다. +
{member.name}{member.major}{member.studentId} + {formatPhoneNumberDisplay(member.phoneNumber)} + +
+ {PAY_SEGMENTS.map((segment, index) => { + const isPressed = member.isPayed === segment.value + const isDisabled = payUpdatingMemberId === member.id + return ( + { + if (!isDisabled && member.isPayed !== segment.value) { + void handleTogglePay(member.id, segment.value) + } + }} + > + {segment.label} + + ) + })} +
+
+ +
+
+ + {totalPages > 1 ? ( +
+ + {pageNumbers.map((p) => ( + + ))} + +
+ ) : null} +
+ + { + setDetailOpen(false) + setSelectedMember(null) + }} + /> +
+ ) +} diff --git a/src/app/dashboard/memo/page.tsx b/src/app/dashboard/memo/page.tsx new file mode 100644 index 0000000..daffae2 --- /dev/null +++ b/src/app/dashboard/memo/page.tsx @@ -0,0 +1,215 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' + +import Loader from '@/components/ui/common/Loader' +import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi' + +type ApiResponse = { + code: number + message: string + data: T +} + +type NotificationTemplateData = { + semester: string + defaultSubject: string + defaultBody: string + lastSubject: string | null + lastBody: string | null +} + +type NotificationEnqueueData = { + semester: string + distinctTargetCount: number + enqueuedCount: number + alreadyProcessedCount: number +} + +type RetryFailedData = { + semester: string + retriedCount: number +} + +const CONFIRM_KEYWORD = '발송' + +export default function DashboardMemoPage() { + const { apiClient } = useAuthenticatedApi() + + const [loading, setLoading] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [retrying, setRetrying] = useState(false) + + const [error, setError] = useState(null) + const [semester, setSemester] = useState('') + const [subject, setSubject] = useState('') + const [body, setBody] = useState('') + const [confirmText, setConfirmText] = useState('') + + const [lastResult, setLastResult] = useState(null) + + const loadTemplate = useCallback(async () => { + setLoading(true) + setError(null) + try { + const response = await apiClient.get>( + '/admin/recruit/member/memo/notifications/template' + ) + const data = response.data?.data + if (!data) { + setError('기본 문구를 불러오지 못했습니다.') + return + } + + setSemester(data.semester) + setSubject((data.lastSubject || data.defaultSubject || '').trim()) + setBody((data.lastBody || data.defaultBody || '').trim()) + } catch (e: any) { + const message = e?.response?.data?.message || '기본 문구 조회에 실패했습니다.' + setError(message) + } finally { + setLoading(false) + } + }, [apiClient]) + + useEffect(() => { + void loadTemplate() + }, [loadTemplate]) + + const isFormValid = useMemo(() => { + return subject.trim().length > 0 && body.trim().length > 0 + }, [body, subject]) + + const isConfirmMatched = confirmText.trim() === CONFIRM_KEYWORD + const canSubmit = isFormValid && isConfirmMatched && !submitting + + const handleEnqueue = async () => { + if (!canSubmit) return + + setSubmitting(true) + try { + const response = await apiClient.post>( + '/admin/recruit/member/memo/notifications/opening', + { + subject: subject.trim(), + body: body.trim() + } + ) + const data = response.data?.data + setLastResult(data ?? null) + alert( + `큐잉 완료\n학기: ${data?.semester ?? '-'}\n대상: ${data?.distinctTargetCount ?? 0}명\n신규 큐: ${data?.enqueuedCount ?? 0}건\n이미 처리됨: ${data?.alreadyProcessedCount ?? 0}건` + ) + setConfirmText('') + } catch (e: any) { + const message = e?.response?.data?.message || '메일 큐잉 요청에 실패했습니다.' + alert(message) + } finally { + setSubmitting(false) + } + } + + const handleRetryFailed = async () => { + if (retrying) return + + setRetrying(true) + try { + const response = await apiClient.post>( + '/admin/recruit/member/memo/notifications/retry-failed' + ) + const data = response.data?.data + alert(`실패건 재시도 반영 완료\n학기: ${data?.semester ?? '-'}\n재시도 건수: ${data?.retriedCount ?? 0}`) + } catch (e: any) { + const message = e?.response?.data?.message || '실패건 재시도 요청에 실패했습니다.' + alert(message) + } finally { + setRetrying(false) + } + } + + return ( +
+ + +
+
+

Memo Notification Dashboard

+

+ 현재 학기: {semester || '-'} (학기+이메일 기준 1회 발송) +

+
+ + {error ? ( +
{error}
+ ) : null} + +
+

발송 문구 작성

+ setSubject(e.target.value)} + placeholder="메일 제목" + className="h-11 w-full rounded-lg border border-gray-300 bg-black px-3 text-white outline-none focus:border-white" + /> +