zioinfo-web/frontend/src/pages/admin/AdminHistory.jsx
DESKTOP-TKLFCPRython 4137e3ec90 feat(history): company history DB management + admin CRUD
- 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>
2026-05-31 17:52:02 +09:00

204 lines
9.6 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 = { 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 또는 20202021"
style={inputStyle} />
<small style={{ color:'#94a3b8' }}>범위 표시 예시: 20202021 (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 };