- CompanyHistory JPA entity (tb_company_history) - CompanyHistoryRepository - GET /api/history: DB-based grouped history (year + items[]) - Admin CRUD: GET/POST/PUT/DELETE /api/admin/history - DataInitializer: 35 history items seeded from 2000 to 2026 - Company.jsx: useHistory() hook -> API fetch with fallback - AdminHistory.jsx: year-grouped timeline CRUD UI - AdminLayout: 회사 연혁 menu added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
204 lines
9.6 KiB
JavaScript
204 lines
9.6 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 = { year: '', content: '', sortOrder: 0, visible: true };
|
||
|
||
export default function AdminHistory() {
|
||
const [items, setItems] = useState([]);
|
||
const [modal, setModal] = useState(null); // null | 'create' | 'edit'
|
||
const [form, setForm] = useState(EMPTY);
|
||
const [editId, setEditId] = useState(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [toast, setToast] = useState(null);
|
||
const [search, setSearch] = useState('');
|
||
|
||
const showToast = (msg, type = 'success') => {
|
||
setToast({ msg, type });
|
||
setTimeout(() => setToast(null), 2500);
|
||
};
|
||
|
||
const load = useCallback(() => {
|
||
authFetch('/api/admin/history').then(r => r.json()).then(setItems).catch(() => {});
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const openCreate = () => { setForm(EMPTY); setEditId(null); setModal('create'); };
|
||
const openEdit = h => { setForm({ year: h.year, content: h.content, sortOrder: h.sortOrder, visible: h.visible }); setEditId(h.id); setModal('edit'); };
|
||
const closeModal = () => { setModal(null); setForm(EMPTY); setEditId(null); };
|
||
|
||
const save = async () => {
|
||
if (!form.year.trim() || !form.content.trim()) { showToast('연도와 내용은 필수입니다.', 'error'); return; }
|
||
setSaving(true);
|
||
try {
|
||
const url = modal === 'edit' ? `/api/admin/history/${editId}` : '/api/admin/history';
|
||
const meth = modal === 'edit' ? 'PUT' : 'POST';
|
||
const r = await authFetch(url, { method: meth, body: JSON.stringify(form) });
|
||
if (!r.ok) throw new Error('저장 실패');
|
||
showToast(modal === 'edit' ? '수정되었습니다.' : '등록되었습니다.');
|
||
closeModal(); load();
|
||
} catch { showToast('저장 중 오류가 발생했습니다.', 'error'); }
|
||
finally { setSaving(false); }
|
||
};
|
||
|
||
const del = async id => {
|
||
if (!confirm('이 항목을 삭제하시겠습니까?')) return;
|
||
await authFetch(`/api/admin/history/${id}`, { method: 'DELETE' });
|
||
showToast('삭제되었습니다.');
|
||
load();
|
||
};
|
||
|
||
const toggleVisible = async h => {
|
||
await authFetch(`/api/admin/history/${h.id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ ...h, visible: !h.visible }),
|
||
});
|
||
load();
|
||
};
|
||
|
||
// 연도별 그룹핑
|
||
const grouped = {};
|
||
items.filter(h => !search || h.year.includes(search) || h.content.includes(search))
|
||
.forEach(h => { (grouped[h.year] = grouped[h.year] || []).push(h); });
|
||
|
||
return (
|
||
<main className="admin-page">
|
||
{toast && (
|
||
<div className={`admin-toast ${toast.type}`} style={{
|
||
position:'fixed', top:24, right:24, zIndex:9999,
|
||
padding:'12px 20px', borderRadius:8, color:'#fff',
|
||
background: toast.type === 'error' ? '#dc2626' : '#16a34a',
|
||
boxShadow:'0 4px 12px rgba(0,0,0,.15)', fontSize:14,
|
||
}}>{toast.msg}</div>
|
||
)}
|
||
|
||
<div style={{ display:'flex', alignItems:'center', gap:12, marginBottom:20 }}>
|
||
<h2 style={{ margin:0, fontSize:20, fontWeight:800 }}>연혁 관리</h2>
|
||
<input value={search} onChange={e=>setSearch(e.target.value)}
|
||
placeholder="연도 또는 내용 검색..."
|
||
style={{ padding:'6px 12px', border:'1px solid #cbd5e1', borderRadius:6, fontSize:13, flex:1, maxWidth:280 }} />
|
||
<button onClick={load} style={btnStyle('#64748b')}>새로고침</button>
|
||
<button onClick={openCreate} style={btnStyle('#4f6ef7')}>+ 항목 추가</button>
|
||
</div>
|
||
|
||
<div style={{ fontSize:13, color:'#64748b', marginBottom:16 }}>
|
||
총 <strong>{items.length}</strong>개 항목
|
||
</div>
|
||
|
||
{/* 연도별 타임라인 테이블 */}
|
||
{Object.entries(grouped).map(([year, rows]) => (
|
||
<div key={year} style={{
|
||
background:'#fff', border:'1px solid #e2e8f0', borderRadius:10,
|
||
marginBottom:16, overflow:'hidden',
|
||
}}>
|
||
<div style={{
|
||
background:'#f1f5f9', padding:'10px 16px', fontWeight:700,
|
||
fontSize:16, color:'#1a3a6b', display:'flex', alignItems:'center', gap:8,
|
||
}}>
|
||
<span style={{ background:'#1a3a6b', color:'#fff', padding:'2px 10px', borderRadius:20, fontSize:13 }}>{year}</span>
|
||
<span style={{ fontSize:13, color:'#64748b', fontWeight:400 }}>{rows.length}개 항목</span>
|
||
</div>
|
||
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:13 }}>
|
||
<thead>
|
||
<tr style={{ background:'#f8fafc' }}>
|
||
{['순서','내용','노출','수정','삭제'].map(h=>(
|
||
<th key={h} style={{ padding:'8px 12px', textAlign:'left', color:'#475569', fontWeight:600, borderBottom:'1px solid #f1f5f9' }}>{h}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.sort((a,b)=>a.sortOrder-b.sortOrder).map(h=>(
|
||
<tr key={h.id} style={{ borderTop:'1px solid #f8fafc' }}>
|
||
<td style={{ padding:'8px 12px', color:'#94a3b8', width:50 }}>{h.sortOrder}</td>
|
||
<td style={{ padding:'8px 12px', color: h.visible ? '#1e293b' : '#94a3b8' }}>
|
||
{!h.visible && <span style={{ fontSize:10, background:'#f1f5f9', color:'#94a3b8', borderRadius:4, padding:'1px 6px', marginRight:6 }}>숨김</span>}
|
||
{h.content}
|
||
</td>
|
||
<td style={{ padding:'8px 12px', width:60 }}>
|
||
<button onClick={()=>toggleVisible(h)} style={{
|
||
padding:'3px 10px', borderRadius:12, border:'none', cursor:'pointer', fontSize:11, fontWeight:600,
|
||
background: h.visible ? '#dcfce7' : '#f1f5f9',
|
||
color: h.visible ? '#16a34a' : '#94a3b8',
|
||
}}>{h.visible ? '공개' : '숨김'}</button>
|
||
</td>
|
||
<td style={{ padding:'8px 12px', width:60 }}>
|
||
<button onClick={()=>openEdit(h)} style={btnSmall('#4f6ef7')}>수정</button>
|
||
</td>
|
||
<td style={{ padding:'8px 12px', width:60 }}>
|
||
<button onClick={()=>del(h.id)} style={btnSmall('#dc2626')}>삭제</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
))}
|
||
|
||
{Object.keys(grouped).length === 0 && (
|
||
<div style={{ textAlign:'center', padding:48, color:'#94a3b8' }}>
|
||
{search ? '검색 결과 없음' : '등록된 연혁이 없습니다.'}
|
||
</div>
|
||
)}
|
||
|
||
{/* 모달 */}
|
||
{modal && (
|
||
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,.4)', zIndex:1000, display:'flex', alignItems:'center', justifyContent:'center' }}>
|
||
<div style={{ background:'#fff', borderRadius:12, width:520, maxHeight:'90vh', overflowY:'auto', padding:28 }}>
|
||
<h3 style={{ margin:'0 0 20px', fontSize:18, fontWeight:800 }}>
|
||
{modal === 'create' ? '연혁 추가' : '연혁 수정'}
|
||
</h3>
|
||
<div style={{ display:'flex', flexDirection:'column', gap:14 }}>
|
||
<label style={labelStyle}>
|
||
연도 <span style={{ color:'#dc2626' }}>*</span>
|
||
<input value={form.year}
|
||
onChange={e=>setForm(p=>({...p, year:e.target.value}))}
|
||
placeholder="예: 2026 또는 2020–2021"
|
||
style={inputStyle} />
|
||
<small style={{ color:'#94a3b8' }}>범위 표시 예시: 2020–2021 (em dash 사용)</small>
|
||
</label>
|
||
<label style={labelStyle}>
|
||
내용 <span style={{ color:'#dc2626' }}>*</span>
|
||
<textarea value={form.content}
|
||
onChange={e=>setForm(p=>({...p, content:e.target.value}))}
|
||
placeholder="연혁 내용을 입력하세요"
|
||
rows={3} style={{ ...inputStyle, resize:'vertical' }} />
|
||
</label>
|
||
<label style={labelStyle}>
|
||
순서 (같은 연도 내)
|
||
<input type="number" value={form.sortOrder} min={0}
|
||
onChange={e=>setForm(p=>({...p, sortOrder:+e.target.value}))}
|
||
style={{ ...inputStyle, width:120 }} />
|
||
</label>
|
||
<label style={{ display:'flex', alignItems:'center', gap:8, fontSize:13, cursor:'pointer' }}>
|
||
<input type="checkbox" checked={form.visible}
|
||
onChange={e=>setForm(p=>({...p, visible:e.target.checked}))} />
|
||
홈페이지에 공개
|
||
</label>
|
||
</div>
|
||
<div style={{ display:'flex', gap:10, marginTop:24, justifyContent:'flex-end' }}>
|
||
<button onClick={closeModal} style={btnStyle('#64748b')}>취소</button>
|
||
<button onClick={save} disabled={saving} style={btnStyle('#4f6ef7')}>
|
||
{saving ? '저장 중...' : '저장'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|
||
|
||
const btnStyle = color => ({
|
||
padding:'7px 16px', background:color, color:'#fff',
|
||
border:'none', borderRadius:6, cursor:'pointer', fontSize:13, fontWeight:600,
|
||
});
|
||
const btnSmall = color => ({
|
||
padding:'3px 10px', background:color, color:'#fff',
|
||
border:'none', borderRadius:4, cursor:'pointer', fontSize:11, fontWeight:600,
|
||
});
|
||
const labelStyle = { display:'flex', flexDirection:'column', gap:4, fontSize:13, fontWeight:600, color:'#475569' };
|
||
const inputStyle = { padding:'8px 10px', border:'1px solid #cbd5e1', borderRadius:6, fontSize:13, outline:'none', marginTop:2 };
|