feat(cms): 뉴스룸/기술블로그/공지사항/FAQ 동적 DB 전환 [auto-sync]

This commit is contained in:
GUARDiA AutoDeploy 2026-06-03 19:42:54 +09:00 committed by DESKTOP-TKLFCPR\ython
parent db58aa26ba
commit f00bdf5943
11 changed files with 433 additions and 189 deletions

View File

@ -16,6 +16,7 @@ public class DataInitializer implements CommandLineRunner {
private final AdminUserRepository adminUserRepo;
private final RecruitRepository recruitRepo;
private final CompanyHistoryRepository historyRepo;
private final FaqRepository faqRepo;
private final PasswordEncoder passwordEncoder;
@Override
@ -24,6 +25,7 @@ public class DataInitializer implements CommandLineRunner {
initNews();
initRecruits();
initHistory();
initFaq();
}
private void initAdmin() {
@ -147,4 +149,39 @@ public class DataInitializer implements CommandLineRunner {
.build());
}
}
private void initFaq() {
if (faqRepo.count() > 0) return;
Object[][] faqs = {
{"GUARDiA ITSM", "GUARDiA ITSM은 어떤 제품인가요?",
"GUARDiA ITSM은 메신저 한 줄 명령으로 1,000개 이상 공공기관의 레거시 IT 인프라를 자동 운영하는 AI 기반 ChatOps 플랫폼입니다. 대상 서버에 별도 소프트웨어 설치 없이 표준 SSH/SFTP 프로토콜만으로 배포·운영·모니터링을 자동화합니다.", 1},
{"GUARDiA ITSM", "서버에 에이전트를 설치해야 하나요?",
"아니요. GUARDiA ITSM은 에이전트리스(Agentless) 방식으로 동작합니다. 대상 서버에 어떠한 소프트웨어도 설치할 필요가 없으며, 표준 SSH(22번 포트)만 열려 있으면 즉시 연동 가능합니다.", 2},
{"GUARDiA ITSM", "클라우드 없이 사용할 수 있나요?",
"예. GUARDiA ITSM은 완전한 온프레미스(On-premise) 솔루션으로, 외부 클라우드나 인터넷 연결 없이 폐쇄망 환경에서도 100% 동작합니다. AI 엔진(Ollama)도 내부 서버에서 구동됩니다.", 3},
{"GUARDiA ITSM", "지원되는 운영체제는 무엇인가요?",
"GUARDiA ITSM 서버: Ubuntu 20.04+, CentOS 7+, RHEL 8+, Windows Server 2019+를 지원합니다. 관리 대상 서버: SSH가 지원되는 모든 Linux/Unix/Windows Server 환경에서 사용 가능합니다.", 4},
{"도입·계약", "도입 비용은 어떻게 되나요?",
"기관 규모와 관리 서버 수에 따라 맞춤 견적을 제공합니다. 7일 무료 체험판을 먼저 신청하신 후 문의 주시면 상세한 견적을 안내해 드립니다.", 1},
{"도입·계약", "체험판을 사용할 수 있나요?",
"예. 7일 무료 체험판을 제공합니다. 문의하기 또는 GUARDiA 페이지의 '무료 데모 신청' 버튼을 통해 신청하시면 영업일 기준 1일 이내에 안내 드립니다.", 2},
{"도입·계약", "공공기관 나라장터 조달 구매가 가능한가요?",
"예. GUARDiA ITSM은 조달청 나라장터 등록을 준비 중이며, 공공기관 입찰을 통한 구매를 지원합니다. 자세한 사항은 영업팀(031-483-1766)에 문의해 주십시오.", 3},
{"기술 지원", "기술 지원은 어떻게 받을 수 있나요?",
"이메일(support@zioinfo.co.kr), 전화(031-483-1766), GUARDiA ITSM 내 챗봇을 통해 기술 지원을 제공합니다. 운영 중 긴급 장애는 24시간 온콜 지원이 가능합니다.", 1},
{"기술 지원", "업그레이드는 어떻게 진행되나요?",
"정기 업데이트는 연 2~4회 제공되며, 보안 패치는 즉시 제공됩니다. 업그레이드는 GUARDiA 내 자동 배포 기능을 통해 다운타임 없이 진행할 수 있습니다.", 2},
};
for (Object[] f : faqs) {
faqRepo.save(Faq.builder()
.category((String) f[0])
.question((String) f[1])
.answer((String) f[2])
.orderNum((Integer) f[3])
.visible(true)
.build());
}
}
}

View File

