zioinfo-mail/workspace/zioinfo-web/frontend/src/pages/admin/AdminRecruit.jsx
DESKTOP-TKLFCPR\ython bc278ff1f2 feat(admin): 홈페이지 관리자 시스템 구현
- 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>
2026-05-30 18:40:24 +09:00

170 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)}
</>
);
}