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:
parent
64921e44e8
commit
4137e3ec90
@ -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)"},
|
||||||
|
{"2020–2021", 0, "현대백화점 HKOS 시스템 개발/구축 (PM)"},
|
||||||
|
{"2020–2021", 1, "서울시립대 대학행정정보시스템 성능 개선 (PL)"},
|
||||||
|
{"2020–2021", 2, "농협 하나로마트 ESL 시스템 구축 (PM)"},
|
||||||
|
{"2018–2019", 0, "이마트 정산시스템 프로젝트 (DA)"},
|
||||||
|
{"2018–2019", 1, "우체국금융 스마트ATM 도입 (PMO)"},
|
||||||
|
{"2018–2019", 2, "현대백화점 무인POS시스템 구축 (PM)"},
|
||||||
|
{"2018–2019", 3, "갤러리아백화점 PDA 정산시스템 (PM)"},
|
||||||
|
{"2015–2017", 0, "LG U+ VAN 고도화 — 승인시스템 개발 FEP/AP/BEP (AA)"},
|
||||||
|
{"2015–2017", 1, "한화그룹 4사 통합 HR시스템 구축 (PL)"},
|
||||||
|
{"2015–2017", 2, "참좋은여행 콜센터 어플리케이션 구축 (PL)"},
|
||||||
|
{"2013–2014", 0, "삼성전자 품질관리시스템(QWINGS) 구축 (PM)"},
|
||||||
|
{"2013–2014", 1, "대우증권 통합인프라시스템 (DBA)"},
|
||||||
|
{"2013–2014", 2, "현대캐피탈 차세대시스템 (PL)"},
|
||||||
|
{"2013–2014", 3, "중소기업 1357 통합콜센터 구축 (PL)"},
|
||||||
|
{"2010–2012", 0, "삼성전자서비스 eZone 갱신 (PL)"},
|
||||||
|
{"2010–2012", 1, "현대모비스 원가관리시스템 (DBA)"},
|
||||||
|
{"2010–2012", 2, "한국전기안전공사 전기안전포털시스템 (DBA)"},
|
||||||
|
{"2008–2009", 0, "국민은행 차세대 포탈 구축 (PL)"},
|
||||||
|
{"2008–2009", 1, "한국원자력연료 인사정보(HMS)시스템 (DBA)"},
|
||||||
|
{"2008–2009", 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 솔루션 정보 ───────────────────────────────────────
|
||||||
|
|||||||
@ -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", "2020–2021") */
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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 />} />
|
||||||
|
|||||||
@ -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: '2020–2021', items: [
|
|
||||||
'현대백화점 HKOS 시스템 개발/구축 (PM)',
|
|
||||||
'서울시립대 대학행정정보시스템 성능 개선 (PL)',
|
|
||||||
'농협 하나로마트 ESL 시스템 구축 (PM)',
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
year: '2018–2019', items: [
|
|
||||||
'이마트 정산시스템 프로젝트 (DA)',
|
|
||||||
'우체국금융 스마트ATM 도입 (PMO)',
|
|
||||||
'현대백화점 무인POS시스템 구축 (PM)',
|
|
||||||
'갤러리아백화점 PDA 정산시스템 (PM)',
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
year: '2015–2017', items: [
|
|
||||||
'LG U+ VAN 고도화 — 승인시스템 개발 FEP/AP/BEP (AA)',
|
|
||||||
'한화그룹 4사 통합 HR시스템 구축 (PL)',
|
|
||||||
'참좋은여행 콜센터 어플리케이션 구축 (PL)',
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
year: '2013–2014', items: [
|
|
||||||
'삼성전자 품질관리시스템(QWINGS) 구축 (PM)',
|
|
||||||
'대우증권 통합인프라시스템 (DBA)',
|
|
||||||
'현대캐피탈 차세대시스템 (PL)',
|
|
||||||
'중소기업 1357 통합콜센터 구축 (PL)',
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
year: '2010–2012', items: [
|
|
||||||
'삼성전자서비스 eZone 갱신 (PL)',
|
|
||||||
'현대모비스 원가관리시스템 (DBA)',
|
|
||||||
'한국전기안전공사 전기안전포털시스템 (DBA)',
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
year: '2008–2009', 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>
|
||||||
|
|||||||
203
frontend/src/pages/admin/AdminHistory.jsx
Normal file
203
frontend/src/pages/admin/AdminHistory.jsx
Normal 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 또는 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 };
|
||||||
@ -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' },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user