1+ 'use client' ;
2+
3+ import { useEffect , useState } from 'react' ;
4+ import { Button , Card , CardBody , CardHeader , Divider , Input } from '@nextui-org/react' ;
5+ import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi' ;
6+
7+ export default function ManitoAdminPage ( ) {
8+ const { apiClient} = useAuthenticatedApi ( ) ;
9+
10+ // 공통
11+ const [ sessionCode , setSessionCode ] = useState ( '' ) ;
12+
13+ // 세션 관리용
14+ const [ sessions , setSessions ] = useState ( [ ] ) ; // [{id, code, name, createdAt,...}]
15+ const [ newSessionCode , setNewSessionCode ] = useState ( '' ) ;
16+ const [ newSessionName , setNewSessionName ] = useState ( '' ) ;
17+ const [ loadingSessions , setLoadingSessions ] = useState ( false ) ;
18+
19+ // 파일
20+ const [ participantsFile , setParticipantsFile ] = useState ( null ) ;
21+ const [ encryptedFile , setEncryptedFile ] = useState ( null ) ;
22+
23+ // 로딩
24+ const [ loadingParticipants , setLoadingParticipants ] = useState ( false ) ;
25+ const [ loadingEncrypted , setLoadingEncrypted ] = useState ( false ) ;
26+
27+ // 메시지
28+ const [ message , setMessage ] = useState ( '' ) ;
29+ const [ error , setError ] = useState ( '' ) ;
30+
31+ const resetMessages = ( ) => {
32+ setMessage ( '' ) ;
33+ setError ( '' ) ;
34+ } ;
35+
36+ /** ====== 세션 목록 불러오기 ====== */
37+ const fetchSessions = async ( ) => {
38+ try {
39+ setLoadingSessions ( true ) ;
40+ const res = await apiClient . get ( '/admin/manito/sessions' ) ;
41+ // ApiResponse 가정: { data: [ ... ] }
42+ const list = res . data ?. data ?? [ ] ;
43+ setSessions ( Array . isArray ( list ) ? list : [ ] ) ;
44+ } catch ( e ) {
45+ console . error ( e ) ;
46+ setError ( '세션 목록을 불러오는 중 오류가 발생했습니다.' ) ;
47+ } finally {
48+ setLoadingSessions ( false ) ;
49+ }
50+ } ;
51+
52+ useEffect ( ( ) => {
53+ fetchSessions ( ) ;
54+ // eslint-disable-next-line react-hooks/exhaustive-deps
55+ } , [ ] ) ;
56+
57+ /** ====== 세션 생성 ====== */
58+ const handleCreateSession = async ( ) => {
59+ resetMessages ( ) ;
60+ const code = newSessionCode . trim ( ) ;
61+ const name = newSessionName . trim ( ) ;
62+
63+ if ( ! code ) {
64+ setError ( '세션 코드를 입력해 주세요.' ) ;
65+ return ;
66+ }
67+ if ( ! name ) {
68+ setError ( '세션 이름을 입력해 주세요.' ) ;
69+ return ;
70+ }
71+
72+ try {
73+ setLoadingSessions ( true ) ;
74+ const res = await apiClient . post ( '/admin/manito/sessions' , { code, name} ) ;
75+ const created = res . data ?. data ;
76+
77+ // 세션 목록 갱신
78+ await fetchSessions ( ) ;
79+ // 공통 sessionCode 에도 세팅
80+ setSessionCode ( code ) ;
81+ setNewSessionCode ( '' ) ;
82+ setNewSessionName ( '' ) ;
83+ setMessage ( `세션이 생성되었습니다. (code: ${ code } )` ) ;
84+ } catch ( e ) {
85+ console . error ( e ) ;
86+ setError ( '세션 생성 중 오류가 발생했습니다.' ) ;
87+ } finally {
88+ setLoadingSessions ( false ) ;
89+ }
90+ } ;
91+
92+ /** ===== 파일 핸들러 ===== */
93+ const handleParticipantsFileChange = ( e ) => {
94+ resetMessages ( ) ;
95+ const file = e . target . files ?. [ 0 ] ?? null ;
96+ setParticipantsFile ( file ) ;
97+ } ;
98+
99+ const handleEncryptedFileChange = ( e ) => {
100+ resetMessages ( ) ;
101+ const file = e . target . files ?. [ 0 ] ?? null ;
102+ setEncryptedFile ( file ) ;
103+ } ;
104+
105+ /** ===== 다운로드 공통 util ===== */
106+ const downloadBlob = ( blob , filename ) => {
107+ const url = window . URL . createObjectURL ( blob ) ;
108+ const a = document . createElement ( 'a' ) ;
109+ a . href = url ;
110+ a . download = filename ;
111+ document . body . appendChild ( a ) ;
112+ a . click ( ) ;
113+ a . remove ( ) ;
114+ window . URL . revokeObjectURL ( url ) ;
115+ } ;
116+
117+ /** ===== 1단계: 참가자 CSV 업로드 → 매칭 CSV 다운로드 ===== */
118+ const handleUploadParticipants = async ( ) => {
119+ resetMessages ( ) ;
120+
121+ if ( ! sessionCode . trim ( ) ) {
122+ setError ( '세션 코드를 선택하거나 입력해 주세요.' ) ;
123+ return ;
124+ }
125+ if ( ! participantsFile ) {
126+ setError ( '참가자 CSV 파일을 선택해 주세요.' ) ;
127+ return ;
128+ }
129+
130+ try {
131+ setLoadingParticipants ( true ) ;
132+
133+ const formData = new FormData ( ) ;
134+ formData . append ( 'sessionCode' , sessionCode . trim ( ) ) ;
135+ formData . append ( 'file' , participantsFile ) ;
136+
137+ const res = await apiClient . post ( '/admin/manito/upload' , formData , {
138+ responseType : 'blob' ,
139+ } ) ;
140+
141+ const blob = res . data ;
142+ const disposition = res . headers ?. [ 'content-disposition' ] || '' ;
143+ let filename = `manito-${ sessionCode . trim ( ) } .csv` ;
144+ const match = disposition . match ( / f i l e n a m e = " ? ( [ ^ " ] + ) " ? / ) ;
145+ if ( match && match [ 1 ] ) {
146+ filename = match [ 1 ] ;
147+ }
148+
149+ downloadBlob ( blob , filename ) ;
150+ setMessage ( '참가자 CSV 업로드 및 매칭 CSV 다운로드가 완료되었습니다.' ) ;
151+ } catch ( e ) {
152+ console . error ( e ) ;
153+ setError ( '참가자 CSV 업로드 또는 매칭 CSV 다운로드 중 오류가 발생했습니다.' ) ;
154+ } finally {
155+ setLoadingParticipants ( false ) ;
156+ }
157+ } ;
158+
159+ /** ===== 2단계: 암호문 CSV 업로드 ===== */
160+ const handleUploadEncrypted = async ( ) => {
161+ resetMessages ( ) ;
162+
163+ if ( ! sessionCode . trim ( ) ) {
164+ setError ( '세션 코드를 선택하거나 입력해 주세요.' ) ;
165+ return ;
166+ }
167+ if ( ! encryptedFile ) {
168+ setError ( '암호문 CSV 파일을 선택해 주세요.' ) ;
169+ return ;
170+ }
171+
172+ try {
173+ setLoadingEncrypted ( true ) ;
174+
175+ const formData = new FormData ( ) ;
176+ formData . append ( 'sessionCode' , sessionCode . trim ( ) ) ;
177+ formData . append ( 'file' , encryptedFile ) ;
178+
179+ const res = await apiClient . post ( '/admin/manito/upload-encrypted' , formData ) ;
180+ const body = res . data ;
181+ setMessage ( body ?. message || '암호문 CSV 업로드가 완료되었습니다.' ) ;
182+ } catch ( e ) {
183+ console . error ( e ) ;
184+ setError ( '암호문 CSV 업로드 중 오류가 발생했습니다.' ) ;
185+ } finally {
186+ setLoadingEncrypted ( false ) ;
187+ }
188+ } ;
189+
190+ return ( < div className = "dark flex flex-col max-w-3xl mx-auto min-h-[100svh] py-16 px-6" >
191+ < h1 className = "font-bold mb-6 text-3xl text-white" > 마니또 관리(Admin)</ h1 >
192+
193+ { /* 공통 설정 + 세션 등록/선택 */ }
194+ < Card className = "mb-6 bg-default-100 dark:bg-zinc-900 border border-zinc-800" >
195+ < CardHeader className = "flex flex-col items-start gap-1" >
196+ < h2 className = "text-xl font-semibold text-white" > 공통 설정 · 세션 관리</ h2 >
197+ < p className = "text-xs text-zinc-400" >
198+ 세션 단위로 참가자/매칭/암호문을 관리합니다.
199+ < br />
200+ 먼저 세션을 생성한 뒤, 해당 세션을 선택하고 아래 단계를 진행하세요.
201+ </ p >
202+ </ CardHeader >
203+ < Divider className = "border-zinc-800" />
204+ < CardBody className = "gap-4 text-white" >
205+ { /* 현재 사용 세션 코드 (직접 입력/수정 가능) */ }
206+ < Input
207+ label = "현재 사용 중인 세션 코드"
208+ placeholder = "예: WINTER_2025"
209+ value = { sessionCode }
210+ onChange = { ( e ) => setSessionCode ( e . target . value ) }
211+ variant = "bordered"
212+ classNames = { {
213+ label : 'text-zinc-300' ,
214+ input : 'text-white' ,
215+ inputWrapper : 'bg-zinc-900 border-zinc-700 group-data-[focus=true]:border-zinc-400' ,
216+ } }
217+ />
218+
219+ < Divider className = "border-zinc-800" />
220+
221+ { /* 새 세션 생성 */ }
222+ < div className = "space-y-2" >
223+ < p className = "text-sm text-zinc-300 font-semibold" > 새 세션 등록</ p >
224+ < div className = "grid grid-cols-1 md:grid-cols-2 gap-3" >
225+ < Input
226+ label = "세션 코드"
227+ placeholder = "예: WINTER_2025"
228+ value = { newSessionCode }
229+ onChange = { ( e ) => setNewSessionCode ( e . target . value ) }
230+ variant = "bordered"
231+ classNames = { {
232+ label : 'text-zinc-300' ,
233+ input : 'text-white' ,
234+ inputWrapper : 'bg-zinc-900 border-zinc-700 group-data-[focus=true]:border-zinc-400' ,
235+ } }
236+ />
237+ < Input
238+ label = "세션 이름"
239+ placeholder = "예: 2025 겨울 마니또"
240+ value = { newSessionName }
241+ onChange = { ( e ) => setNewSessionName ( e . target . value ) }
242+ variant = "bordered"
243+ classNames = { {
244+ label : 'text-zinc-300' ,
245+ input : 'text-white' ,
246+ inputWrapper : 'bg-zinc-900 border-zinc-700 group-data-[focus=true]:border-zinc-400' ,
247+ } }
248+ />
249+ </ div >
250+ < Button
251+ color = "primary"
252+ variant = "flat"
253+ size = "sm"
254+ onPress = { handleCreateSession }
255+ isLoading = { loadingSessions }
256+ className = "mt-1"
257+ >
258+ 새 세션 생성
259+ </ Button >
260+ </ div >
261+
262+ { /* 세션 목록 */ }
263+ < Divider className = "border-zinc-800 my-4" />
264+ < div className = "space-y-2" >
265+ < p className = "text-sm text-zinc-300 font-semibold" > 세션 목록</ p >
266+ < div className = "max-h-40 overflow-auto space-y-1 text-sm" >
267+ { loadingSessions && ( < p className = "text-xs text-zinc-400" > 세션 목록을 불러오는 중...</ p > ) }
268+ { ! loadingSessions && sessions . length === 0 && ( < p className = "text-xs text-zinc-500" >
269+ 등록된 세션이 없습니다. 위에서 새 세션을 생성해 주세요.
270+ </ p > ) }
271+ { sessions . map ( ( s ) => ( < div
272+ key = { s . id ?? s . code }
273+ className = "flex items-center justify-between py-1 border-b border-zinc-800/40"
274+ >
275+ < div className = "flex flex-col" >
276+ < span className = "font-medium text-zinc-100" >
277+ { s . name || '(이름 없음)' }
278+ </ span >
279+ < span className = "text-xs text-zinc-400" >
280+ code: { s . code }
281+ </ span >
282+ </ div >
283+ < Button
284+ size = "sm"
285+ variant = { sessionCode === s . code ? 'solid' : 'flat' }
286+ color = "secondary"
287+ onPress = { ( ) => setSessionCode ( s . code ) }
288+ >
289+ 사용
290+ </ Button >
291+ </ div > ) ) }
292+ </ div >
293+ </ div >
294+ </ CardBody >
295+ </ Card >
296+
297+ { /* 1단계: 참가자 CSV 업로드 */ }
298+ < Card className = "mb-6 bg-default-100 dark:bg-zinc-900 border border-zinc-800" >
299+ < CardHeader className = "flex flex-col items-start gap-1" >
300+ < h2 className = "text-lg font-semibold text-white" >
301+ 1단계 · 참가자 CSV 업로드 & 매칭 생성
302+ </ h2 >
303+ < p className = "text-xs text-zinc-400" >
304+ CSV 헤더: < code > studentId,name,pin</ code > · 업로드 후, 서버에서 매칭을 생성하고
305+ < br />
306+ < code > giverStudentId,giverName,receiverStudentId,receiverName</ code > CSV를
307+ 바로 다운로드합니다.
308+ </ p >
309+ </ CardHeader >
310+ < Divider className = "border-zinc-800" />
311+ < CardBody className = "gap-4 text-white" >
312+ < input
313+ type = "file"
314+ accept = ".csv"
315+ onChange = { handleParticipantsFileChange }
316+ className = "text-sm text-zinc-300"
317+ />
318+ < Button
319+ color = "primary"
320+ variant = "flat"
321+ onPress = { handleUploadParticipants }
322+ isLoading = { loadingParticipants }
323+ isDisabled = { ! sessionCode . trim ( ) || ! participantsFile || loadingParticipants }
324+ className = "mt-2"
325+ >
326+ 참가자 CSV 업로드 & 매칭 CSV 다운로드
327+ </ Button >
328+ </ CardBody >
329+ </ Card >
330+
331+ { /* 2단계: 암호문 CSV 업로드 */ }
332+ < Card className = "mb-6 bg-default-100 dark:bg-zinc-900 border border-zinc-800" >
333+ < CardHeader className = "flex flex-col items-start gap-1" >
334+ < h2 className = "text-lg font-semibold text-white" >
335+ 2단계 · 암호문(encryptedManitto) CSV 업로드
336+ </ h2 >
337+ < p className = "text-xs text-zinc-400" >
338+ 클라이언트에서 매칭 CSV를 기반으로 암호화한 결과를 업로드합니다.
339+ < br />
340+ CSV 헤더 예시: < code > studentId,encryptedManitto</ code >
341+ </ p >
342+ </ CardHeader >
343+ < Divider className = "border-zinc-800" />
344+ < CardBody className = "gap-4 text-white" >
345+ < input
346+ type = "file"
347+ accept = ".csv"
348+ onChange = { handleEncryptedFileChange }
349+ className = "text-sm text-zinc-300"
350+ />
351+ < Button
352+ color = "secondary"
353+ variant = "flat"
354+ onPress = { handleUploadEncrypted }
355+ isLoading = { loadingEncrypted }
356+ isDisabled = { ! sessionCode . trim ( ) || ! encryptedFile || loadingEncrypted }
357+ className = "mt-2"
358+ >
359+ 암호문 CSV 업로드
360+ </ Button >
361+ </ CardBody >
362+ </ Card >
363+
364+ { ( message || error ) && ( < Card className = "bg-default-100 dark:bg-zinc-900 border border-zinc-800" >
365+ < CardBody className = "text-sm" >
366+ { message && ( < p className = "text-emerald-400 whitespace-pre-line" > { message } </ p > ) }
367+ { error && < p className = "text-red-400 whitespace-pre-line" > { error } </ p > }
368+ </ CardBody >
369+ </ Card > ) }
370+ </ div > ) ;
371+ }
0 commit comments