feat(cms): 뉴스룸/기술블로그/공지사항/FAQ 동적 DB 전환 [auto-sync]
This commit is contained in:
parent
db58aa26ba
commit
f00bdf5943
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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() {
|
||||
|
||||
31
backend/src/main/java/kr/co/zioinfo/web/model/Faq.java
Normal file
31
backend/src/main/java/kr/co/zioinfo/web/model/Faq.java
Normal 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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
<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}
|
||||
</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>
|
||||
</div>
|
||||
<button className="blog-read-btn" onClick={() => alert('블로그 상세 페이지는 준비 중입니다.')}>
|
||||
읽기 →
|
||||
</button>
|
||||
{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>
|
||||
</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" 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>📅 {formatDate(b.createdAt)}</span>
|
||||
</div>
|
||||
<button className="blog-read-btn" onClick={e => { e.stopPropagation(); setSelected(b.id); }}>
|
||||
읽기 →
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
136
frontend/src/pages/admin/AdminFAQ.jsx
Normal file
136
frontend/src/pages/admin/AdminFAQ.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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': '회원 관리',
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user