Skip to content

Commit 17939cd

Browse files
authored
Merge pull request #198 from CSE-Shaco/develop
feat(manito): 마니또 기능 추가
2 parents 9c95d63 + 173ac92 commit 17939cd

File tree

4 files changed

+621
-0
lines changed

4 files changed

+621
-0
lines changed

src/app/manitto/admin/layout.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import MenuHeader from "@/components/ui/common/MenuHeader";
2+
import ApiCodeGuard from "@/components/auth/ApiCodeGuard.jsx";
3+
4+
export const metadata = {
5+
title: "Manitto Admin", description: "GDGoC INHA Manitto Management",
6+
};
7+
8+
export default function ManittoAdminLayout({children}) {
9+
return (<ApiCodeGuard requiredRole="CORE" requiredTeam="HR" nextOverride="/manitto/admin">
10+
<>
11+
<MenuHeader/>
12+
{children}
13+
</>
14+
</ApiCodeGuard>);
15+
}

src/app/manitto/admin/page.jsx

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
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(/filename="?([^"]+)"?/);
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

Comments
 (0)