Skip to content

Commit acb9b5f

Browse files
authored
Merge pull request #298 from GDGoCINHA/develop
merge dev
2 parents 4e88e3e + f763d6c commit acb9b5f

19 files changed

Lines changed: 1687 additions & 216 deletions

File tree

src/app/dashboard/members/page.tsx

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
'use client'
2+
3+
import { useCallback, useEffect, useMemo, useState } from 'react'
4+
import Link from 'next/link'
5+
6+
import Loader from '@/components/ui/common/Loader'
7+
import UserDetailsModal from '@/components/admin/UserDetailsModal'
8+
import { GdgSegmentedButton } from '@/components/ui/design-system'
9+
import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi'
10+
import { formatPhoneNumberDisplay } from '@/utils/phoneNumber'
11+
12+
type RecruitMemberSummary = {
13+
id: number
14+
name: string
15+
phoneNumber: string
16+
major: string
17+
studentId: string
18+
admissionSemester: string | null
19+
isPayed: boolean
20+
}
21+
22+
type RecruitMemberDetail = {
23+
id?: number
24+
name: string
25+
enrolledClassification?: string
26+
phoneNumber?: string
27+
email?: string
28+
gender?: string
29+
birth?: string
30+
major: string
31+
studentId: string
32+
admissionSemester?: string
33+
isPayed: boolean
34+
createdAt?: string
35+
updatedAt?: string
36+
answers?: {
37+
answers?: Array<{
38+
id: number
39+
inputType: string
40+
responseValue: unknown
41+
}>
42+
}
43+
}
44+
45+
type MembersApiResponse = {
46+
code: number
47+
message: string
48+
data: RecruitMemberSummary[]
49+
meta?: {
50+
page: number
51+
size: number
52+
totalElements: number
53+
totalPages: number
54+
hasNext: boolean
55+
hasPrevious: boolean
56+
sort: string
57+
direction: string
58+
}
59+
}
60+
61+
type MemberDetailApiResponse = {
62+
code: number
63+
message: string
64+
data: RecruitMemberDetail
65+
}
66+
67+
const PAY_SEGMENTS = [
68+
{ label: '미입금', value: false },
69+
{ label: '입금 완료', value: true }
70+
] as const
71+
72+
const SEGMENT_WIDTH_PX = Math.max(...PAY_SEGMENTS.map((item) => item.label.length * 16 + 26))
73+
74+
export default function DashboardMembersPage() {
75+
const { apiClient } = useAuthenticatedApi()
76+
77+
const [members, setMembers] = useState<RecruitMemberSummary[]>([])
78+
const [loading, setLoading] = useState(false)
79+
const [error, setError] = useState<string | null>(null)
80+
81+
const [searchInput, setSearchInput] = useState('')
82+
const [question, setQuestion] = useState('')
83+
const [page, setPage] = useState(1)
84+
const [size] = useState(20)
85+
const [totalPages, setTotalPages] = useState(1)
86+
const [totalElements, setTotalElements] = useState(0)
87+
88+
const [selectedMember, setSelectedMember] = useState<RecruitMemberDetail | null>(null)
89+
const [detailOpen, setDetailOpen] = useState(false)
90+
const [payUpdatingMemberId, setPayUpdatingMemberId] = useState<number | null>(null)
91+
92+
const fetchMembers = useCallback(async () => {
93+
setLoading(true)
94+
setError(null)
95+
try {
96+
const response = await apiClient.get<MembersApiResponse>('/recruit/member', {
97+
params: {
98+
page: page - 1,
99+
size,
100+
sort: 'createdAt',
101+
dir: 'DESC',
102+
...(question.trim() ? { question: question.trim() } : {})
103+
}
104+
})
105+
106+
setMembers(response.data?.data ?? [])
107+
setTotalPages(response.data?.meta?.totalPages || 1)
108+
setTotalElements(response.data?.meta?.totalElements || 0)
109+
} catch (e: any) {
110+
const message =
111+
e?.response?.data?.message ||
112+
'지원자 목록을 불러오지 못했습니다. 권한 또는 네트워크 상태를 확인해 주세요.'
113+
setError(message)
114+
setMembers([])
115+
setTotalPages(1)
116+
setTotalElements(0)
117+
} finally {
118+
setLoading(false)
119+
}
120+
}, [apiClient, page, question, size])
121+
122+
useEffect(() => {
123+
void fetchMembers()
124+
}, [fetchMembers])
125+
126+
const handleSearch = () => {
127+
setPage(1)
128+
setQuestion(searchInput)
129+
}
130+
131+
const handleTogglePay = async (memberId: number, nextState: boolean) => {
132+
try {
133+
setPayUpdatingMemberId(memberId)
134+
await apiClient.patch(`/recruit/member/${memberId}/payment`, { isPayed: nextState })
135+
setMembers((prev) =>
136+
prev.map((member) => (member.id === memberId ? { ...member, isPayed: nextState } : member))
137+
)
138+
139+
if (selectedMember && selectedMember.studentId) {
140+
setSelectedMember((prev) => (prev ? { ...prev, isPayed: nextState } : prev))
141+
}
142+
} catch (e: any) {
143+
const message = e?.response?.data?.message || '입금 상태 변경에 실패했습니다.'
144+
alert(message)
145+
} finally {
146+
setPayUpdatingMemberId((prev) => (prev === memberId ? null : prev))
147+
}
148+
}
149+
150+
const openDetail = async (memberId: number) => {
151+
try {
152+
setLoading(true)
153+
const response = await apiClient.get<MemberDetailApiResponse>(`/recruit/member/${memberId}`)
154+
setSelectedMember(response.data?.data ?? null)
155+
setDetailOpen(true)
156+
} catch (e: any) {
157+
const message = e?.response?.data?.message || '상세 정보를 불러오지 못했습니다.'
158+
alert(message)
159+
} finally {
160+
setLoading(false)
161+
}
162+
}
163+
164+
const pageNumbers = useMemo(() => {
165+
const maxVisible = 7
166+
const pages: number[] = []
167+
const start = Math.max(1, page - Math.floor(maxVisible / 2))
168+
const end = Math.min(totalPages, start + maxVisible - 1)
169+
for (let p = start; p <= end; p += 1) pages.push(p)
170+
return pages
171+
}, [page, totalPages])
172+
173+
return (
174+
<div className="min-h-screen bg-black px-6 py-8 text-white pc:px-10">
175+
<Loader isLoading={loading} />
176+
177+
<div className="mx-auto w-full max-w-[1280px] space-y-6">
178+
<div className="flex flex-col gap-4 pc:flex-row pc:items-center pc:justify-between">
179+
<div className="flex items-center gap-3">
180+
<h1 className="typo-h4 mobile:typo-m-h3">Members Dashboard</h1>
181+
<Link
182+
href="/dashboard/memo"
183+
className="inline-flex h-9 items-center rounded-lg border border-white/20 px-3 typo-pc-c2 text-white hover:border-white"
184+
>
185+
Memo 발송
186+
</Link>
187+
</div>
188+
<div className="flex w-full gap-2 pc:w-auto">
189+
<input
190+
value={searchInput}
191+
onChange={(e) => setSearchInput(e.target.value)}
192+
onKeyDown={(e) => {
193+
if (e.key === 'Enter') handleSearch()
194+
}}
195+
placeholder="이름 검색"
196+
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]"
197+
/>
198+
<button
199+
type="button"
200+
onClick={handleSearch}
201+
className="h-11 rounded-lg bg-red px-4 typo-pc-b3 text-white"
202+
>
203+
검색
204+
</button>
205+
</div>
206+
</div>
207+
208+
<div className="rounded-xl border border-white/10 bg-gray-100/30 p-4">
209+
<p className="typo-pc-b3 text-gray-700">
210+
전체 {totalElements}명 / 페이지 {page} of {totalPages}
211+
</p>
212+
</div>
213+
214+
{error ? (
215+
<div className="rounded-xl border border-red bg-red-400/30 p-4 typo-pc-b3 text-red">
216+
{error}
217+
</div>
218+
) : null}
219+
220+
<div className="overflow-x-auto rounded-xl border border-white/10">
221+
<table className="w-full min-w-[820px] border-collapse">
222+
<thead>
223+
<tr className="bg-gray-100 text-left">
224+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">이름</th>
225+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">학과</th>
226+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">학번</th>
227+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">전화번호</th>
228+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">회비</th>
229+
<th className="px-4 py-3 typo-pc-b3 text-gray-700">상세</th>
230+
</tr>
231+
</thead>
232+
<tbody>
233+
{members.length === 0 ? (
234+
<tr>
235+
<td colSpan={6} className="px-4 py-8 text-center typo-pc-b3 text-gray-700">
236+
조회된 지원자가 없습니다.
237+
</td>
238+
</tr>
239+
) : (
240+
members.map((member) => (
241+
<tr key={member.id} className="border-t border-white/10 bg-black">
242+
<td className="px-4 py-3 typo-pc-b3">{member.name}</td>
243+
<td className="px-4 py-3 typo-pc-b3">{member.major}</td>
244+
<td className="px-4 py-3 typo-pc-b3">{member.studentId}</td>
245+
<td className="px-4 py-3 typo-pc-b3">
246+
{formatPhoneNumberDisplay(member.phoneNumber)}
247+
</td>
248+
<td className="px-4 py-3">
249+
<div className="inline-flex">
250+
{PAY_SEGMENTS.map((segment, index) => {
251+
const isPressed = member.isPayed === segment.value
252+
const isDisabled = payUpdatingMemberId === member.id
253+
return (
254+
<GdgSegmentedButton
255+
key={segment.label}
256+
device="pc"
257+
edge={index === 0 ? 'left' : 'right'}
258+
state={isDisabled ? 'disabled' : isPressed ? 'pressed' : 'default'}
259+
style={{ width: `${SEGMENT_WIDTH_PX}px` }}
260+
className="typo-pc-c2"
261+
onClick={() => {
262+
if (!isDisabled && member.isPayed !== segment.value) {
263+
void handleTogglePay(member.id, segment.value)
264+
}
265+
}}
266+
>
267+
{segment.label}
268+
</GdgSegmentedButton>
269+
)
270+
})}
271+
</div>
272+
</td>
273+
<td className="px-4 py-3">
274+
<button
275+
type="button"
276+
onClick={() => openDetail(member.id)}
277+
className="rounded-md border border-white px-3 py-1 typo-pc-c2 text-white hover:bg-white hover:text-black"
278+
>
279+
보기
280+
</button>
281+
</td>
282+
</tr>
283+
))
284+
)}
285+
</tbody>
286+
</table>
287+
</div>
288+
289+
{totalPages > 1 ? (
290+
<div className="flex items-center justify-center gap-2 pt-2">
291+
<button
292+
type="button"
293+
disabled={page <= 1}
294+
onClick={() => setPage((p) => Math.max(1, p - 1))}
295+
className="rounded-md border border-white/20 px-3 py-1 disabled:cursor-not-allowed disabled:opacity-40"
296+
>
297+
이전
298+
</button>
299+
{pageNumbers.map((p) => (
300+
<button
301+
key={p}
302+
type="button"
303+
onClick={() => setPage(p)}
304+
className={`rounded-md px-3 py-1 ${
305+
p === page ? 'bg-red text-white' : 'border border-white/20 text-white'
306+
}`}
307+
>
308+
{p}
309+
</button>
310+
))}
311+
<button
312+
type="button"
313+
disabled={page >= totalPages}
314+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
315+
className="rounded-md border border-white/20 px-3 py-1 disabled:cursor-not-allowed disabled:opacity-40"
316+
>
317+
다음
318+
</button>
319+
</div>
320+
) : null}
321+
</div>
322+
323+
<UserDetailsModal
324+
user={selectedMember}
325+
isOpen={detailOpen}
326+
preventClose={false}
327+
onClose={() => {
328+
setDetailOpen(false)
329+
setSelectedMember(null)
330+
}}
331+
/>
332+
</div>
333+
)
334+
}

0 commit comments

Comments
 (0)