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>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-05-31 17:52:02 +09:00
parent 1c1411f9cb
commit 8d1168f50c
9 changed files with 428 additions and 115 deletions

View File

@ -15,6 +15,7 @@ public class DataInitializer implements CommandLineRunner {
private final NewsRepository newsRepo; private final NewsRepository newsRepo;
private final AdminUserRepository adminUserRepo; private final AdminUserRepository adminUserRepo;
private final RecruitRepository recruitRepo; private final RecruitRepository recruitRepo;
private final CompanyHistoryRepository historyRepo;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
@Override @Override
@ -22,6 +23,7 @@ public class DataInitializer implements CommandLineRunner {
initAdmin(); initAdmin();
initNews(); initNews();
initRecruits(); initRecruits();
initHistory();
} }
private void initAdmin() { private void initAdmin() {
@ -94,4 +96,55 @@ public class DataInitializer implements CommandLineRunner {
.preferred("- 공공기관 정보보호 인증 자격증 (정보처리기사 등)\n- Kubernetes 경험") .preferred("- 공공기관 정보보호 인증 자격증 (정보처리기사 등)\n- Kubernetes 경험")
.deadline(LocalDate.of(2026, 7, 31)).headcount(1).active(true).build()); .deadline(LocalDate.of(2026, 7, 31)).headcount(1).active(true).build());
} }
private void initHistory() {
if (historyRepo.count() > 0) return;
Object[][] data = {
{"2026", 0, "GUARDiA ITSM v2.0 출시 — AI ChatOps 오케스트레이션 플랫폼"},
{"2026", 1, "GS인증 1등급 신청 준비 완료 (TTA 심사 예정)"},
{"2026", 2, "공공기관 1,000개 이상 멀티테넌트 지원 목표 달성"},
{"2025", 0, "삼성전자 차세대 CRM 구축 (DB Migration / DA / 튜닝)"},
{"2025", 1, "GUARDiA ITSM v1.0 베타 서비스 개시"},
{"2025", 2, "AI 기반 인프라 자동화 특허 출원"},
{"2024", 0, "DELL 차세대 CRM 구축 — DBA 역할 수행 (엠로)"},
{"2024", 1, "소상공인컨설팅시스템 구축 (서울신용보증재단, PM)"},
{"2024", 2, "국민연금 차세대 시스템 구축 (AA)"},
{"2023", 0, "헌법재판소 포털시스템 구축 (PM)"},
{"2023", 1, "서울신용보증재단 모바일앱 구축 완료 (PM)"},
{"2022", 0, "에이텍에이피 통합유지보수관리시스템 개발 (PM)"},
{"2022", 1, "헌법재판소 통합보안관제시스템 구축 (PM)"},
{"20202021", 0, "현대백화점 HKOS 시스템 개발/구축 (PM)"},
{"20202021", 1, "서울시립대 대학행정정보시스템 성능 개선 (PL)"},
{"20202021", 2, "농협 하나로마트 ESL 시스템 구축 (PM)"},
{"20182019", 0, "이마트 정산시스템 프로젝트 (DA)"},
{"20182019", 1, "우체국금융 스마트ATM 도입 (PMO)"},
{"20182019", 2, "현대백화점 무인POS시스템 구축 (PM)"},
{"20182019", 3, "갤러리아백화점 PDA 정산시스템 (PM)"},
{"20152017", 0, "LG U+ VAN 고도화 — 승인시스템 개발 FEP/AP/BEP (AA)"},
{"20152017", 1, "한화그룹 4사 통합 HR시스템 구축 (PL)"},
{"20152017", 2, "참좋은여행 콜센터 어플리케이션 구축 (PL)"},
{"20132014", 0, "삼성전자 품질관리시스템(QWINGS) 구축 (PM)"},
{"20132014", 1, "대우증권 통합인프라시스템 (DBA)"},
{"20132014", 2, "현대캐피탈 차세대시스템 (PL)"},
{"20132014", 3, "중소기업 1357 통합콜센터 구축 (PL)"},
{"20102012", 0, "삼성전자서비스 eZone 갱신 (PL)"},
{"20102012", 1, "현대모비스 원가관리시스템 (DBA)"},
{"20102012", 2, "한국전기안전공사 전기안전포털시스템 (DBA)"},
{"20082009", 0, "국민은행 차세대 포탈 구축 (PL)"},
{"20082009", 1, "한국원자력연료 인사정보(HMS)시스템 (DBA)"},
{"20082009", 2, "한국전기안전공사 안전점검 고도화 (DBA)"},
{"2000", 0, "(주)지오정보기술 창립"},
{"2000", 1, "공공기관 IT 인프라 서비스 개시"},
};
for (Object[] row : data) {
historyRepo.save(CompanyHistory.builder()
.year((String) row[0])
.sortOrder((Integer) row[1])
.content((String) row[2])
.visible(true)
.build());
}
}
} }