@ -22,6 +22,7 @@ public class AdminController {
private final RecruitRepository recruitRepo;
private final MemberRepository memberRepo;
private final CompanyHistoryRepository historyRepo;
private final FaqRepository faqRepo;
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
@ -147,6 +148,38 @@ public class AdminController {
}).orElse(ResponseEntity.notFound().build());
}
// FAQ 관리
@GetMapping("/faq")
public ResponseEntity<?> adminFaqList() {
return ResponseEntity.ok(faqRepo.findAllByOrderByCategoryAscOrderNumAsc());
}
@PostMapping("/faq")
public ResponseEntity<?> createFaq(@RequestBody kr.co.zioinfo.web.model.Faq faq) {
faq.setId(null); faq.setCreatedAt(null);
return ResponseEntity.ok(faqRepo.save(faq));
}
@PutMapping("/faq/{id}")
public ResponseEntity<?> updateFaq(@PathVariable Long id,
@RequestBody kr.co.zioinfo.web.model.Faq body) {
return faqRepo.findById(id).map(f -> {
f.setCategory(body.getCategory());
f.setQuestion(body.getQuestion());
f.setAnswer(body.getAnswer());
f.setOrderNum(body.getOrderNum());
f.setVisible(body.isVisible());
return ResponseEntity.ok(faqRepo.save(f));
}).orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/faq/{id}")
public ResponseEntity<Void> deleteFaq(@PathVariable Long id) {
if (!faqRepo.existsById(id)) return ResponseEntity.notFound().build();
faqRepo.deleteById(id);
return ResponseEntity.noContent().build();
}
// 문의 관리
@GetMapping("/inquiries")
public ResponseEntity<Page<Inquiry>> adminInquiries(

View File

@ -1,9 +1,11 @@
package kr.co.zioinfo.web.controller;
import kr.co.zioinfo.web.model.CompanyHistory;
import kr.co.zioinfo.web.model.Faq;
import kr.co.zioinfo.web.model.Inquiry;
import kr.co.zioinfo.web.model.News;
import kr.co.zioinfo.web.repository.CompanyHistoryRepository;
import kr.co.zioinfo.web.repository.FaqRepository;
import kr.co.zioinfo.web.repository.RecruitRepository;
import kr.co.zioinfo.web.service.InquiryService;
import kr.co.zioinfo.web.service.NewsService;
@ -23,6 +25,7 @@ public class ApiController {
private final InquiryService inquiryService;
private final RecruitRepository recruitRepo;
private final CompanyHistoryRepository historyRepo;
private final FaqRepository faqRepo;
// 회사 정보
@GetMapping("/company")
@ -138,6 +141,21 @@ public class ApiController {
return ResponseEntity.ok(recruitRepo.findByActiveTrueOrderByCreatedAtDesc());
}
// FAQ (공개)
@GetMapping("/faq")
public ResponseEntity<?> getFaq() {
var items = faqRepo.findByVisibleTrueOrderByOrderNumAscCategoryAscCreatedAtAsc();
// category별 그룹핑
var grouped = new java.util.LinkedHashMap<String, java.util.List<Map<String,Object>>>();
for (var f : items) {
grouped.computeIfAbsent(f.getCategory(), k -> new java.util.ArrayList<>())
.add(Map.of("id", f.getId(), "q", f.getQuestion(), "a", f.getAnswer()));
}
var result = new java.util.ArrayList<>();
grouped.forEach((cat, list) -> result.add(Map.of("cat", cat, "items", list)));
return ResponseEntity.ok(result);
}
// 메뉴 구조
@GetMapping("/menu")
public ResponseEntity<List<Map<String, Object>>> getMenu() {

View File

@ -0,0 +1,31 @@
package kr.co.zioinfo.web.model;
import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Entity @Table(name = "faq")
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class Faq {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String category; // GUARDiA ITSM / 도입·계약 / 기술 지원
@Column(nullable = false, length = 300)
private String question;
@Column(nullable = false, columnDefinition = "TEXT")
private String answer;
private int orderNum = 0;
private boolean visible = true;
@CreatedDate
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,10 @@
package kr.co.zioinfo.web.repository;
import kr.co.zioinfo.web.model.Faq;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface FaqRepository extends JpaRepository<Faq, Long> {
List<Faq> findByVisibleTrueOrderByOrderNumAscCategoryAscCreatedAtAsc();
List<Faq> findAllByOrderByCategoryAscOrderNumAsc();
}

View File

@ -30,6 +30,7 @@ const AdminRecruit = lazy(() => import('./pages/admin/AdminRecruit'));
const AdminSettings = lazy(() => import('./pages/admin/AdminSettings'));
const AdminMember = lazy(() => import('./pages/admin/AdminMember'));
const AdminHistory = lazy(() => import('./pages/admin/AdminHistory'));
const AdminFAQ = lazy(() => import('./pages/admin/AdminFAQ'));
function Loading() {
return (
@ -66,6 +67,7 @@ export default function App() {
<Route path="inquiries" element={<AdminInquiry />} />
<Route path="recruit" element={<AdminRecruit />} />
<Route path="members" element={<AdminMember />} />
<Route path="faq" element={<AdminFAQ />} />
<Route path="history" element={<AdminHistory />} />
<Route path="settings" element={<AdminSettings />} />
</Route>

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Routes, Route, NavLink, Link } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Routes, Route, NavLink } from 'react-router-dom';
import './Common.css';
import './NewsPage.css';
@ -31,55 +31,37 @@ function SubNav({ title }) {
);
}
/* ── 뉴스룸 ── */
const NEWS = [
{
id:1, cat:'제품 출시', date:'2026.05.15',
title:'GUARDiA ITSM v2.0 정식 출시 — AI ChatOps 오케스트레이션 플랫폼',
summary:'메신저 한 줄 명령으로 1,000개+ 공공기관 레거시 인프라를 자동 운영하는 GUARDiA ITSM v2.0이 정식 출시되었습니다. 신규 기능으로 AI 자연어 명령, 에이전트리스 배포 엔진, 멀티테넌트 지원이 추가됐습니다.',
content: `GUARDiA ITSM v2.0은 공공기관의 레거시 IT 인프라 운영 자동화를 위한 AI 기반 플랫폼입니다.\n\n주요 신기능:\n- AI ChatOps: 메신저 자연어 명령 → Ollama LLM 파싱 → 자동 실행\n- 에이전트리스 배포: SSH/SFTP만으로 WAS 배포·롤백 자동화\n- 멀티테넌트: 1,000개+ 기관 동시 관리\n- GS인증 1등급 신청 완료\n\n자세한 사항은 GUARDiA 소개 페이지를 참조해 주십시오.`,
hot: true,
},
{
id:2, cat:'수주 소식', date:'2026.04.20',
title:'삼성전자 차세대 CRM 시스템 DB 마이그레이션 프로젝트 수주',
summary:'(주)지오정보기술이 삼성전자 차세대 CRM 구축 프로젝트의 DB Migration/DA/튜닝을 담당합니다. EDB PostgreSQL 환경으로의 전환을 포함한 대규모 DB 현대화 작업을 수행합니다.',
content: '삼성전자와의 두 번째 협력 프로젝트로, DB 마이그레이션 및 성능 튜닝을 담당합니다.',
hot: false,
},
{
id:3, cat:'기술 인증', date:'2026.03.10',
title:'GUARDiA ITSM GS인증 1등급 신청 완료 — TTA 심사 예정',
summary:'GUARDiA ITSM이 한국정보통신기술협회(TTA)에 GS인증 1등급을 신청하였습니다. 기능적합성, 신뢰성, 사용성, 보안성 등 ISO/IEC 25010 기준 8대 품질 특성 심사를 앞두고 있습니다.',
content: 'GS인증 심사는 2026년 9월 예정이며, 1등급 취득 시 조달청 나라장터 우선 등재가 가능합니다.',
hot: false,
},
{
id:4, cat:'수주 소식', date:'2026.02.15',
title:'국민연금공단 차세대 시스템 구축 — AA 역할 수행',
summary:'국민연금공단 차세대 시스템 구축 프로젝트에 Application Architect(AA)로 참여합니다. JSP/Java, Nexacro, Spring 기반의 대규모 공공기관 시스템 구축을 담당합니다.',
content: '국민연금관리공단의 차세대 시스템은 수천만 가입자의 연금 관리 시스템으로, CI/CD 파이프라인 기반의 현대적인 개발 환경을 구축합니다.',
hot: false,
},
{
id:5, cat:'기업 소식', date:'2025.12.01',
title:'2025년 사업실적 — 연간 프로젝트 10건 성공 수행',
summary:'2025년 한 해 동안 삼성전자, 서울신용보증재단, 헌법재판소 등 10개 주요 프로젝트를 성공적으로 완료했습니다. 매출은 전년 대비 25% 성장하였습니다.',
content: '창립 이래 최대 성과를 기록한 2025년 사업실적을 공유드립니다.',
hot: false,
},
{
id:6, cat:'파트너십', date:'2025.09.10',
title:'URP 공식 파트너사 등록 — 공공기관 SI/SM 프로젝트 솔루션 강화',
summary:'인프라 사업 1위 업체의 URP 공식 파트너사로 등록되었습니다. SI/SM프로젝트는 물론, 공공기관 시스템 현대화 사업을 공동으로 추진합니다.',
content: '공공기관의 시스템 유지보수 및 구축 비용 절감을 위한 AI 전환 프로젝트를 전문적으로 지원합니다.',
hot: false,
},
];
function useNews(category) {
const [news, setNews] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/news?category=${encodeURIComponent(category)}&size=20`)
.then(r => r.json())
.then(data => setNews(data.content ?? data))
.catch(() => setNews([]))
.finally(() => setLoading(false));
}, [category]);
return { news, loading };
}
function formatDate(dt) {
if (!dt) return '';
return String(dt).slice(0, 10).replace(/-/g, '.');
}
/* ── 뉴스룸 ── */
function Newsroom() {
const { news, loading } = useNews('뉴스룸');
const [selected, setSelected] = useState(null);
const item = NEWS.find(n => n.id === selected);
const item = news.find(n => n.id === selected);
if (loading) return (
<main className="inner-page">
<SubNav title="뉴스룸" />
<section className="section"><div className="container" style={{textAlign:'center',padding:'60px',color:'var(--gray-400)'}}>불러오는 ...</div></section>
</main>
);
return (
<main id="main-content" className="inner-page">
<SubNav title="뉴스룸" />
@ -89,33 +71,38 @@ function Newsroom() {
<div style={{ maxWidth: '760px', margin: '0 auto' }}>
<button className="notice-back" onClick={() => setSelected(null)}> 뉴스 목록</button>
<div className="news-detail card" style={{ padding: '40px' }}>
<span className="news-cat-badge" style={{ background: 'var(--primary-light)', color: 'var(--primary)' }}>{item.cat}</span>
<span className="news-cat-badge" style={{ background: 'var(--primary-light)', color: 'var(--primary)' }}>{item.category}</span>
<h2 style={{ fontSize: '24px', fontWeight: '900', margin: '16px 0 8px', lineHeight: '1.4' }}>{item.title}</h2>
<p style={{ fontSize: '13px', color: 'var(--gray-400)', marginBottom: '32px' }}>{item.date}</p>
<p style={{ fontSize: '13px', color: 'var(--gray-400)', marginBottom: '32px' }}>{formatDate(item.createdAt)}</p>
<div className="divider divider-left" style={{ marginBottom: '32px' }} />
{item.content.split('\n').map((p, i) => (
{(item.content || item.summary || '').split('\n').map((p, i) => (
p.trim() ? <p key={i} style={{ fontSize: '15px', color: 'var(--gray-700)', lineHeight: '1.85', marginBottom: '16px' }}>{p}</p> : null
))}
</div>
</div>
) : news.length === 0 ? (
<div style={{ textAlign: 'center', padding: '80px 0', color: 'var(--gray-400)' }}>
<p style={{ fontSize: '48px', marginBottom: '16px' }}>📰</p>
<p>등록된 뉴스가 없습니다.</p>
<p style={{ fontSize: '13px', marginTop: '8px' }}>관리자 페이지에서 뉴스를 추가해 주세요.</p>
</div>
) : (
<>
{/* 메인 뉴스 */}
<div className="news-main card" onClick={() => setSelected(NEWS[0].id)}>
<div className="news-main card" onClick={() => setSelected(news[0].id)} style={{ cursor: 'pointer' }}>
<div className="news-main-content">
<span className="news-cat-badge hot">🔥 {NEWS[0].cat}</span>
<h2 className="news-main-title">{NEWS[0].title}</h2>
<p className="news-main-summary">{NEWS[0].summary}</p>
<span className="news-date">{NEWS[0].date}</span>
<span className="news-cat-badge hot">🔥 {news[0].category}</span>
<h2 className="news-main-title">{news[0].title}</h2>
<p className="news-main-summary">{news[0].summary}</p>
<span className="news-date">{formatDate(news[0].createdAt)}</span>
</div>
</div>
<div className="grid-3" style={{ marginTop: '24px' }}>
{NEWS.slice(1).map(n => (
<div key={n.id} className="card news-card" onClick={() => setSelected(n.id)}>
<span className="news-cat-badge" style={{ background: 'var(--primary-light)', color: 'var(--primary)' }}>{n.cat}</span>
{news.slice(1).map(n => (
<div key={n.id} className="card news-card" onClick={() => setSelected(n.id)} style={{ cursor: 'pointer' }}>
<span className="news-cat-badge" style={{ background: 'var(--primary-light)', color: 'var(--primary)' }}>{n.category}</span>
<h3 className="news-card-title">{n.title}</h3>
<p className="news-card-summary">{n.summary}</p>
<span className="news-date">{n.date}</span>
<span className="news-date">{formatDate(n.createdAt)}</span>
</div>
))}
</div>
@ -128,79 +115,68 @@ function Newsroom() {
}
/* ── 기술 블로그 ── */
const BLOGS = [
{
id:1, tag:'AI·LLM', date:'2026.05.20',
title:'온프레미스 Ollama로 폐쇄망 ChatOps 구현하기',
summary:'인터넷 없이 내부망에서 LLM을 운영하는 방법. Llama-3-8B 모델을 Ollama로 구동하고 FastAPI와 연동하는 전체 과정을 설명합니다.',
readMin: 12,
},
{
id:2, tag:'DevOps', date:'2026.05.10',
title:'에이전트리스 WAS 배포 자동화 — paramiko SSH로 레거시 서버 관리',
summary:'JEUS·Tomcat 등 레거시 WAS에 SSH/SFTP만으로 배포하는 방법. 백업→배포→헬스체크→롤백 파이프라인 구현 예제.',
readMin: 15,
},
{
id:3, tag:'보안', date:'2026.04.28',
title:'AES-256-GCM으로 서버 자격증명을 안전하게 저장하는 법',
summary:'공공기관 서버 SSH 비밀번호를 DB에 안전하게 암호화 저장하는 방법. IV·암호문·GCM Tag 구조 설계와 Python 구현.',
readMin: 8,
},
{
id:4, tag:'데이터베이스', date:'2026.04.15',
title:'Oracle 19c → EDB PostgreSQL 마이그레이션 실전 가이드',
summary:'삼성전자 CRM 프로젝트에서 실제 수행한 Oracle→EDB 마이그레이션 경험 공유. Smeta, ExemOne 활용 SQL 변환 전략.',
readMin: 20,
},
{
id:5, tag:'성능', date:'2026.03.25',
title:'공공기관 행정정보시스템 SQL 튜닝 — 서울시립대 사례',
summary:'대학행정정보시스템 성능 개선 프로젝트 실전 사례. JMeter 부하테스트와 Oracle 실행계획 분석으로 응답시간 60% 단축.',
readMin: 18,
},
{
id:6, tag:'아키텍처', date:'2026.03.10',
title:'FastAPI 비동기 WebSocket으로 실시간 대시보드 구축하기',
summary:'GUARDiA ITSM 실시간 모니터링 대시보드 구현 방법. FastAPI SSE + WebSocket + React를 조합한 풀스택 아키텍처.',
readMin: 14,
},
];
const TAG_COLORS = {
'AI·LLM': '#7c3aed', 'DevOps': '#0051A2', '보안': '#dc2626',
'데이터베이스': '#d97706', '성능': '#059669', '아키텍처': '#0891b2'
};
function Blog() {
const { news: blogs, loading } = useNews('기술블로그');
const [selected, setSelected] = useState(null);
const item = blogs.find(b => b.id === selected);
if (loading) return (
<main className="inner-page">
<SubNav title="기술 블로그" />
<section className="section"><div className="container" style={{textAlign:'center',padding:'60px',color:'var(--gray-400)'}}>불러오는 ...</div></section>
</main>
);
return (
<main id="main-content" className="inner-page">
<SubNav title="기술 블로그" />
<section className="section">
<div className="container">
{item ? (
<div style={{ maxWidth: '760px', margin: '0 auto' }}>
<button className="notice-back" onClick={() => setSelected(null)}> 블로그 목록</button>
<div className="news-detail card" style={{ padding: '40px' }}>
<span className="news-cat-badge" style={{ background: '#7c3aed18', color: '#7c3aed' }}>{item.category}</span>
<h2 style={{ fontSize: '24px', fontWeight: '900', margin: '16px 0 8px', lineHeight: '1.4' }}>{item.title}</h2>
<p style={{ fontSize: '13px', color: 'var(--gray-400)', marginBottom: '32px' }}>{formatDate(item.createdAt)}</p>
<div className="divider divider-left" style={{ marginBottom: '32px' }} />
{(item.content || item.summary || '').split('\n').map((p, i) => (
p.trim() ? <p key={i} style={{ fontSize: '15px', color: 'var(--gray-700)', lineHeight: '1.85', marginBottom: '16px' }}>{p}</p> : null
))}
</div>
</div>
) : blogs.length === 0 ? (
<div style={{ textAlign: 'center', padding: '80px 0', color: 'var(--gray-400)' }}>
<p style={{ fontSize: '48px', marginBottom: '16px' }}></p>
<p>등록된 블로그 포스트가 없습니다.</p>
<p style={{ fontSize: '13px', marginTop: '8px' }}>관리자 페이지에서 블로그를 추가해 주세요.</p>
</div>
) : (
<>
<div className="section-header">
<span className="section-label">Tech Blog</span>
<h2 className="section-title">기술 인사이트 공유</h2>
<p className="section-desc">20 이상의 프로젝트 경험에서 얻은 기술 노하우를 공유합니다</p>
</div>
<div className="grid-3">
{BLOGS.map(b => (
<div key={b.id} className="card blog-card">
<div className="blog-tag" style={{ background: TAG_COLORS[b.tag] + '18', color: TAG_COLORS[b.tag] }}>
{b.tag}
{blogs.map(b => (
<div key={b.id} className="card blog-card" onClick={() => setSelected(b.id)} style={{ cursor: 'pointer' }}>
<div className="blog-tag" style={{ background: '#7c3aed18', color: '#7c3aed' }}>
{b.category}
</div>
<h3 className="blog-title">{b.title}</h3>
<p className="blog-summary">{b.summary}</p>
<div className="blog-meta">
<span>📅 {b.date}</span>
<span> {b.readMin} 읽기</span>
<span>📅 {formatDate(b.createdAt)}</span>
</div>
<button className="blog-read-btn" onClick={() => alert('블로그 상세 페이지는 준비 중입니다.')}>
<button className="blog-read-btn" onClick={e => { e.stopPropagation(); setSelected(b.id); }}>
읽기
</button>
</div>
))}
</div>
</>
)}
</div>
</section>
</main>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Routes, Route, NavLink } from 'react-router-dom';
import './Common.css';
import './Support.css';
@ -34,55 +34,64 @@ function SubNav({ title }) {
}
/* ── 공지사항 ── */
const NOTICES = [
{ id:1, cat:'공지', title:'GUARDiA ITSM v2.0 정식 출시 안내', date:'2026.05.15', hot:true },
{ id:2, cat:'공지', title:'2026년 상반기 유지보수 점검 일정 안내 (6월 1일~2일)', date:'2026.05.10', hot:false },
{ id:3, cat:'보안', title:'Apache Log4j 취약점 긴급 패치 안내', date:'2026.04.28', hot:false },
{ id:4, cat:'공지', title:'개인정보처리방침 개정 안내 (2026년 4월)', date:'2026.04.01', hot:false },
{ id:5, cat:'이벤트', title:'2026 공공기관 디지털전환 세미나 참가 안내 (5월 20일)', date:'2026.03.25', hot:false },
{ id:6, cat:'공지', title:'GUARDiA ITSM GS인증 1등급 신청 완료 안내', date:'2026.03.10', hot:false },
{ id:7, cat:'공지', title:'신규 파트너사 협약 체결 — URP 공식 파트너 등록', date:'2026.02.20', hot:false },
{ id:8, cat:'보안', title:'2026년 정보보안 교육 실시 안내 (임직원 필독)', date:'2026.01.15', hot:false },
{ id:9, cat:'공지', title:'2025년 사업성과 및 2026년 사업계획 발표', date:'2026.01.02', hot:false },
{ id:10,'cat':'공지', title:'연말연시 고객지원팀 운영시간 안내 (12/24~1/3)', date:'2025.12.20', hot:false },
];
const CAT_COLORS = { '공지':'var(--primary)', '보안':'var(--danger)', '이벤트':'var(--accent)' };
const CAT_COLORS = { '공지':'var(--primary)', '보안':'#dc2626', '이벤트':'var(--accent)', '공지사항':'var(--primary)' };
function formatDate(dt) {
if (!dt) return '';
return String(dt).slice(0, 10).replace(/-/g, '.');
}
function Notice() {
const [notices, setNotices] = useState([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState(null);
useEffect(() => {
fetch('/api/news?category=공지사항&size=30')
.then(r => r.json())
.then(d => setNotices(d.content ?? d))
.catch(() => setNotices([]))
.finally(() => setLoading(false));
}, []);
const item = notices.find(n => n.id === selected);
return (
<main id="main-content" className="inner-page">
<SubNav title="공지사항" />
<section className="section">
<div className="container" style={{ maxWidth: '860px' }}>
{selected ? (
{loading ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--gray-400)' }}>불러오는 ...</div>
) : item ? (
<div className="notice-detail">
<button className="notice-back" onClick={() => setSelected(null)}> 목록으로</button>
<div className="notice-detail-header">
<span className="notice-cat" style={{ background: CAT_COLORS[selected.cat] + '18', color: CAT_COLORS[selected.cat] }}>{selected.cat}</span>
<h2>{selected.title}</h2>
<p className="notice-date">{selected.date}</p>
<span className="notice-cat" style={{ background: 'var(--primary-light)', color: 'var(--primary)' }}>{item.category}</span>
<h2>{item.title}</h2>
<p className="notice-date">{formatDate(item.createdAt)}</p>
</div>
<div className="notice-body">
<p>안녕하세요, ()지오정보기술입니다.</p>
<p> 공지는 <strong>{selected.title}</strong> 관한 안내입니다.</p>
<p>자세한 사항은 고객지원팀(031-483-1766)으로 문의해 주시기 바랍니다.</p>
<p>감사합니다.</p>
{item.content
? item.content.split('\n').map((p, i) => p.trim() ? <p key={i}>{p}</p> : null)
: <><p>안녕하세요, ()지오정보기술입니다.</p><p>자세한 사항은 고객지원팀(031-483-1766)으로 문의해 주시기 바랍니다.</p></>}
</div>
</div>
) : notices.length === 0 ? (
<div style={{ textAlign: 'center', padding: '80px 0', color: 'var(--gray-400)' }}>
<p style={{ fontSize: '48px' }}>📋</p>
<p>등록된 공지사항이 없습니다.</p>
</div>
) : (
<div className="notice-list">
<div className="notice-header-row">
<span>구분</span><span>제목</span><span>등록일</span>
</div>
{NOTICES.map(n => (
<div key={n.id} className="notice-row" onClick={() => setSelected(n)}>
<span className="notice-cat" style={{ background: CAT_COLORS[n.cat] + '18', color: CAT_COLORS[n.cat] }}>{n.cat}</span>
<span className="notice-title-text">
{n.hot && <span className="notice-hot">HOT</span>}
{n.title}
</span>
<span className="notice-date">{n.date}</span>
{notices.map(n => (
<div key={n.id} className="notice-row" onClick={() => setSelected(n.id)}>
<span className="notice-cat" style={{ background: 'var(--primary-light)', color: 'var(--primary)' }}>{n.category}</span>
<span className="notice-title-text">{n.title}</span>
<span className="notice-date">{formatDate(n.createdAt)}</span>
</div>
))}
</div>
@ -94,36 +103,20 @@ function Notice() {
}
/* ── FAQ ── */
const FAQS = [
{
cat: 'GUARDiA ITSM',
items: [
{ q: 'GUARDiA ITSM은 어떤 제품인가요?', a: 'GUARDiA ITSM은 메신저 한 줄 명령으로 1,000개 이상 공공기관의 레거시 IT 인프라를 자동 운영하는 AI 기반 ChatOps 플랫폼입니다. 대상 서버에 별도 소프트웨어 설치 없이 표준 SSH/SFTP 프로토콜만으로 배포·운영·모니터링을 자동화합니다.' },
{ q: '서버에 에이전트를 설치해야 하나요?', a: '아니요. GUARDiA ITSM은 에이전트리스(Agentless) 방식으로 동작합니다. 대상 서버에 어떠한 소프트웨어도 설치할 필요가 없으며, 표준 SSH(22번 포트)만 열려 있으면 즉시 연동 가능합니다.' },
{ q: '클라우드 없이 사용할 수 있나요?', a: '예. GUARDiA ITSM은 완전한 온프레미스(On-premise) 솔루션으로, 외부 클라우드나 인터넷 연결 없이 폐쇄망 환경에서도 100% 동작합니다. AI 엔진(Ollama)도 내부 서버에서 구동됩니다.' },
{ q: '지원되는 운영체제는 무엇인가요?', a: 'GUARDiA ITSM 서버: Ubuntu 20.04+, CentOS 7+, RHEL 8+, Windows Server 2019+를 지원합니다. 관리 대상 서버: SSH가 지원되는 모든 Linux/Unix/Windows Server 환경에서 사용 가능합니다.' },
]
},
{
cat: '도입·계약',
items: [
{ q: '도입 비용은 어떻게 되나요?', a: '기관 규모와 관리 서버 수에 따라 맞춤 견적을 제공합니다. 7일 무료 체험판을 먼저 신청하신 후 문의 주시면 상세한 견적을 안내해 드립니다.' },
{ q: '체험판을 사용할 수 있나요?', a: '예. 7일 무료 체험판을 제공합니다. 문의하기 또는 GUARDiA 페이지의 "무료 데모 신청" 버튼을 통해 신청하시면 영업일 기준 1일 이내에 안내 드립니다.' },
{ q: '공공기관 나라장터 조달 구매가 가능한가요?', a: '예. GUARDiA ITSM은 조달청 나라장터 등록을 준비 중이며, 공공기관 입찰을 통한 구매를 지원합니다. 자세한 사항은 영업팀(031-483-1766)에 문의해 주십시오.' },
]
},
{
cat: '기술 지원',
items: [
{ q: '기술 지원은 어떻게 받을 수 있나요?', a: '이메일(support@zioinfo.co.kr), 전화(031-483-1766), GUARDiA ITSM 내 챗봇을 통해 기술 지원을 제공합니다. 운영 중 긴급 장애는 24시간 온콜 지원이 가능합니다.' },
{ q: '업그레이드는 어떻게 진행되나요?', a: '정기 업데이트는 연 2~4회 제공되며, 보안 패치는 즉시 제공됩니다. 업그레이드는 GUARDiA 내 자동 배포 기능을 통해 다운타임 없이 진행할 수 있습니다.' },
]
},
];
function FAQ() {
const [faqs, setFaqs] = useState([]);
const [loading, setLoading] = useState(true);
const [openIdx, setOpenIdx] = useState({});
const toggle = (ci, qi) => setOpenIdx(p => ({ ...p, [`${ci}-${qi}`]: !p[`${ci}-${qi}`] }));
useEffect(() => {
fetch('/api/faq')
.then(r => r.json())
.then(data => setFaqs(data))
.catch(() => setFaqs([]))
.finally(() => setLoading(false));
}, []);
return (
<main id="main-content" className="inner-page">
<SubNav title="자주 묻는 질문" />
@ -133,7 +126,13 @@ function FAQ() {
<span className="section-label">FAQ</span>
<h2 className="section-title">자주 묻는 질문</h2>
</div>
{FAQS.map((cat, ci) => (
{loading ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--gray-400)' }}>불러오는 ...</div>
) : faqs.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--gray-400)' }}>
<p style={{ fontSize: '40px' }}></p><p>등록된 FAQ가 없습니다.</p>
</div>
) : faqs.map((cat, ci) => (
<div key={ci} className="faq-cat-wrap">
<h3 className="faq-cat-title">{cat.cat}</h3>
{cat.items.map((item, qi) => {

View File

@ -0,0 +1,136 @@
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 = { category: 'GUARDiA ITSM', question: '', answer: '', orderNum: 0, visible: true };
const CATS = ['GUARDiA ITSM', '도입·계약', '기술 지원', '일반'];
export default function AdminFAQ() {
const [list, setList] = useState([]);
const [modal, setModal] = useState(null); // null | 'add' | 'edit'
const [form, setForm] = useState(EMPTY);
const [editId, setEditId] = useState(null);
const [filterCat, setFilterCat] = useState('');
const load = useCallback(() => {
authFetch('/api/admin/faq').then(r => r.json()).then(setList).catch(() => {});
}, []);
useEffect(() => { load(); }, [load]);
const open = (item = null) => {
if (item) { setForm({ ...item }); setEditId(item.id); setModal('edit'); }
else { setForm(EMPTY); setEditId(null); setModal('add'); }
};
const save = async () => {
const url = modal === 'edit' ? `/api/admin/faq/${editId}` : '/api/admin/faq';
const method = modal === 'edit' ? 'PUT' : 'POST';
await authFetch(url, { method, body: JSON.stringify(form) });
setModal(null); load();
};
const del = async (id) => {
if (!window.confirm('삭제하시겠습니까?')) return;
await authFetch(`/api/admin/faq/${id}`, { method: 'DELETE' });
load();
};
const filtered = filterCat ? list.filter(f => f.category === filterCat) : list;
return (
<div style={{ padding: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<h2 style={{ fontSize: '20px', fontWeight: '700' }}>FAQ 관리</h2>
<button onClick={() => open()} style={{ background: 'var(--primary)', color: '#fff', border: 'none', padding: '8px 20px', borderRadius: '6px', cursor: 'pointer', fontWeight: '600' }}>
+ FAQ 추가
</button>
</div>
{/* 카테고리 필터 */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px', flexWrap: 'wrap' }}>
{['전체', ...CATS].map(c => (
<button key={c} onClick={() => setFilterCat(c === '전체' ? '' : c)}
style={{ padding: '4px 14px', borderRadius: '20px', border: '1px solid', cursor: 'pointer', fontWeight: '500', fontSize: '13px',
background: (c === '전체' ? !filterCat : filterCat === c) ? 'var(--primary)' : '#fff',
color: (c === '전체' ? !filterCat : filterCat === c) ? '#fff' : 'var(--gray-600)',
borderColor:(c === '전체' ? !filterCat : filterCat === c) ? 'var(--primary)' : 'var(--gray-200)' }}>
{c}
</button>
))}
</div>
{/* FAQ 목록 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{filtered.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--gray-400)' }}>등록된 FAQ가 없습니다.</div>
) : filtered.map(f => (
<div key={f.id} style={{ background: '#fff', border: '1px solid var(--gray-200)', borderRadius: '8px', padding: '16px 20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '12px' }}>
<div style={{ flex: 1 }}>
<span style={{ fontSize: '11px', background: 'var(--primary-light)', color: 'var(--primary)', padding: '2px 8px', borderRadius: '10px', fontWeight: '600' }}>{f.category}</span>
<p style={{ fontWeight: '600', margin: '8px 0 4px', fontSize: '14px' }}>Q. {f.question}</p>
<p style={{ fontSize: '13px', color: 'var(--gray-600)', lineHeight: '1.6' }}>A. {f.answer}</p>
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
<span style={{ fontSize: '11px', color: f.visible ? '#059669' : 'var(--gray-400)', fontWeight: '600' }}>{f.visible ? '공개' : '비공개'}</span>
<button onClick={() => open(f)} style={{ padding: '4px 12px', border: '1px solid var(--gray-200)', borderRadius: '4px', cursor: 'pointer', fontSize: '12px' }}>수정</button>
<button onClick={() => del(f.id)} style={{ padding: '4px 12px', background: '#fee2e2', color: '#dc2626', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px' }}>삭제</button>
</div>
</div>
</div>
))}
</div>
{/* 모달 */}
{modal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#fff', borderRadius: '12px', padding: '32px', width: '600px', maxHeight: '90vh', overflowY: 'auto' }}>
<h3 style={{ fontSize: '18px', fontWeight: '700', marginBottom: '24px' }}>{modal === 'edit' ? 'FAQ 수정' : 'FAQ 추가'}</h3>
<label style={{ display: 'block', marginBottom: '16px' }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: 'var(--gray-700)', display: 'block', marginBottom: '6px' }}>카테고리</span>
<select value={form.category} onChange={e => setForm(p => ({ ...p, category: e.target.value }))}
style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--gray-200)', borderRadius: '6px', fontSize: '14px' }}>
{CATS.map(c => <option key={c}>{c}</option>)}
</select>
</label>
<label style={{ display: 'block', marginBottom: '16px' }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: 'var(--gray-700)', display: 'block', marginBottom: '6px' }}>질문 (Q)</span>
<input value={form.question} onChange={e => setForm(p => ({ ...p, question: e.target.value }))}
placeholder="질문을 입력하세요"
style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--gray-200)', borderRadius: '6px', fontSize: '14px' }} />
</label>
<label style={{ display: 'block', marginBottom: '16px' }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: 'var(--gray-700)', display: 'block', marginBottom: '6px' }}>답변 (A)</span>
<textarea value={form.answer} onChange={e => setForm(p => ({ ...p, answer: e.target.value }))}
rows={5} placeholder="답변을 입력하세요"
style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--gray-200)', borderRadius: '6px', fontSize: '14px', resize: 'vertical' }} />
</label>
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={form.visible} onChange={e => setForm(p => ({ ...p, visible: e.target.checked }))} />
<span style={{ fontSize: '13px', fontWeight: '600' }}>공개</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '13px', fontWeight: '600' }}>순서</span>
<input type="number" value={form.orderNum} min={0} onChange={e => setForm(p => ({ ...p, orderNum: +e.target.value }))}
style={{ width: '70px', padding: '4px 8px', border: '1px solid var(--gray-200)', borderRadius: '4px', fontSize: '14px' }} />
</label>
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button onClick={() => setModal(null)} style={{ padding: '10px 24px', border: '1px solid var(--gray-200)', borderRadius: '6px', cursor: 'pointer' }}>취소</button>
<button onClick={save} style={{ padding: '10px 24px', background: 'var(--primary)', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: '600' }}>저장</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -6,7 +6,8 @@ const NAV = [
{ section: '메인' },
{ path: '/admin/dashboard', icon: '📊', label: '대시보드' },
{ section: '콘텐츠 관리' },
{ path: '/admin/news', icon: '📰', label: '뉴스/공지사항' },
{ path: '/admin/news', icon: '📰', label: '뉴스/블로그/공지' },
{ path: '/admin/faq', icon: '❓', label: 'FAQ 관리' },
{ path: '/admin/history', icon: '📅', label: '회사 연혁' },
{ path: '/admin/recruit', icon: '👥', label: '채용공고' },
{ section: '고객 관리' },
@ -34,7 +35,8 @@ export default function AdminLayout() {
useEffect(() => {
const map = {
'/admin/dashboard': '대시보드',
'/admin/news': '뉴스/공지사항 관리',
'/admin/news': '뉴스/블로그/공지 관리',
'/admin/faq': 'FAQ 관리',
'/admin/inquiries': '문의 관리',
'/admin/recruit': '채용공고 관리',
'/admin/members': '회원 관리',

View File

@ -4,8 +4,8 @@ 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: '', category: '공지사항', summary: '', content: '', thumbnailUrl: '', visible: true };
const CATS = ['공지사항', '보도자료', '이벤트'];
const EMPTY = { title: '', category: '뉴스룸', summary: '', content: '', thumbnailUrl: '', visible: true };
const CATS = ['뉴스룸', '기술블로그', '공지사항', '보도자료', '이벤트', '보안'];
export default function AdminNews() {
const [page, setPage] = useState(0);