Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 67 additions & 14 deletions src/app/core-recruit/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default function CoreRecruit() {
const [uploading, setUploading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [touched, setTouched] = useState(false);
const [agreed, setAgreed] = useState(false);

const handleValueChange = (key) => (value) => {
setForm((prev) => ({ ...prev, [key]: value }));
Expand Down Expand Up @@ -116,9 +117,22 @@ export default function CoreRecruit() {

return (
<div className='flex flex-col max-w-[900px] mx-auto min-h-[100svh] justify-start text-white py-16 px-6'>
<h1 className='text-4xl font-bold mb-6 flex items-center gap-3'>
<h1
className={`
font-bold mb-6 flex items-center gap-3
text-4xl
tablet:text-3xl
mobile:text-2xl
`}
>
{/* 구글 로고 - 커스텀 그라데이션 적용 */}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 268.1522 273.8827" width="40" height="40" className="inline-block">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 268.1522 273.8827"
width="40"
height="40"
className="inline-block flex-shrink-0"
>
<defs>
<linearGradient id="a">
<stop offset="0" stopColor="#0fbc5c"/>
Expand Down Expand Up @@ -222,7 +236,16 @@ export default function CoreRecruit() {
</g>
</g>
</svg>
GDGoC INHA Core Member 지원
<span
className={`
leading-tight
break-keep
whitespace-nowrap
mobile:whitespace-normal
`}
>
GDGoC INHA<wbr /> Core Member 지원
</span>
</h1>

<div className='grid grid-cols-1 gap-14 mt-11'>
Expand Down Expand Up @@ -464,17 +487,47 @@ export default function CoreRecruit() {
</div>

<div className='rounded-xl bg-[#111111] p-5 border border-white/10'>
<div className='text-lg font-semibold mb-2'>마지막으로 일정을 확인해주시기 바랍니다!</div>
<div className='space-y-1 text-white/90'>
<div className='text-xl font-bold mb-2'>코어멤버 모집 안내</div>
<div>✅ 서류 지원 기간 : 2025년 09월 8일 (월) - 2025년 09월 12일 (금) 23:59:59</div>
<div>✅ 서류 결과 발표 : ~ 2025년 09월 14일 (일)</div>
<div>✅ 면접 진행 기간 : 2025년 09월 15일 (월) - 2025년 09월 19일 (금)</div>
<div>✅ 최종 결과 발표 : ~ 2025년 09월 21일 (일)</div>
<div className='text-white/70 text-sm mt-3'>※ 면접은 지원자와 면접관의 일정에 따라 조정되며, 인하대학교 내부 장소에서 진행됩니다.</div>
<div className='text-white/70 text-sm'>※ 면접은 대면을 원칙으로 하며, 부득이한 경우에만 비대면으로 진행됩니다.</div>
<div className='text-[#EA4336] text-sm mt-3'>❗️운영진으로 활동 시, 매주 화요일 19:00~21:00 정기 운영진 회의에 반드시 참석해야 합니다.</div>
<div className='text-lg font-semibold mb-4'>
마지막으로 공지 및 일정을 확인해주시기 바랍니다!
</div>
<div className='space-y-6 text-white/90'>
<div>
<div className='text-xl font-bold mb-4'>모집 일정</div>
<div className='mb-2'>✅ 서류 지원 기간 : 2025년 09월 8일 (월) ~ 2025년 09월 21일 (일) 11:59:59</div>
<div className='mb-2'>✅ 서류 결과 발표 : ~ 2025년 09월 21일 (일)</div>
<div className='mb-2'>✅ 면접 진행 기간 : 2025년 09월 22일 (월) ~ 2025년 09월 26일 (금)</div>
<div className='text-white/70 text-sm mb-2'>※ 지원자 및 면접관의 일정에 따라 마감 전 조기 면접 진행이 가능할 수 있습니다.</div>
<div className='mb-2'>✅ 최종 결과 발표 : ~ 2025년 09월 28일 (일)</div>
<div className='mb-2'>❗️ 첫 온보딩 : 2025년 09월 30일 (화)</div>
</div>
<div className="border-t border-white/20 my-4" />
<div>
<div className='text-xl font-bold mb-4'>면접 안내</div>
<div className='mb-2'>• 원칙적으로 대면 면접을 진행하며, 부득이한 경우에 한해 비대면으로 조정될 수 있습니다.</div>
<div className='mb-2'>• 면접은 인하대학교 내부 장소에서 진행됩니다.</div>
</div>
<div className="border-t border-white/20 my-4" />
<div>
<div className='text-xl font-bold mb-4'>활동 안내</div>
<div className='mb-2'>• 운영진으로 활동 시, 매주 화요일 19:00~21:00 정기 운영진 회의에 필수참석해야합니다.</div>
<div className='mb-2'>• 단, 2시간 전체 참석이 아닌 최소 1시간 이상 필참을 원칙으로 합니다.</div>
</div>
</div>
</div>
<div className='flex w-full items-center justify-end'>
<Checkbox
isSelected={agreed}
onValueChange={setAgreed}
radius='none'
color='danger'
className='text-white text-base font-semibold'
classNames={{
wrapper: 'group-data-[selected=true]:after:bg-red-500',
icon: 'bg-red-500',
}}
>
공지사항 및 일정을 확인하였으며, 이에 동의합니다.
</Checkbox>
</div>

<div className='flex justify-end gap-3 mt-6'>
Expand All @@ -485,7 +538,7 @@ export default function CoreRecruit() {
<Button
className='bg-red-500 text-white rounded-full w-[183px] h-[57px] text-lg font-semibold'
onPress={handleSubmit}
isDisabled={!isValid || uploading || submitting}
isDisabled={!isValid || !agreed || uploading || submitting}
isLoading={uploading || submitting}
>제출</Button>
</div>
Expand Down
14 changes: 6 additions & 8 deletions src/app/core-recruit/submit/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,12 @@ export default function CoreRecruitSubmit() {
<div className="text-3xl mt-[50px] font-semibold mobile:text-2xl">코어 멤버 지원서 제출이 완료되었습니다.</div>
<div className='text-xl mt-[20px] mobile:text-sm items-center justify-center mobile:text-center'>
<div className="flex flex-col items-start mt-5 justify-center text-white gap-2">
<div className="text-xl font-bold mb-2">아래 일정과 안내를 한번더 확인해주세요!</div>
<div>✅ 서류 지원 기간 : 2025년 09월 8일 (월) - 2025년 09월 12일 (금) 23:59:59</div>
<div>✅ 서류 결과 발표 : ~ 2025년 09월 14일 (일)</div>
<div>✅ 면접 진행 기간 : 2025년 09월 15일 (월) - 2025년 09월 19일 (금)</div>
<div>✅ 최종 결과 발표 : ~ 2025년 09월 21일 (일)</div>
<div className="text-white/70 text-sm mt-3">※ 면접은 지원자와 면접관의 일정에 따라 조정되며, 인하대학교 내부 장소에서 진행됩니다.</div>
<div className="text-white/70 text-sm">※ 면접은 대면을 원칙으로 하며, 부득이한 경우에만 비대면으로 진행됩니다.</div>
<div className="text-[#EA4336] text-sm mt-3">❗️운영진으로 활동 시, 매주 화요일 19:00~21:00 정기 운영진 회의에 반드시 참석해야 합니다.</div>
<div className="text-xl font-bold mb-2">아래 일정을 한번 더 확인해주세요!</div>
<div>✅ 서류 지원 기간 : 2025년 09월 8일 (월) ~ 2025년 09월 21일 (일) 11:59:59</div>
<div>✅ 서류 결과 발표 : ~ 2025년 09월 21일 (일)</div>
<div>✅ 면접 진행 기간 : 2025년 09월 22일 (월) ~ 2025년 09월 26일 (금)</div>
<div className="text-white/70 text-sm mb-2">※ 지원자 및 면접관의 일정에 따라 마감 전 조기 면접 진행이 가능할 수 있습니다.</div>
<div>✅ 최종 결과 발표 : ~ 2025년 09월 28일 (일)</div>
</div>
</div>
<div className='flex gap-3 mt-[50px] mb-[50px]'>
Expand Down
20 changes: 20 additions & 0 deletions src/app/coreadmin/layout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import MenuHeader from '@/components/ui/common/MenuHeader';
import ApiCodeGuard from '@/components/auth/ApiCodeGuard.jsx';

export const metadata = {
title: 'CoreAdmin',
description: 'Core member application management',
};

export default function CoreAdminLayout({ children }) {
return (
<ApiCodeGuard>
<>
<MenuHeader />
{children}
</>
</ApiCodeGuard>
);
}


194 changes: 194 additions & 0 deletions src/app/coreadmin/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"use client";

import React, { useCallback, useEffect, useRef } from 'react';
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, Chip } from '@nextui-org/react';

import UserDetailsModal from '@/components/admin/UserDetailsModal';
import AdminTableTopContent from '@/components/admin/AdminTableTopContent';
import AdminTableBottomContent from '@/components/admin/AdminTableBottomContent';
import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi';

const columns = [
{ name: 'NAME', uid: 'name' },
{ name: 'MAJOR / ID', uid: 'major' },
{ name: 'TEAM', uid: 'team' },
{ name: 'EMAIL', uid: 'email' },
{ name: 'PHONE', uid: 'phone' },
];

export default function CoreAdminPage() {
const { apiClient } = useAuthenticatedApi();

const [page, setPage] = React.useState(1);
const [modalOpen, setModalOpen] = React.useState(false);
const modalClosing = useRef(false);

const [selectedUser, setSelectedUser] = React.useState(null);
const [searchValue, setSearchValue] = React.useState('');
const [query, setQuery] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState('');
const [currentUsers, setCurrentUsers] = React.useState([]);
const [totalUsers, setTotalUsers] = React.useState(0);
const [totalPages, setTotalPages] = React.useState(0);

const rowsPerPage = 10;

const fetchApplicants = useCallback(async () => {
setLoading(true);
setError('');
try {
const params = {
page: page - 1,
size: rowsPerPage,
sort: 'createdAt',
dir: 'DESC',
question: query || undefined,
};
const res = await apiClient.get('/core-recruit/applicants', { params });
const list = Array.isArray(res?.data?.data) ? res.data.data : [];
const total = res?.data?.meta?.totalElements ?? list.length;
const computedTotalPages = Math.max(1, Math.ceil(total / rowsPerPage));

setCurrentUsers(list);
setTotalUsers(total);
setTotalPages(computedTotalPages);
} catch (err) {
setError(String(err?.message || 'failed to load applicants'));
setCurrentUsers([]);
setTotalUsers(0);
} finally {
setLoading(false);
}
}, [apiClient, page, rowsPerPage, query]);

const renderCell = useCallback((user, columnKey) => {
const value = user[columnKey];
switch (columnKey) {
case 'name':
return <span className='text-white'>{user?.name ?? ''}</span>;
case 'major':
return (
<div className='flex flex-col'>
<p className='text-white text-bold text-sm capitalize'>{user?.major ?? ''}</p>
<p className='text-bold text-sm capitalize text-default-400'>{user?.studentId ?? ''}</p>
</div>
);
case 'team': {
const teamLabel = user?.team ?? '';
const teamColorMap = {
HR: '#EA4335',
BD: '#34A853',
TECH: '#4285F4',
'PR/DESIGN': '#F9AB00',
};
const color = teamColorMap[teamLabel] || '#9CA3AF';
return (
<Chip
size='sm'
variant='bordered'
style={{
borderColor: color,
color,
}}
>
{teamLabel}
</Chip>
);
}
case 'email':
return <span className='text-white'>{user?.email ?? ''}</span>;
case 'phone':
return <span className='text-white'>{user?.phone ?? ''}</span>;
case 'createdAt':
return <span className='text-white'>{user?.createdAt ? new Date(user.createdAt).toLocaleString() : ''}</span>;
default:
return value;
}
}, []);

const handleRowClick = async (user) => {
if (modalClosing.current) return;
try {
const id = user?.id;
if (!id) throw new Error('지원자 ID 없음');
const res = await apiClient.get(`/core-recruit/applicants/${id}`);
const detail = res?.data?.data ?? null;
if (!detail) throw new Error('상세 정보 없음');
setSelectedUser(detail);
setModalOpen(true);
} catch (e) {
alert('상세 정보를 불러오는 중 오류가 발생했습니다.');
}
};

const handleSearch = () => {
setPage(1);
setQuery((searchValue || '').trim());
};

const handleCloseModal = () => {
modalClosing.current = true;
setModalOpen(false);
setTimeout(() => {
modalClosing.current = false;
}, 300);
};

useEffect(() => {
if (searchValue === '' && query !== '') {
setPage(1);
setQuery('');
}
}, [searchValue, query]);

useEffect(() => {
fetchApplicants();
}, [fetchApplicants]);

return (
<div>
<Table
className='dark text-white py-[30px] px-[96px] mobile:px-[10px]'
aria-label='Core applicants table'
bottomContent={
<div className='relative'>
<AdminTableBottomContent
page={page}
totalPages={totalPages}
totalUsers={totalUsers}
onChangePage={(newPage) => setPage(newPage)}
/>
</div>
}
topContent={
<AdminTableTopContent searchValue={searchValue} setSearchValue={setSearchValue} onSearch={handleSearch} />
}
>
<TableHeader columns={columns}>
{(column) => (
<TableColumn key={column.uid} align='start'>
{column.name}
</TableColumn>
)}
</TableHeader>
<TableBody items={currentUsers} isLoading={loading} emptyContent={loading ? '불러오는 중...' : '데이터가 없습니다.'}>
{(item) => (
<TableRow className='hover:bg-[#35353b99] cursor-pointer text-white' key={item.id} onClick={() => handleRowClick(item)}>
{(columnKey) => (
<TableCell>
{renderCell(item, columnKey)}
</TableCell>
)}
</TableRow>
)}
</TableBody>
</Table>

{/* 기존 UserDetailsModal 재사용: 핵심 질문/자유문항은 response 배열로 표시됨 */}
<UserDetailsModal user={selectedUser} isOpen={modalOpen} onClose={handleCloseModal} preventClose />
</div>
);
}


Loading