Compare commits
2 Commits
894bb0e877
...
f00bdf5943
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f00bdf5943 | ||
|
|
db58aa26ba |
@ -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>
|
||||
|
||||
@ -61,12 +61,12 @@ function Greeting() {
|
||||
'안녕하세요, (주)지오정보기술 대표이사 홍영택입니다.',
|
||||
'비즈니스의 현장에서 수많은 프로젝트를 마주하며 얻은 한 가지 확실한 진리가 있습니다. 1억짜리 프로젝트건, 1조짜리 프로젝트건, 결국 고객의 요구사항은 대동소이하다는 점입니다. 규모의 차이는 있을지언정 모든 고객의 본질적인 염원은 단 하나, ‘가장 안전하고 편리하게, 내가 신경 쓰지 않아도 완벽하게 인프라가 작동하는 것’입니다.',
|
||||
'하지만 현실의 시스템 관리는 여전히 고단합니다. 쏟아지는 메트릭을 감시하느라 밤을 지새우고, 보안 취약점과 시스템 데드락(Deadlock) 앞에서 늘 긴장해야 하는 것이 관리자들의 일상입니다. 거창한 AI 혁명을 외치면서도 결국 사람이 밤새 설정과 프롬프트를 붙잡고 있다면, 그것은 진정한 진보가 아닙니다.',
|
||||
'그래서 저희 zioinfo는 규모를 불문하고 모든 고객의 본질적인 갈증을 해소할 지능형 시스템 관리 솔루션, ‘가디아(Guardia)’를 선보입니다. 가디아가 지향하는 가치는 명확합니다.',
|
||||
'그래서 저희 지오정보기술은 규모를 불문하고 모든 고객의 본질적인 갈증을 해소할 지능형 시스템 관리 솔루션, ‘가디아(Guardia)’를 선보입니다. 가디아가 지향하는 가치는 명확합니다.',
|
||||
'“일은 AI가 하고, 사람은 오직 확인만 한다.”',
|
||||
'가디아는 Ollama 온프레미스 sLLM을 기반으로, 철저한 폐쇄망 환경 속에서도 스스로 생각하고 움직입니다. 메신저 한 줄로 자동 배포와 운영을 완결하며, RAG 하이브리드 검색과 Text-to-SQL 기술을 통해 "이번 달 HIGH SR 건수?"라는 자연어 질문 하나로 ITSM DB를 관통하는 명쾌한 답을 제시합니다.',
|
||||
'디도스(DDoS) 공격과 구성 드리프트(Drift)의 실시간 차단, llava 비전 모델을 통한 에러 화면 분석, 나라장터 조달 연동부터 주간 보고서 자동 생성까지—그동안 인간의 영혼을 소모시키던 모든 복잡한 데이터 노가다는 가디아의 AI 에이전트들이 24시간 내내 완벽하게 처리해 놓을 것입니다.',
|
||||
'이제 최고관리자의 역할은 모니터 앞의 초조한 감시자가 아닙니다. 출근길 따뜻한 커피 한 잔과 함께, 가디아가 결점 없이 차려놓은 대시보드를 읽으며 [확인] 버튼 하나를 품위 있게 누르는 지적인 여유를 누리십시오. AI가 시스템 수호의 99%를 완벽하게 빌드하고, 인간은 단 1%의 핵심 결정권으로 거대한 인프라를 통제하는 최첨단 아키텍처가 여기에 있습니다.',
|
||||
'프로젝트의 규모가 얼마이든 관계없습니다. 복잡하고 위험한 시스템의 안녕은 가디아(Guardia)에게 완벽히 일임하시고, 사람은 그 안정성과 여유를 확신하기만 하는 위대한 특이점을 경험해 보십시오. zioinfo가 당신의 비즈니스를 가장 품격 있는 미래로 연결해 드리겠습니다. 감사합니다.',
|
||||
'프로젝트의 규모가 얼마이든 관계없습니다. 복잡하고 위험한 시스템의 안녕은 가디아(Guardia)에게 완벽히 일임하시고, 사람은 그 안정성과 여유를 확신하기만 하는 위대한 특이점을 경험해 보십시오. 지오정보기술이 당신의 비즈니스를 가장 품격 있는 미래로 연결해 드리겠습니다. 감사합니다.',
|
||||
'“위대한 기술은 인간을 더 바쁘게 만드는 것이 아니라, 기계가 완벽히 수호하는 성벽 위에서 인간이 최고의 여유를 누리게 하는 것이며, 여러분에게 완벽한 여유를 선물하는 것. 그것이 지오정보기술이 존재하는 이유입니다.”',
|
||||
].map((p, i) => (
|
||||
<p key={i} className="ceo-para">{p}</p>
|
||||
@ -177,7 +177,7 @@ function History() {
|
||||
function Organization() {
|
||||
return (
|
||||
<main id="main-content" className="inner-page">
|
||||
<SubNav title="조직도" />
|
||||
<SubNav title="조직도(모두 AI AGENT 입니다)" />
|
||||
<section className="section">
|
||||
<div className="container" style={{ maxWidth: '960px' }}>
|
||||
<div className="section-header">
|
||||
|
||||
@ -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:'Tibero 공식 파트너사 등록 — 공공기관 DB 전환 솔루션 강화',
|
||||
summary:'국산 DBMS Tibero의 공식 파트너사로 등록되었습니다. Oracle에서 Tibero로의 마이그레이션 및 공공기관 DB 현대화 사업을 공동으로 추진합니다.',
|
||||
content: '공공기관의 Oracle 라이선스 절감을 위한 Tibero 전환 프로젝트를 전문적으로 지원합니다.',
|
||||
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>
|
||||
|
||||
@ -55,8 +55,8 @@ const JOBS = [
|
||||
},
|
||||
{
|
||||
id: 4, title: '인프라 운영 엔지니어 (DBA)', dept: '운영팀', type: '정규직', exp: '경력 3년 이상',
|
||||
stack: ['Oracle', 'Tibero', 'Linux', 'Shell'],
|
||||
desc: 'Oracle/Tibero DB 설계·튜닝·이관. 삼성전자·국민연금급 대형 DB 운영 경험 우대.',
|
||||
stack: ['Oracle', 'EDB(PostgreSQL)', 'Linux', 'Shell'],
|
||||
desc: 'DB 설계·튜닝·이관. 삼성전자·국민연금급 대형 DB 운영 경험 우대.',
|
||||
deadline: '2026.06.15', hot: false,
|
||||
},
|
||||
{
|
||||
|
||||
@ -59,7 +59,7 @@ function ERP() {
|
||||
고객사의 업무 프로세스에 최적화된 맞춤형 ERP를 제공합니다.
|
||||
</p>
|
||||
<div className="sol-features">
|
||||
{['공공기관 표준 회계 기준 적용', 'Oracle / Tibero DB 지원', '모바일 결재·보고 지원', '기존 레거시 시스템 연계'].map((f, i) => (
|
||||
{['공공기관 표준 회계 기준 적용', 'DB 지원', '모바일 결재·보고 지원', '기존 레거시 시스템 연계'].map((f, i) => (
|
||||
<div key={i} className="sol-feature-item">
|
||||
<span className="sol-check">✓</span> {f}
|
||||
</div>
|
||||
@ -215,7 +215,7 @@ function BI() {
|
||||
기존 레거시 DB에서 실시간 데이터를 수집해 경영 인사이트를 제공합니다.
|
||||
</p>
|
||||
<div className="sol-features">
|
||||
{['OZ·MiPlatform·JasperReport 연동', '실시간 대시보드 (WebSocket)', 'Oracle·Tibero·PostgreSQL 지원', '공공기관 표준 보고서 양식'].map((f, i) => (
|
||||
{['OZ·MiPlatform·JasperReport 연동', '실시간 대시보드 (WebSocket)', '모든 DBMS 지원', '공공기관 표준 보고서 양식'].map((f, i) => (
|
||||
<div key={i} className="sol-feature-item">
|
||||
<span className="sol-check">✓</span> {f}
|
||||
</div>
|
||||
|
||||
@ -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:'신규 파트너사 협약 체결 — Tibero 공식 파트너 등록', 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