Compare commits

...

4 Commits

4 changed files with 642 additions and 688 deletions

95
Jenkinsfile vendored
View File

@ -1,108 +1,59 @@
pipeline {
agent any
environment {
DEPLOY_DIR = '/var/www/zioinfo'
APP_DIR = '/opt/zioinfo/app'
JAVA_HOME = '/usr/lib/jvm/java-21-openjdk-amd64'
MVN = '/usr/bin/mvn'
NODE_HOME = '/usr/bin'
SRC = '/opt/zioinfo/src'
JAR_DIR = '/opt/zioinfo/app'
STATIC = '/var/www/zioinfo'
MVN = '/usr/bin/mvn'
NOTIFY = "${ITSM_BASE_URL}/api/messenger/webhook"
}
options {
buildDiscarder(logRotator(numToKeepStr: '5'))
timeout(time: 20, unit: 'MINUTES')
timestamps()
}
stages {
stage('Checkout') {
steps {
echo "브랜치: ${env.GIT_BRANCH ?: 'main'} | 커밋: ${env.GIT_COMMIT?.take(7) ?: '-'}"
checkout([
$class: 'GitSCM',
branches: scm.branches,
$class: 'GitSCM', branches: scm.branches,
userRemoteConfigs: scm.userRemoteConfigs,
extensions: [
// manual/ 폴더 체크아웃 제외 (배포 대상 아님)
[$class: 'SparseCheckoutPaths', sparseCheckoutPaths: [
[path: 'frontend'],
[path: 'backend'],
]]
]
extensions: [[$class: 'SparseCheckoutPaths',
sparseCheckoutPaths: [[path: 'frontend'], [path: 'backend']]
]]
])
}
}
stage('Frontend Build') {
steps {
dir('frontend') {
sh '''
echo "=== [1/3] React 빌드 ==="
npm ci --legacy-peer-deps --prefer-offline 2>/dev/null || npm install --legacy-peer-deps
npm run build
echo "빌드 결과: $(ls ../backend/src/main/resources/static/assets/ | wc -l) 파일"
'''
sh 'npm ci --legacy-peer-deps --prefer-offline 2>/dev/null || npm install --legacy-peer-deps'
sh 'npm run build'
}
}
}
stage('Backend Build') {
steps {
dir('backend') {
sh '''
echo "=== [2/3] Spring Boot 빌드 ==="
${MVN} clean package -DskipTests -q
JAR=$(find target -name "*.jar" ! -name "*sources*" | head -1)
echo "JAR: $JAR ($(du -sh $JAR | cut -f1))"
'''
sh "${MVN} clean package -DskipTests -q"
}
}
}
stage('Deploy') {
when { branch 'main' }
steps {
sh '''
echo "=== [3/3] 배포 ==="
JAR=$(find backend/target -name "*.jar" ! -name "*sources*" | head -1)
# 앱 디렉터리 확인
mkdir -p ${APP_DIR} ${DEPLOY_DIR}
# JAR 배포
cp "$JAR" ${APP_DIR}/app.jar
# React 정적 파일 배포
cp -r backend/src/main/resources/static/. ${DEPLOY_DIR}/
# Spring Boot 서비스 재시작
systemctl restart zioinfo || true
sh """
cp backend/target/*.jar ${JAR_DIR}/app.jar
cp -r backend/src/main/resources/static/. ${STATIC}/
systemctl restart zioinfo
sleep 4
# 헬스체크
for i in 1 2 3 4 5; do
HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/company 2>/dev/null)
if [ "$HTTP" = "200" ]; then
echo "배포 성공 (Spring Boot HTTP $HTTP)"
exit 0
fi
echo "헬스체크 ${i}/5 대기중 (HTTP: $HTTP)..."
sleep 3
done
echo "경고: Spring Boot 응답 없음 — 서비스 상태 확인 필요"
'''
systemctl is-active zioinfo || exit 1
"""
}
}
}
post {
success {
echo "✅ 배포 완료: ${currentBuild.displayName} (${currentBuild.durationString})"
}
failure {
echo "❌ 배포 실패: ${currentBuild.displayName} — 로그 확인 필요"
}
always {
cleanWs(cleanWhenNotBuilt: false, cleanWhenSuccess: false)
}
success { sh "curl -sf -X POST ${NOTIFY} -H 'Content-Type:application/json' -d '{\"event\":\"build_result\",\"room\":\"ops\",\"success\":true,\"result_summary\":\"✅ zioinfo-web 배포 완료 #${BUILD_NUMBER}\"}' 2>/dev/null || true" }
failure { sh "curl -sf -X POST ${NOTIFY} -H 'Content-Type:application/json' -d '{\"event\":\"build_result\",\"room\":\"ops\",\"success\":false,\"result_summary\":\"❌ zioinfo-web 빌드 실패 #${BUILD_NUMBER}\"}' 2>/dev/null || true" }
}
}
}

View File

@ -1,126 +1,126 @@
/* ─── Header ──────────────────────────────────────────────── */
.skip-link {
position: absolute; top: -60px; left: 0; z-index: 9999;
background: var(--primary); color: #fff;
padding: 10px 20px; border-radius: 0 0 8px 0;
transition: top .2s;
}
.skip-link:focus { top: 0; }
.header {
position: fixed; top: 0; left: 0; right: 0;
z-index: 1000; height: var(--header-h);
background: rgba(26, 26, 46, 0.96);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255,255,255,.08);
transition: all var(--mid) var(--ease);
}
.header.scrolled {
background: rgba(26, 26, 46, 0.99);
box-shadow: 0 4px 24px rgba(0,0,0,.3);
}
.header-inner {
display: flex; align-items: center; gap: 32px;
height: 100%;
}
/* 로고 */
.logo { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.logo img { height: 40px; width: auto; filter: brightness(0) invert(1); }
.logo-text { color: #fff; font-size: 20px; font-weight: 700; }
.logo-text strong { color: var(--accent); }
/* 데스크톱 메뉴 */
.nav-desktop {
display: flex; align-items: center; gap: 4px;
margin-left: 24px; flex: 1;
}
.nav-item { position: relative; }
.nav-trigger {
height: var(--header-h);
padding: 0 16px;
color: rgba(255,255,255,.85);
font-size: 15px; font-weight: 500;
transition: color var(--fast);
display: flex; align-items: center;
}
.nav-trigger:hover,
.nav-item.active .nav-trigger { color: #fff; }
.nav-item.active .nav-trigger { border-bottom: 2px solid var(--accent); }
/* 드롭다운 */
.dropdown {
position: absolute; top: calc(var(--header-h) - 2px); left: 0;
min-width: 180px;
background: #fff;
border-radius: 0 0 var(--radius) var(--radius);
box-shadow: var(--shadow-lg);
border-top: 3px solid var(--primary);
padding: 8px 0;
animation: fadeDown .18s ease;
}
@keyframes fadeDown {
from { opacity:0; transform:translateY(-8px); }
to { opacity:1; transform:translateY(0); }
}
.dropdown-item {
display: flex; align-items: center; gap: 8px;
padding: 10px 20px;
font-size: 14px; color: var(--gray-700);
transition: all var(--fast);
}
.dropdown-item:hover, .dropdown-item.current {
background: var(--primary-light);
color: var(--primary);
}
/* CTA 버튼 */
.header-cta { margin-left: auto; flex-shrink: 0; }
/* 햄버거 */
.hamburger {
display: none;
flex-direction: column;
gap: 5px;
padding: 8px;
margin-left: auto;
}
.hamburger span {
display: block; width: 24px; height: 2px;
background: #fff;
border-radius: 2px;
transition: all var(--mid);
}
/* 모바일 메뉴 */
.nav-mobile {
display: none;
flex-direction: column;
background: var(--secondary);
border-top: 1px solid rgba(255,255,255,.1);
max-height: calc(100vh - var(--header-h));
overflow-y: auto;
}
.mobile-group { border-bottom: 1px solid rgba(255,255,255,.08); }
.mobile-group-header {
display: flex; align-items: center;
padding: 14px 24px;
color: rgba(255,255,255,.85);
font-size: 15px; font-weight: 500;
cursor: pointer;
}
.mobile-children { background: rgba(0,0,0,.2); }
.mobile-child {
display: flex; align-items: center; gap: 8px;
padding: 10px 36px;
font-size: 14px; color: rgba(255,255,255,.7);
}
.mobile-child:hover { color: #fff; }
/* 반응형 */
@media (max-width: 1024px) {
.nav-desktop, .header-cta { display: none; }
.hamburger { display: flex; }
.header.mobile-open .nav-mobile { display: flex; }
.header.mobile-open { height: auto; }
}
/* ─── Header ──────────────────────────────────────────────── */
.skip-link {
position: absolute; top: -60px; left: 0; z-index: 9999;
background: var(--primary); color: #fff;
padding: 10px 20px; border-radius: 0 0 8px 0;
transition: top .2s;
}
.skip-link:focus { top: 0; }
.header {
position: fixed; top: 0; left: 0; right: 0;
z-index: 1000; height: var(--header-h);
background: rgba(26, 26, 46, 0.96);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255,255,255,.08);
transition: all var(--mid) var(--ease);
}
.header.scrolled {
background: rgba(26, 26, 46, 0.99);
box-shadow: 0 4px 24px rgba(0,0,0,.3);
}
.header-inner {
display: flex; align-items: center; gap: 32px;
height: 100%;
}
/* 로고 */
.logo { display: flex; align-items: center; gap: 10px; flex-shrink: 0; cursor: pointer; text-decoration: none; }
.logo img { height: 40px; width: auto; }
.logo-text { color: #fff; font-size: 20px; font-weight: 700; }
.logo-text strong { color: var(--accent); }
/* 데스크톱 메뉴 */
.nav-desktop {
display: flex; align-items: center; gap: 4px;
margin-left: 24px; flex: 1;
}
.nav-item { position: relative; }
.nav-trigger {
height: var(--header-h);
padding: 0 16px;
color: rgba(255,255,255,.85);
font-size: 15px; font-weight: 500;
transition: color var(--fast);
display: flex; align-items: center;
}
.nav-trigger:hover,
.nav-item.active .nav-trigger { color: #fff; }
.nav-item.active .nav-trigger { border-bottom: 2px solid var(--accent); }
/* 드롭다운 */
.dropdown {
position: absolute; top: calc(var(--header-h) - 2px); left: 0;
min-width: 180px;
background: #fff;
border-radius: 0 0 var(--radius) var(--radius);
box-shadow: var(--shadow-lg);
border-top: 3px solid var(--primary);
padding: 8px 0;
animation: fadeDown .18s ease;
}
@keyframes fadeDown {
from { opacity:0; transform:translateY(-8px); }
to { opacity:1; transform:translateY(0); }
}
.dropdown-item {
display: flex; align-items: center; gap: 8px;
padding: 10px 20px;
font-size: 14px; color: var(--gray-700);
transition: all var(--fast);
}
.dropdown-item:hover, .dropdown-item.current {
background: var(--primary-light);
color: var(--primary);
}
/* CTA 버튼 */
.header-cta { margin-left: auto; flex-shrink: 0; }
/* 햄버거 */
.hamburger {
display: none;
flex-direction: column;
gap: 5px;
padding: 8px;
margin-left: auto;
}
.hamburger span {
display: block; width: 24px; height: 2px;
background: #fff;
border-radius: 2px;
transition: all var(--mid);
}
/* 모바일 메뉴 */
.nav-mobile {
display: none;
flex-direction: column;
background: var(--secondary);
border-top: 1px solid rgba(255,255,255,.1);
max-height: calc(100vh - var(--header-h));
overflow-y: auto;
}
.mobile-group { border-bottom: 1px solid rgba(255,255,255,.08); }
.mobile-group-header {
display: flex; align-items: center;
padding: 14px 24px;
color: rgba(255,255,255,.85);
font-size: 15px; font-weight: 500;
cursor: pointer;
}
.mobile-children { background: rgba(0,0,0,.2); }
.mobile-child {
display: flex; align-items: center; gap: 8px;
padding: 10px 36px;
font-size: 14px; color: rgba(255,255,255,.7);
}
.mobile-child:hover { color: #fff; }
/* 반응형 */
@media (max-width: 1024px) {
.nav-desktop, .header-cta { display: none; }
.hamburger { display: flex; }
.header.mobile-open .nav-mobile { display: flex; }
.header.mobile-open { height: auto; }
}

View File

@ -1,198 +1,200 @@
import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import './Header.css';
const MENU = [
{
id: 'company', label: '회사소개',
children: [
{ label: 'CEO 인사말', path: '/company/greeting' },
{ label: '연혁', path: '/company/history' },
{ label: '조직도', path: '/company/organization' },
{ label: 'CI 소개', path: '/company/ci' },
{ label: '오시는 길', path: '/company/location' },
]
},
{
id: 'solution', label: '솔루션',
children: [
{ label: 'GUARDiA ITSM', path: '/solution/guardia', badge: 'NEW' },
{ label: 'ERP', path: '/solution/erp' },
{ label: 'CRM', path: '/solution/crm' },
{ label: 'BI', path: '/solution/bi' },
]
},
{
id: 'business', label: '사업실적',
children: [
{ label: '구축 레퍼런스', path: '/business/reference' },
{ label: '파트너', path: '/business/partner' },
]
},
{
id: 'support', label: '고객지원',
children: [
{ label: '공지사항', path: '/support/notice' },
{ label: 'FAQ', path: '/support/faq' },
{ label: '카탈로그', path: '/support/catalog' },
{ label: '문의하기', path: '/support/contact' },
]
},
{
id: 'recruit', label: '채용',
children: [
{ label: '채용공고', path: '/recruit/jobs' },
{ label: '복리후생', path: '/recruit/welfare' },
{ label: '지원하기', path: '/recruit/apply' },
]
},
{
id: 'news', label: '뉴스',
children: [
{ label: '뉴스룸', path: '/news/newsroom' },
{ label: '기술 블로그', path: '/news/blog' },
]
},
];
export default function Header() {
const [scrolled, setScrolled] = useState(false);
const [activeMenu, setActiveMenu] = useState(null);
const [mobileOpen, setMobileOpen] = useState(false);
const [member, setMember] = useState(null);
const location = useLocation();
const navigate = useNavigate();
//
useEffect(() => {
const sync = () => {
const u = localStorage.getItem('member_user');
setMember(u ? JSON.parse(u) : null);
};
sync();
window.addEventListener('storage', sync);
return () => window.removeEventListener('storage', sync);
}, [location]);
const logout = () => {
localStorage.removeItem('member_token');
localStorage.removeItem('member_user');
setMember(null);
navigate('/');
};
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 60);
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
useEffect(() => {
setMobileOpen(false);
setActiveMenu(null);
}, [location]);
const isActive = (menu) =>
menu.children?.some(c => location.pathname.startsWith(c.path));
return (
<>
{/* 접근성 스킵 링크 */}
<a href="#main-content" className="skip-link">본문 바로가기</a>
<header className={`header ${scrolled ? 'scrolled' : ''} ${mobileOpen ? 'mobile-open' : ''}`}
role="banner">
<div className="header-inner container">
{/* 로고 */}
<Link to="/" className="logo" aria-label="(주)지오정보기술 홈으로">
<img src="/zioinfo-logo-dark.png" alt="(주)지오정보기술 로고" height="40"
onError={e => { e.target.src='/zioinfo-logo.png'; e.target.onerror = () => { e.target.style.display='none'; e.target.nextSibling.style.display='flex'; }; }} />
<span className="logo-text" style={{display:'none'}}>
<strong>Zio</strong>Info
</span>
</Link>
{/* 데스크톱 메뉴 */}
<nav className="nav-desktop" role="navigation" aria-label="주요 메뉴">
{MENU.map(menu => (
<div key={menu.id}
className={`nav-item ${isActive(menu) ? 'active' : ''}`}
onMouseEnter={() => setActiveMenu(menu.id)}
onMouseLeave={() => setActiveMenu(null)}>
<button className="nav-trigger" aria-haspopup="true"
aria-expanded={activeMenu === menu.id}>
{menu.label}
</button>
{activeMenu === menu.id && (
<div className="dropdown" role="menu">
{menu.children.map(child => (
<Link key={child.path} to={child.path}
className={`dropdown-item ${location.pathname === child.path ? 'current' : ''}`}
role="menuitem">
{child.label}
{child.badge && <span className="badge badge-new">{child.badge}</span>}
</Link>
))}
</div>
)}
</div>
))}
</nav>
{/* 우측 버튼 영역 */}
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<Link to="/support/contact" className="btn btn-outline btn-sm">문의하기</Link>
{member ? (
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<span style={{ fontSize:13, color:'var(--gray-600)', maxWidth:100,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{member.name}
</span>
<button onClick={logout}
style={{ padding:'6px 14px', background:'none', border:'1px solid #e2e8f0',
borderRadius:8, fontSize:12, color:'#64748b', cursor:'pointer' }}>
로그아웃
</button>
</div>
) : (
<Link to="/login" className="btn btn-primary btn-sm">로그인</Link>
)}
</div>
{/* 햄버거 (모바일) */}
<button className="hamburger" aria-label="모바일 메뉴"
aria-expanded={mobileOpen}
onClick={() => setMobileOpen(v => !v)}>
<span/><span/><span/>
</button>
</div>
{/* 모바일 메뉴 */}
{mobileOpen && (
<nav className="nav-mobile" role="navigation" aria-label="모바일 메뉴">
{MENU.map(menu => (
<details key={menu.id} className="mobile-group">
<summary className="mobile-group-header">{menu.label}</summary>
<div className="mobile-children">
{menu.children.map(child => (
<Link key={child.path} to={child.path} className="mobile-child">
{child.label}
{child.badge && <span className="badge badge-new">{child.badge}</span>}
</Link>
))}
</div>
</details>
))}
<div style={{ display:'flex', gap:8, margin:'16px' }}>
<Link to="/support/contact" className="btn btn-outline" style={{ flex:1 }}>문의하기</Link>
{member
? <button onClick={logout} className="btn btn-primary" style={{ flex:1 }}>로그아웃</button>
: <Link to="/login" className="btn btn-primary" style={{ flex:1 }}>로그인 / 가입</Link>
}
</div>
</nav>
)}
</header>
</>
);
}
import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import './Header.css';
const MENU = [
{
id: 'company', label: '회사소개',
children: [
{ label: 'CEO 인사말', path: '/company/greeting' },
{ label: '연혁', path: '/company/history' },
{ label: '조직도', path: '/company/organization' },
{ label: 'CI 소개', path: '/company/ci' },
{ label: '오시는 길', path: '/company/location' },
]
},
{
id: 'solution', label: '솔루션',
children: [
{ label: 'GUARDiA ITSM', path: '/solution/guardia', badge: 'NEW' },
{ label: 'ERP', path: '/solution/erp' },
{ label: 'CRM', path: '/solution/crm' },
{ label: 'BI', path: '/solution/bi' },
]
},
{
id: 'business', label: '사업실적',
children: [
{ label: '구축 레퍼런스', path: '/business/reference' },
{ label: '파트너', path: '/business/partner' },
]
},
{
id: 'support', label: '고객지원',
children: [
{ label: '공지사항', path: '/support/notice' },
{ label: 'FAQ', path: '/support/faq' },
{ label: '카탈로그', path: '/support/catalog' },
{ label: '문의하기', path: '/support/contact' },
]
},
{
id: 'recruit', label: '채용',
children: [
{ label: '채용공고', path: '/recruit/jobs' },
{ label: '복리후생', path: '/recruit/welfare' },
{ label: '지원하기', path: '/recruit/apply' },
]
},
{
id: 'news', label: '뉴스',
children: [
{ label: '뉴스룸', path: '/news/newsroom' },
{ label: '기술 블로그', path: '/news/blog' },
]
},
];
export default function Header() {
const [scrolled, setScrolled] = useState(false);
const [activeMenu, setActiveMenu] = useState(null);
const [mobileOpen, setMobileOpen] = useState(false);
const [member, setMember] = useState(null);
const closeTimer = React.useRef(null);
const location = useLocation();
const navigate = useNavigate();
//
useEffect(() => {
const sync = () => {
const u = localStorage.getItem('member_user');
setMember(u ? JSON.parse(u) : null);
};
sync();
window.addEventListener('storage', sync);
return () => window.removeEventListener('storage', sync);
}, [location]);
const logout = () => {
localStorage.removeItem('member_token');
localStorage.removeItem('member_user');
setMember(null);
navigate('/');
};
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 60);
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
useEffect(() => {
setMobileOpen(false);
setActiveMenu(null);
}, [location]);
const isActive = (menu) =>
menu.children?.some(c => location.pathname.startsWith(c.path));
return (
<>
{/* 접근성 스킵 링크 */}
<a href="#main-content" className="skip-link">본문 바로가기</a>
<header className={`header ${scrolled ? 'scrolled' : ''} ${mobileOpen ? 'mobile-open' : ''}`}
role="banner">
<div className="header-inner container">
{/* 로고 */}
<Link to="/" className="logo" aria-label="(주)지오정보기술 홈으로">
<img src="/zioinfo-logo-dark.png" alt="(주)지오정보기술 로고" height="40"
onError={e => { e.target.src='/zioinfo-logo.png'; e.target.onerror = () => { e.target.style.display='none'; e.target.nextSibling.style.display='flex'; }; }} />
<span className="logo-text" style={{display:'none'}}>
<strong>Zio</strong>Info
</span>
</Link>
{/* 데스크톱 메뉴 */}
<nav className="nav-desktop" role="navigation" aria-label="주요 메뉴">
{MENU.map(menu => (
<div key={menu.id}
className={`nav-item ${isActive(menu) ? 'active' : ''}`}
onMouseEnter={() => { clearTimeout(closeTimer.current); setActiveMenu(menu.id); }}
onMouseLeave={() => { closeTimer.current = setTimeout(() => setActiveMenu(null), 200); }}>
<button className="nav-trigger" aria-haspopup="true"
aria-expanded={activeMenu === menu.id}
onClick={() => navigate(menu.children[0].path)}>
{menu.label}
</button>
{activeMenu === menu.id && (
<div className="dropdown" role="menu">
{menu.children.map(child => (
<Link key={child.path} to={child.path}
className={`dropdown-item ${location.pathname === child.path ? 'current' : ''}`}
role="menuitem">
{child.label}
{child.badge && <span className="badge badge-new">{child.badge}</span>}
</Link>
))}
</div>
)}
</div>
))}
</nav>
{/* 우측 버튼 영역 */}
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<Link to="/support/contact" className="btn btn-outline btn-sm">문의하기</Link>
{member ? (
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<span style={{ fontSize:13, color:'var(--gray-600)', maxWidth:100,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{member.name}
</span>
<button onClick={logout}
style={{ padding:'6px 14px', background:'none', border:'1px solid #e2e8f0',
borderRadius:8, fontSize:12, color:'#64748b', cursor:'pointer' }}>
로그아웃
</button>
</div>
) : (
<Link to="/login" className="btn btn-primary btn-sm">로그인</Link>
)}
</div>
{/* 햄버거 (모바일) */}
<button className="hamburger" aria-label="모바일 메뉴"
aria-expanded={mobileOpen}
onClick={() => setMobileOpen(v => !v)}>
<span/><span/><span/>
</button>
</div>
{/* 모바일 메뉴 */}
{mobileOpen && (
<nav className="nav-mobile" role="navigation" aria-label="모바일 메뉴">
{MENU.map(menu => (
<details key={menu.id} className="mobile-group">
<summary className="mobile-group-header">{menu.label}</summary>
<div className="mobile-children">
{menu.children.map(child => (
<Link key={child.path} to={child.path} className="mobile-child">
{child.label}
{child.badge && <span className="badge badge-new">{child.badge}</span>}
</Link>
))}
</div>
</details>
))}
<div style={{ display:'flex', gap:8, margin:'16px' }}>
<Link to="/support/contact" className="btn btn-outline" style={{ flex:1 }}>문의하기</Link>
{member
? <button onClick={logout} className="btn btn-primary" style={{ flex:1 }}>로그아웃</button>
: <Link to="/login" className="btn btn-primary" style={{ flex:1 }}>로그인 / 가입</Link>
}
</div>
</nav>
)}
</header>
</>
);
}

View File

@ -1,292 +1,293 @@
import React from 'react';
import { Routes, Route, NavLink } from 'react-router-dom';
import { Link } from 'react-router-dom';
import './Common.css';
import './SolutionPage.css';
const SUB_NAV = [
{ path: '/solution/guardia', label: 'GUARDiA ITSM', badge: 'NEW' },
{ path: '/solution/erp', label: 'ERP' },
{ path: '/solution/crm', label: 'CRM' },
{ path: '/solution/bi', label: 'BI' },
];
function SubNav({ title }) {
return (
<>
<div className="page-hero">
<div className="container">
<span className="section-label">Solution</span>
<h1 className="page-hero-title">{title}</h1>
</div>
</div>
<nav className="sub-nav">
<div className="container">
{SUB_NAV.map(n => (
<NavLink key={n.path} to={n.path}
className={({ isActive }) => 'sub-nav-item' + (isActive ? ' active' : '')}>
{n.label}
{n.badge && <span className="badge badge-new" style={{ marginLeft: '6px', fontSize: '10px' }}>{n.badge}</span>}
</NavLink>
))}
</div>
</nav>
</>
);
}
/* ── ERP ── */
function ERP() {
const modules = [
{ icon: '💰', name: '재무·회계', desc: '전표처리, 결산, 세무신고, 원가계산 자동화' },
{ icon: '🏭', name: '생산관리', desc: 'BOM 관리, 생산계획, 공정관리, 품질관리' },
{ icon: '📦', name: '구매·재고', desc: '발주, 입출고, 재고 현황, 협력사 포털' },
{ icon: '👥', name: '인사·급여', desc: '근태관리, 급여계산, 조직도, 인사평가' },
{ icon: '🛒', name: '영업·물류', desc: '수주관리, 배송, 매출 분석, 고객 관리' },
{ icon: '📊', name: '경영 분석', desc: 'KPI 대시보드, 예산 vs 실적, 경영 보고서' },
];
return (
<main id="main-content" className="inner-page">
<SubNav title="ERP 솔루션" />
<section className="section">
<div className="container">
<div className="sol-hero-grid">
<div>
<span className="section-label">Enterprise Resource Planning</span>
<h2 className="sol-title">공공·중견기업 맞춤형<br /><em>통합 ERP 솔루션</em></h2>
<p className="sol-desc">
20 이상 현대모비스, 한화그룹, 이마트 국내 주요 기업의 핵심 업무 시스템을 구축한 경험을 바탕으로,
고객사의 업무 프로세스에 최적화된 맞춤형 ERP를 제공합니다.
</p>
<div className="sol-features">
{['공공기관 표준 회계 기준 적용', 'Oracle / Tibero DB 지원', '모바일 결재·보고 지원', '기존 레거시 시스템 연계'].map((f, i) => (
<div key={i} className="sol-feature-item">
<span className="sol-check"></span> {f}
</div>
))}
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '32px', flexWrap: 'wrap' }}>
<Link to="/support/contact?type=데모 신청" className="btn btn-primary btn-lg">무료 데모 신청</Link>
<Link to="/support/catalog" className="btn btn-outline btn-lg">카탈로그 다운로드</Link>
</div>
</div>
<div className="sol-visual erp-visual">
<div className="sol-screen">
<div className="sol-screen-header"><span />재무 대시보드</div>
<div className="sol-chart-bar-wrap">
{[80, 65, 90, 72, 88, 55, 95].map((h, i) => (
<div key={i} className="sol-chart-bar" style={{ height: h + '%' }} />
))}
</div>
<div className="sol-stat-row">
<div className="sol-stat"><strong>12.4</strong><span>이번달 매출</span></div>
<div className="sol-stat"><strong>98.2%</strong><span>예산 집행률</span></div>
<div className="sol-stat"><strong>+18%</strong><span>전월 대비</span></div>
</div>
</div>
</div>
</div>
{/* 모듈 */}
<div style={{ marginTop: '80px' }}>
<div className="section-header">
<span className="section-label">Modules</span>
<h2 className="section-title">6 핵심 모듈</h2>
</div>
<div className="grid-3">
{modules.map((m, i) => (
<div key={i} className="card sol-module-card">
<div className="sol-module-icon">{m.icon}</div>
<h3>{m.name}</h3>
<p>{m.desc}</p>
</div>
))}
</div>
</div>
</div>
</section>
</main>
);
}
/* ── CRM ── */
function CRM() {
const features = [
{ icon: '📇', name: '고객 360˚', desc: '고객 정보, 구매이력, 상담이력, 선호도를 단일 뷰로 통합' },
{ icon: '📞', name: '멀티채널 상담', desc: '전화·이메일·채팅·SNS 통합 인입, 상담 이력 자동 기록' },
{ icon: '🎯', name: '영업 파이프라인', desc: '리드 발굴부터 계약까지 전 단계 시각화 관리' },
{ icon: '📨', name: '마케팅 자동화', desc: '고객 세그먼트별 자동 캠페인, 이메일·SMS 발송' },
{ icon: '🤖', name: 'AI 상담 추천', desc: 'Ollama LLM 기반 최적 답변 자동 추천 및 요약' },
{ icon: '📈', name: '성과 분석', desc: '상담사별·채널별 KPI, 고객 만족도, 전환율 리포트' },
];
return (
<main id="main-content" className="inner-page">
<SubNav title="CRM 솔루션" />
<section className="section">
<div className="container">
<div className="sol-hero-grid">
<div>
<span className="section-label">Customer Relationship Management</span>
<h2 className="sol-title">AI 기반<br /><em>고객 관계 관리 플랫폼</em></h2>
<p className="sol-desc">
삼성전자 차세대 CRM, LG U+ VAN 고도화, 현대캐피탈 차세대 시스템
국내 최대 규모 CRM 프로젝트를 성공적으로 수행한 전문 역량으로 구축합니다.
온프레미스 AI(Ollama) 연동으로 데이터 외부 유출 없이 지능형 상담을 실현합니다.
</p>
<div className="sol-features">
{['삼성전자·LG·현대 구축 레퍼런스', '온프레미스 AI 상담 추천', 'CTI 연동 (콜센터 솔루션)', '공공기관 개인정보보호법 준수'].map((f, i) => (
<div key={i} className="sol-feature-item">
<span className="sol-check"></span> {f}
</div>
))}
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '32px', flexWrap: 'wrap' }}>
<Link to="/support/contact?type=데모 신청" className="btn btn-primary btn-lg">데모 신청</Link>
<Link to="/support/catalog" className="btn btn-outline btn-lg">카탈로그</Link>
</div>
</div>
<div className="sol-visual crm-visual">
<div className="sol-screen">
<div className="sol-screen-header"><span />고객 상담 현황</div>
<div className="crm-items">
{[
{ name: '김민준', type: '제품문의', status: '처리중', color: '#f59e0b' },
{ name: '이서연', type: '기술지원', status: '완료', color: '#10b981' },
{ name: '박지후', type: '불만접수', status: '대기', color: '#ef4444' },
{ name: '최수아', type: '데모신청', status: '완료', color: '#10b981' },
].map((c, i) => (
<div key={i} className="crm-item">
<div className="crm-avatar">{c.name[0]}</div>
<div className="crm-info">
<strong>{c.name}</strong>
<span>{c.type}</span>
</div>
<span className="crm-status" style={{ color: c.color }}>{c.status}</span>
</div>
))}
</div>
</div>
</div>
</div>
<div style={{ marginTop: '80px' }}>
<div className="section-header">
<span className="section-label">Features</span>
<h2 className="section-title">주요 기능</h2>
</div>
<div className="grid-3">
{features.map((f, i) => (
<div key={i} className="card sol-module-card">
<div className="sol-module-icon">{f.icon}</div>
<h3>{f.name}</h3>
<p>{f.desc}</p>
</div>
))}
</div>
</div>
</div>
</section>
</main>
);
}
/* ── BI ── */
function BI() {
const charts = [
{ icon: '📊', name: '경영 대시보드', desc: '실시간 KPI 모니터링, 부서별 성과 지표, 경영진 요약 보고' },
{ icon: '📉', name: '매출·비용 분석', desc: '기간별·제품별·채널별 매출 트렌드, 비용 구조 분석' },
{ icon: '🗺️', name: '지역별 분석', desc: '지도 기반 시각화, 공공기관 지역별 서비스 현황' },
{ icon: '🔮', name: 'AI 예측 분석', desc: '머신러닝 기반 수요 예측, 이상 패턴 자동 탐지' },
{ icon: '📋', name: '자동 보고서', desc: '일·주·월 보고서 자동 생성, 이메일·메신저 배포' },
{ icon: '🔗', name: 'ETL 파이프라인', desc: 'Oracle, SAP, 공공DB 등 다양한 소스 데이터 연계' },
];
return (
<main id="main-content" className="inner-page">
<SubNav title="BI 솔루션" />
<section className="section">
<div className="container">
<div className="sol-hero-grid">
<div>
<span className="section-label">Business Intelligence</span>
<h2 className="sol-title">데이터 기반<br /><em>의사결정 플랫폼</em></h2>
<p className="sol-desc">
OZ Report, MiPlatform, JasperReports 다양한 보고 도구와의 연동 경험을 바탕으로,
공공기관·중견기업 맞춤형 BI 플랫폼을 구축합니다.
기존 레거시 DB에서 실시간 데이터를 수집해 경영 인사이트를 제공합니다.
</p>
<div className="sol-features">
{['OZ·MiPlatform·JasperReport 연동', '실시간 대시보드 (WebSocket)', 'Oracle·Tibero·PostgreSQL 지원', '공공기관 표준 보고서 양식'].map((f, i) => (
<div key={i} className="sol-feature-item">
<span className="sol-check"></span> {f}
</div>
))}
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '32px', flexWrap: 'wrap' }}>
<Link to="/support/contact?type=데모 신청" className="btn btn-primary btn-lg">데모 신청</Link>
<Link to="/support/catalog" className="btn btn-outline btn-lg">카탈로그</Link>
</div>
</div>
<div className="sol-visual bi-visual">
<div className="sol-screen">
<div className="sol-screen-header"><span />경영 대시보드</div>
<div className="bi-kpis">
{[
{ label:'매출', val:'₩48.2억', up:true, delta:'+12%' },
{ label:'비용', val:'₩31.7억', up:false, delta:'-3%' },
{ label:'이익', val:'₩16.5억', up:true, delta:'+28%' },
{ label:'고객', val:'1,240명', up:true, delta:'+8%' },
].map((k, i) => (
<div key={i} className="bi-kpi">
<span className="bi-kpi-label">{k.label}</span>
<strong className="bi-kpi-val">{k.val}</strong>
<span className="bi-kpi-delta" style={{ color: k.up ? '#10b981' : '#ef4444' }}>
{k.delta}
</span>
</div>
))}
</div>
<div className="bi-bar-chart">
{['1Q','2Q','3Q','4Q'].map((q, i) => (
<div key={i} className="bi-bar-group">
<div className="bi-bar-pair">
<div className="bi-bar revenue" style={{ height: [60, 75, 85, 100][i] + '%' }} />
<div className="bi-bar cost" style={{ height: [70, 65, 60, 55][i] + '%' }} />
</div>
<span>{q}</span>
</div>
))}
</div>
</div>
</div>
</div>
<div style={{ marginTop: '80px' }}>
<div className="section-header">
<span className="section-label">Features</span>
<h2 className="section-title">주요 기능</h2>
</div>
<div className="grid-3">
{charts.map((c, i) => (
<div key={i} className="card sol-module-card">
<div className="sol-module-icon">{c.icon}</div>
<h3>{c.name}</h3>
<p>{c.desc}</p>
</div>
))}
</div>
</div>
</div>
</section>
</main>
);
}
export default function SolutionPage() {
return (
<Routes>
<Route path="erp" element={<ERP />} />
<Route path="crm" element={<CRM />} />
<Route path="bi" element={<BI />} />
</Routes>
);
}
import React from 'react';
import { Routes, Route, NavLink } from 'react-router-dom';
import { Link } from 'react-router-dom';
import './Common.css';
import './SolutionPage.css';
const SUB_NAV = [
{ path: '/solution/guardia', label: 'GUARDiA ITSM', badge: 'NEW' },
{ path: '/solution/erp', label: 'ERP' },
{ path: '/solution/crm', label: 'CRM' },
{ path: '/solution/bi', label: 'BI' },
];
function SubNav({ title }) {
return (
<>
<div className="page-hero">
<div className="container">
<span className="section-label">Solution</span>
<h1 className="page-hero-title">{title}</h1>
</div>
</div>
<nav className="sub-nav">
<div className="container">
{SUB_NAV.map(n => (
<NavLink key={n.path} to={n.path}
className={({ isActive }) => 'sub-nav-item' + (isActive ? ' active' : '')}>
{n.label}
{n.badge && <span className="badge badge-new" style={{ marginLeft: '6px', fontSize: '10px' }}>{n.badge}</span>}
</NavLink>
))}
</div>
</nav>
</>
);
}
/* ── ERP ── */
function ERP() {
const modules = [
{ icon: '💰', name: '재무·회계', desc: '전표처리, 결산, 세무신고, 원가계산 자동화' },
{ icon: '🏭', name: '생산관리', desc: 'BOM 관리, 생산계획, 공정관리, 품질관리' },
{ icon: '📦', name: '구매·재고', desc: '발주, 입출고, 재고 현황, 협력사 포털' },
{ icon: '👥', name: '인사·급여', desc: '근태관리, 급여계산, 조직도, 인사평가' },
{ icon: '🛒', name: '영업·물류', desc: '수주관리, 배송, 매출 분석, 고객 관리' },
{ icon: '📊', name: '경영 분석', desc: 'KPI 대시보드, 예산 vs 실적, 경영 보고서' },
];
return (
<main id="main-content" className="inner-page">
<SubNav title="ERP 솔루션" />
<section className="section">
<div className="container">
<div className="sol-hero-grid">
<div>
<span className="section-label">Enterprise Resource Planning</span>
<h2 className="sol-title">공공·중견기업 맞춤형<br /><em>통합 ERP 솔루션</em></h2>
<p className="sol-desc">
20 이상 현대모비스, 한화그룹, 이마트 국내 주요 기업의 핵심 업무 시스템을 구축한 경험을 바탕으로,
고객사의 업무 프로세스에 최적화된 맞춤형 ERP를 제공합니다.
</p>
<div className="sol-features">
{['공공기관 표준 회계 기준 적용', 'Oracle / Tibero DB 지원', '모바일 결재·보고 지원', '기존 레거시 시스템 연계'].map((f, i) => (
<div key={i} className="sol-feature-item">
<span className="sol-check"></span> {f}
</div>
))}
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '32px', flexWrap: 'wrap' }}>
<Link to="/support/contact?type=데모 신청" className="btn btn-primary btn-lg">무료 데모 신청</Link>
<Link to="/support/catalog" className="btn btn-outline btn-lg">카탈로그 다운로드</Link>
</div>
</div>
<div className="sol-visual erp-visual">
<div className="sol-screen">
<div className="sol-screen-header"><span />재무 대시보드</div>
<div className="sol-chart-bar-wrap">
{[80, 65, 90, 72, 88, 55, 95].map((h, i) => (
<div key={i} className="sol-chart-bar" style={{ height: h + '%' }} />
))}
</div>
<div className="sol-stat-row">
<div className="sol-stat"><strong>12.4</strong><span>이번달 매출</span></div>
<div className="sol-stat"><strong>98.2%</strong><span>예산 집행률</span></div>
<div className="sol-stat"><strong>+18%</strong><span>전월 대비</span></div>
</div>
</div>
</div>
</div>
{/* 모듈 */}
<div style={{ marginTop: '80px' }}>
<div className="section-header">
<span className="section-label">Modules</span>
<h2 className="section-title">6 핵심 모듈</h2>
</div>
<div className="grid-3">
{modules.map((m, i) => (
<div key={i} className="card sol-module-card">
<div className="sol-module-icon">{m.icon}</div>
<h3>{m.name}</h3>
<p>{m.desc}</p>
</div>
))}
</div>
</div>
</div>
</section>
</main>
);
}
/* ── CRM ── */
function CRM() {
const features = [
{ icon: '📇', name: '고객 360˚', desc: '고객 정보, 구매이력, 상담이력, 선호도를 단일 뷰로 통합' },
{ icon: '📞', name: '멀티채널 상담', desc: '전화·이메일·채팅·SNS 통합 인입, 상담 이력 자동 기록' },
{ icon: '🎯', name: '영업 파이프라인', desc: '리드 발굴부터 계약까지 전 단계 시각화 관리' },
{ icon: '📨', name: '마케팅 자동화', desc: '고객 세그먼트별 자동 캠페인, 이메일·SMS 발송' },
{ icon: '🤖', name: 'AI 상담 추천', desc: 'Ollama LLM 기반 최적 답변 자동 추천 및 요약' },
{ icon: '📈', name: '성과 분석', desc: '상담사별·채널별 KPI, 고객 만족도, 전환율 리포트' },
];
return (
<main id="main-content" className="inner-page">
<SubNav title="CRM 솔루션" />
<section className="section">
<div className="container">
<div className="sol-hero-grid">
<div>
<span className="section-label">Customer Relationship Management</span>
<h2 className="sol-title">AI 기반<br /><em>고객 관계 관리 플랫폼</em></h2>
<p className="sol-desc">
삼성전자 차세대 CRM, LG U+ VAN 고도화, 현대캐피탈 차세대 시스템
국내 최대 규모 CRM 프로젝트를 성공적으로 수행한 전문 역량으로 구축합니다.
온프레미스 AI(Ollama) 연동으로 데이터 외부 유출 없이 지능형 상담을 실현합니다.
</p>
<div className="sol-features">
{['삼성전자·LG·현대 구축 레퍼런스', '온프레미스 AI 상담 추천', 'CTI 연동 (콜센터 솔루션)', '공공기관 개인정보보호법 준수'].map((f, i) => (
<div key={i} className="sol-feature-item">
<span className="sol-check"></span> {f}
</div>
))}
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '32px', flexWrap: 'wrap' }}>
<Link to="/support/contact?type=데모 신청" className="btn btn-primary btn-lg">데모 신청</Link>
<Link to="/support/catalog" className="btn btn-outline btn-lg">카탈로그</Link>
</div>
</div>
<div className="sol-visual crm-visual">
<div className="sol-screen">
<div className="sol-screen-header"><span />고객 상담 현황</div>
<div className="crm-items">
{[
{ name: '김민준', type: '제품문의', status: '처리중', color: '#f59e0b' },
{ name: '이서연', type: '기술지원', status: '완료', color: '#10b981' },
{ name: '박지후', type: '불만접수', status: '대기', color: '#ef4444' },
{ name: '최수아', type: '데모신청', status: '완료', color: '#10b981' },
].map((c, i) => (
<div key={i} className="crm-item">
<div className="crm-avatar">{c.name[0]}</div>
<div className="crm-info">
<strong>{c.name}</strong>
<span>{c.type}</span>
</div>
<span className="crm-status" style={{ color: c.color }}>{c.status}</span>
</div>
))}
</div>
</div>
</div>
</div>
<div style={{ marginTop: '80px' }}>
<div className="section-header">
<span className="section-label">Features</span>
<h2 className="section-title">주요 기능</h2>
</div>
<div className="grid-3">
{features.map((f, i) => (
<div key={i} className="card sol-module-card">
<div className="sol-module-icon">{f.icon}</div>
<h3>{f.name}</h3>
<p>{f.desc}</p>
</div>
))}
</div>
</div>
</div>
</section>
</main>
);
}
/* ── BI ── */
function BI() {
const charts = [
{ icon: '📊', name: '경영 대시보드', desc: '실시간 KPI 모니터링, 부서별 성과 지표, 경영진 요약 보고' },
{ icon: '📉', name: '매출·비용 분석', desc: '기간별·제품별·채널별 매출 트렌드, 비용 구조 분석' },
{ icon: '🗺️', name: '지역별 분석', desc: '지도 기반 시각화, 공공기관 지역별 서비스 현황' },
{ icon: '🔮', name: 'AI 예측 분석', desc: '머신러닝 기반 수요 예측, 이상 패턴 자동 탐지' },
{ icon: '📋', name: '자동 보고서', desc: '일·주·월 보고서 자동 생성, 이메일·메신저 배포' },
{ icon: '🔗', name: 'ETL 파이프라인', desc: 'Oracle, SAP, 공공DB 등 다양한 소스 데이터 연계' },
];
return (
<main id="main-content" className="inner-page">
<SubNav title="BI 솔루션" />
<section className="section">
<div className="container">
<div className="sol-hero-grid">
<div>
<span className="section-label">Business Intelligence</span>
<h2 className="sol-title">데이터 기반<br /><em>의사결정 플랫폼</em></h2>
<p className="sol-desc">
OZ Report, MiPlatform, JasperReports 다양한 보고 도구와의 연동 경험을 바탕으로,
공공기관·중견기업 맞춤형 BI 플랫폼을 구축합니다.
기존 레거시 DB에서 실시간 데이터를 수집해 경영 인사이트를 제공합니다.
</p>
<div className="sol-features">
{['OZ·MiPlatform·JasperReport 연동', '실시간 대시보드 (WebSocket)', 'Oracle·Tibero·PostgreSQL 지원', '공공기관 표준 보고서 양식'].map((f, i) => (
<div key={i} className="sol-feature-item">
<span className="sol-check"></span> {f}
</div>
))}
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '32px', flexWrap: 'wrap' }}>
<Link to="/support/contact?type=데모 신청" className="btn btn-primary btn-lg">데모 신청</Link>
<Link to="/support/catalog" className="btn btn-outline btn-lg">카탈로그</Link>
</div>
</div>
<div className="sol-visual bi-visual">
<div className="sol-screen">
<div className="sol-screen-header"><span />경영 대시보드</div>
<div className="bi-kpis">
{[
{ label:'매출', val:'₩48.2억', up:true, delta:'+12%' },
{ label:'비용', val:'₩31.7억', up:false, delta:'-3%' },
{ label:'이익', val:'₩16.5억', up:true, delta:'+28%' },
{ label:'고객', val:'1,240명', up:true, delta:'+8%' },
].map((k, i) => (
<div key={i} className="bi-kpi">
<span className="bi-kpi-label">{k.label}</span>
<strong className="bi-kpi-val">{k.val}</strong>
<span className="bi-kpi-delta" style={{ color: k.up ? '#10b981' : '#ef4444' }}>
{k.delta}
</span>
</div>
))}
</div>
<div className="bi-bar-chart">
{['1Q','2Q','3Q','4Q'].map((q, i) => (
<div key={i} className="bi-bar-group">
<div className="bi-bar-pair">
<div className="bi-bar revenue" style={{ height: [60, 75, 85, 100][i] + '%' }} />
<div className="bi-bar cost" style={{ height: [70, 65, 60, 55][i] + '%' }} />
</div>
<span>{q}</span>
</div>
))}
</div>
</div>
</div>
</div>
<div style={{ marginTop: '80px' }}>
<div className="section-header">
<span className="section-label">Features</span>
<h2 className="section-title">주요 기능</h2>
</div>
<div className="grid-3">
{charts.map((c, i) => (
<div key={i} className="card sol-module-card">
<div className="sol-module-icon">{c.icon}</div>
<h3>{c.name}</h3>
<p>{c.desc}</p>
</div>
))}
</div>
</div>
</div>
</section>
</main>
);
}
export default function SolutionPage() {
return (
<Routes>
<Route path="erp" element={<ERP />} />
<Route path="crm" element={<CRM />} />
<Route path="bi" element={<BI />} />
<Route path="*" element={<ERP />} />
</Routes>
);
}