- Spring Security + JWT 인증 (8시간 토큰) - AdminUser / Recruit 엔터티 추가 - AdminController: 로그인, 대시보드, 뉴스/문의/채용 CRUD - React 어드민 SPA: /admin/* 라우트 (Header/Footer 없음) - 로그인, 대시보드, 뉴스 관리, 문의 관리, 채용공고 관리, 설정 - Jenkinsfile: 서버 환경 맞춤 CI/CD 파이프라인 - .gitignore 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
170 lines
8.5 KiB
JavaScript
170 lines
8.5 KiB
JavaScript
import { useEffect, useState, useCallback } from 'react';
|
||
|
||
const token = () => localStorage.getItem('admin_token');
|
||
const authFetch = (url, opts = {}) =>
|
||
fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}`, ...opts.headers } });
|
||
|
||
const EMPTY = { title: '', department: '', jobType: '정규직', description: '', requirements: '', preferred: '', deadline: '', headcount: 1, active: true };
|
||
const JOB_TYPES = ['정규직', '계약직', '인턴', '프리랜서'];
|
||
|
||
export default function AdminRecruit() {
|
||
const [page, setPage] = useState(0);
|
||
const [data, setData] = useState({ content: [], totalPages: 0, totalElements: 0 });
|
||
const [modal, setModal] = useState(false);
|
||
const [form, setForm] = useState(EMPTY);
|
||
const [editId, setEditId] = useState(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [toast, setToast] = useState(null);
|
||
|
||
const showToast = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 2500); };
|
||
|
||
const load = useCallback(() => {
|
||
authFetch(`/api/admin/recruits?page=${page}&size=10`)
|
||
.then(r => r.json()).then(setData);
|
||
}, [page]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const openCreate = () => { setForm(EMPTY); setEditId(null); setModal(true); };
|
||
const openEdit = (r) => { setForm({ ...r, deadline: r.deadline || '' }); setEditId(r.id); setModal(true); };
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true);
|
||
const url = editId ? `/api/admin/recruits/${editId}` : '/api/admin/recruits';
|
||
const res = await authFetch(url, { method: editId ? 'PUT' : 'POST', body: JSON.stringify(form) });
|
||
setSaving(false);
|
||
if (res.ok) { setModal(false); load(); showToast(editId ? '수정되었습니다.' : '등록되었습니다.'); }
|
||
else showToast('저장 실패', 'error');
|
||
};
|
||
|
||
const handleDelete = async (id) => {
|
||
if (!confirm('삭제하시겠습니까?')) return;
|
||
const res = await authFetch(`/api/admin/recruits/${id}`, { method: 'DELETE' });
|
||
if (res.ok) { load(); showToast('삭제되었습니다.'); }
|
||
};
|
||
|
||
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
|
||
|
||
return (
|
||
<>
|
||
{toast && <div className="admin-toast"><div className={`toast-item ${toast.type}`}>{toast.msg}</div></div>}
|
||
|
||
<div className="admin-card">
|
||
<div className="admin-toolbar">
|
||
<span style={{ fontSize: 13, color: '#64748b' }}>전체 {data.totalElements}건</span>
|
||
<div className="admin-toolbar-right">
|
||
<button className="btn btn-primary" onClick={openCreate}>+ 채용공고 등록</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="admin-table-wrap">
|
||
<table className="admin-table">
|
||
<thead>
|
||
<tr><th>No</th><th>공고명</th><th>부서</th><th>유형</th><th>모집인원</th><th>마감일</th><th>상태</th><th>관리</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.content.map((r, i) => (
|
||
<tr key={r.id}>
|
||
<td style={{ color: '#94a3b8', fontSize: 12 }}>{data.totalElements - page * 10 - i}</td>
|
||
<td><span className="truncate" style={{ display: 'block' }}>{r.title}</span></td>
|
||
<td>{r.department || '-'}</td>
|
||
<td><span className={`badge ${r.jobType === '정규직' ? 'badge-blue' : r.jobType === '인턴' ? 'badge-orange' : 'badge-gray'}`}>{r.jobType}</span></td>
|
||
<td>{r.headcount}명</td>
|
||
<td style={{ fontSize: 12 }}>{r.deadline || '상시'}</td>
|
||
<td><span className={`badge ${r.active ? 'badge-green' : 'badge-red'}`}>{r.active ? '진행중' : '마감'}</span></td>
|
||
<td>
|
||
<div className="action-btns">
|
||
<button className="btn btn-outline btn-sm" onClick={() => openEdit(r)}>수정</button>
|
||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(r.id)}>삭제</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{!data.content.length && (
|
||
<tr><td colSpan={8}><div className="empty-state"><div className="empty-icon">👥</div><p>등록된 채용공고가 없습니다.</p></div></td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{data.totalPages > 1 && (
|
||
<div className="admin-pagination">
|
||
<span className="admin-pagination-info">페이지 {page + 1} / {data.totalPages}</span>
|
||
<div className="pagination-btns">
|
||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}>‹</button>
|
||
{Array.from({ length: data.totalPages }, (_, i) => (
|
||
<button key={i} className={page === i ? 'active' : ''} onClick={() => setPage(i)}>{i + 1}</button>
|
||
))}
|
||
<button disabled={page >= data.totalPages - 1} onClick={() => setPage(p => p + 1)}>›</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{modal && (
|
||
<div className="modal-backdrop" onClick={e => e.target === e.currentTarget && setModal(false)}>
|
||
<div className="modal">
|
||
<div className="modal-header">
|
||
<h3>{editId ? '채용공고 수정' : '채용공고 등록'}</h3>
|
||
<button onClick={() => setModal(false)}>✕</button>
|
||
</div>
|
||
<div className="modal-body">
|
||
<div className="form-group">
|
||
<label>공고 제목 *</label>
|
||
<input className="form-control" value={form.title} onChange={e => set('title', e.target.value)} placeholder="예: 백엔드 개발자 (Java/Spring)" />
|
||
</div>
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label>부서</label>
|
||
<input className="form-control" value={form.department} onChange={e => set('department', e.target.value)} placeholder="개발팀, 영업팀 등" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>고용형태</label>
|
||
<select className="form-control" value={form.jobType} onChange={e => set('jobType', e.target.value)}>
|
||
{JOB_TYPES.map(t => <option key={t}>{t}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label>모집 인원</label>
|
||
<input type="number" min={1} className="form-control" value={form.headcount} onChange={e => set('headcount', parseInt(e.target.value))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>마감일</label>
|
||
<input type="date" className="form-control" value={form.deadline} onChange={e => set('deadline', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>공고 상태</label>
|
||
<select className="form-control" value={form.active} onChange={e => set('active', e.target.value === 'true')}>
|
||
<option value="true">진행중</option>
|
||
<option value="false">마감</option>
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>담당 업무</label>
|
||
<textarea className="form-control" rows={4} value={form.description} onChange={e => set('description', e.target.value)} placeholder="- 주요 담당 업무를 입력하세요" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>지원 자격</label>
|
||
<textarea className="form-control" rows={4} value={form.requirements} onChange={e => set('requirements', e.target.value)} placeholder="- 필수 자격요건을 입력하세요" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>우대 사항</label>
|
||
<textarea className="form-control" rows={3} value={form.preferred} onChange={e => set('preferred', e.target.value)} placeholder="- 우대 사항을 입력하세요" />
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="btn btn-outline" onClick={() => setModal(false)}>취소</button>
|
||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.title}>
|
||
{saving ? '저장 중...' : (editId ? '수정 완료' : '등록')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|