View File

@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/admin") @RequestMapping("/api/admin")
@ -20,6 +21,7 @@ public class AdminController {
private final InquiryRepository inquiryRepo; private final InquiryRepository inquiryRepo;
private final RecruitRepository recruitRepo; private final RecruitRepository recruitRepo;
private final MemberRepository memberRepo; private final MemberRepository memberRepo;
private final CompanyHistoryRepository historyRepo;
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
@ -55,6 +57,52 @@ public class AdminController {
return ResponseEntity.ok(stats); return ResponseEntity.ok(stats);
} }
// 연혁 관리 (CRUD)
@GetMapping("/history")
public ResponseEntity<List<CompanyHistory>> adminHistory() {
return ResponseEntity.ok(historyRepo.findAllByOrderByYearDescSortOrderAsc());
}
@PostMapping("/history")
public ResponseEntity<CompanyHistory> createHistory(@RequestBody CompanyHistory h) {
h.setId(null);
return ResponseEntity.ok(historyRepo.save(h));
}
@PutMapping("/history/{id}")
public ResponseEntity<CompanyHistory> updateHistory(
@PathVariable Long id, @RequestBody CompanyHistory body) {
return historyRepo.findById(id).map(h -> {
h.setYear(body.getYear());
h.setContent(body.getContent());
h.setSortOrder(body.getSortOrder());
h.setVisible(body.isVisible());
return ResponseEntity.ok(historyRepo.save(h));
}).orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/history/{id}")
public ResponseEntity<Void> deleteHistory(@PathVariable Long id) {
historyRepo.deleteById(id);
return ResponseEntity.noContent().build();
}
/** 연도별 그룹 조회 (프론트 미리보기용) */
@GetMapping("/history/grouped")
public ResponseEntity<List<Map<String, Object>>> adminHistoryGrouped() {
List<CompanyHistory> rows = historyRepo.findAllByOrderByYearDescSortOrderAsc();
Map<String, List<Map<String, Object>>> grouped = new LinkedHashMap<>();
for (CompanyHistory h : rows) {
grouped.computeIfAbsent(h.getYear(), k -> new ArrayList<>())
.add(Map.of("id", h.getId(), "content", h.getContent(),
"sortOrder", h.getSortOrder(), "visible", h.isVisible()));
}
List<Map<String, Object>> result = grouped.entrySet().stream()
.map(e -> Map.of("year", (Object)e.getKey(), "items", e.getValue()))
.collect(Collectors.toList());
return ResponseEntity.ok(result);
}
// 뉴스 관리 // 뉴스 관리
@GetMapping("/news") @GetMapping("/news")
public ResponseEntity<Page<News>> adminNews( public ResponseEntity<Page<News>> adminNews(

View File

@ -1,7 +1,9 @@
package kr.co.zioinfo.web.controller; package kr.co.zioinfo.web.controller;
import kr.co.zioinfo.web.model.CompanyHistory;
import kr.co.zioinfo.web.model.Inquiry; import kr.co.zioinfo.web.model.Inquiry;
import kr.co.zioinfo.web.model.News; import kr.co.zioinfo.web.model.News;
import kr.co.zioinfo.web.repository.CompanyHistoryRepository;
import kr.co.zioinfo.web.repository.RecruitRepository; import kr.co.zioinfo.web.repository.RecruitRepository;
import kr.co.zioinfo.web.service.InquiryService; import kr.co.zioinfo.web.service.InquiryService;
import kr.co.zioinfo.web.service.NewsService; import kr.co.zioinfo.web.service.NewsService;
@ -20,6 +22,7 @@ public class ApiController {
private final NewsService newsService; private final NewsService newsService;
private final InquiryService inquiryService; private final InquiryService inquiryService;
private final RecruitRepository recruitRepo; private final RecruitRepository recruitRepo;
private final CompanyHistoryRepository historyRepo;
// 회사 정보 // 회사 정보
@GetMapping("/company") @GetMapping("/company")
@ -40,33 +43,25 @@ public class ApiController {
return ResponseEntity.ok(info); return ResponseEntity.ok(info);
} }
// 연혁 // 연혁 (DB 기반)
@GetMapping("/history") @GetMapping("/history")
public ResponseEntity<List<Map<String, Object>>> getHistory() { public ResponseEntity<List<Map<String, Object>>> getHistory() {
List<Map<String, Object>> history = new ArrayList<>(); List<CompanyHistory> rows = historyRepo.findByVisibleTrueOrderByYearDescSortOrderAsc();
history.add(Map.of("year", "2026", "events", List.of(
"GUARDiA ITSM v2.0 출시 (AI 자율 운영 플랫폼)", // 연도별 그룹핑
"공공기관 AI 인프라 자동화 사업 수주" Map<String, List<String>> grouped = new LinkedHashMap<>();
))); for (CompanyHistory h : rows) {
history.add(Map.of("year", "2024", "events", List.of( grouped.computeIfAbsent(h.getYear(), k -> new ArrayList<>()).add(h.getContent());
"GUARDiA ITSM v1.0 개발 완료", }
"관공서 레거시 인프라 자동화 특허 출원"
))); List<Map<String, Object>> result = new ArrayList<>();
history.add(Map.of("year", "2022", "events", List.of( for (Map.Entry<String, List<String>> e : grouped.entrySet()) {
"AI 기반 ChatOps 플랫폼 연구 개발 착수", Map<String, Object> m = new LinkedHashMap<>();
"행정기관 SI 사업 10건 수주" m.put("year", e.getKey());
))); m.put("items", e.getValue());
history.add(Map.of("year", "2020", "events", List.of( result.add(m);
"창립 20주년", }
"클라우드 전환 컨설팅 사업 진출" return ResponseEntity.ok(result);
)));
history.add(Map.of("year", "2010", "events", List.of(
"ERP·CRM 솔루션 공급 100개사 달성"
)));
history.add(Map.of("year", "2000", "events", List.of(
"(주)지오정보기술 설립"
)));
return ResponseEntity.ok(history);
} }
// GUARDiA 솔루션 정보 // GUARDiA 솔루션 정보

View File

@ -0,0 +1,38 @@
package kr.co.zioinfo.web.model;
import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Entity @Table(name = "company_history")
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class CompanyHistory {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 연도 또는 기간 표시 (예: "2026", "20202021") */
@Column(nullable = false, length = 20)
private String year;
/** 항목 내용 */
@Column(nullable = false, length = 500)
private String content;
/** 같은 연도 내 순서 */
@Column(name = "sort_order")
private int sortOrder = 0;
/** 노출 여부 */
private boolean visible = true;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,21 @@
package kr.co.zioinfo.web.repository;
import kr.co.zioinfo.web.model.CompanyHistory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface CompanyHistoryRepository extends JpaRepository<CompanyHistory, Long> {
/** 공개 연혁만, 연도 역순 → 순서 오름차순 */
List<CompanyHistory> findByVisibleTrueOrderByYearDescSortOrderAsc();
/** 관리자용 전체 목록 */
List<CompanyHistory> findAllByOrderByYearDescSortOrderAsc();
/** 연도별 항목 수 */
long countByYear(String year);
/** 특정 연도 삭제 */
void deleteByYear(String year);
}

View File

@ -29,6 +29,7 @@ const AdminInquiry = lazy(() => import('./pages/admin/AdminInquiry'));
const AdminRecruit = lazy(() => import('./pages/admin/AdminRecruit')); const AdminRecruit = lazy(() => import('./pages/admin/AdminRecruit'));
const AdminSettings = lazy(() => import('./pages/admin/AdminSettings')); const AdminSettings = lazy(() => import('./pages/admin/AdminSettings'));
const AdminMember = lazy(() => import('./pages/admin/AdminMember')); const AdminMember = lazy(() => import('./pages/admin/AdminMember'));
const AdminHistory = lazy(() => import('./pages/admin/AdminHistory'));
function Loading() { function Loading() {
return ( return (
@ -65,6 +66,7 @@ export default function App() {
<Route path="inquiries" element={<AdminInquiry />} /> <Route path="inquiries" element={<AdminInquiry />} />
<Route path="recruit" element={<AdminRecruit />} /> <Route path="recruit" element={<AdminRecruit />} />
<Route path="members" element={<AdminMember />} /> <Route path="members" element={<AdminMember />} />
<Route path="history" element={<AdminHistory />} />
<Route path="settings" element={<AdminSettings />} /> <Route path="settings" element={<AdminSettings />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/admin/login" replace />} /> <Route path="*" element={<Navigate to="/admin/login" replace />} />

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { Routes, Route, NavLink, useNavigate } from 'react-router-dom'; import { Routes, Route, NavLink, useNavigate } from 'react-router-dom';
import './Common.css'; import './Common.css';
import './Company.css'; import './Company.css';
@ -97,83 +97,25 @@ function Greeting() {
); );
} }
/* ── 연혁 ── */ /* ── 연혁 (API에서 로드) ── */
const HISTORY = [ function useHistory() {
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/history')
.then(r => r.json())
.then(data => setHistory(data))
.catch(() => setHistory([]))
.finally(() => setLoading(false));
}, []);
return { history, loading };
}
/* ── 연혁 (정적 폴백 — API 실패 시) ── */
const HISTORY_FALLBACK = [
{ {
year: '2026', items: [ year: '2026', items: [
'GUARDiA ITSM v2.0 출시 — AI ChatOps 오케스트레이션 플랫폼', 'GUARDiA ITSM v2.0 출시 — AI ChatOps 오케스트레이션 플랫폼',
'GS인증 1등급 신청 준비 완료 (TTA 심사 예정)',
'공공기관 1,000개 이상 멀티테넌트 지원 목표 달성',
]
},
{
year: '2025', items: [
'삼성전자 차세대 CRM 구축 (DB Migration / DA / 튜닝)',
'GUARDiA ITSM v1.0 베타 서비스 개시',
'AI 기반 인프라 자동화 특허 출원',
]
},
{
year: '2024', items: [
'DELL 차세대 CRM 구축 — DBA 역할 수행 (엠로)',
'소상공인컨설팅시스템 구축 (서울신용보증재단, PM)',
'국민연금 차세대 시스템 구축 (AA)',
]
},
{
year: '2023', items: [
'헌법재판소 포털시스템 구축 (PM)',
'서울신용보증재단 모바일앱 구축 완료 (PM)',
]
},
{
year: '2022', items: [
'에이텍에이피 통합유지보수관리시스템 개발 (PM)',
'헌법재판소 통합보안관제시스템 구축 (PM)',
]
},
{
year: '20202021', items: [
'현대백화점 HKOS 시스템 개발/구축 (PM)',
'서울시립대 대학행정정보시스템 성능 개선 (PL)',
'농협 하나로마트 ESL 시스템 구축 (PM)',
]
},
{
year: '20182019', items: [
'이마트 정산시스템 프로젝트 (DA)',
'우체국금융 스마트ATM 도입 (PMO)',
'현대백화점 무인POS시스템 구축 (PM)',
'갤러리아백화점 PDA 정산시스템 (PM)',
]
},
{
year: '20152017', items: [
'LG U+ VAN 고도화 — 승인시스템 개발 FEP/AP/BEP (AA)',
'한화그룹 4사 통합 HR시스템 구축 (PL)',
'참좋은여행 콜센터 어플리케이션 구축 (PL)',
]
},
{
year: '20132014', items: [
'삼성전자 품질관리시스템(QWINGS) 구축 (PM)',
'대우증권 통합인프라시스템 (DBA)',
'현대캐피탈 차세대시스템 (PL)',
'중소기업 1357 통합콜센터 구축 (PL)',
]
},
{
year: '20102012', items: [
'삼성전자서비스 eZone 갱신 (PL)',
'현대모비스 원가관리시스템 (DBA)',
'한국전기안전공사 전기안전포털시스템 (DBA)',
]
},
{
year: '20082009', items: [
'국민은행 차세대 포탈 구축 (PL)',
'한국원자력연료 인사정보(HMS)시스템 (DBA)',
'한국전기안전공사 안전점검 고도화 (DBA)',
] ]
}, },
{ {
@ -183,8 +125,12 @@ const HISTORY = [
] ]
}, },
]; ];
const HISTORY = HISTORY_FALLBACK;
function History() { function History() {
const { history, loading } = useHistory();
const data = history.length > 0 ? history : HISTORY_FALLBACK;
return ( return (
<main id="main-content" className="inner-page"> <main id="main-content" className="inner-page">
<SubNav title="연혁" /> <SubNav title="연혁" />
@ -195,22 +141,28 @@ function History() {
<h2 className="section-title">20+ 성장의 역사</h2> <h2 className="section-title">20+ 성장의 역사</h2>
<p className="section-desc">2000 창립 이래 국내 주요 기관·기업과 함께 성장해 왔습니다</p> <p className="section-desc">2000 창립 이래 국내 주요 기관·기업과 함께 성장해 왔습니다</p>
</div> </div>
<div className="timeline"> {loading ? (
{HISTORY.map((h, i) => ( <div style={{ textAlign: 'center', padding: '40px', color: 'var(--gray-400)' }}>
<div key={i} className="timeline-row"> 연혁을 불러오는 ...
<div className="timeline-year">{h.year}</div> </div>
<div className="timeline-dot" /> ) : (
<div className="timeline-content"> <div className="timeline">
{h.items.map((item, j) => ( {data.map((h, i) => (
<div key={j} className="timeline-item"> <div key={i} className="timeline-row">
<span className="timeline-bullet" /> <div className="timeline-year">{h.year}</div>
{item} <div className="timeline-dot" />
</div> <div className="timeline-content">
))} {h.items.map((item, j) => (
<div key={j} className="timeline-item">
<span className="timeline-bullet" />
{item}
</div>
))}
</div>
</div> </div>
</div> ))}
))} </div>
</div> )}
</div> </div>
</section> </section>
</main> </main>

View File

@ -0,0 +1,203 @@
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 };

View File

@ -7,6 +7,7 @@ const NAV = [
{ path: '/admin/dashboard', icon: '📊', label: '대시보드' }, { path: '/admin/dashboard', icon: '📊', label: '대시보드' },
{ section: '콘텐츠 관리' }, { section: '콘텐츠 관리' },
{ path: '/admin/news', icon: '📰', label: '뉴스/공지사항' }, { path: '/admin/news', icon: '📰', label: '뉴스/공지사항' },
{ path: '/admin/history', icon: '📅', label: '회사 연혁' },
{ path: '/admin/recruit', icon: '👥', label: '채용공고' }, { path: '/admin/recruit', icon: '👥', label: '채용공고' },
{ section: '고객 관리' }, { section: '고객 관리' },
{ path: '/admin/inquiries', icon: '📩', label: '문의 관리', badgeKey: 'pendingInquiries' }, { path: '/admin/inquiries', icon: '📩', label: '문의 관리', badgeKey: 'pendingInquiries' },