diff --git a/.gitignore b/.gitignore
index 6fa5ad6..66abc74 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,7 +12,6 @@ out/
# Node
node_modules/
-wtm-frontend/node_modules/
# OS
.DS_Store
@@ -22,9 +21,10 @@ Thumbs.db
.env.local
.env.*.local
-# Claude/OMC
+# Claude/OMC/AI
.claude/
.omc/
+CLAUDE.md
# Logs
*.log
@@ -32,3 +32,11 @@ logs/
# Plans
plans/
+
+# Document generation scripts (keep PDFs, exclude generators)
+wbx-spring-core/docs/generate_system_design.py
+wbx-spring-core/docs/regenerate_pdfs.py
+
+# Package lock files (frontend)
+wtm-frontend-vue/package-lock.json
+wtm-frontend-react/package-lock.json
diff --git a/wbx-spring-core/.omc/state/agent-replay-6666f085-e1ae-4dd4-86ee-7f7d5466a239.jsonl b/wbx-spring-core/.omc/state/agent-replay-6666f085-e1ae-4dd4-86ee-7f7d5466a239.jsonl
deleted file mode 100644
index 38868f2..0000000
--- a/wbx-spring-core/.omc/state/agent-replay-6666f085-e1ae-4dd4-86ee-7f7d5466a239.jsonl
+++ /dev/null
@@ -1,12 +0,0 @@
-{"t":0,"agent":"a18f090","agent_type":"Explore","event":"agent_start","parent_mode":"none"}
-{"t":0,"agent":"a18f090","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":44564}
-{"t":0,"agent":"ad9b656","agent_type":"architect","event":"agent_start","parent_mode":"none"}
-{"t":0,"agent":"ad9b656","agent_type":"architect","event":"agent_stop","success":true,"duration_ms":169049}
-{"t":0,"agent":"ad7c101","agent_type":"architect","event":"agent_start","parent_mode":"none"}
-{"t":0,"agent":"ad7c101","agent_type":"architect","event":"agent_stop","success":true,"duration_ms":202690}
-{"t":0,"agent":"aeff378","agent_type":"critic","event":"agent_start","parent_mode":"none"}
-{"t":0,"agent":"aeff378","agent_type":"critic","event":"agent_stop","success":true,"duration_ms":175651}
-{"t":0,"agent":"a35d81f","agent_type":"general-purpose","event":"agent_start","parent_mode":"none"}
-{"t":0,"agent":"a66b53d","agent_type":"general-purpose","event":"agent_start","parent_mode":"none"}
-{"t":0,"agent":"a35d81f","agent_type":"general-purpose","event":"agent_stop","success":true,"duration_ms":105638}
-{"t":0,"agent":"a66b53d","agent_type":"general-purpose","event":"agent_stop","success":true,"duration_ms":411042}
diff --git a/wbx-spring-core/.omc/state/subagent-tracking.json b/wbx-spring-core/.omc/state/subagent-tracking.json
deleted file mode 100644
index 0b209b2..0000000
--- a/wbx-spring-core/.omc/state/subagent-tracking.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "agents": [
- {
- "agent_id": "a18f0906df8444921",
- "agent_type": "Explore",
- "started_at": "2026-03-25T09:57:59.104Z",
- "parent_mode": "none",
- "status": "completed",
- "completed_at": "2026-03-25T09:58:43.668Z",
- "duration_ms": 44564
- },
- {
- "agent_id": "ad9b65686f679467a",
- "agent_type": "oh-my-claudecode:architect",
- "started_at": "2026-03-25T10:12:41.765Z",
- "parent_mode": "none",
- "status": "completed",
- "completed_at": "2026-03-25T10:15:30.814Z",
- "duration_ms": 169049
- },
- {
- "agent_id": "ad7c101944473c52f",
- "agent_type": "oh-my-claudecode:architect",
- "started_at": "2026-03-25T10:20:25.037Z",
- "parent_mode": "none",
- "status": "completed",
- "completed_at": "2026-03-25T10:23:47.727Z",
- "duration_ms": 202690
- },
- {
- "agent_id": "aeff378642946b837",
- "agent_type": "oh-my-claudecode:critic",
- "started_at": "2026-03-25T10:32:26.548Z",
- "parent_mode": "none",
- "status": "completed",
- "completed_at": "2026-03-25T10:35:22.199Z",
- "duration_ms": 175651
- },
- {
- "agent_id": "a35d81fe5c1491345",
- "agent_type": "general-purpose",
- "started_at": "2026-03-25T10:54:19.348Z",
- "parent_mode": "none",
- "status": "completed",
- "completed_at": "2026-03-25T10:56:04.986Z",
- "duration_ms": 105638
- },
- {
- "agent_id": "a66b53d0c9bd248ef",
- "agent_type": "general-purpose",
- "started_at": "2026-03-25T10:54:45.602Z",
- "parent_mode": "none",
- "status": "completed",
- "completed_at": "2026-03-25T11:01:36.644Z",
- "duration_ms": 411042
- }
- ],
- "total_spawned": 6,
- "total_completed": 6,
- "total_failed": 0,
- "last_updated": "2026-03-25T11:01:36.764Z"
-}
\ No newline at end of file
diff --git a/wbx-spring-core/build/libs/wbx-spring-core-1.0.0-SNAPSHOT.jar b/wbx-spring-core/build/libs/wbx-spring-core-1.0.0-SNAPSHOT.jar
index 7ace0ac..17bfcc2 100644
Binary files a/wbx-spring-core/build/libs/wbx-spring-core-1.0.0-SNAPSHOT.jar and b/wbx-spring-core/build/libs/wbx-spring-core-1.0.0-SNAPSHOT.jar differ
diff --git a/wbx-spring-core/build/resources/main/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/wbx-spring-core/build/resources/main/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index c0db889..4cefd95 100644
--- a/wbx-spring-core/build/resources/main/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/wbx-spring-core/build/resources/main/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -1,2 +1,4 @@
+kr.co.accura.wbx.spring.config.EmbeddedRedisConfig
+kr.co.accura.wbx.spring.config.RedisCacheAutoConfig
kr.co.accura.wbx.spring.config.WbxAutoConfiguration
kr.co.accura.wbx.spring.admin.AdminAutoConfiguration
diff --git a/wbx-spring-core/build/resources/main/application-example.yml b/wbx-spring-core/build/resources/main/application-example.yml
index d3f6f67..0dda507 100644
--- a/wbx-spring-core/build/resources/main/application-example.yml
+++ b/wbx-spring-core/build/resources/main/application-example.yml
@@ -26,10 +26,11 @@ spring:
minimum-idle: 5
connection-timeout: 30000
- data:
- redis:
- host: localhost
- port: 6379
+ # Redis (선택 — 미설정 시 인메모리 캐시 자동 사용)
+ # data:
+ # redis:
+ # host: localhost
+ # port: 6379
server:
port: 8080
diff --git a/wbx-spring-core/build/resources/main/application-test.yml b/wbx-spring-core/build/resources/main/application-test.yml
index 18cdd23..61f1f86 100644
--- a/wbx-spring-core/build/resources/main/application-test.yml
+++ b/wbx-spring-core/build/resources/main/application-test.yml
@@ -16,11 +16,6 @@ spring:
flyway:
enabled: false
- data:
- redis:
- host: localhost
- port: 6379
-
wbx:
spring:
jwt:
diff --git a/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/AdminController.class.uniqueId1 b/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/AdminController.class.uniqueId1
deleted file mode 100644
index 20ede33..0000000
Binary files a/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/AdminController.class.uniqueId1 and /dev/null differ
diff --git a/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/AdminLoginController.class.uniqueId3 b/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/AdminLoginController.class.uniqueId3
deleted file mode 100644
index 6be8634..0000000
Binary files a/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/AdminLoginController.class.uniqueId3 and /dev/null differ
diff --git a/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/AdminUserDetailsService.class.uniqueId4 b/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/AdminUserDetailsService.class.uniqueId4
deleted file mode 100644
index f148e83..0000000
Binary files a/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/AdminUserDetailsService.class.uniqueId4 and /dev/null differ
diff --git a/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/AdminViewController.class.uniqueId2 b/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/AdminViewController.class.uniqueId2
deleted file mode 100644
index 2c10c4a..0000000
Binary files a/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/AdminViewController.class.uniqueId2 and /dev/null differ
diff --git a/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/SecurityAutoConfig.class.uniqueId0 b/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/SecurityAutoConfig.class.uniqueId0
deleted file mode 100644
index 9091e81..0000000
Binary files a/wbx-spring-core/build/tmp/compileJava/compileTransaction/stash-dir/SecurityAutoConfig.class.uniqueId0 and /dev/null differ
diff --git a/wbx-spring-core/build/tmp/compileJava/previous-compilation-data.bin b/wbx-spring-core/build/tmp/compileJava/previous-compilation-data.bin
index 0699c34..29f7c53 100644
Binary files a/wbx-spring-core/build/tmp/compileJava/previous-compilation-data.bin and b/wbx-spring-core/build/tmp/compileJava/previous-compilation-data.bin differ
diff --git a/wbx-spring-core/docs/generate_system_design.py b/wbx-spring-core/docs/generate_system_design.py
deleted file mode 100644
index 9fc3158..0000000
--- a/wbx-spring-core/docs/generate_system_design.py
+++ /dev/null
@@ -1,838 +0,0 @@
-"""
-한화오션 EPU WTM 시스템 설계서 PPT 생성
-요구사항정의 → To-Be → 시스템구성도 → 기능정의 → 시스템설계
-"""
-from pptx import Presentation
-from pptx.util import Inches, Pt, Emu
-from pptx.dml.color import RGBColor
-from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
-from pptx.enum.shapes import MSO_SHAPE
-import os
-
-OUTPUT = os.path.join(os.path.dirname(__file__), "한화오션_EPU_WTM_시스템설계서.pptx")
-
-# 색상
-PRIMARY = RGBColor(30, 60, 120)
-ACCENT = RGBColor(0, 120, 200)
-DARK = RGBColor(40, 40, 40)
-BODY = RGBColor(80, 80, 80)
-WHITE = RGBColor(255, 255, 255)
-LIGHT_BG = RGBColor(240, 245, 255)
-GREEN_BG = RGBColor(220, 240, 220)
-ORANGE_BG = RGBColor(255, 240, 220)
-RED_ACCENT = RGBColor(200, 60, 60)
-
-
-def add_box(slide, left, top, width, height, text, fill_color=LIGHT_BG, font_color=PRIMARY, font_size=10, bold=True):
- shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height)
- shape.fill.solid()
- shape.fill.fore_color.rgb = fill_color
- shape.line.color.rgb = PRIMARY
- shape.line.width = Pt(1)
- tf = shape.text_frame
- tf.word_wrap = True
- p = tf.paragraphs[0]
- p.text = text
- p.font.size = Pt(font_size)
- p.font.color.rgb = font_color
- p.font.bold = bold
- p.alignment = PP_ALIGN.CENTER
- tf.paragraphs[0].space_before = Pt(2)
- return shape
-
-
-def add_textbox(slide, left, top, width, height, text, font_size=10, color=BODY, bold=False, align=PP_ALIGN.LEFT):
- txBox = slide.shapes.add_textbox(left, top, width, height)
- tf = txBox.text_frame
- tf.word_wrap = True
- p = tf.paragraphs[0]
- p.text = text
- p.font.size = Pt(font_size)
- p.font.color.rgb = color
- p.font.bold = bold
- p.alignment = align
- return txBox
-
-
-def add_multiline_box(slide, left, top, width, height, title, items, fill_color=LIGHT_BG):
- shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height)
- shape.fill.solid()
- shape.fill.fore_color.rgb = fill_color
- shape.line.color.rgb = PRIMARY
- shape.line.width = Pt(1)
- tf = shape.text_frame
- tf.word_wrap = True
- p = tf.paragraphs[0]
- p.text = title
- p.font.size = Pt(9)
- p.font.color.rgb = PRIMARY
- p.font.bold = True
- for item in items:
- p2 = tf.add_paragraph()
- p2.text = f"• {item}"
- p2.font.size = Pt(7)
- p2.font.color.rgb = BODY
- p2.space_before = Pt(1)
- return shape
-
-
-def add_arrow(slide, start_left, start_top, end_left, end_top):
- connector = slide.shapes.add_connector(
- 1, start_left, start_top, end_left, end_top) # 1 = straight
- connector.line.color.rgb = PRIMARY
- connector.line.width = Pt(1.5)
- return connector
-
-
-def add_table(slide, left, top, width, rows_data, col_widths=None):
- rows = len(rows_data)
- cols = len(rows_data[0])
- table_shape = slide.shapes.add_table(rows, cols, left, top, width, Inches(0.3 * rows))
- table = table_shape.table
-
- if col_widths:
- for i, w in enumerate(col_widths):
- table.columns[i].width = w
-
- for ri, row in enumerate(rows_data):
- for ci, val in enumerate(row):
- cell = table.cell(ri, ci)
- cell.text = str(val)
- p = cell.text_frame.paragraphs[0]
- p.font.size = Pt(8)
- if ri == 0:
- p.font.bold = True
- p.font.color.rgb = WHITE
- cell.fill.solid()
- cell.fill.fore_color.rgb = PRIMARY
- else:
- p.font.color.rgb = BODY
- if ri % 2 == 0:
- cell.fill.solid()
- cell.fill.fore_color.rgb = LIGHT_BG
- p.alignment = PP_ALIGN.CENTER if ci > 0 else PP_ALIGN.LEFT
-
-
-def title_slide(prs, title, subtitle):
- slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
- # 배경
- bg = slide.background.fill
- bg.solid()
- bg.fore_color.rgb = PRIMARY
- # 타이틀
- add_textbox(slide, Inches(1), Inches(2), Inches(8), Inches(1),
- title, font_size=32, color=WHITE, bold=True, align=PP_ALIGN.CENTER)
- add_textbox(slide, Inches(1), Inches(3.2), Inches(8), Inches(0.8),
- subtitle, font_size=16, color=RGBColor(180, 200, 230), align=PP_ALIGN.CENTER)
- add_textbox(slide, Inches(1), Inches(5.5), Inches(8), Inches(0.5),
- "아큐라시스템 | 2026년 3월", font_size=12, color=RGBColor(150, 170, 200), align=PP_ALIGN.CENTER)
- return slide
-
-
-def section_slide(prs, num, title, subtitle=""):
- slide = prs.slides.add_slide(prs.slide_layouts[6])
- bg = slide.background.fill
- bg.solid()
- bg.fore_color.rgb = RGBColor(25, 50, 100)
- # 큰 번호
- add_textbox(slide, Inches(0.8), Inches(1.5), Inches(2), Inches(1.2),
- f"{num:02d}", font_size=72, color=ACCENT, bold=True)
- # 구분선
- shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE,
- Inches(0.8), Inches(3.0), Inches(8), Pt(3))
- shape.fill.solid()
- shape.fill.fore_color.rgb = ACCENT
- shape.line.fill.background()
- # 타이틀
- add_textbox(slide, Inches(0.8), Inches(3.3), Inches(9), Inches(0.8),
- title, font_size=28, color=WHITE, bold=True)
- # 서브타이틀
- if subtitle:
- add_textbox(slide, Inches(0.8), Inches(4.3), Inches(8), Inches(0.5),
- subtitle, font_size=12, color=RGBColor(150, 180, 220))
- return slide
-
-
-def content_slide(prs, title):
- slide = prs.slides.add_slide(prs.slide_layouts[6])
- # 상단 바
- shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, Inches(10), Inches(0.6))
- shape.fill.solid()
- shape.fill.fore_color.rgb = PRIMARY
- shape.line.fill.background()
- add_textbox(slide, Inches(0.3), Inches(0.05), Inches(9), Inches(0.5),
- title, font_size=16, color=WHITE, bold=True)
- return slide
-
-
-def build():
- prs = Presentation()
- prs.slide_width = Inches(10)
- prs.slide_height = Inches(7.5)
-
- # ============================================================
- # 표지
- # ============================================================
- title_slide(prs,
- "한화오션 EPU\nWork Time Manager (WTM)",
- "시스템 설계서 — 요구사항정의 · To-Be · 시스템구성 · 기능정의 · 설계")
-
- # ============================================================
- # 목차
- # ============================================================
- slide = content_slide(prs, "목차")
- toc = [
- "01. 프로젝트 개요",
- "02. 요구사항 정의서",
- "03. As-Is / To-Be 분석",
- "04. 시스템 구성도",
- "05. 기능 정의서",
- "06. 데이터베이스 설계",
- "07. API 설계",
- "08. 화면 구성",
- "09. 인터페이스 설계 (SAP/P6/Cognite)",
- "10. 보안 설계",
- "11. 단계별 추진 일정",
- ]
- y = 1.0
- for item in toc:
- add_textbox(slide, Inches(1.5), Inches(y), Inches(7), Inches(0.35),
- item, font_size=14, color=DARK)
- y += 0.4
-
- # ============================================================
- # 01. 프로젝트 개요
- # ============================================================
- section_slide(prs, 1, "프로젝트 개요", "WTM 시스템 범위, 기술 스택, 추진 구조")
-
- slide = content_slide(prs, "01. 프로젝트 개요")
- overview_data = [
- ["항목", "내용"],
- ["프로젝트명", "WTM (Work Time Manager)"],
- ["고객", "한화오션 EPU (Engineering Procurement Unit)"],
- ["목적", "EPC 프로젝트 인력 시수 관리, WBS 연동, 결재, 리포트"],
- ["기능 요구사항", "86개 (PH1 Y=62 / PH2 이관=24)"],
- ["비기능 요구사항", "17개 (NF.1~17)"],
- ["화면 수", "49개 (PH1-1차 26 / PH1-2차 11 / PH2 12)"],
- ["API 수", "79개 (PH1-1: 67 / PH1-2: 6 / PH2: 6)"],
- ["기술 스택", "Spring Boot 3.5.0 + Java 21 (wbx-spring 프레임워크)"],
- ["DB", "Azure SQL (프로필 전환: Oracle/MSSQL/MySQL/PG)"],
- ["인증", "Azure Entra ID SSO + MFA/TOTP"],
- ]
- add_table(slide, Inches(0.5), Inches(1.0), Inches(9),
- overview_data, col_widths=[Inches(2.5), Inches(6.5)])
-
- # ============================================================
- # 02. 요구사항 정의서
- # ============================================================
- section_slide(prs, 2, "요구사항 정의서", "기능 86개 (Y=62/N=24) + 비기능 17개 + 확인필요사항 7건")
-
- # 기능 요구사항 요약
- slide = content_slide(prs, "02-1. 기능 요구사항 (86개) — 영역별 분류")
- req_data = [
- ["영역", "PH1 (Y)", "PH2 이관 (N)", "합계"],
- ["User Registration (No.1~7)", "5", "2", "7"],
- ["Login / 인증 (No.8~16)", "8", "1", "9"],
- ["User Home / 권한 (No.17~24)", "6", "2", "8"],
- ["Project / WBS (No.25~41)", "14", "3", "17"],
- ["Resource Assignment (No.42~54)", "6", "7", "13"],
- ["Time Sheet (No.55~69)", "13", "2", "15"],
- ["Approval (No.70~75)", "6", "0", "6"],
- ["Reporting (No.76~86)", "2", "9", "11"],
- ["합계", "62", "24", "86"],
- ]
- add_table(slide, Inches(0.5), Inches(1.0), Inches(9),
- req_data, col_widths=[Inches(4), Inches(2), Inches(2), Inches(1)])
-
- # 비기능 요구사항
- slide = content_slide(prs, "02-2. 비기능 요구사항 (17개)")
- nf_data = [
- ["No.", "카테고리", "요구사항", "비고"],
- ["NF.1~2", "클라우드", "Azure Hybrid Security Zone 내 구성", "MS Azure"],
- ["NF.3", "보안(서버)", "HIWARE, V3, Secuver TOS", "한화 표준"],
- ["NF.4", "보안(DB)", "Cubeone(암호화), Dbsafer(접근제어)", "한화 표준"],
- ["NF.5", "보안(Cloud)", "Defender + Analytics", "Native"],
- ["NF.6~7", "정보보호", "보안성 심의, 컴플라이언스", "그룹 표준"],
- ["NF.8~10", "모니터링", "onTune(SMS), Maxguage(DB), Jennifer(WAS)", "한화 표준"],
- ["NF.11", "인증(내부)", "Azure Entra ID SSO", ""],
- ["NF.12", "인증(외부)", "2-Way 인증 (MFA)", "TOTP"],
- ["NF.13", "I/F(HR)", "SAP BTP 경유 SuccessFactors 연동", ""],
- ["NF.14", "I/F(P6)", "파일 기반 (물리적 I/F 없음)", ""],
- ["NF.15", "I/F(Cognite)", "Extractor 서버 구성", "PH2"],
- ["NF.16~17", "아키텍처", "이중화, 백업 전략", ""],
- ]
- add_table(slide, Inches(0.3), Inches(1.0), Inches(9.4),
- nf_data, col_widths=[Inches(1), Inches(1.5), Inches(4.5), Inches(1.5)])
-
- # 확인필요사항
- slide = content_slide(prs, "02-3. 확인필요사항 (7건)")
- confirm_data = [
- ["#", "영역", "내용", "현재 상태"],
- ["1", "User", "파트너사/외주 인력 관리 정책", "파트너 마스터 미연계"],
- ["2", "권한", "사용자 관리규정 및 권한정책", "No.18 확정 (기능 기반 역할)"],
- ["3", "WBS", "WBS별 Resource Plan 방식", "TT에서 투입시수 세팅"],
- ["4", "배정", "인력 배치 시 스케줄 확인 절차", "Discipline별 인력 선정 후 제출"],
- ["5", "EPC", "C단계 시수 입력 방안", "C단계 대상 제외 (EPU만)"],
- ["6", "결재", "업무별 승인 절차", "User→DL→PM 3단계 확정"],
- ["7", "배정", "Project Assignment 프로세스", "SA 생성→PM 배정→확정"],
- ]
- add_table(slide, Inches(0.3), Inches(1.0), Inches(9.4),
- confirm_data, col_widths=[Inches(0.5), Inches(1), Inches(3.5), Inches(3.5)])
-
- # ============================================================
- # 03. As-Is / To-Be
- # ============================================================
- section_slide(prs, 3, "As-Is / To-Be 분석", "Excel 수작업 → WTM 시스템 전환 효과")
-
- slide = content_slide(prs, "03. As-Is → To-Be 전환")
-
- # As-Is 박스들
- add_textbox(slide, Inches(0.5), Inches(0.9), Inches(4), Inches(0.4),
- "As-Is (현재)", font_size=14, color=RED_ACCENT, bold=True)
-
- as_is = [
- ("Excel 기반 시수 관리", "수작업 입력, 오류 빈번"),
- ("이메일 기반 결재", "추적 불가, 지연"),
- ("P6 WBS 수동 매핑", "버전 관리 어려움"),
- ("리포트 수동 집계", "실시간 분석 불가"),
- ("인력 배정 구두 전달", "이력 관리 없음"),
- ]
- y = 1.4
- for title, desc in as_is:
- add_multiline_box(slide, Inches(0.5), Inches(y), Inches(4), Inches(0.55),
- title, [desc], fill_color=RGBColor(255, 235, 235))
- y += 0.6
-
- # 화살표 영역
- add_textbox(slide, Inches(4.5), Inches(2.5), Inches(1), Inches(0.5),
- "→", font_size=36, color=ACCENT, bold=True, align=PP_ALIGN.CENTER)
-
- # To-Be 박스들
- add_textbox(slide, Inches(5.5), Inches(0.9), Inches(4), Inches(0.4),
- "To-Be (WTM 시스템)", font_size=14, color=ACCENT, bold=True)
-
- to_be = [
- ("통합 시수 입력 (3종)", "Non-Project/Other/EPC 탭 전환, 규칙 엔진"),
- ("온라인 결재 워크플로우", "User→DL→PM 3단계, 일괄 승인, 실시간 알림"),
- ("P6 WBS 자동 파싱", "Canonical WBS 매핑, 버전 관리, 비교 UI"),
- ("실시간 리포트", "프로젝트별/WBS별 시수 분석, Excel Export"),
- ("체계적 인력 배정", "프로젝트-Discipline-인력 매핑, 이력 관리"),
- ]
- y = 1.4
- for title, desc in to_be:
- add_multiline_box(slide, Inches(5.5), Inches(y), Inches(4), Inches(0.55),
- title, [desc], fill_color=RGBColor(230, 245, 255))
- y += 0.6
-
- # 하단 기대효과
- add_textbox(slide, Inches(0.5), Inches(5.0), Inches(9), Inches(0.3),
- "기대효과", font_size=12, color=PRIMARY, bold=True)
- effects = "• 시수 입력 오류 90% 감소 • 결재 처리 시간 70% 단축 • 실시간 프로젝트 시수 현황 파악 • WBS 변경 추적 자동화"
- add_textbox(slide, Inches(0.5), Inches(5.3), Inches(9), Inches(0.5),
- effects, font_size=10, color=BODY)
-
- # ============================================================
- # 04. 시스템 구성도
- # ============================================================
- section_slide(prs, 4, "시스템 구성도", "전체 아키텍처, 기술 스택, 네트워크 구성")
-
- # 전체 아키텍처
- slide = content_slide(prs, "04-1. 전체 시스템 아키텍처")
-
- # 클라이언트 영역
- add_textbox(slide, Inches(0.3), Inches(0.9), Inches(2), Inches(0.3),
- "클라이언트", font_size=10, color=BODY, bold=True)
- clients = ["WBX No-Code", "Web Browser", "Mobile"]
- cx = 0.3
- for c in clients:
- add_box(slide, Inches(cx), Inches(1.2), Inches(1.4), Inches(0.5),
- c, fill_color=RGBColor(200, 220, 255), font_size=8)
- cx += 1.6
-
- # Nginx
- add_box(slide, Inches(5.5), Inches(1.2), Inches(1.8), Inches(0.5),
- "Nginx (SSL/LB)", fill_color=RGBColor(220, 220, 220), font_color=DARK, font_size=9)
-
- # Spring Boot WTM
- add_multiline_box(slide, Inches(0.3), Inches(2.2), Inches(4.5), Inches(2.8),
- "Spring Boot WTM-API (wbx-spring 프레임워크)", [
- "JWT 인증 / Azure Entra ID SSO / MFA",
- "RBAC 권한 (dept_scope)",
- "통합 결재 엔진 (Handler Registry)",
- "SSE 실시간 알림",
- "시수 입력 3종 + 규칙 엔진",
- "WBS/TEAL 관리 + P6 파서",
- "리포트 (QueryDSL)",
- "SAP BTP HR 연동",
- ], fill_color=RGBColor(230, 240, 255))
-
- # WBX FastAPI
- add_multiline_box(slide, Inches(5.2), Inches(2.2), Inches(2.3), Inches(2.0),
- "WBX FastAPI (선택)", [
- "문서관리",
- "이메일",
- "게시판/일정",
- "/api/gw/*"
- ], fill_color=RGBColor(245, 240, 255))
-
- # DB
- add_multiline_box(slide, Inches(7.8), Inches(2.2), Inches(2), Inches(1.5),
- "Database", [
- "wtm_db (Azure SQL)",
- "wbx_gw (MySQL)",
- "프로필 전환 지원"
- ], fill_color=RGBColor(255, 245, 230))
-
- # Redis
- add_box(slide, Inches(7.8), Inches(4.0), Inches(1), Inches(0.5),
- "Redis", fill_color=GREEN_BG, font_size=9)
-
- # Blob Storage
- add_box(slide, Inches(8.9), Inches(4.0), Inches(1), Inches(0.5),
- "Storage", fill_color=ORANGE_BG, font_size=9)
-
- # SSO
- add_box(slide, Inches(5.2), Inches(4.5), Inches(2.3), Inches(0.5),
- "Azure Entra ID (SSO+MFA)", fill_color=RGBColor(255, 235, 235), font_size=8)
-
- # SAP BTP
- add_box(slide, Inches(0.3), Inches(5.3), Inches(2), Inches(0.5),
- "SAP BTP (HR)", fill_color=RGBColor(255, 240, 220), font_size=9)
-
- # P6
- add_box(slide, Inches(2.5), Inches(5.3), Inches(1.5), Inches(0.5),
- "P6 (WBS)", fill_color=RGBColor(255, 240, 220), font_size=9)
-
- # Cognite
- add_box(slide, Inches(4.2), Inches(5.3), Inches(1.5), Inches(0.5),
- "Cognite", fill_color=RGBColor(255, 240, 220), font_size=9)
-
- # 기술 스택 슬라이드
- slide = content_slide(prs, "04-2. 기술 스택")
- tech_data = [
- ["레이어", "기술", "버전/비고"],
- ["Language", "Java", "21 (LTS)"],
- ["Framework", "Spring Boot + wbx-spring", "3.5.0"],
- ["Security", "Spring Security 6 + OAuth2", "Entra ID SSO + MFA"],
- ["ORM", "Spring Data JPA + QueryDSL", "Hibernate 6"],
- ["DB", "Azure SQL / Oracle / MySQL / PG", "프로필 전환"],
- ["Migration", "Flyway", "DBMS별 DDL 분리"],
- ["Cache", "Redis", "세션 + 캐시"],
- ["WEB/WAS", "Nginx + Embedded Tomcat", "이중화"],
- ["CI/CD", "GitHub Actions + Azure DevOps", "승인 게이트"],
- ["형상관리", "GitHub + Azure Repos", "SemVer"],
- ["모니터링", "Jennifer + Actuator + Prometheus", "한화 표준"],
- ["Cloud", "Microsoft Azure", "Hybrid Security Zone"],
- ]
- add_table(slide, Inches(0.5), Inches(1.0), Inches(9),
- tech_data, col_widths=[Inches(2), Inches(3.5), Inches(3.5)])
-
- # ============================================================
- # 05. 기능 정의서
- # ============================================================
- section_slide(prs, 5, "기능 정의서", "시수 입력 3종, WBS/TEAL, 결재 워크플로우, 리포트")
-
- # 시수 입력
- slide = content_slide(prs, "05-1. 시수 입력 (3종 통합 UI)")
-
- # 탭 UI 도식
- tabs = ["Non-Project", "Other Project", "EPC Project"]
- tx = 0.5
- for i, tab in enumerate(tabs):
- color = ACCENT if i == 2 else RGBColor(180, 180, 180)
- add_box(slide, Inches(tx), Inches(1.0), Inches(2), Inches(0.4),
- tab, fill_color=RGBColor(230, 240, 255) if i == 2 else RGBColor(245, 245, 245),
- font_color=color, font_size=9)
- tx += 2.2
-
- # EPC 입력 폼 도식
- fields = [
- "프로젝트: [EPU-2025-001 ▼]",
- "WBS: [E.01.03 Piping Detail ▼]",
- "TEAL: [Detail Engineering ▼]",
- ]
- fy = 1.6
- for field in fields:
- add_textbox(slide, Inches(0.8), Inches(fy), Inches(6), Inches(0.3),
- field, font_size=10, color=DARK)
- fy += 0.35
-
- # 주간 그리드
- days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "합계"]
- vals = ["8.0", "8.0", "8.0", "8.0", "4.0", "-", "36.0h"]
- grid_data = [days, vals]
- add_table(slide, Inches(0.5), Inches(2.9), Inches(6.5),
- grid_data, col_widths=[Inches(0.9)] * 7)
-
- # 규칙 엔진 박스
- add_multiline_box(slide, Inches(7.3), Inches(1.0), Inches(2.5), Inches(2.5),
- "규칙 엔진", [
- "일 최소 8시간",
- "주 최대 52시간",
- "Activity 최소 1개",
- "Location별 규칙",
- "미래 날짜 입력 불가",
- "초과 시 경고/차단",
- ], fill_color=ORANGE_BG)
-
- # 하단 설명
- add_textbox(slide, Inches(0.5), Inches(3.6), Inches(6), Inches(0.3),
- "[임시 저장] [결재 요청] 주간 합계: 36.0 / 52h",
- font_size=9, color=BODY)
-
- ts_desc = [
- ("Non-Project (No.55~59)", "Overhead Type 선택 → TEAL Activity → 시간 입력. WBS/프로젝트 입력 불가"),
- ("Other Project (No.60~62)", "프로젝트 선택 → Canonical WBS L2~4 → Activity → 시간. P6 연동 불필요"),
- ("EPC Project (No.64~69)", "프로젝트 → Canonical WBS L2~5 → TEAL → 시간. Revision 관리(PH1-2)"),
- ]
- fy = 4.2
- for title, desc in ts_desc:
- add_textbox(slide, Inches(0.5), Inches(fy), Inches(9), Inches(0.2),
- title, font_size=9, color=PRIMARY, bold=True)
- add_textbox(slide, Inches(0.5), Inches(fy + 0.2), Inches(9), Inches(0.25),
- desc, font_size=8, color=BODY)
- fy += 0.5
-
- # WBS/TEAL
- slide = content_slide(prs, "05-2. WBS · TEAL 관리")
-
- # Canonical WBS 구조
- add_textbox(slide, Inches(0.5), Inches(0.9), Inches(4), Inches(0.3),
- "Canonical WBS 구조 (No.34)", font_size=12, color=PRIMARY, bold=True)
- wbs_levels = [
- ["Level", "구분", "설명", "예시"],
- ["L1", "Project", "프로젝트", "EPU-2025-001"],
- ["L2", "Phase", "Engineering/Procurement/Construction/...", "Engineering"],
- ["L3", "Asset/Area", "자산 또는 영역", "Hull Structure"],
- ["L4", "Work/Discipline", "작업 또는 Discipline", "Piping"],
- ["L5", "Deliverable", "산출물 (Eng/SCM only)", "Detail Drawing"],
- ]
- add_table(slide, Inches(0.5), Inches(1.3), Inches(9),
- wbs_levels, col_widths=[Inches(0.8), Inches(1.5), Inches(4), Inches(2.5)])
-
- # WBS 흐름
- add_textbox(slide, Inches(0.5), Inches(3.5), Inches(4), Inches(0.3),
- "WBS 업로드 흐름", font_size=12, color=PRIMARY, bold=True)
- flow_steps = ["P6 Export\n(PM 업로드)", "WBS 파싱\n(Level 1~5)", "Canonical\nWBS 매핑", "PCM 승인\n버전 등록", "TEAL\nActivity 연결"]
- fx = 0.3
- for i, step in enumerate(flow_steps):
- add_box(slide, Inches(fx), Inches(3.9), Inches(1.6), Inches(0.8),
- step, fill_color=RGBColor(230, 240, 255) if i % 2 == 0 else GREEN_BG,
- font_size=8, font_color=DARK)
- if i < len(flow_steps) - 1:
- add_textbox(slide, Inches(fx + 1.6), Inches(4.05), Inches(0.3), Inches(0.3),
- "→", font_size=16, color=ACCENT, bold=True, align=PP_ALIGN.CENTER)
- fx += 1.9
-
- # 결재 워크플로우
- slide = content_slide(prs, "05-3. 결재 워크플로우")
-
- # 결재 흐름도
- steps = [
- ("User\n시수 입력", RGBColor(200, 220, 255)),
- ("제출\n(결재 요청)", RGBColor(255, 240, 200)),
- ("DL\n1차 결재", RGBColor(220, 240, 220)),
- ("PM\n최종 결재", RGBColor(220, 240, 220)),
- ("승인 완료\n(APPROVED)", RGBColor(200, 255, 200)),
- ]
- sx = 0.3
- for i, (label, color) in enumerate(steps):
- add_box(slide, Inches(sx), Inches(1.3), Inches(1.6), Inches(0.9),
- label, fill_color=color, font_color=DARK, font_size=10)
- if i < len(steps) - 1:
- add_textbox(slide, Inches(sx + 1.6), Inches(1.5), Inches(0.3), Inches(0.3),
- "→", font_size=18, color=ACCENT, bold=True, align=PP_ALIGN.CENTER)
- sx += 1.9
-
- # 반려 흐름
- add_box(slide, Inches(3.5), Inches(2.5), Inches(2.5), Inches(0.5),
- "반려 → User 수정 후 재제출", fill_color=RGBColor(255, 230, 230),
- font_color=RED_ACCENT, font_size=9)
-
- # 결재 기능 표
- approval_data = [
- ["기능", "설명", "요구사항"],
- ["시수 결재 요청", "User가 주간 시수 작성 후 제출", "No.70"],
- ["승인/반려 + 코멘트", "DL/PM이 승인 또는 반려 (사유 필수)", "No.72"],
- ["일괄 승인", "여러 시수를 한번에 승인", "No.73"],
- ["결재 이력 조회", "과거 결재 내역 전체 조회", "No.74"],
- ["초과 하이라이트", "기준 초과 시 결재자에게 강조 표시", "No.75 (PH1-2)"],
- ["미완료 알림", "미입력/미제출 시수 자동 리마인더", "No.70"],
- ]
- add_table(slide, Inches(0.3), Inches(3.3), Inches(9.4),
- approval_data, col_widths=[Inches(2), Inches(5), Inches(1.5)])
-
- # 리포트
- slide = content_slide(prs, "05-4. 리포트")
- report_data = [
- ["리포트", "설명", "Phase", "필터"],
- ["프로젝트별 시수 분석", "프로젝트별 실제 투입 Manhour", "PH1-1", "기간, 프로젝트, Discipline"],
- ["WBS Level별 시수 분석", "Canonical WBS Level별 Manhour", "PH1-1", "기간, 프로젝트, WBS Level"],
- ["Phase별 시수 비율", "Engineering/Procurement 등 비율", "PH1-2", "기간, 프로젝트"],
- ["Non-Project 비율", "Overhead Manhour 투입 비율", "PH1-2", "기간, 부서"],
- ["WBS 버전 이력", "WBS 변경 이력 조회", "PH1-2", "프로젝트, 버전"],
- ["Discipline 생산성", "Discipline별 생산성 분석", "PH2", "기간, Discipline"],
- ]
- add_table(slide, Inches(0.3), Inches(1.0), Inches(9.4),
- report_data, col_widths=[Inches(2.2), Inches(3.5), Inches(1), Inches(2)])
-
- # ============================================================
- # 06. 데이터베이스 설계
- # ============================================================
- section_slide(prs, 6, "데이터베이스 설계", "ERD, 주요 테이블 14개, Flyway 마이그레이션")
-
- slide = content_slide(prs, "06-1. ERD 개요 (wtm_db)")
-
- # ERD 도식
- entities = [
- ("users", Inches(0.3), Inches(1.0), ["id, employee_number", "email, full_name", "department, discipline", "is_active, mfa_enabled"]),
- ("projects", Inches(3.3), Inches(1.0), ["id, project_code", "name, project_type", "status, pm_user_id"]),
- ("canonical_wbs", Inches(6.3), Inches(1.0), ["id, project_id", "wbs_code, level", "name, discipline"]),
- ("timesheets", Inches(0.3), Inches(3.2), ["id, user_id", "week_start_date", "status, total_hours"]),
- ("timesheet_entries", Inches(3.3), Inches(3.2), ["id, timesheet_id", "entry_type, entry_date", "hours, epc_project_id", "canonical_wbs_id"]),
- ("tt_approvals", Inches(6.3), Inches(3.2), ["id, timesheet_id", "requester_id, status", "submitted_at"]),
- ("tt_approval_lines", Inches(6.3), Inches(5.0), ["id, approval_id", "approver_id, order", "role_code, status"]),
- ("wbs_versions", Inches(3.3), Inches(5.0), ["id, project_id", "version_number", "effective_date"]),
- ]
-
- for name, x, y, fields in entities:
- h = Inches(0.2 + len(fields) * 0.2)
- add_multiline_box(slide, x, y, Inches(2.7), h, name, fields, fill_color=LIGHT_BG)
-
- # DB 테이블 목록
- slide = content_slide(prs, "06-2. 주요 테이블 목록")
- db_data = [
- ["테이블", "용도", "주요 컬럼"],
- ["users", "사용자 (HR 필드 포함)", "employee_number, business_unit~part (5레벨)"],
- ["projects", "프로젝트 (EPC/Other/Non)", "project_code, type, status, pm_user_id"],
- ["wbs_versions", "P6 WBS 스냅샷", "version_number, effective_date, source_filename"],
- ["wbs_nodes", "WBS 트리 (L1~L5)", "wbs_code, level, parent_id, planned_hours"],
- ["canonical_wbs", "정규 WBS", "wbs_code, mapped_p6_code, discipline"],
- ["teal_entries", "TEAL Activity", "canonical_wbs_id, activity_code, activity_name"],
- ["timesheets", "주간 시수 헤더", "user_id, week_start_date, status, total_hours"],
- ["timesheet_entries", "일별 시수 상세", "entry_type, entry_date, hours, remark"],
- ["tt_approvals", "결재 요청", "timesheet_id, requester_id, status"],
- ["tt_approval_lines", "결재 라인 (DL/PM)", "approver_id, approval_order, role_code"],
- ["work_rules", "근무 규칙", "location_code, min_daily, max_weekly"],
- ["overhead_types", "Overhead 유형", "code, name (SA 관리)"],
- ["project_assignments", "인력 배정", "project_id, user_id, discipline"],
- ]
- add_table(slide, Inches(0.2), Inches(1.0), Inches(9.6),
- db_data, col_widths=[Inches(2), Inches(2.5), Inches(4.5)])
-
- # ============================================================
- # 07. API 설계
- # ============================================================
- section_slide(prs, 7, "API 설계", "REST API 79개 — /api/wtm/ prefix")
-
- slide = content_slide(prs, "07. REST API 스펙 (79개, /api/wtm/)")
- api_data = [
- ["모듈", "PH1-1", "PH1-2", "PH2", "합계", "주요 Endpoint"],
- ["Auth", "8", "0", "0", "8", "/auth/login, /auth/sso, /auth/mfa/*"],
- ["Users", "7", "1", "0", "8", "/users, /users/upload/internal"],
- ["Projects", "7", "0", "0", "7", "/projects, /projects/my, /projects/{id}/members"],
- ["WBS", "6", "1", "0", "7", "/projects/{id}/wbs/upload, /canonical-wbs"],
- ["TEAL", "4", "0", "0", "4", "/projects/{id}/teal/active"],
- ["Timesheets", "8", "0", "0", "8", "/timesheets/week, /entries/batch, /upload"],
- ["Approvals", "7", "1", "0", "8", "/approvals/unified/action/{type}/{id}/*"],
- ["Reports", "4", "2", "3", "9", "/reports/project-hours, /wbs-hours"],
- ["Resource", "8", "0", "0", "8", "/projects/{id}/assignments"],
- ["Config", "4", "0", "0", "4", "/overhead-types, /work-rules"],
- ["Integration", "1", "1", "1", "3", "/integration/hr/sync, /cognite/export"],
- ["Notification", "3", "0", "0", "3", "/notifications/stream (SSE)"],
- ["합계", "67", "6", "6", "79", ""],
- ]
- add_table(slide, Inches(0.2), Inches(1.0), Inches(9.6),
- api_data, col_widths=[Inches(1.2), Inches(0.7), Inches(0.7), Inches(0.7), Inches(0.7), Inches(5)])
-
- # ============================================================
- # 08. 화면 구성
- # ============================================================
- section_slide(prs, 8, "화면 구성", "전체 49개 화면 — PH1-1차 26 / PH1-2차 11 / PH2 12")
-
- slide = content_slide(prs, "08. 전체 화면 목록 (49개)")
- screen_data = [
- ["그룹", "PH1-1차 (26개)", "PH1-2차 (11개)", "PH2 (12개)"],
- ["로그인/인증", "로그인, SSO, PW찾기, PW만료 (4)", "", ""],
- ["홈/권한", "역할별 홈 5종 (5)", "권한라우팅, SA로그 (2)", ""],
- ["사용자/인력", "내부/외주 업로드, 관리 (3)", "", "외주 개별입력 (1)"],
- ["프로젝트/WBS", "등록, P6, WBS, TEAL 등 (6)", "WBS비교, 시수조회 (3)", ""],
- ["시수 입력", "Non/Other/EPC 입력, Excel (4)", "EPC Revision (1)", "벤치마킹, Rate (2)"],
- ["결재", "결재요청, 목록, 일괄승인 (3)", "초과알림, MFA (2)", ""],
- ["리포트", "프로젝트별, WBS별 (2)", "Phase비율, NP비율 (2)", "RCP 3종, Discipline 등 (5)"],
- ["기타", "", "HR배치 (1)", "외주포털, Favorite 등 (4)"],
- ]
- add_table(slide, Inches(0.2), Inches(1.0), Inches(9.6),
- screen_data, col_widths=[Inches(1.5), Inches(3), Inches(2.5), Inches(2.5)])
-
- # ============================================================
- # 09. 인터페이스 설계
- # ============================================================
- section_slide(prs, 9, "인터페이스 설계", "SAP BTP (HR), P6 (WBS 파일), Cognite (Export)")
-
- slide = content_slide(prs, "09. 외부 시스템 인터페이스")
-
- # SAP BTP
- add_multiline_box(slide, Inches(0.3), Inches(1.0), Inches(2.5), Inches(2),
- "SAP SuccessFactors", [
- "HR Master Data",
- "Employee Central",
- "OData API"
- ], fill_color=RGBColor(255, 245, 220))
-
- add_textbox(slide, Inches(2.8), Inches(1.7), Inches(0.8), Inches(0.3),
- "→ BTP →", font_size=10, color=ACCENT, bold=True, align=PP_ALIGN.CENTER)
-
- add_multiline_box(slide, Inches(3.6), Inches(1.0), Inches(2.5), Inches(2),
- "SAP BTP (CPI)", [
- "OData → REST 변환",
- "필드 매핑",
- "일 1회 배치 (PH1-2)",
- "실시간 이벤트 (PH2)"
- ], fill_color=RGBColor(240, 235, 255))
-
- add_textbox(slide, Inches(6.1), Inches(1.7), Inches(0.5), Inches(0.3),
- "→", font_size=14, color=ACCENT, bold=True, align=PP_ALIGN.CENTER)
-
- add_multiline_box(slide, Inches(6.6), Inches(1.0), Inches(3), Inches(2),
- "WTM Spring Boot", [
- "POST /api/wtm/integration/hr/sync",
- "HrSyncService",
- "사용자 자동 동기화",
- "PH1-1: Excel 수동 업로드"
- ], fill_color=RGBColor(230, 240, 255))
-
- # P6
- add_box(slide, Inches(0.3), Inches(3.5), Inches(2.5), Inches(0.7),
- "P6 (WBS Export)\n파일 기반 (No I/F)", fill_color=RGBColor(255, 245, 220),
- font_color=DARK, font_size=9)
-
- add_textbox(slide, Inches(2.8), Inches(3.65), Inches(1), Inches(0.3),
- "→ 파일 →", font_size=9, color=ACCENT, bold=True, align=PP_ALIGN.CENTER)
-
- add_box(slide, Inches(3.8), Inches(3.5), Inches(3), Inches(0.7),
- "WTM: P6 WBS 파서\nPM 업로드 → PCM 승인", fill_color=RGBColor(230, 240, 255),
- font_color=DARK, font_size=9)
-
- # Cognite
- add_box(slide, Inches(0.3), Inches(4.5), Inches(2.5), Inches(0.7),
- "Cognite (PH2)\nExtractor 서버", fill_color=RGBColor(255, 245, 220),
- font_color=DARK, font_size=9)
-
- add_textbox(slide, Inches(2.8), Inches(4.65), Inches(1), Inches(0.3),
- "← Export", font_size=9, color=ACCENT, bold=True, align=PP_ALIGN.CENTER)
-
- add_box(slide, Inches(3.8), Inches(4.5), Inches(5.5), Inches(0.7),
- "Export: Employee + Project + WBS + Time Fact + Mapping Version Metadata",
- fill_color=RGBColor(230, 240, 255), font_color=DARK, font_size=8)
-
- # ============================================================
- # 10. 보안 설계
- # ============================================================
- section_slide(prs, 10, "보안 설계", "한화오션 표준 보안 SW 12종, Azure Entra ID SSO + MFA")
-
- slide = content_slide(prs, "10. 한화오션 표준 보안 SW")
- sec_data = [
- ["분류", "SW", "용도", "구성"],
- ["서버 보안", "HIWARE", "서버 접근 제어", "Agent 설치"],
- ["서버 보안", "V3 (AhnLab)", "서버 백신", "Agent 설치"],
- ["서버 보안", "Secuver TOS", "파일 무결성", "Agent 설치"],
- ["DB 보안", "Cubeone", "DB 암호화 (컬럼)", "연동"],
- ["DB 보안", "Dbsafer", "DB 접근 제어/감사", "Proxy"],
- ["클라우드", "Azure Defender", "위협 탐지", "Native"],
- ["모니터링", "onTune", "SMS (서버)", "Agent"],
- ["모니터링", "Maxguage", "DB 모니터링", "Proxy"],
- ["모니터링", "Jennifer", "WAS APM", "-javaagent"],
- ["인증", "Azure Entra ID", "SSO + Conditional Access", "OAuth2 OIDC"],
- ["인증", "TOTP (MFA)", "외부사용자 2-Way 인증", "Google/MS Auth"],
- ]
- add_table(slide, Inches(0.3), Inches(1.0), Inches(9.4),
- sec_data, col_widths=[Inches(1.3), Inches(2), Inches(3), Inches(2.5)])
-
- # ============================================================
- # 11. 단계별 추진 일정
- # ============================================================
- section_slide(prs, 11, "단계별 추진 일정", "PH1-1차(9주) + PH1-2차(4주) = 15 M/M")
-
- slide = content_slide(prs, "11-1. 전체 일정 (Gantt)")
-
- # Gantt-like 도식
- months = ["4월", "5월", "6월", "7월~"]
- mx = 2.5
- for m in months:
- add_box(slide, Inches(mx), Inches(1.0), Inches(1.8), Inches(0.35),
- m, fill_color=PRIMARY, font_color=WHITE, font_size=10)
- mx += 1.8
-
- phases = [
- ("분석·설계", 2.5, 2.5, RGBColor(100, 160, 230)),
- ("BE 개발", 3.0, 5.5, RGBColor(70, 140, 200)),
- ("FE 개발", 3.5, 5.0, RGBColor(90, 170, 220)),
- ("QA·UAT", 6.5, 2.0, RGBColor(230, 160, 60)),
- ("PH1-2차", 6.1, 3.6, RGBColor(140, 200, 140)),
- ]
- gy = 1.6
- for name, start, width, color in phases:
- add_box(slide, Inches(start), Inches(gy), Inches(width), Inches(0.35),
- name, fill_color=color, font_color=WHITE, font_size=9)
- gy += 0.45
-
- # 마일스톤
- milestones = [
- ("4/10", "설계 확정"),
- ("5/7", "BE API 완료"),
- ("5/20", "FE 완료"),
- ("5/31", "★ PH1-1 오픈"),
- ("6/30", "★ PH1-2 오픈"),
- ]
- add_textbox(slide, Inches(0.5), Inches(4.0), Inches(9), Inches(0.3),
- "주요 마일스톤", font_size=12, color=PRIMARY, bold=True)
- my = 4.4
- for date, desc in milestones:
- add_textbox(slide, Inches(0.5), Inches(my), Inches(1.5), Inches(0.25),
- date, font_size=10, color=ACCENT, bold=True)
- add_textbox(slide, Inches(2.0), Inches(my), Inches(7), Inches(0.25),
- desc, font_size=10, color=DARK)
- my += 0.3
-
- # 인력 투입
- slide = content_slide(prs, "11-2. 인력 투입 계획")
- manpower = [
- ["역할", "PH1-1차 (9주)", "PH1-2차 (4주)", "담당"],
- ["BE 시니어 / 기술리드", "2.0 M/M", "1.0 M/M", "아키텍처, SSO, 결재, 코드리뷰"],
- ["풀스택 엔지니어 ①", "2.0 M/M", "1.0 M/M", "WBS, TEAL, 프로젝트"],
- ["풀스택 엔지니어 ②", "2.0 M/M", "1.0 M/M", "시수 입력 3종, 규칙 엔진"],
- ["풀스택 엔지니어 ③", "2.0 M/M", "1.0 M/M", "리포트, 로그인, 사용자"],
- ["DevOps (파트타임)", "1.0 M/M", "0.5 M/M", "Azure 인프라, CI/CD"],
- ["QA 겸임", "1.0 M/M", "0.5 M/M", "통합테스트, UAT 지원"],
- ["합계", "~10.0 M/M", "~5.0 M/M", "총 PH1: ~15 M/M"],
- ]
- add_table(slide, Inches(0.3), Inches(1.0), Inches(9.4),
- manpower, col_widths=[Inches(2.2), Inches(1.5), Inches(1.5), Inches(4)])
-
- # ============================================================
- # 마지막
- # ============================================================
- slide = prs.slides.add_slide(prs.slide_layouts[6])
- bg = slide.background.fill
- bg.solid()
- bg.fore_color.rgb = PRIMARY
- add_textbox(slide, Inches(1), Inches(2.5), Inches(8), Inches(1),
- "감사합니다", font_size=36, color=WHITE, bold=True, align=PP_ALIGN.CENTER)
- add_textbox(slide, Inches(1), Inches(3.8), Inches(8), Inches(0.5),
- "한화오션 EPU Time Tracking 시스템을\n함께 성공적으로 완수하겠습니다.",
- font_size=14, color=RGBColor(180, 200, 230), align=PP_ALIGN.CENTER)
- add_textbox(slide, Inches(1), Inches(5), Inches(8), Inches(0.5),
- "아큐라시스템 | accura@accurasoft.co.kr",
- font_size=12, color=RGBColor(150, 170, 200), align=PP_ALIGN.CENTER)
-
- prs.save(OUTPUT)
- size_kb = os.path.getsize(OUTPUT) // 1024
- try:
- print(f"Generated: {OUTPUT} ({size_kb} KB)")
- except UnicodeEncodeError:
- print(f"Generated: PPT ({size_kb} KB)")
-
-
-if __name__ == "__main__":
- build()
diff --git a/wbx-spring-core/docs/regenerate_pdfs.py b/wbx-spring-core/docs/regenerate_pdfs.py
deleted file mode 100644
index 9650080..0000000
--- a/wbx-spring-core/docs/regenerate_pdfs.py
+++ /dev/null
@@ -1,372 +0,0 @@
-"""
-WBX Spring Framework PDF 가이드 재생성 스크립트.
-Docker/Redis → Embedded Redis 변경 사항 반영.
-
-사용법: python regenerate_pdfs.py
-"""
-import os
-import copy
-from PyPDF2 import PdfReader, PdfWriter
-from fpdf import FPDF
-
-DOCS_DIR = os.path.dirname(os.path.abspath(__file__))
-FONT_PATH = r"C:\Windows\Fonts\malgun.ttf"
-FONT_BOLD_PATH = r"C:\Windows\Fonts\malgunbd.ttf"
-
-
-class KoreanPDF(FPDF):
- """Korean-capable PDF with consistent styling."""
-
- def __init__(self):
- super().__init__()
- self.add_font("malgun", "", FONT_PATH)
- self.add_font("malgun", "B", FONT_BOLD_PATH)
- self.set_auto_page_break(auto=True, margin=20)
-
- def header_line(self, title):
- self.set_font("malgun", "B", 9)
- self.set_text_color(100, 100, 100)
- self.cell(0, 6, title, ln=True)
- self.set_draw_color(30, 60, 120)
- self.set_line_width(0.5)
- self.line(10, self.get_y(), 200, self.get_y())
- self.ln(4)
-
- def section_title(self, text):
- self.set_font("malgun", "B", 14)
- self.set_text_color(30, 60, 120)
- self.cell(0, 10, text, ln=True)
- self.ln(2)
-
- def sub_title(self, text):
- self.set_font("malgun", "B", 11)
- self.set_text_color(40, 40, 40)
- self.cell(0, 8, text, ln=True)
- self.ln(1)
-
- def body(self, text):
- self.set_font("malgun", "", 10)
- self.set_text_color(60, 60, 60)
- self.multi_cell(0, 6, text)
- self.ln(2)
-
- def code_block(self, text):
- self.set_fill_color(245, 245, 245)
- self.set_font("malgun", "", 9)
- self.set_text_color(40, 40, 40)
- x = self.get_x()
- self.set_x(x + 5)
- self.multi_cell(180, 5.5, text, fill=True)
- self.ln(3)
-
- def bullet(self, text):
- self.set_font("malgun", "", 10)
- self.set_text_color(60, 60, 60)
- x = self.get_x()
- self.set_x(x + 5)
- self.cell(5, 6, "\u2022")
- self.multi_cell(170, 6, text)
-
- def note_box(self, text):
- self.set_fill_color(255, 248, 220)
- self.set_draw_color(200, 180, 100)
- self.set_font("malgun", "B", 9)
- self.set_text_color(120, 90, 0)
- y = self.get_y()
- self.rect(12, y, 186, 20, style="DF")
- self.set_xy(15, y + 3)
- self.multi_cell(180, 5, text)
- self.set_y(y + 23)
-
- def page_number(self):
- self.set_y(-15)
- self.set_font("malgun", "", 8)
- self.set_text_color(150, 150, 150)
- self.cell(0, 10, f"- {self.page_no()} -", align="C")
-
-
-def replace_pages(src_path, replacements, out_path):
- """Replace specific pages in a PDF with new fpdf2-generated pages.
-
- replacements: dict of {page_index: fpdf_generation_function}
- """
- reader = PdfReader(src_path)
- writer = PdfWriter()
-
- # Generate replacement pages
- replacement_pages = {}
- for page_idx, gen_func in replacements.items():
- pdf = gen_func()
- tmp = os.path.join(DOCS_DIR, f"_tmp_page_{page_idx}.pdf")
- pdf.output(tmp)
- tmp_reader = PdfReader(tmp)
- replacement_pages[page_idx] = tmp_reader.pages[0]
-
- # Build output
- for i, page in enumerate(reader.pages):
- if i in replacement_pages:
- writer.add_page(replacement_pages[i])
- else:
- writer.add_page(page)
-
- with open(out_path, "wb") as f:
- writer.write(f)
-
- # Cleanup temp files
- for page_idx in replacements:
- tmp = os.path.join(DOCS_DIR, f"_tmp_page_{page_idx}.pdf")
- if os.path.exists(tmp):
- os.remove(tmp)
-
- print(f" [OK] {os.path.basename(out_path)}")
-
-
-# ============================================================
-# 1. 설치가이드_OnPremise.pdf — Page 3 (requirements), Page 9 (Redis)
-# ============================================================
-
-def onprem_page3():
- """Page 3: 사전 요구사항 - Redis를 선택으로 변경."""
- pdf = KoreanPDF()
- pdf.add_page()
- pdf.header_line("WBX Spring Framework | On-Premise 설치 가이드")
- pdf.section_title("1. 사전 요구사항 \u00b7 서버 사양")
-
- pdf.sub_title("1-1. 최소 서버 사양")
- pdf.body("항목 최소 사양 권장 사양\n"
- "OS RHEL 8+ / Ubuntu 22.04+ RHEL 9 / Rocky Linux 9\n"
- "CPU 4 vCPU 8 vCPU\n"
- "RAM 8 GB 16 GB\n"
- "Disk 50 GB SSD 100 GB SSD (RAID)\n"
- "Network 1 Gbps 10 Gbps")
-
- pdf.sub_title("1-2. 필수 소프트웨어")
- pdf.body("소프트웨어 버전 용도\n"
- "JDK 21 (Temurin LTS) Spring Boot 런타임\n"
- "DB Oracle 19c+ 등 애플리케이션 데이터\n"
- "Nginx 1.24+ 리버스 프록시, SSL")
-
- pdf.sub_title("1-2b. 선택 소프트웨어")
- pdf.body("소프트웨어 버전 용도\n"
- "Redis 7.x 캐시 (미설치 시 Embedded Redis 자동 구동)")
- pdf.note_box("NOTE: Redis를 별도 설치하지 않아도 WBX Spring에 내장된 Embedded Redis가\n"
- "자동으로 시작됩니다. 운영 환경에서는 성능을 위해 외부 Redis 설치를 권장합니다.")
-
- pdf.sub_title("1-3. 네트워크 포트")
- pdf.body("포트 서비스 접근 범위 비고\n"
- "443 HTTPS 외부 Nginx SSL\n"
- "80 HTTP 외부->443 리다이렉트\n"
- "8080 Spring Boot 내부 Nginx에서만 접근\n"
- "8081 wtm-api 내부 WTM 프로젝트\n"
- "3306 MySQL 내부 DB 선택에 따라\n"
- "5432 PostgreSQL 내부 \n"
- "1521 Oracle 내부 \n"
- "1433 MSSQL 내부 \n"
- "6379 Redis 내부 선택 (Embedded Redis 자동 대체)")
-
- pdf.page_number()
- return pdf
-
-
-def onprem_page9():
- """Page 9: Redis 설치 섹션 재작성."""
- pdf = KoreanPDF()
- pdf.add_page()
- pdf.header_line("WBX Spring Framework | On-Premise 설치 가이드")
- pdf.section_title("5. Redis 설치 (선택)")
-
- pdf.note_box("WBX Spring Framework에는 Embedded Redis가 내장되어 있어 별도 설치 없이도\n"
- "앱이 정상 구동됩니다. 운영 환경에서 높은 성능이 필요한 경우에만 설치하세요.")
-
- pdf.sub_title("5-1. Embedded Redis (기본 — 설치 불필요)")
- pdf.body("앱 시작 시 다음 순서로 자동 동작합니다:")
- pdf.bullet("외부 Redis 감지 시 -> 외부 Redis 사용 (운영 환경)")
- pdf.bullet("외부 Redis 없음 -> Embedded Redis 자동 시작 (개발/소규모 운영)")
- pdf.bullet("Embedded Redis 실패 -> 인메모리 캐시 전환 (최후 안전장치)")
- pdf.ln(3)
-
- pdf.sub_title("5-2. 외부 Redis 설치 (선택 — 대규모 운영 환경 권장)")
- pdf.code_block(
- "sudo dnf install -y redis\n"
- "sudo systemctl enable --now redis\n\n"
- "# 보안: bind + password\n"
- "sudo sed -i 's/^bind .*/bind 127.0.0.1/' /etc/redis/redis.conf\n"
- "echo 'requirepass RedisP@ss123' | sudo tee -a /etc/redis/redis.conf\n"
- "sudo systemctl restart redis\n\n"
- "# 확인\n"
- "redis-cli -a RedisP@ss123 ping # PONG"
- )
-
- pdf.sub_title("5-3. .env 설정 (외부 Redis 사용 시)")
- pdf.code_block(
- "SPRING_DATA_REDIS_HOST=127.0.0.1\n"
- "# 또는 원격 Redis 서버 주소\n"
- "# SPRING_DATA_REDIS_HOST=redis.company.com"
- )
-
- pdf.page_number()
- return pdf
-
-
-# ============================================================
-# 2. 개발자가이드.pdf — Page 3 (사전 준비), Page 4 (Docker Compose)
-# ============================================================
-
-def dev_page3():
- """Page 3: 개발환경 설정 - Docker/Redis 부분 수정."""
- pdf = KoreanPDF()
- pdf.add_page()
- pdf.header_line("WBX Spring Framework | 개발자 가이드")
- pdf.section_title("Chapter 0. 개발환경 설정 - IDE \u00b7 프로젝트 생성")
-
- pdf.sub_title("0-1. 사전 준비")
- pdf.bullet("JDK 21 설치 (Eclipse Temurin 권장)")
- pdf.bullet("Git 설치 (git config user.name / user.email)")
- pdf.bullet("Docker Desktop 설치 (선택 - 로컬 DB 컨테이너 사용 시에만)")
- pdf.bullet("IDE 설치 (아래 택 1)")
- pdf.ln(2)
-
- pdf.note_box("NOTE: Redis는 Embedded Redis가 앱과 함께 자동 시작되므로 별도 설치가 필요\n"
- "없습니다. Docker Desktop도 DB를 직접 설치하면 불필요합니다.")
-
- pdf.sub_title("온보딩 플로우")
- pdf.body("1) JDK 설치 -> 2) IDE 설정 -> 3) DB 준비 -> 4) 프로젝트 생성 -> 5) bootRun -> 6) Swagger 확인")
-
- pdf.sub_title("0-2. IDE 선택 가이드")
- pdf.body("IDE 권장 대상 핵심 장점\n"
- "IntelliJ IDEA 메인 개발 Spring Boot 최적, 리팩터링, DB 브라우저\n"
- "VS Code 경량/FE 병행 Extension Pack, DevContainer\n"
- "Eclipse/STS 무료/레거시 Spring Tool Suite 플러그인")
-
- pdf.sub_title("0-3. IntelliJ IDEA 필수 설정")
- pdf.bullet("플러그인: Spring Boot, Lombok, JPA Buddy, GitToolBox, .env, SonarLint")
- pdf.bullet("Annotation Processor 활성화 (Settings > Build > Compiler)")
- pdf.bullet("Hot Reload: Build project automatically + Allow auto-make")
- pdf.ln(1)
- pdf.code_block(
- "Run Configuration:\n"
- " Main class: kr.co.accura.wbx.spring.WbxSpringCoreApplication\n"
- " Env: JWT_SECRET=dev-secret-key"
- )
-
- pdf.sub_title("0-4. VS Code 필수 설정")
- pdf.bullet("Extension: Java Pack, Spring Boot Pack, Lombok, Gradle, REST Client, GitLens")
- pdf.bullet(".vscode/settings.json - Lombok, Gradle Wrapper, 포맷터 설정")
- pdf.bullet(".vscode/launch.json - Spring Boot 디버깅 프로필 (local, test)")
-
- pdf.page_number()
- return pdf
-
-
-def dev_page4():
- """Page 4: Docker Compose 및 프로젝트 생성 - Redis 부분 수정."""
- pdf = KoreanPDF()
- pdf.add_page()
- pdf.header_line("WBX Spring Framework | 개발자 가이드")
-
- pdf.code_block(
- '// .vscode/launch.json\n'
- '{\n'
- ' "type": "java",\n'
- ' "name": "App (Local)",\n'
- ' "mainClass": "kr.co.accura.wbx.spring.WbxSpringCoreApplication",\n'
- ' "env": {"JWT_SECRET": "dev-secret-key"}\n'
- '}'
- )
-
- pdf.sub_title("0-5. Eclipse / STS 설정")
- pdf.bullet("Spring Tools 4 플러그인 설치 (Eclipse Marketplace)")
- pdf.bullet("Lombok 설치: java -jar lombok.jar -> Eclipse 경로 선택")
- pdf.bullet("Gradle Import: File > Import > Existing Gradle Project")
- pdf.bullet("Annotation Processor: Project Properties > Java Compiler 에서 활성화")
- pdf.ln(2)
-
- pdf.sub_title("0-6. 프로젝트 생성 (3가지 방법)")
- pdf.body("방법 A: Spring Initializr (start.spring.io)")
- pdf.code_block("Project: Gradle-Kotlin | Java: 21 | Boot: 3.5.0\n"
- "Group: kr.co.accura | Artifact: {앱}-api\n"
- "-> Generate -> 압축 해제 -> wbx-spring-starter 의존성 추가")
- pdf.body("방법 B: Git Template Repository")
- pdf.code_block("git clone https://git.wbx.kr/accura/wbx-spring-template.git my-app\n"
- "cd my-app && ./init.sh --name my-app --group kr.co.accura")
-
- pdf.sub_title("0-7. 설치 스크립트 (권장)")
- pdf.body("프로젝트에 포함된 설치 스크립트가 JDK 자동 설치, Git 사전 검사, 빌드, .env 템플릿 생성을 자동 처리합니다.")
- pdf.code_block("# Windows\nscripts\\install.bat\n\n# Linux/macOS\nchmod +x scripts/install.sh && ./scripts/install.sh")
- pdf.body("TIP: JDK 미설치 시 스크립트가 자동으로 설치합니다.")
-
- pdf.sub_title("0-8. 로컬 개발 인프라")
- pdf.body("Redis는 Embedded Redis가 앱 시작 시 자동 구동되므로 별도 설치가 필요 없습니다.\n"
- "DB만 Docker Compose 또는 직접 설치하면 됩니다.")
- pdf.code_block("# Docker로 DB만 시작 (Redis 불필요)\n"
- "docker compose -f docker-compose-dev.yml up -d mysql\n\n"
- "# 또는 DB를 직접 설치한 경우 바로 앱 실행\n"
- "gradlew.bat bootRun")
- pdf.note_box("Embedded Redis 동작: 앱 시작 시 외부 Redis 감지 -> 없으면 자동 시작 -> 실패 시 인메모리 캐시")
-
- pdf.page_number()
- return pdf
-
-
-def dev_page5():
- """Page 5: DevContainer + 코드 품질 도구 - Redis 부분 수정."""
- pdf = KoreanPDF()
- pdf.add_page()
- pdf.header_line("WBX Spring Framework | 개발자 가이드")
-
- pdf.sub_title("0-9. DevContainer (VS Code)")
- pdf.body("devcontainer.json 으로 JDK 21 + DB가 포함된 완전한 개발 환경을 컨테이너로 제공합니다.\n"
- "Redis는 Embedded Redis가 자동 구동되므로 DevContainer에 별도 포함하지 않아도 됩니다.\n"
- "팀원 전체 동일 환경 보장.")
-
- pdf.sub_title("0-10. 코드 품질 도구")
- pdf.bullet("Spotless - 코드 포맷 자동화 (./gradlew spotlessApply)")
- pdf.bullet("CheckStyle - 코드 규칙 검사")
- pdf.bullet("JaCoCo - 테스트 커버리지 (최소 70%)")
- pdf.bullet("SonarLint - IDE 실시간 코드 분석")
- pdf.bullet(".editorconfig - IDE 공통 코드 스타일 (UTF-8, LF, 4 spaces)")
-
- pdf.page_number()
- return pdf
-
-
-# ============================================================
-# Main
-# ============================================================
-
-def main():
- print("WBX Spring Framework PDF 가이드 재생성")
- print("=" * 50)
-
- # 1. 설치가이드_OnPremise.pdf
- print("\n[1] 설치가이드_OnPremise.pdf")
- src = os.path.join(DOCS_DIR, "WBX_Spring_Framework_설치가이드_OnPremise.pdf")
- out = os.path.join(DOCS_DIR, "WBX_Spring_Framework_설치가이드_OnPremise.pdf")
- replace_pages(src, {
- 2: onprem_page3, # Page 3: 사전 요구사항 (Redis 선택으로)
- 8: onprem_page9, # Page 9: Redis 설치 (Embedded Redis 안내)
- }, out)
-
- # 2. 설치가이드_Cloud.pdf — 변경 없음 (클라우드 관리형 Redis 사용)
- print("\n[2] 설치가이드_Cloud.pdf")
- print(" [SKIP] 클라우드 환경은 관리형 Redis(Azure Cache/ElastiCache) 사용 - 변경 불필요")
-
- # 3. 개발자가이드.pdf
- print("\n[3] 개발자가이드.pdf")
- src = os.path.join(DOCS_DIR, "WBX_Spring_Framework_개발자가이드.pdf")
- out = os.path.join(DOCS_DIR, "WBX_Spring_Framework_개발자가이드.pdf")
- replace_pages(src, {
- 2: dev_page3, # Page 3: 사전 준비 (Docker/Redis 선택)
- 3: dev_page4, # Page 4: Docker Compose (Redis 불필요)
- 4: dev_page5, # Page 5: DevContainer (Redis 제외)
- }, out)
-
- print("\n" + "=" * 50)
- print("완료! 변경된 PDF:")
- print(" - WBX_Spring_Framework_설치가이드_OnPremise.pdf (Page 3, 9)")
- print(" - WBX_Spring_Framework_개발자가이드.pdf (Page 3, 4, 5)")
- print(" - WBX_Spring_Framework_설치가이드_Cloud.pdf (변경 없음)")
-
-
-if __name__ == "__main__":
- main()
diff --git a/wbx-spring-core/docs/개발환경_사전설치_가이드.txt b/wbx-spring-core/docs/개발환경_사전설치_가이드.txt
index 158660f..8a8f198 100644
--- a/wbx-spring-core/docs/개발환경_사전설치_가이드.txt
+++ b/wbx-spring-core/docs/개발환경_사전설치_가이드.txt
@@ -120,7 +120,46 @@
GRANT ALL ON mos.* TO 'jsh'@'%';
- [2-3] Redis (설치 불필요)
+ [2-3] Node.js (프론트엔드 개발 시 필요)
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ 용도: WTM 프론트엔드 개발 서버 실행 (Vue 3 또는 React 18)
+
+ * Windows
+ winget install --id OpenJS.NodeJS.LTS
+
+ * macOS
+ brew install node@22
+
+ * Linux
+ curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -
+ sudo apt install -y nodejs
+
+ 설치 확인:
+ node -v → v22.x.x
+ npm -v → 10.x.x
+
+ 프론트엔드 선택:
+ +----------------------+------------------+----------+---------------------+
+ | 프로젝트 | 프레임워크 | 포트 | UI 라이브러리 |
+ +----------------------+------------------+----------+---------------------+
+ | wtm-frontend-vue | Vue 3 + Vite | 5173 | PrimeVue 4 |
+ | wtm-frontend-react | React 18 + Vite | 5174 | PrimeReact 10 |
+ +----------------------+------------------+----------+---------------------+
+
+ 두 프론트엔드는 동일한 백엔드 API(wtm-api :8081)에 연결됩니다.
+ 프로젝트 상황에 맞는 프레임워크를 선택하세요.
+
+ 빠른 시작:
+ # Vue 3 버전
+ cd wtm-frontend-vue
+ npm install && npm run dev → http://localhost:5173
+
+ # React 18 버전
+ cd wtm-frontend-react
+ npm install && npm run dev → http://localhost:5174
+
+
+ [2-4] Redis (설치 불필요)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
용도: 캐시, 세션 저장소
@@ -178,6 +217,17 @@
http://localhost:8081/swagger-ui → WTM API 문서
http://localhost:8081/admin/login → WTM 관리 콘솔
+ [WTM 프론트엔드 실행 — Vue 3 또는 React 18 중 택 1]
+ # Vue 3 버전
+ cd wtm-frontend-vue
+ npm install && npm run dev
+ http://localhost:5173 → WTM 프론트엔드 (Vue 3)
+
+ # React 18 버전
+ cd wtm-frontend-react
+ npm install && npm run dev
+ http://localhost:5174 → WTM 프론트엔드 (React 18)
+
[방법 B] DB 직접 설치 (Docker 없이)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -228,7 +278,7 @@
[환경별 수정]
SPRING_PROFILES_ACTIVE 프로필 조합 (아래 참고)
DB_HOST / DB_PORT DB 서버 주소
- CORS_ORIGINS 프론트엔드 URL
+ CORS_ORIGINS 프론트엔드 URL (Vue: 5173, React: 5174)
[프로필 조합 예시]
로컬 개발 (MySQL) : local,mysql
@@ -250,7 +300,8 @@
| 3306 | MySQL | 기본 DB (mysql 프로필) |
| 5432 | PostgreSQL | 대안 DB (postgresql 프로필) |
| 6379 | Redis | Embedded Redis 자동 구동 (별도 설치 불필요)|
- | 5173 | wtm-frontend | Vue 3 개발 서버 |
+ | 5173 | wtm-frontend-vue | Vue 3 개발 서버 |
+ | 5174 | wtm-frontend-react| React 18 개발 서버 |
| 8001 | WBX FastAPI | 선택, 그룹웨어 동시 운영 시 |
+--------+------------------+------------------------------------------+
@@ -285,6 +336,19 @@
A: netstat -ano | findstr :8080 으로 프로세스 확인 후 종료
또는 application.yml에서 server.port 변경
+ Q: 프론트엔드(Vue/React) npm install 실패
+ A: Node.js 22 LTS가 설치되어 있는지 확인: node -v
+ node_modules 삭제 후 재설치: rm -rf node_modules && npm install
+
+ Q: 프론트엔드에서 API 호출 시 CORS 에러
+ A: wtm-api의 application.yml에서 CORS 설정 확인:
+ cors.allowed-origins에 http://localhost:5173, http://localhost:5174 포함 필요
+ 또는 .env에서 CORS_ORIGINS 설정
+
+ Q: Vue(5173)와 React(5174) 동시 실행 가능한가?
+ A: 네, 포트가 다르므로 동시 실행 가능합니다. 둘 다 동일한 wtm-api(:8081)에
+ 연결되며 API 프록시가 각각 설정되어 있습니다.
+
--------------------------------------------------------------------------------
8. 참고 문서
diff --git a/wtm-frontend/.env b/wtm-frontend-react/.env
similarity index 100%
rename from wtm-frontend/.env
rename to wtm-frontend-react/.env
diff --git a/wtm-frontend/.env.development b/wtm-frontend-react/.env.development
similarity index 100%
rename from wtm-frontend/.env.development
rename to wtm-frontend-react/.env.development
diff --git a/wtm-frontend/.env.production b/wtm-frontend-react/.env.production
similarity index 100%
rename from wtm-frontend/.env.production
rename to wtm-frontend-react/.env.production
diff --git a/wtm-frontend-react/index.html b/wtm-frontend-react/index.html
new file mode 100644
index 0000000..158e5e5
--- /dev/null
+++ b/wtm-frontend-react/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ WTM - Work Time Manager
+
+
+
+
+
+
diff --git a/wtm-frontend-react/package.json b/wtm-frontend-react/package.json
new file mode 100644
index 0000000..4feeffb
--- /dev/null
+++ b/wtm-frontend-react/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "wtm-frontend-react",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "lint": "eslint ."
+ },
+ "dependencies": {
+ "axios": "^1.7.9",
+ "chart.js": "^4.4.7",
+ "primeicons": "^7.0.0",
+ "primereact": "^10.8.5",
+ "react": "^18.3.1",
+ "react-chartjs-2": "^5.2.0",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.28.0",
+ "zustand": "^5.0.2"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.16.0",
+ "@types/node": "^25.5.0",
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "eslint": "^9.16.0",
+ "eslint-plugin-react-hooks": "^5.1.0",
+ "eslint-plugin-react-refresh": "^0.4.16",
+ "globals": "^15.14.0",
+ "sass": "^1.82.0",
+ "typescript": "~5.6.3",
+ "typescript-eslint": "^8.18.0",
+ "vite": "^6.0.5"
+ }
+}
diff --git a/wtm-frontend-react/src/app/App.tsx b/wtm-frontend-react/src/app/App.tsx
new file mode 100644
index 0000000..55c71af
--- /dev/null
+++ b/wtm-frontend-react/src/app/App.tsx
@@ -0,0 +1,6 @@
+import { RouterProvider } from 'react-router-dom';
+import { router } from './router';
+
+export default function App() {
+ return ;
+}
diff --git a/wtm-frontend-react/src/app/main.tsx b/wtm-frontend-react/src/app/main.tsx
new file mode 100644
index 0000000..8715d2d
--- /dev/null
+++ b/wtm-frontend-react/src/app/main.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { PrimeReactProvider } from 'primereact/api';
+import App from './App';
+
+import 'primereact/resources/themes/lara-light-blue/theme.css';
+import 'primereact/resources/primereact.min.css';
+import 'primeicons/primeicons.css';
+import '@/assets/styles/main.scss';
+
+const primeReactConfig = {
+ ripple: true,
+};
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+);
diff --git a/wtm-frontend-react/src/app/router.tsx b/wtm-frontend-react/src/app/router.tsx
new file mode 100644
index 0000000..d91fd0e
--- /dev/null
+++ b/wtm-frontend-react/src/app/router.tsx
@@ -0,0 +1,68 @@
+import { lazy, Suspense } from 'react';
+import { createBrowserRouter, Navigate } from 'react-router-dom';
+import { ProgressSpinner } from 'primereact/progressspinner';
+import AppLayout from '@/core/components/AppLayout';
+import AuthGuard from '@/core/components/AuthGuard';
+import NotFoundView from '@/core/components/NotFoundView';
+
+// Lazy-loaded views
+const LoginView = lazy(() => import('@/modules/auth/views/LoginView'));
+const ForgotPasswordView = lazy(() => import('@/modules/auth/views/ForgotPasswordView'));
+const ChangePasswordView = lazy(() => import('@/modules/auth/views/ChangePasswordView'));
+const DashboardView = lazy(() => import('@/modules/dashboard/views/DashboardView'));
+const TimesheetWeekView = lazy(() => import('@/modules/timesheet/views/TimesheetWeekView'));
+const TimesheetHistoryView = lazy(() => import('@/modules/timesheet/views/TimesheetHistoryView'));
+const TimesheetUploadView = lazy(() => import('@/modules/timesheet/views/TimesheetUploadView'));
+const ApprovalPendingView = lazy(() => import('@/modules/approval/views/ApprovalPendingView'));
+const ApprovalHistoryView = lazy(() => import('@/modules/approval/views/ApprovalHistoryView'));
+const ProjectListView = lazy(() => import('@/modules/project/views/ProjectListView'));
+const ProjectDetailView = lazy(() => import('@/modules/project/views/ProjectDetailView'));
+const WbsTreeView = lazy(() => import('@/modules/wbs/views/WbsTreeView'));
+const TealListView = lazy(() => import('@/modules/teal/views/TealListView'));
+const ReportView = lazy(() => import('@/modules/report/views/ReportView'));
+const UserListView = lazy(() => import('@/modules/user/views/UserListView'));
+const UserDetailView = lazy(() => import('@/modules/user/views/UserDetailView'));
+const SettingsView = lazy(() => import('@/modules/settings/views/SettingsView'));
+
+function SuspenseWrapper({ children }: { children: React.ReactNode }) {
+ return (
+ }>
+ {children}
+
+ );
+}
+
+export const router = createBrowserRouter([
+ // Auth routes (no layout)
+ { path: '/login', element: },
+ { path: '/forgot-password', element: },
+
+ // Protected routes (with layout)
+ {
+ path: '/',
+ element: (
+
+
+
+ ),
+ children: [
+ { index: true, element: },
+ { path: 'dashboard', element: },
+ { path: 'change-password', element: },
+ { path: 'timesheets', element: },
+ { path: 'timesheets/history', element: },
+ { path: 'timesheets/upload', element: },
+ { path: 'approvals', element: },
+ { path: 'approvals/history', element: },
+ { path: 'projects', element: },
+ { path: 'projects/:id', element: },
+ { path: 'wbs', element: },
+ { path: 'teal', element: },
+ { path: 'reports', element: },
+ { path: 'users', element: },
+ { path: 'users/:id', element: },
+ { path: 'settings', element: },
+ { path: '*', element: },
+ ],
+ },
+]);
diff --git a/wtm-frontend/src/assets/images/logo.svg b/wtm-frontend-react/src/assets/images/logo.svg
similarity index 100%
rename from wtm-frontend/src/assets/images/logo.svg
rename to wtm-frontend-react/src/assets/images/logo.svg
diff --git a/wtm-frontend/src/assets/styles/_form-grid.scss b/wtm-frontend-react/src/assets/styles/_form-grid.scss
similarity index 100%
rename from wtm-frontend/src/assets/styles/_form-grid.scss
rename to wtm-frontend-react/src/assets/styles/_form-grid.scss
diff --git a/wtm-frontend-react/src/assets/styles/_overrides.scss b/wtm-frontend-react/src/assets/styles/_overrides.scss
new file mode 100644
index 0000000..f9d1ab8
--- /dev/null
+++ b/wtm-frontend-react/src/assets/styles/_overrides.scss
@@ -0,0 +1 @@
+// PrimeReact theme overrides
diff --git a/wtm-frontend-react/src/assets/styles/_variables.scss b/wtm-frontend-react/src/assets/styles/_variables.scss
new file mode 100644
index 0000000..b34df23
--- /dev/null
+++ b/wtm-frontend-react/src/assets/styles/_variables.scss
@@ -0,0 +1,53 @@
+// Breakpoints
+$bp-mobile: 576px;
+$bp-tablet: 768px;
+$bp-desktop: 992px;
+$bp-wide: 1200px;
+$bp-ultra: 1400px;
+
+// Layout
+$sidebar-width: 260px;
+$sidebar-collapsed-width: 64px;
+$topbar-height: 56px;
+$page-padding-x: 1.5rem;
+$page-padding-y: 1.25rem;
+
+// Spacing (8px base)
+$space-xs: 0.25rem;
+$space-sm: 0.5rem;
+$space-md: 1rem;
+$space-lg: 1.5rem;
+$space-xl: 2rem;
+$space-2xl: 3rem;
+
+// Typography
+$font-size-xs: 0.75rem;
+$font-size-sm: 0.875rem;
+$font-size-base: 1rem;
+$font-size-lg: 1.125rem;
+$font-size-xl: 1.25rem;
+$font-size-2xl: 1.5rem;
+
+// Border Radius
+$radius-sm: 6px;
+$radius-md: 8px;
+$radius-lg: 12px;
+
+// Z-Index
+$z-sidebar: 100;
+$z-topbar: 110;
+$z-overlay: 200;
+$z-dialog: 300;
+$z-toast: 400;
+
+// Semantic Colors (PrimeReact tokens)
+$color-surface: var(--p-surface-0);
+$color-surface-card: var(--p-surface-0);
+$color-surface-hover: var(--p-surface-100);
+$color-border: var(--p-surface-200);
+$color-text: var(--p-text-color);
+$color-text-muted: var(--p-text-muted-color);
+$color-primary: var(--p-primary-color);
+$color-danger: var(--p-red-500);
+$color-success: var(--p-green-500);
+$color-warning: var(--p-yellow-500);
diff --git a/wtm-frontend/src/assets/styles/main.scss b/wtm-frontend-react/src/assets/styles/main.scss
similarity index 100%
rename from wtm-frontend/src/assets/styles/main.scss
rename to wtm-frontend-react/src/assets/styles/main.scss
diff --git a/wtm-frontend/src/core/api/api.types.ts b/wtm-frontend-react/src/core/api/api.types.ts
similarity index 100%
rename from wtm-frontend/src/core/api/api.types.ts
rename to wtm-frontend-react/src/core/api/api.types.ts
diff --git a/wtm-frontend-react/src/core/api/axios.ts b/wtm-frontend-react/src/core/api/axios.ts
new file mode 100644
index 0000000..2a9cf5e
--- /dev/null
+++ b/wtm-frontend-react/src/core/api/axios.ts
@@ -0,0 +1,63 @@
+import axios from 'axios';
+import type { InternalAxiosRequestConfig, AxiosError } from 'axios';
+import { authService } from '@/core/auth/auth.service';
+
+const api = axios.create({
+ baseURL: import.meta.env.VITE_API_BASE_URL,
+ timeout: 30000,
+ headers: { 'Content-Type': 'application/json' },
+});
+
+// Request: attach JWT
+api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
+ const token = authService.getAccessToken();
+ if (token) config.headers.Authorization = `Bearer ${token}`;
+ return config;
+});
+
+// Response: 401 token refresh + retry
+let isRefreshing = false;
+let failedQueue: Array<{ resolve: (token: string) => void; reject: (error: unknown) => void }> =
+ [];
+
+api.interceptors.response.use(
+ (response) => response,
+ async (error: AxiosError) => {
+ const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
+
+ if (error.response?.status === 401 && !original._retry) {
+ if (isRefreshing) {
+ return new Promise((resolve, reject) => {
+ failedQueue.push({
+ resolve: (token: string) => {
+ original.headers.Authorization = `Bearer ${token}`;
+ resolve(api(original));
+ },
+ reject,
+ });
+ });
+ }
+
+ original._retry = true;
+ isRefreshing = true;
+ try {
+ const newToken = await authService.refreshToken();
+ failedQueue.forEach((q) => q.resolve(newToken));
+ failedQueue = [];
+ original.headers.Authorization = `Bearer ${newToken}`;
+ return api(original);
+ } catch {
+ failedQueue.forEach((q) => q.reject(error));
+ failedQueue = [];
+ authService.clearTokens();
+ window.location.href = '/login';
+ return Promise.reject(error);
+ } finally {
+ isRefreshing = false;
+ }
+ }
+ return Promise.reject(error);
+ },
+);
+
+export default api;
diff --git a/wtm-frontend/src/core/auth/auth.service.ts b/wtm-frontend-react/src/core/auth/auth.service.ts
similarity index 100%
rename from wtm-frontend/src/core/auth/auth.service.ts
rename to wtm-frontend-react/src/core/auth/auth.service.ts
diff --git a/wtm-frontend/src/core/auth/auth.types.ts b/wtm-frontend-react/src/core/auth/auth.types.ts
similarity index 100%
rename from wtm-frontend/src/core/auth/auth.types.ts
rename to wtm-frontend-react/src/core/auth/auth.types.ts
diff --git a/wtm-frontend-react/src/core/components/AppLayout.scss b/wtm-frontend-react/src/core/components/AppLayout.scss
new file mode 100644
index 0000000..1826589
--- /dev/null
+++ b/wtm-frontend-react/src/core/components/AppLayout.scss
@@ -0,0 +1,31 @@
+@use '@/assets/styles/variables' as *;
+
+.app-layout {
+ min-height: 100vh;
+ background: $color-surface;
+
+ &__overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.4);
+ z-index: calc(#{$z-sidebar} - 1);
+ }
+
+ &__main {
+ transition: margin-left 0.2s ease;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__content {
+ flex: 1;
+ padding: $page-padding-y $page-padding-x;
+ padding-top: calc(#{$topbar-height} + #{$page-padding-y});
+
+ @media (max-width: $bp-mobile) {
+ padding: $space-sm;
+ padding-top: calc(#{$topbar-height} + #{$space-sm});
+ }
+ }
+}
diff --git a/wtm-frontend-react/src/core/components/AppLayout.tsx b/wtm-frontend-react/src/core/components/AppLayout.tsx
new file mode 100644
index 0000000..44975fb
--- /dev/null
+++ b/wtm-frontend-react/src/core/components/AppLayout.tsx
@@ -0,0 +1,73 @@
+import { useState, useEffect, useCallback } from 'react';
+import { Outlet, useLocation } from 'react-router-dom';
+import AppSidebar from './AppSidebar';
+import AppTopbar from './AppTopbar';
+import { BREAKPOINTS, LAYOUT } from '@/core/constants/app.constants';
+import './AppLayout.scss';
+
+function useWindowWidth() {
+ const [width, setWidth] = useState(window.innerWidth);
+ useEffect(() => {
+ const handler = () => setWidth(window.innerWidth);
+ window.addEventListener('resize', handler);
+ return () => window.removeEventListener('resize', handler);
+ }, []);
+ return width;
+}
+
+export default function AppLayout() {
+ const width = useWindowWidth();
+ const location = useLocation();
+
+ const isMobile = width < BREAKPOINTS.tablet;
+ const isTablet = width >= BREAKPOINTS.tablet && width < BREAKPOINTS.desktop;
+
+ const [sidebarVisible, setSidebarVisible] = useState(!isMobile);
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(isTablet);
+
+ useEffect(() => {
+ if (isMobile) {
+ setSidebarVisible(false);
+ setSidebarCollapsed(false);
+ } else {
+ setSidebarVisible(true);
+ setSidebarCollapsed(isTablet);
+ }
+ }, [isMobile, isTablet]);
+
+ // Close sidebar on route change (mobile)
+ useEffect(() => {
+ if (isMobile) setSidebarVisible(false);
+ }, [location.pathname, isMobile]);
+
+ const toggleSidebar = useCallback(() => {
+ if (isMobile) {
+ setSidebarVisible((v) => !v);
+ } else {
+ setSidebarCollapsed((c) => !c);
+ }
+ }, [isMobile]);
+
+ const contentMarginLeft = isMobile
+ ? '0'
+ : sidebarCollapsed
+ ? `${LAYOUT.sidebarCollapsedWidth}px`
+ : `${LAYOUT.sidebarWidth}px`;
+
+ return (
+
+ {isMobile && sidebarVisible && (
+
setSidebarVisible(false)} />
+ )}
+
+
+
+
+
+ );
+}
diff --git a/wtm-frontend-react/src/core/components/AppSidebar.scss b/wtm-frontend-react/src/core/components/AppSidebar.scss
new file mode 100644
index 0000000..1810f72
--- /dev/null
+++ b/wtm-frontend-react/src/core/components/AppSidebar.scss
@@ -0,0 +1,62 @@
+@use '@/assets/styles/variables' as *;
+
+.app-sidebar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100vh;
+ background: $color-surface-card;
+ border-right: 1px solid $color-border;
+ z-index: $z-sidebar;
+ overflow-y: auto;
+ overflow-x: hidden;
+ transition: width 0.2s ease, transform 0.2s ease;
+
+ &--mobile {
+ transform: translateX(-100%);
+ width: $sidebar-width !important;
+
+ &.app-sidebar--visible {
+ transform: translateX(0);
+ }
+ }
+
+ &--collapsed {
+ .app-sidebar__title,
+ .app-sidebar__nav .p-panelmenu-header-content span,
+ .app-sidebar__nav .p-menuitem-text {
+ display: none;
+ }
+ }
+
+ &__header {
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ height: $topbar-height;
+ padding: 0 $space-md;
+ border-bottom: 1px solid $color-border;
+ }
+
+ &__logo {
+ width: 32px;
+ height: 32px;
+ flex-shrink: 0;
+ }
+
+ &__title {
+ font-size: $font-size-lg;
+ font-weight: 700;
+ color: $color-primary;
+ white-space: nowrap;
+ }
+
+ &__nav {
+ padding: $space-sm 0;
+ }
+
+ &__menu .p-panelmenu {
+ border: none;
+ background: transparent;
+ }
+}
diff --git a/wtm-frontend-react/src/core/components/AppSidebar.tsx b/wtm-frontend-react/src/core/components/AppSidebar.tsx
new file mode 100644
index 0000000..e14a375
--- /dev/null
+++ b/wtm-frontend-react/src/core/components/AppSidebar.tsx
@@ -0,0 +1,60 @@
+import { useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { PanelMenu } from 'primereact/panelmenu';
+import { MENU_ITEMS, LAYOUT } from '@/core/constants/app.constants';
+import { useCurrentUser } from '@/core/hooks/useCurrentUser';
+import logo from '@/assets/images/logo.svg';
+import './AppSidebar.scss';
+
+interface Props {
+ visible: boolean;
+ collapsed: boolean;
+ mobile: boolean;
+}
+
+export default function AppSidebar({ visible, collapsed, mobile }: Props) {
+ const { currentUser } = useCurrentUser();
+ const navigate = useNavigate();
+
+ const filteredMenu = useMemo(() => {
+ const userRoles = currentUser?.roles ?? [];
+ return MENU_ITEMS
+ .filter((item) => item.roles.some((r) => userRoles.includes(r)))
+ .map((item) => ({
+ ...item,
+ command: item.to ? () => navigate(item.to!) : undefined,
+ items: item.items
+ ? item.items
+ .filter((sub) => sub.roles.some((r) => userRoles.includes(r)))
+ .map((sub) => ({
+ ...sub,
+ command: () => navigate(sub.to),
+ }))
+ : undefined,
+ }));
+ }, [currentUser, navigate]);
+
+ const sidebarWidth = collapsed ? `${LAYOUT.sidebarCollapsedWidth}px` : `${LAYOUT.sidebarWidth}px`;
+
+ const classNames = [
+ 'app-sidebar',
+ visible && 'app-sidebar--visible',
+ collapsed && 'app-sidebar--collapsed',
+ mobile && 'app-sidebar--mobile',
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ return (
+
+ );
+}
diff --git a/wtm-frontend-react/src/core/components/AppTopbar.scss b/wtm-frontend-react/src/core/components/AppTopbar.scss
new file mode 100644
index 0000000..1300ccf
--- /dev/null
+++ b/wtm-frontend-react/src/core/components/AppTopbar.scss
@@ -0,0 +1,39 @@
+@use '@/assets/styles/variables' as *;
+
+.app-topbar {
+ position: fixed;
+ top: 0;
+ right: 0;
+ left: 0;
+ height: $topbar-height;
+ background: $color-surface-card;
+ border-bottom: 1px solid $color-border;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 $space-md;
+ z-index: $z-topbar;
+
+ &__right {
+ display: flex;
+ align-items: center;
+ gap: $space-xs;
+ }
+
+ &__notify-btn {
+ position: relative;
+ }
+
+ &__user-btn {
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ }
+
+ &__username {
+ font-size: $font-size-sm;
+ @media (max-width: $bp-tablet) {
+ display: none;
+ }
+ }
+}
diff --git a/wtm-frontend-react/src/core/components/AppTopbar.tsx b/wtm-frontend-react/src/core/components/AppTopbar.tsx
new file mode 100644
index 0000000..bebff5d
--- /dev/null
+++ b/wtm-frontend-react/src/core/components/AppTopbar.tsx
@@ -0,0 +1,47 @@
+import { useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Button } from 'primereact/button';
+import { Avatar } from 'primereact/avatar';
+import { Menu } from 'primereact/menu';
+import { useCurrentUser } from '@/core/hooks/useCurrentUser';
+import './AppTopbar.scss';
+
+interface Props {
+ onToggleSidebar: () => void;
+}
+
+export default function AppTopbar({ onToggleSidebar }: Props) {
+ const { currentUser } = useCurrentUser();
+ const navigate = useNavigate();
+ const userMenu = useRef