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(null); + + const userMenuItems = [ + { label: '내 정보', icon: 'pi pi-user', command: () => navigate('/profile') }, + { label: '비밀번호 변경', icon: 'pi pi-key', command: () => navigate('/change-password') }, + { separator: true }, + { label: '로그아웃', icon: 'pi pi-sign-out', command: () => navigate('/login') }, + ]; + + return ( +
+
+
+ +
+ + +
+
+ ); +} diff --git a/wtm-frontend-react/src/core/components/AuthGuard.tsx b/wtm-frontend-react/src/core/components/AuthGuard.tsx new file mode 100644 index 0000000..8a177ce --- /dev/null +++ b/wtm-frontend-react/src/core/components/AuthGuard.tsx @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import { Navigate } from 'react-router-dom'; +import { ProgressSpinner } from 'primereact/progressspinner'; +import { authService } from '@/core/auth/auth.service'; +import { useAuthStore } from '@/modules/auth/auth.store'; + +export default function AuthGuard({ children }: { children: React.ReactNode }) { + const currentUser = useAuthStore((s) => s.currentUser); + const fetchMe = useAuthStore((s) => s.fetchMe); + const [checking, setChecking] = useState(true); + + useEffect(() => { + if (!authService.isAuthenticated()) { + setChecking(false); + return; + } + if (!currentUser) { + fetchMe().finally(() => setChecking(false)); + } else { + setChecking(false); + } + }, [currentUser, fetchMe]); + + if (checking) { + return ( +
+ +
+ ); + } + + if (!authService.isAuthenticated()) { + return ; + } + + return <>{children}; +} diff --git a/wtm-frontend-react/src/core/components/BaseCrudTable.scss b/wtm-frontend-react/src/core/components/BaseCrudTable.scss new file mode 100644 index 0000000..cb64f97 --- /dev/null +++ b/wtm-frontend-react/src/core/components/BaseCrudTable.scss @@ -0,0 +1,36 @@ +@use '@/assets/styles/variables' as *; + +.crud-table { + &__toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: $space-sm; + margin-bottom: $space-md; + } + + &__toolbar-left, + &__toolbar-right { + display: flex; + gap: $space-sm; + align-items: center; + flex-wrap: wrap; + } + + &__empty { + display: flex; + flex-direction: column; + align-items: center; + gap: $space-sm; + padding: $space-2xl; + color: $color-text-muted; + } + + @media (max-width: $bp-mobile) { + &__toolbar { + flex-direction: column; + align-items: stretch; + } + } +} diff --git a/wtm-frontend-react/src/core/components/BaseCrudTable.tsx b/wtm-frontend-react/src/core/components/BaseCrudTable.tsx new file mode 100644 index 0000000..808907c --- /dev/null +++ b/wtm-frontend-react/src/core/components/BaseCrudTable.tsx @@ -0,0 +1,91 @@ +import { useState, type ReactNode } from 'react'; +import { DataTable, type DataTablePageEvent } from 'primereact/datatable'; +import { InputText } from 'primereact/inputtext'; +import { PAGINATION } from '@/core/constants/app.constants'; +import './BaseCrudTable.scss'; + +interface Props { + value: T[]; + loading?: boolean; + totalRecords?: number; + dataKey?: string; + globalFilterFields?: string[]; + paginator?: boolean; + rowsPerPage?: number; + emptyMessage?: string; + selectionMode?: 'single' | 'multiple' | 'checkbox' | 'radiobutton'; + exportFilename?: string; + toolbarLeft?: ReactNode; + toolbarRight?: ReactNode; + onRowSelect?: (row: T) => void; + onPage?: (event: DataTablePageEvent) => void; + children: ReactNode; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default function BaseCrudTable>({ + value, + loading = false, + totalRecords, + dataKey = 'id', + globalFilterFields, + paginator = true, + rowsPerPage = PAGINATION.defaultPageSize, + emptyMessage = '데이터가 없습니다.', + toolbarLeft, + toolbarRight, + onRowSelect, + onPage, + children, +}: Props) { + const [globalFilter, setGlobalFilter] = useState(''); + + return ( +
+
+
{toolbarLeft}
+
+ {toolbarRight} + {globalFilterFields && globalFilterFields.length > 0 && ( + + + setGlobalFilter(e.target.value)} + placeholder="검색..." + size={1} + /> + + )} +
+
+ + onRowSelect?.(e.data as T)} + onPage={onPage} + emptyMessage={ +
+ +

{emptyMessage}

+
+ } + > + {children} +
+
+ ); +} diff --git a/wtm-frontend-react/src/core/components/BaseFormDialog.scss b/wtm-frontend-react/src/core/components/BaseFormDialog.scss new file mode 100644 index 0000000..cc2ef84 --- /dev/null +++ b/wtm-frontend-react/src/core/components/BaseFormDialog.scss @@ -0,0 +1,15 @@ +@use '@/assets/styles/variables' as *; + +.form-dialog { + &__body { + display: flex; + flex-direction: column; + gap: $space-md; + } + + &__footer { + display: flex; + justify-content: flex-end; + gap: $space-sm; + } +} diff --git a/wtm-frontend-react/src/core/components/BaseFormDialog.tsx b/wtm-frontend-react/src/core/components/BaseFormDialog.tsx new file mode 100644 index 0000000..2b0fa27 --- /dev/null +++ b/wtm-frontend-react/src/core/components/BaseFormDialog.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react'; +import { Dialog } from 'primereact/dialog'; +import { Button } from 'primereact/button'; +import './BaseFormDialog.scss'; + +interface Props { + visible: boolean; + onHide: () => void; + title: string; + width?: string; + loading?: boolean; + submitLabel?: string; + cancelLabel?: string; + onSubmit: () => void; + children: ReactNode; +} + +export default function BaseFormDialog({ + visible, + onHide, + title, + width = '540px', + loading = false, + submitLabel = '저장', + cancelLabel = '취소', + onSubmit, + children, +}: Props) { + const footer = ( +
+
+ ); + + return ( + +
{children}
+
+ ); +} diff --git a/wtm-frontend-react/src/core/components/BasePageHeader.scss b/wtm-frontend-react/src/core/components/BasePageHeader.scss new file mode 100644 index 0000000..4fcbd13 --- /dev/null +++ b/wtm-frontend-react/src/core/components/BasePageHeader.scss @@ -0,0 +1,37 @@ +@use '@/assets/styles/variables' as *; + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: $space-md; + margin-bottom: $space-lg; + + &__title { + font-size: $font-size-2xl; + font-weight: 700; + margin: 0; + color: $color-text; + } + + &__subtitle { + font-size: $font-size-sm; + color: $color-text-muted; + margin: $space-xs 0 0; + } + + &__actions { + display: flex; + gap: $space-sm; + flex-wrap: wrap; + } + + @media (max-width: $bp-mobile) { + flex-direction: column; + &__actions { + width: 100%; + justify-content: flex-end; + } + } +} diff --git a/wtm-frontend-react/src/core/components/BasePageHeader.tsx b/wtm-frontend-react/src/core/components/BasePageHeader.tsx new file mode 100644 index 0000000..5f5e8dc --- /dev/null +++ b/wtm-frontend-react/src/core/components/BasePageHeader.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; +import './BasePageHeader.scss'; + +interface Props { + title: string; + subtitle?: string; + actions?: ReactNode; +} + +export default function BasePageHeader({ title, subtitle, actions }: Props) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/wtm-frontend-react/src/core/components/NotFoundView.tsx b/wtm-frontend-react/src/core/components/NotFoundView.tsx new file mode 100644 index 0000000..96533c8 --- /dev/null +++ b/wtm-frontend-react/src/core/components/NotFoundView.tsx @@ -0,0 +1,11 @@ +import { Link } from 'react-router-dom'; + +export default function NotFoundView() { + return ( +
+

404

+

페이지를 찾을 수 없습니다.

+ 홈으로 이동 +
+ ); +} diff --git a/wtm-frontend-react/src/core/constants/app.constants.ts b/wtm-frontend-react/src/core/constants/app.constants.ts new file mode 100644 index 0000000..a0a914a --- /dev/null +++ b/wtm-frontend-react/src/core/constants/app.constants.ts @@ -0,0 +1,151 @@ +// Breakpoints +export const BREAKPOINTS = { + mobile: 576, + tablet: 768, + desktop: 992, + wide: 1200, + ultra: 1400, +} as const; + +// Layout +export const LAYOUT = { + sidebarWidth: 260, + sidebarCollapsedWidth: 64, + topbarHeight: 56, +} as const; + +// Pagination +export const PAGINATION = { + defaultPageSize: 20, + pageSizeOptions: [10, 20, 50, 100], +} as const; + +// Toast +export const TOAST = { + defaultLife: 3000, + errorLife: 5000, +} as const; + +// Date formats +export const DATE_FORMAT = { + display: 'YYYY-MM-DD', + api: 'YYYY-MM-DD', + datetime: 'YYYY-MM-DD HH:mm', + weekStart: 1, +} as const; + +// Timesheet rules +export const TIMESHEET_RULES = { + maxDailyHours: 24, + warnDailyHours: 10, + defaultDailyHours: 8, + maxWeeklyHours: 52, +} as const; + +// Roles +export const ROLES = { + SA: 'SA', + PM: 'PM', + PCM: 'PCM', + PTK: 'PTK', + DL: 'DL', + USER: 'USER', +} as const; + +// Timesheet status +export const TIMESHEET_STATUS: Record = { + DRAFT: { label: '작성중', severity: 'secondary' }, + SUBMITTED: { label: '제출됨', severity: 'info' }, + DL_APPROVED: { label: 'DL승인', severity: 'warning' }, + APPROVED: { label: '승인', severity: 'success' }, + REJECTED: { label: '반려', severity: 'danger' }, +}; + +// Project status +export const PROJECT_STATUS: Record = { + ACTIVE: { label: '진행중', severity: 'success' }, + CLOSED: { label: '종료', severity: 'secondary' }, + HOLD: { label: '보류', severity: 'warning' }, +}; + +// Entry types +export const ENTRY_TYPES: Record = { + NON_PROJECT: { label: 'Non-Project', icon: 'pi pi-calendar' }, + OTHER_PROJECT: { label: 'Other Project', icon: 'pi pi-briefcase' }, + EPC: { label: 'EPC Project', icon: 'pi pi-building' }, +}; + +// Non-Project categories +export const NP_CATEGORIES = [ + { value: 'ANNUAL_LEAVE', label: '연차' }, + { value: 'SICK_LEAVE', label: '병가' }, + { value: 'TRAINING', label: '교육' }, + { value: 'ADMIN', label: '행정' }, + { value: 'PUBLIC_HOLIDAY', label: '공휴일' }, + { value: 'OTHER', label: '기타' }, +] as const; + +// Sidebar menu +export interface MenuItem { + label: string; + icon: string; + to?: string; + roles: string[]; + items?: { label: string; to: string; roles: string[] }[]; +} + +export const MENU_ITEMS: MenuItem[] = [ + { + label: '대시보드', + icon: 'pi pi-home', + to: '/dashboard', + roles: ['SA', 'PM', 'PCM', 'PTK', 'DL', 'USER'], + }, + { + label: '시수 관리', + icon: 'pi pi-clock', + roles: ['SA', 'PM', 'DL', 'USER'], + items: [ + { label: '시수 입력', to: '/timesheets', roles: ['USER', 'DL', 'PM', 'SA'] }, + { label: '시수 이력', to: '/timesheets/history', roles: ['USER', 'DL', 'PM', 'SA'] }, + { label: 'Excel 업로드', to: '/timesheets/upload', roles: ['USER'] }, + ], + }, + { + label: '결재', + icon: 'pi pi-check-square', + roles: ['DL', 'PM', 'SA'], + items: [ + { label: '결재 대기', to: '/approvals', roles: ['DL', 'PM', 'SA'] }, + { label: '결재 이력', to: '/approvals/history', roles: ['DL', 'PM', 'SA'] }, + ], + }, + { + label: '프로젝트', + icon: 'pi pi-briefcase', + roles: ['SA', 'PM', 'PCM'], + items: [ + { label: '프로젝트 목록', to: '/projects', roles: ['SA', 'PM', 'PCM'] }, + { label: 'WBS 관리', to: '/wbs', roles: ['SA', 'PM', 'PCM'] }, + { label: 'TEAL 관리', to: '/teal', roles: ['SA', 'PM', 'PCM'] }, + ], + }, + { + label: '리포트', + icon: 'pi pi-chart-bar', + to: '/reports', + roles: ['SA', 'PM', 'PCM', 'DL'], + }, + { + label: '사용자 관리', + icon: 'pi pi-users', + to: '/users', + roles: ['SA', 'PTK'], + }, + { + label: '시스템 설정', + icon: 'pi pi-cog', + to: '/settings', + roles: ['SA'], + }, +]; diff --git a/wtm-frontend-react/src/core/hooks/useCurrentUser.ts b/wtm-frontend-react/src/core/hooks/useCurrentUser.ts new file mode 100644 index 0000000..310afd9 --- /dev/null +++ b/wtm-frontend-react/src/core/hooks/useCurrentUser.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; +import { useAuthStore } from '@/modules/auth/auth.store'; + +export function useCurrentUser() { + const currentUser = useAuthStore((s) => s.currentUser); + + const roles = useMemo(() => currentUser?.roles ?? [], [currentUser]); + + const isAuthenticated = !!currentUser; + + const hasRole = (role: string) => roles.includes(role); + + const hasAnyRole = (...checkRoles: string[]) => checkRoles.some((r) => roles.includes(r)); + + return { currentUser, isAuthenticated, roles, hasRole, hasAnyRole }; +} diff --git a/wtm-frontend-react/src/modules/approval/approval.service.ts b/wtm-frontend-react/src/modules/approval/approval.service.ts new file mode 100644 index 0000000..e97857c --- /dev/null +++ b/wtm-frontend-react/src/modules/approval/approval.service.ts @@ -0,0 +1,14 @@ +import api from '@/core/api/axios'; + +const BASE = '/api/wtm/approvals'; + +export const approvalService = { + getPending: () => api.get(`${BASE}/pending`), + approve: (id: number, comment?: string) => api.post(`${BASE}/${id}/approve`, { comment }), + reject: (id: number, comment?: string) => api.post(`${BASE}/${id}/reject`, { comment }), + batchApprove: (ids: number[]) => api.post(`${BASE}/batch-approve`, { ids }), + addComment: (id: number, comment: string) => api.post(`${BASE}/${id}/comments`, { comment }), + getById: (id: number) => api.get(`${BASE}/${id}`), + getHistory: (params?: Record) => api.get(`${BASE}/history`, { params }), + getOverdue: () => api.get(`${BASE}/overdue`), +}; diff --git a/wtm-frontend-react/src/modules/approval/approval.types.ts b/wtm-frontend-react/src/modules/approval/approval.types.ts new file mode 100644 index 0000000..418595c --- /dev/null +++ b/wtm-frontend-react/src/modules/approval/approval.types.ts @@ -0,0 +1,25 @@ +export interface Approval { + id: number; + timesheetId: number; + requesterId: number; + requesterName: string; + projectName?: string; + weekStartDate: string; + weekEndDate: string; + totalHours: number; + status: string; + submittedAt?: string; + approvedAt?: string; + comment?: string; +} + +export interface ApprovalLine { + id: number; + approvalId: number; + approverId: number; + approverName: string; + sequence: number; + status: string; + comment?: string; + actedAt?: string; +} diff --git a/wtm-frontend-react/src/modules/approval/views/ApprovalHistoryView.tsx b/wtm-frontend-react/src/modules/approval/views/ApprovalHistoryView.tsx new file mode 100644 index 0000000..4f822bf --- /dev/null +++ b/wtm-frontend-react/src/modules/approval/views/ApprovalHistoryView.tsx @@ -0,0 +1,54 @@ +import { useState, useEffect } from 'react'; +import { Column } from 'primereact/column'; +import { Tag } from 'primereact/tag'; +import { Calendar } from 'primereact/calendar'; +import { Dropdown } from 'primereact/dropdown'; +import BaseCrudTable from '@/core/components/BaseCrudTable'; +import BasePageHeader from '@/core/components/BasePageHeader'; +import { approvalService } from '../approval.service'; +import { TIMESHEET_STATUS } from '@/core/constants/app.constants'; + +export default function ApprovalHistoryView() { + const [loading, setLoading] = useState(false); + const [history, setHistory] = useState[]>([]); + const [statusFilter, setStatusFilter] = useState(null); + const [dateFrom, setDateFrom] = useState(null); + const [dateTo, setDateTo] = useState(null); + + useEffect(() => { + setLoading(true); + const params: Record = {}; + if (statusFilter) params.status = statusFilter; + if (dateFrom) params.from = dateFrom.toISOString().slice(0, 10); + if (dateTo) params.to = dateTo.toISOString().slice(0, 10); + + approvalService.getHistory(params) + .then(({ data }) => setHistory((data as { items?: unknown[] }).items as Record[] ?? data as Record[])) + .catch(() => setHistory([])) + .finally(() => setLoading(false)); + }, [statusFilter, dateFrom, dateTo]); + + const statusOptions = Object.entries(TIMESHEET_STATUS).map(([key, val]) => ({ label: val.label, value: key })); + + return ( +
+ +
+ setDateFrom(e.value as Date)} placeholder="시작일" dateFormat="yy-mm-dd" /> + setDateTo(e.value as Date)} placeholder="종료일" dateFormat="yy-mm-dd" /> + setStatusFilter(e.value)} placeholder="상태" showClear /> +
+ + + + + `${row.totalHours}h`} /> + { + const s = TIMESHEET_STATUS[row.status as string]; + return ; + }} /> + + +
+ ); +} diff --git a/wtm-frontend-react/src/modules/approval/views/ApprovalPendingView.tsx b/wtm-frontend-react/src/modules/approval/views/ApprovalPendingView.tsx new file mode 100644 index 0000000..446e26b --- /dev/null +++ b/wtm-frontend-react/src/modules/approval/views/ApprovalPendingView.tsx @@ -0,0 +1,72 @@ +import { useState, useEffect } from 'react'; +import { Column } from 'primereact/column'; +import { Button } from 'primereact/button'; +import { Tag } from 'primereact/tag'; +import BaseCrudTable from '@/core/components/BaseCrudTable'; +import BasePageHeader from '@/core/components/BasePageHeader'; +import { approvalService } from '../approval.service'; +import { TIMESHEET_STATUS } from '@/core/constants/app.constants'; +import type { Approval } from '../approval.types'; + +export default function ApprovalPendingView() { + const [loading, setLoading] = useState(false); + const [approvals, setApprovals] = useState([]); + const [selected, setSelected] = useState([]); + + function load() { + setLoading(true); + approvalService.getPending() + .then(({ data }) => setApprovals((data as { items?: Approval[] }).items ?? data as Approval[])) + .catch(() => setApprovals([])) + .finally(() => setLoading(false)); + } + + useEffect(() => { load(); }, []); + + async function handleApprove(id: number) { + await approvalService.approve(id); + load(); + } + + async function handleReject(id: number) { + await approvalService.reject(id); + load(); + } + + async function batchApprove() { + if (selected.length === 0) return; + await approvalService.batchApprove(selected.map((s) => s.id)); + setSelected([]); + load(); + } + + return ( +
+ + + } + > + + + + + `${row.totalHours}h`} sortable /> + { + const s = TIMESHEET_STATUS[row.status]; + return ; + }} /> + ( +
+
+ )} /> +
+
+ ); +} diff --git a/wtm-frontend/src/modules/auth/auth.service.ts b/wtm-frontend-react/src/modules/auth/auth.service.ts similarity index 100% rename from wtm-frontend/src/modules/auth/auth.service.ts rename to wtm-frontend-react/src/modules/auth/auth.service.ts diff --git a/wtm-frontend-react/src/modules/auth/auth.store.ts b/wtm-frontend-react/src/modules/auth/auth.store.ts new file mode 100644 index 0000000..2d6eed3 --- /dev/null +++ b/wtm-frontend-react/src/modules/auth/auth.store.ts @@ -0,0 +1,64 @@ +import { create } from 'zustand'; +import { authService as tokenService } from '@/core/auth/auth.service'; +import { authApi } from './auth.service'; +import type { AuthUser } from '@/core/auth/auth.types'; + +interface AuthState { + currentUser: AuthUser | null; + loading: boolean; + unreadCount: number; + login: (email: string, password: string) => Promise; + fetchMe: () => Promise; + logout: () => void; + reset: () => void; +} + +function mapUser(u: Record): AuthUser { + return { + id: u.id as number, + email: u.email as string, + fullName: (u.full_name ?? u.fullName ?? '') as string, + roles: Array.isArray(u.roles) && u.roles.length ? u.roles : (u.is_admin ? ['SA'] : ['USER']), + departmentId: (u.department_id ?? u.departmentId) as number | undefined, + }; +} + +export const useAuthStore = create((set) => ({ + currentUser: null, + loading: false, + unreadCount: 0, + + login: async (email, password) => { + set({ loading: true }); + try { + const { data } = await authApi.login({ email, password }); + const accessToken = (data.access_token ?? data.accessToken) as string; + const refreshToken = (data.refresh_token ?? data.refreshToken) as string; + tokenService.setTokens(accessToken, refreshToken); + set({ currentUser: mapUser(data.user as unknown as Record) }); + } finally { + set({ loading: false }); + } + }, + + fetchMe: async () => { + try { + const { data } = await authApi.me(); + set({ currentUser: mapUser(data as unknown as Record) }); + } catch { + tokenService.clearTokens(); + set({ currentUser: null }); + window.location.href = '/login'; + } + }, + + logout: () => { + tokenService.clearTokens(); + set({ currentUser: null }); + window.location.href = '/login'; + }, + + reset: () => { + set({ currentUser: null, loading: false, unreadCount: 0 }); + }, +})); diff --git a/wtm-frontend/src/modules/auth/auth.types.ts b/wtm-frontend-react/src/modules/auth/auth.types.ts similarity index 100% rename from wtm-frontend/src/modules/auth/auth.types.ts rename to wtm-frontend-react/src/modules/auth/auth.types.ts diff --git a/wtm-frontend-react/src/modules/auth/views/ChangePasswordView.tsx b/wtm-frontend-react/src/modules/auth/views/ChangePasswordView.tsx new file mode 100644 index 0000000..6830d2d --- /dev/null +++ b/wtm-frontend-react/src/modules/auth/views/ChangePasswordView.tsx @@ -0,0 +1,66 @@ +import { useState, type FormEvent } from 'react'; +import { Password } from 'primereact/password'; +import { Button } from 'primereact/button'; +import { Card } from 'primereact/card'; +import { Message } from 'primereact/message'; +import BasePageHeader from '@/core/components/BasePageHeader'; +import { authApi } from '../auth.service'; + +export default function ChangePasswordView() { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState<{ severity: 'success' | 'error'; text: string } | null>(null); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + setMessage(null); + + if (newPassword.length < 8) { + setMessage({ severity: 'error', text: '비밀번호는 8자 이상이어야 합니다.' }); + return; + } + if (newPassword !== confirmPassword) { + setMessage({ severity: 'error', text: '새 비밀번호가 일치하지 않습니다.' }); + return; + } + + setLoading(true); + try { + await authApi.changePassword({ currentPassword, newPassword }); + setMessage({ severity: 'success', text: '비밀번호가 변경되었습니다.' }); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + } catch { + setMessage({ severity: 'error', text: '비밀번호 변경에 실패했습니다.' }); + } finally { + setLoading(false); + } + } + + return ( +
+ + + {message && } +
+
+ + setCurrentPassword(e.target.value)} feedback={false} toggleMask inputStyle={{ width: '100%' }} /> +
+
+ + setNewPassword(e.target.value)} feedback={false} toggleMask inputStyle={{ width: '100%' }} /> +
+
+ + setConfirmPassword(e.target.value)} feedback={false} toggleMask inputStyle={{ width: '100%' }} /> +
+
+ ); +} diff --git a/wtm-frontend-react/src/modules/auth/views/ForgotPasswordView.tsx b/wtm-frontend-react/src/modules/auth/views/ForgotPasswordView.tsx new file mode 100644 index 0000000..83d9b4e --- /dev/null +++ b/wtm-frontend-react/src/modules/auth/views/ForgotPasswordView.tsx @@ -0,0 +1,11 @@ +import { Card } from 'primereact/card'; + +export default function ForgotPasswordView() { + return ( +
+ +

비밀번호 찾기 기능은 준비 중입니다.

+
+
+ ); +} diff --git a/wtm-frontend-react/src/modules/auth/views/LoginView.scss b/wtm-frontend-react/src/modules/auth/views/LoginView.scss new file mode 100644 index 0000000..39e4f29 --- /dev/null +++ b/wtm-frontend-react/src/modules/auth/views/LoginView.scss @@ -0,0 +1,64 @@ +@use '@/assets/styles/variables' as *; + +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: var(--p-surface-50); + padding: $space-md; + + &__card { + width: 100%; + max-width: 420px; + } + + &__content { + display: flex; + flex-direction: column; + align-items: center; + gap: $space-lg; + } + + &__logo { + text-align: center; + } + + &__title { + font-size: $font-size-2xl; + font-weight: 700; + margin: $space-sm 0 0; + color: $color-text; + } + + &__subtitle { + font-size: $font-size-sm; + color: $color-text-muted; + margin: $space-xs 0 0; + } + + &__form { + width: 100%; + display: flex; + flex-direction: column; + gap: $space-md; + } + + &__submit { + margin-top: $space-sm; + } + + &__links { + text-align: center; + } + + &__link { + font-size: $font-size-sm; + color: $color-primary; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/wtm-frontend-react/src/modules/auth/views/LoginView.tsx b/wtm-frontend-react/src/modules/auth/views/LoginView.tsx new file mode 100644 index 0000000..e5f2af3 --- /dev/null +++ b/wtm-frontend-react/src/modules/auth/views/LoginView.tsx @@ -0,0 +1,86 @@ +import { useState, type FormEvent } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { InputText } from 'primereact/inputtext'; +import { Password } from 'primereact/password'; +import { Button } from 'primereact/button'; +import { Card } from 'primereact/card'; +import { Message } from 'primereact/message'; +import { useAuthStore } from '../auth.store'; +import './LoginView.scss'; + +export default function LoginView() { + const login = useAuthStore((s) => s.login); + const loading = useAuthStore((s) => s.loading); + const navigate = useNavigate(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + + async function onLogin(e: FormEvent) { + e.preventDefault(); + setError(''); + try { + await login(email, password); + navigate('/dashboard'); + } catch (err: unknown) { + const axiosErr = err as { response?: { data?: { detail?: string } } }; + setError(axiosErr?.response?.data?.detail ?? '로그인에 실패했습니다. 이메일과 비밀번호를 확인하세요.'); + } + } + + return ( +
+ +
+
+ +

WTM

+

Work Time Manager

+
+ + {error && } + +
+
+ + setEmail(e.target.value)} + type="email" + placeholder="user@hanwha.com" + required + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="비밀번호 입력" + feedback={false} + toggleMask + inputStyle={{ width: '100%' }} + /> +
+ +
+
+
+ ); +} diff --git a/wtm-frontend-react/src/modules/dashboard/dashboard.service.ts b/wtm-frontend-react/src/modules/dashboard/dashboard.service.ts new file mode 100644 index 0000000..2ca94f5 --- /dev/null +++ b/wtm-frontend-react/src/modules/dashboard/dashboard.service.ts @@ -0,0 +1,8 @@ +import api from '@/core/api/axios'; + +const BASE = '/api/wtm/home'; + +export const dashboardService = { + getDashboard: () => api.get(`${BASE}/dashboard`), + getNotifications: () => api.get(`${BASE}/notifications`), +}; diff --git a/wtm-frontend-react/src/modules/dashboard/dashboard.types.ts b/wtm-frontend-react/src/modules/dashboard/dashboard.types.ts new file mode 100644 index 0000000..6c5f2cd --- /dev/null +++ b/wtm-frontend-react/src/modules/dashboard/dashboard.types.ts @@ -0,0 +1,6 @@ +export interface DashboardStat { + label: string; + value: number; + icon: string; + trend?: number; +} diff --git a/wtm-frontend-react/src/modules/dashboard/views/DashboardView.scss b/wtm-frontend-react/src/modules/dashboard/views/DashboardView.scss new file mode 100644 index 0000000..7c425e3 --- /dev/null +++ b/wtm-frontend-react/src/modules/dashboard/views/DashboardView.scss @@ -0,0 +1,83 @@ +@use '@/assets/styles/variables' as *; + +.dashboard-view { + &__loading { + display: flex; + justify-content: center; + padding: $space-2xl; + } + + &__stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: $space-md; + margin-bottom: $space-lg; + + @media (max-width: $bp-tablet) { + grid-template-columns: repeat(2, 1fr); + } + @media (max-width: $bp-mobile) { + grid-template-columns: 1fr; + } + } + + &__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $space-md; + + @media (max-width: $bp-tablet) { + grid-template-columns: 1fr; + } + } + + &__chart-wrapper { + height: 280px; + position: relative; + } +} + +.stat-card { + display: flex; + align-items: center; + gap: $space-md; + + &__icon { + width: 48px; + height: 48px; + border-radius: $radius-lg; + background: var(--p-primary-100); + color: var(--p-primary-color); + display: flex; + align-items: center; + justify-content: center; + font-size: $font-size-xl; + flex-shrink: 0; + } + + &__info { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__value { + font-size: $font-size-2xl; + font-weight: 700; + color: $color-text; + line-height: 1.2; + } + + &__label { + font-size: $font-size-sm; + color: $color-text-muted; + } + + &__trend { + margin-left: auto; + font-size: $font-size-sm; + font-weight: 600; + &--up { color: $color-success; } + &--down { color: $color-danger; } + } +} diff --git a/wtm-frontend-react/src/modules/dashboard/views/DashboardView.tsx b/wtm-frontend-react/src/modules/dashboard/views/DashboardView.tsx new file mode 100644 index 0000000..95506bd --- /dev/null +++ b/wtm-frontend-react/src/modules/dashboard/views/DashboardView.tsx @@ -0,0 +1,123 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Card } from 'primereact/card'; +import { Tag } from 'primereact/tag'; +import { DataTable } from 'primereact/datatable'; +import { Column } from 'primereact/column'; +import { ProgressSpinner } from 'primereact/progressspinner'; +import { Chart } from 'primereact/chart'; +import BasePageHeader from '@/core/components/BasePageHeader'; +import { dashboardService } from '../dashboard.service'; +import { TIMESHEET_STATUS } from '@/core/constants/app.constants'; +import type { DashboardStat } from '../dashboard.types'; +import './DashboardView.scss'; + +const defaultStats: DashboardStat[] = [ + { label: '금주 시수', value: 0, icon: 'pi pi-clock' }, + { label: '미제출 건수', value: 0, icon: 'pi pi-exclamation-triangle' }, + { label: '결재 대기', value: 0, icon: 'pi pi-check-square' }, + { label: '프로젝트 수', value: 0, icon: 'pi pi-briefcase' }, +]; + +export default function DashboardView() { + const [loading, setLoading] = useState(false); + const [stats, setStats] = useState(defaultStats); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [weeklyHoursData, setWeeklyHoursData] = useState(null); + const [pendingApprovals, setPendingApprovals] = useState[]>([]); + + const chartOptions = useMemo(() => ({ + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + y: { beginAtZero: true, title: { display: true, text: '시간 (h)' } }, + x: { title: { display: true, text: '요일' } }, + }, + }), []); + + useEffect(() => { + setLoading(true); + dashboardService.getDashboard() + .then(({ data }) => { + setStats(data.stats ?? defaultStats); + setPendingApprovals(data.pendingApprovals ?? []); + setWeeklyHoursData({ + labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + datasets: [{ + label: '시수', + backgroundColor: 'var(--p-primary-color)', + data: data.weeklyHours ?? [0, 0, 0, 0, 0, 0], + }], + }); + }) + .catch(() => { + setStats(defaultStats); + setWeeklyHoursData({ + labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + datasets: [{ label: '시수', backgroundColor: 'var(--p-primary-color)', data: [0, 0, 0, 0, 0, 0] }], + }); + }) + .finally(() => setLoading(false)); + }, []); + + function statusSeverity(status: string) { + return (TIMESHEET_STATUS[status]?.severity ?? 'secondary') as 'success' | 'info' | 'warning' | 'danger' | 'secondary'; + } + function statusLabel(status: string) { + return TIMESHEET_STATUS[status]?.label ?? status; + } + + if (loading) { + return ( +
+ +
+
+ ); + } + + return ( +
+ + +
+ {stats.map((stat, idx) => ( + +
+
+
+ {stat.value} + {stat.label} +
+ {stat.trend != null && ( +
0 ? 'stat-card__trend--up' : stat.trend < 0 ? 'stat-card__trend--down' : ''}`}> + 0 ? 'pi pi-arrow-up' : stat.trend < 0 ? 'pi pi-arrow-down' : 'pi pi-minus'} /> + {Math.abs(stat.trend)}% +
+ )} +
+
+ ))} +
+ +
+ +
+ {weeklyHoursData && } +
+
+ + + 5} size="small" stripedRows + emptyMessage={
결재 대기 건이 없습니다.
} + > + + + `${row.totalHours}h`} /> + } /> +
+
+
+
+ ); +} diff --git a/wtm-frontend-react/src/modules/project/components/ProjectFormDialog.tsx b/wtm-frontend-react/src/modules/project/components/ProjectFormDialog.tsx new file mode 100644 index 0000000..8049195 --- /dev/null +++ b/wtm-frontend-react/src/modules/project/components/ProjectFormDialog.tsx @@ -0,0 +1,81 @@ +import { useState, useEffect } from 'react'; +import { InputText } from 'primereact/inputtext'; +import { InputTextarea } from 'primereact/inputtextarea'; +import { Dropdown } from 'primereact/dropdown'; +import BaseFormDialog from '@/core/components/BaseFormDialog'; +import type { Project } from '../project.types'; + +interface Props { + visible: boolean; + onHide: () => void; + project: Project | null; + onSave: (data: Partial) => Promise; +} + +export default function ProjectFormDialog({ visible, onHide, project, onSave }: Props) { + const [code, setCode] = useState(''); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [type, setType] = useState(''); + const [status, setStatus] = useState('ACTIVE'); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (project) { + setCode(project.code); + setName(project.name); + setDescription(project.description ?? ''); + setType(project.type ?? ''); + setStatus(project.status); + } else { + setCode(''); setName(''); setDescription(''); setType(''); setStatus('ACTIVE'); + } + }, [project, visible]); + + async function handleSubmit() { + setLoading(true); + try { + await onSave({ code, name, description, type, status }); + } finally { + setLoading(false); + } + } + + const typeOptions = [ + { label: 'EPC', value: 'EPC' }, + { label: 'Other', value: 'OTHER' }, + { label: 'Internal', value: 'INTERNAL' }, + ]; + const statusOptions = [ + { label: '진행중', value: 'ACTIVE' }, + { label: '종료', value: 'CLOSED' }, + { label: '보류', value: 'HOLD' }, + ]; + + return ( + +
+
+ + setCode(e.target.value)} disabled={!!project} /> +
+
+ + setName(e.target.value)} /> +
+
+ + setType(e.value)} placeholder="선택" /> +
+
+ + setStatus(e.value)} /> +
+
+ + setDescription(e.target.value)} rows={3} /> +
+
+
+ ); +} diff --git a/wtm-frontend-react/src/modules/project/project.service.ts b/wtm-frontend-react/src/modules/project/project.service.ts new file mode 100644 index 0000000..65666f8 --- /dev/null +++ b/wtm-frontend-react/src/modules/project/project.service.ts @@ -0,0 +1,13 @@ +import api from '@/core/api/axios'; + +const BASE = '/api/wtm/projects'; + +export const projectService = { + getAll: (params?: Record) => api.get(BASE, { params }), + getById: (id: number) => api.get(`${BASE}/${id}`), + create: (data: unknown) => api.post(BASE, data), + update: (id: number, data: unknown) => api.put(`${BASE}/${id}`, data), + getMy: () => api.get(`${BASE}/my`), + getMembers: (id: number) => api.get(`${BASE}/${id}/members`), + getAssignments: (id: number) => api.get(`${BASE}/${id}/assignments`), +}; diff --git a/wtm-frontend-react/src/modules/project/project.types.ts b/wtm-frontend-react/src/modules/project/project.types.ts new file mode 100644 index 0000000..a91b4a0 --- /dev/null +++ b/wtm-frontend-react/src/modules/project/project.types.ts @@ -0,0 +1,12 @@ +export interface Project { + id: number; + code: string; + name: string; + description?: string; + type?: string; + status: string; + startDate?: string; + endDate?: string; + managerId?: number; + managerName?: string; +} diff --git a/wtm-frontend-react/src/modules/project/views/ProjectDetailView.tsx b/wtm-frontend-react/src/modules/project/views/ProjectDetailView.tsx new file mode 100644 index 0000000..6e934b3 --- /dev/null +++ b/wtm-frontend-react/src/modules/project/views/ProjectDetailView.tsx @@ -0,0 +1,13 @@ +import { Card } from 'primereact/card'; +import BasePageHeader from '@/core/components/BasePageHeader'; + +export default function ProjectDetailView() { + return ( +
+ + +

프로젝트 상세 페이지는 준비 중입니다.

+
+
+ ); +} diff --git a/wtm-frontend-react/src/modules/project/views/ProjectListView.tsx b/wtm-frontend-react/src/modules/project/views/ProjectListView.tsx new file mode 100644 index 0000000..a1da72d --- /dev/null +++ b/wtm-frontend-react/src/modules/project/views/ProjectListView.tsx @@ -0,0 +1,69 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Column } from 'primereact/column'; +import { Button } from 'primereact/button'; +import { Tag } from 'primereact/tag'; +import BaseCrudTable from '@/core/components/BaseCrudTable'; +import BasePageHeader from '@/core/components/BasePageHeader'; +import ProjectFormDialog from '../components/ProjectFormDialog'; +import { projectService } from '../project.service'; +import { PROJECT_STATUS } from '@/core/constants/app.constants'; +import type { Project } from '../project.types'; + +export default function ProjectListView() { + const [loading, setLoading] = useState(false); + const [projects, setProjects] = useState([]); + const [dialogVisible, setDialogVisible] = useState(false); + const [editProject, setEditProject] = useState(null); + const navigate = useNavigate(); + + function load() { + setLoading(true); + projectService.getAll() + .then(({ data }) => setProjects((data as { items?: Project[] }).items ?? data as Project[])) + .catch(() => setProjects([])) + .finally(() => setLoading(false)); + } + + useEffect(() => { load(); }, []); + + function openCreate() { + setEditProject(null); + setDialogVisible(true); + } + + function openEdit(p: Project) { + setEditProject(p); + setDialogVisible(true); + } + + async function onSave(data: Partial) { + if (editProject) { + await projectService.update(editProject.id, data); + } else { + await projectService.create(data); + } + setDialogVisible(false); + load(); + } + + return ( +
+ } /> + navigate(`/projects/${row.id}`)}> + + + + { + const s = PROJECT_STATUS[row.status]; + return ; + }} sortable /> + +
+ ); +} diff --git a/wtm-frontend-react/src/modules/report/report.service.ts b/wtm-frontend-react/src/modules/report/report.service.ts new file mode 100644 index 0000000..2d9c576 --- /dev/null +++ b/wtm-frontend-react/src/modules/report/report.service.ts @@ -0,0 +1,12 @@ +import api from '@/core/api/axios'; + +const BASE = '/api/wtm/reports'; + +export const reportService = { + getProjectHours: (params: Record) => api.get(`${BASE}/project-hours`, { params }), + exportProjectHours: (params: Record) => api.get(`${BASE}/project-hours/export`, { params, responseType: 'blob' }), + getWbsHours: (params: Record) => api.get(`${BASE}/wbs-hours`, { params }), + exportWbsHours: (params: Record) => api.get(`${BASE}/wbs-hours/export`, { params, responseType: 'blob' }), + getPhaseRatio: (params: Record) => api.get(`${BASE}/phase-ratio`, { params }), + getNpRatio: (params: Record) => api.get(`${BASE}/np-ratio`, { params }), +}; diff --git a/wtm-frontend-react/src/modules/report/report.types.ts b/wtm-frontend-react/src/modules/report/report.types.ts new file mode 100644 index 0000000..04b79fe --- /dev/null +++ b/wtm-frontend-react/src/modules/report/report.types.ts @@ -0,0 +1,7 @@ +export interface ReportFilter { + projectId?: number; + from?: string; + to?: string; + groupBy?: string; + wbsLevel?: number; +} diff --git a/wtm-frontend-react/src/modules/report/views/ReportView.tsx b/wtm-frontend-react/src/modules/report/views/ReportView.tsx new file mode 100644 index 0000000..9e74d49 --- /dev/null +++ b/wtm-frontend-react/src/modules/report/views/ReportView.tsx @@ -0,0 +1,124 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Dropdown } from 'primereact/dropdown'; +import { Calendar } from 'primereact/calendar'; +import { Button } from 'primereact/button'; +import { Card } from 'primereact/card'; +import { DataTable } from 'primereact/datatable'; +import { Column } from 'primereact/column'; +import { Chart } from 'primereact/chart'; +import BasePageHeader from '@/core/components/BasePageHeader'; +import { reportService } from '../report.service'; +import { projectService } from '@/modules/project/project.service'; + +export default function ReportView() { + const [projects, setProjects] = useState<{ id: number; name: string }[]>([]); + const [projectId, setProjectId] = useState(null); + const [dateFrom, setDateFrom] = useState(null); + const [dateTo, setDateTo] = useState(null); + const [groupBy, setGroupBy] = useState('project'); + const [loading, setLoading] = useState(false); + const [data, setData] = useState[]>([]); + + const groupByOptions = [ + { label: '프로젝트', value: 'project' }, + { label: 'WBS', value: 'wbs' }, + { label: '사용자', value: 'user' }, + { label: '월별', value: 'month' }, + ]; + + useEffect(() => { + projectService.getAll() + .then(({ data }) => setProjects((data as { items?: unknown[] }).items as { id: number; name: string }[] ?? data as { id: number; name: string }[])) + .catch(() => setProjects([])); + }, []); + + function search() { + setLoading(true); + const params: Record = { groupBy }; + if (projectId) params.projectId = projectId; + if (dateFrom) params.from = dateFrom.toISOString().slice(0, 10); + if (dateTo) params.to = dateTo.toISOString().slice(0, 10); + + reportService.getProjectHours(params) + .then(({ data }) => setData(Array.isArray(data) ? data : (data as { items?: unknown[] }).items as Record[] ?? [])) + .catch(() => setData([])) + .finally(() => setLoading(false)); + } + + function exportExcel() { + const params: Record = { groupBy }; + if (projectId) params.projectId = projectId; + if (dateFrom) params.from = dateFrom.toISOString().slice(0, 10); + if (dateTo) params.to = dateTo.toISOString().slice(0, 10); + + reportService.exportProjectHours(params).then(({ data: blob }) => { + const url = URL.createObjectURL(blob as Blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'report.xlsx'; + a.click(); + URL.revokeObjectURL(url); + }); + } + + const chartData = useMemo(() => ({ + labels: data.map((d) => (d.label ?? d.name ?? '') as string), + datasets: [{ + label: '시수', + backgroundColor: 'var(--p-primary-color)', + data: data.map((d) => (d.totalHours ?? d.hours ?? 0) as number), + }], + }), [data]); + + const chartOptions = useMemo(() => ({ + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { y: { beginAtZero: true } }, + }), []); + + return ( +
+ + + +
+
+ + ({ label: p.name, value: p.id }))} onChange={(e) => setProjectId(e.value)} placeholder="전체" showClear /> +
+
+ + setDateFrom(e.value as Date)} dateFormat="yy-mm-dd" /> +
+
+ + setDateTo(e.value as Date)} dateFormat="yy-mm-dd" /> +
+
+ + setGroupBy(e.value)} /> +
+
+
+ + {data.length > 0 && ( + <> + +
+ +
+
+ + + row.label ?? row.name ?? '-'} /> + `${row.totalHours ?? row.hours ?? 0}h`} /> + + + + )} +
+ ); +} diff --git a/wtm-frontend-react/src/modules/settings/components/OverheadTypeDialog.tsx b/wtm-frontend-react/src/modules/settings/components/OverheadTypeDialog.tsx new file mode 100644 index 0000000..67db0cc --- /dev/null +++ b/wtm-frontend-react/src/modules/settings/components/OverheadTypeDialog.tsx @@ -0,0 +1,57 @@ +import { useState, useEffect } from 'react'; +import { InputText } from 'primereact/inputtext'; +import { InputSwitch } from 'primereact/inputswitch'; +import BaseFormDialog from '@/core/components/BaseFormDialog'; +import type { OverheadType } from '../settings.types'; + +interface Props { + visible: boolean; + onHide: () => void; + overheadType: OverheadType | null; + onSave: (data: Partial) => Promise; +} + +export default function OverheadTypeDialog({ visible, onHide, overheadType, onSave }: Props) { + const [code, setCode] = useState(''); + const [name, setName] = useState(''); + const [isActive, setIsActive] = useState(true); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (overheadType) { + setCode(overheadType.code); + setName(overheadType.name); + setIsActive(overheadType.isActive); + } else { + setCode(''); setName(''); setIsActive(true); + } + }, [overheadType, visible]); + + async function handleSubmit() { + setLoading(true); + try { + await onSave({ code, name, isActive }); + } finally { + setLoading(false); + } + } + + return ( + +
+ + setCode(e.target.value)} disabled={!!overheadType} /> +
+
+ + setName(e.target.value)} /> +
+ {overheadType && ( +
+ + setIsActive(e.value ?? false)} /> +
+ )} +
+ ); +} diff --git a/wtm-frontend-react/src/modules/settings/settings.service.ts b/wtm-frontend-react/src/modules/settings/settings.service.ts new file mode 100644 index 0000000..6d7adb3 --- /dev/null +++ b/wtm-frontend-react/src/modules/settings/settings.service.ts @@ -0,0 +1,11 @@ +import api from '@/core/api/axios'; + +const BASE = '/api/wtm'; + +export const settingsService = { + getOverheadTypes: () => api.get(`${BASE}/overhead-types`), + createOverheadType: (data: unknown) => api.post(`${BASE}/overhead-types`, data), + updateOverheadType: (id: number, data: unknown) => api.put(`${BASE}/overhead-types/${id}`, data), + getWorkRules: () => api.get(`${BASE}/work-rules`), + updateWorkRules: (data: unknown) => api.put(`${BASE}/work-rules`, data), +}; diff --git a/wtm-frontend-react/src/modules/settings/settings.types.ts b/wtm-frontend-react/src/modules/settings/settings.types.ts new file mode 100644 index 0000000..9b44a24 --- /dev/null +++ b/wtm-frontend-react/src/modules/settings/settings.types.ts @@ -0,0 +1,13 @@ +export interface OverheadType { + id: number; + code: string; + name: string; + isActive: boolean; +} + +export interface WorkRule { + id: number; + minDailyHours: number; + maxWeeklyHours: number; + location?: string; +} diff --git a/wtm-frontend-react/src/modules/settings/views/SettingsView.tsx b/wtm-frontend-react/src/modules/settings/views/SettingsView.tsx new file mode 100644 index 0000000..e072e87 --- /dev/null +++ b/wtm-frontend-react/src/modules/settings/views/SettingsView.tsx @@ -0,0 +1,104 @@ +import { useState, useEffect } from 'react'; +import { TabView, TabPanel } from 'primereact/tabview'; +import { Button } from 'primereact/button'; +import { Column } from 'primereact/column'; +import { Tag } from 'primereact/tag'; +import { Card } from 'primereact/card'; +import { InputNumber } from 'primereact/inputnumber'; +import BaseCrudTable from '@/core/components/BaseCrudTable'; +import BasePageHeader from '@/core/components/BasePageHeader'; +import OverheadTypeDialog from '../components/OverheadTypeDialog'; +import { settingsService } from '../settings.service'; +import type { OverheadType, WorkRule } from '../settings.types'; + +export default function SettingsView() { + const [overheadTypes, setOverheadTypes] = useState([]); + const [workRule, setWorkRule] = useState({ id: 0, minDailyHours: 8, maxWeeklyHours: 52 }); + const [loading, setLoading] = useState(false); + const [dialogVisible, setDialogVisible] = useState(false); + const [editOt, setEditOt] = useState(null); + const [savingRule, setSavingRule] = useState(false); + + function loadOverheadTypes() { + settingsService.getOverheadTypes() + .then(({ data }) => setOverheadTypes(Array.isArray(data) ? data : (data as { items?: OverheadType[] }).items ?? [])) + .catch(() => setOverheadTypes([])); + } + + function loadWorkRules() { + settingsService.getWorkRules() + .then(({ data }) => { + const rules = Array.isArray(data) ? data : [data]; + if (rules.length > 0) setWorkRule(rules[0] as WorkRule); + }) + .catch(() => {}); + } + + useEffect(() => { + setLoading(true); + Promise.all([loadOverheadTypes(), loadWorkRules()]).finally(() => setLoading(false)); + }, []); + + function openCreate() { setEditOt(null); setDialogVisible(true); } + function openEdit(ot: OverheadType) { setEditOt(ot); setDialogVisible(true); } + + async function onSaveOt(data: Partial) { + if (editOt) { + await settingsService.updateOverheadType(editOt.id, data); + } else { + await settingsService.createOverheadType(data); + } + setDialogVisible(false); + loadOverheadTypes(); + } + + async function saveWorkRule() { + setSavingRule(true); + try { + await settingsService.updateWorkRules(workRule); + } finally { + setSavingRule(false); + } + } + + return ( +
+ + + + }> + + + } /> +
+
+ + + + + setDialogVisible(false)} overheadType={editOt} onSave={onSaveOt} /> + + ); +} diff --git a/wtm-frontend-react/src/modules/teal/components/TealUploadDialog.tsx b/wtm-frontend-react/src/modules/teal/components/TealUploadDialog.tsx new file mode 100644 index 0000000..06ec335 --- /dev/null +++ b/wtm-frontend-react/src/modules/teal/components/TealUploadDialog.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import { FileUpload, type FileUploadSelectEvent } from 'primereact/fileupload'; +import { Calendar } from 'primereact/calendar'; +import BaseFormDialog from '@/core/components/BaseFormDialog'; + +interface Props { + visible: boolean; + onHide: () => void; + onUpload: (file: File, effectiveDate: string) => Promise; +} + +export default function TealUploadDialog({ visible, onHide, onUpload }: Props) { + const [file, setFile] = useState(null); + const [effectiveDate, setEffectiveDate] = useState(null); + const [loading, setLoading] = useState(false); + + function onSelect(e: FileUploadSelectEvent) { + setFile(e.files[0] ?? null); + } + + async function handleSubmit() { + if (!file || !effectiveDate) return; + setLoading(true); + try { + await onUpload(file, effectiveDate.toISOString().slice(0, 10)); + } finally { + setLoading(false); + } + } + + return ( + +
+ + +
+
+ + setEffectiveDate(e.value as Date)} dateFormat="yy-mm-dd" /> +
+
+ ); +} diff --git a/wtm-frontend-react/src/modules/teal/teal.service.ts b/wtm-frontend-react/src/modules/teal/teal.service.ts new file mode 100644 index 0000000..c808a31 --- /dev/null +++ b/wtm-frontend-react/src/modules/teal/teal.service.ts @@ -0,0 +1,13 @@ +import api from '@/core/api/axios'; + +export const tealService = { + upload: (projectId: number, file: File, effectiveDate: string) => { + const formData = new FormData(); + formData.append('file', file); + formData.append('effectiveDate', effectiveDate); + return api.post(`/api/wtm/projects/${projectId}/teal/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }); + }, + getVersions: (projectId: number) => api.get(`/api/wtm/projects/${projectId}/teal/versions`), + getActive: (projectId: number) => api.get(`/api/wtm/projects/${projectId}/teal/active`), + getByWbs: (projectId: number, wbsId: number) => api.get(`/api/wtm/projects/${projectId}/teal/by-wbs/${wbsId}`), +}; diff --git a/wtm-frontend-react/src/modules/teal/teal.types.ts b/wtm-frontend-react/src/modules/teal/teal.types.ts new file mode 100644 index 0000000..174dda5 --- /dev/null +++ b/wtm-frontend-react/src/modules/teal/teal.types.ts @@ -0,0 +1,16 @@ +export interface TealEntry { + id: number; + activityCode: string; + activityName: string; + discipline?: string; + canonicalWbsId?: number; +} + +export interface TealVersion { + id: number; + projectId: number; + versionNumber: number; + effectiveDate: string; + status: string; + entryCount?: number; +} diff --git a/wtm-frontend-react/src/modules/teal/views/TealListView.tsx b/wtm-frontend-react/src/modules/teal/views/TealListView.tsx new file mode 100644 index 0000000..4b01f6e --- /dev/null +++ b/wtm-frontend-react/src/modules/teal/views/TealListView.tsx @@ -0,0 +1,73 @@ +import { useState, useEffect } from 'react'; +import { Dropdown } from 'primereact/dropdown'; +import { Button } from 'primereact/button'; +import { Column } from 'primereact/column'; +import { Tag } from 'primereact/tag'; +import BaseCrudTable from '@/core/components/BaseCrudTable'; +import BasePageHeader from '@/core/components/BasePageHeader'; +import TealUploadDialog from '../components/TealUploadDialog'; +import { tealService } from '../teal.service'; +import { projectService } from '@/modules/project/project.service'; +import type { TealEntry, TealVersion } from '../teal.types'; + +export default function TealListView() { + const [projects, setProjects] = useState<{ id: number; name: string; code: string }[]>([]); + const [selectedProjectId, setSelectedProjectId] = useState(null); + const [versions, setVersions] = useState([]); + const [selectedVersionId, setSelectedVersionId] = useState(null); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [uploadVisible, setUploadVisible] = useState(false); + + useEffect(() => { + projectService.getAll() + .then(({ data }) => setProjects((data as { items?: unknown[] }).items as { id: number; name: string; code: string }[] ?? data as { id: number; name: string; code: string }[])) + .catch(() => setProjects([])); + }, []); + + useEffect(() => { + if (!selectedProjectId) { setVersions([]); setEntries([]); return; } + tealService.getVersions(selectedProjectId) + .then(({ data }) => setVersions(data as TealVersion[])) + .catch(() => setVersions([])); + }, [selectedProjectId]); + + useEffect(() => { + if (!selectedProjectId || !selectedVersionId) { setEntries([]); return; } + setLoading(true); + tealService.getActive(selectedProjectId) + .then(({ data }) => setEntries(data as TealEntry[])) + .catch(() => setEntries([])) + .finally(() => setLoading(false)); + }, [selectedProjectId, selectedVersionId]); + + async function handleUpload(file: File, effectiveDate: string) { + if (!selectedProjectId) return; + await tealService.upload(selectedProjectId, file, effectiveDate); + setUploadVisible(false); + const { data } = await tealService.getVersions(selectedProjectId); + setVersions(data as TealVersion[]); + } + + return ( +
+ setUploadVisible(true)} />} /> + +
+ ({ label: `${p.code} - ${p.name}`, value: p.id }))} onChange={(e) => { setSelectedProjectId(e.value); setSelectedVersionId(null); }} placeholder="프로젝트 선택" style={{ minWidth: '250px' }} /> + ({ label: `v${v.versionNumber} (${v.effectiveDate})`, value: v.id }))} onChange={(e) => setSelectedVersionId(e.value)} placeholder="버전 선택" disabled={!selectedProjectId} /> + {versions.find((v) => v.id === selectedVersionId) && ( + v.id === selectedVersionId)?.status ?? ''} severity={versions.find((v) => v.id === selectedVersionId)?.status === 'ACTIVE' ? 'success' : 'secondary'} /> + )} +
+ + + + + + + + setUploadVisible(false)} onUpload={handleUpload} /> +
+ ); +} diff --git a/wtm-frontend-react/src/modules/timesheet/components/TimesheetEntryRow.tsx b/wtm-frontend-react/src/modules/timesheet/components/TimesheetEntryRow.tsx new file mode 100644 index 0000000..f986259 --- /dev/null +++ b/wtm-frontend-react/src/modules/timesheet/components/TimesheetEntryRow.tsx @@ -0,0 +1,91 @@ +import { InputNumber } from 'primereact/inputnumber'; +import { Dropdown } from 'primereact/dropdown'; +import { Button } from 'primereact/button'; +import { NP_CATEGORIES } from '@/core/constants/app.constants'; +import type { EntryType } from '../timesheet.types'; + +interface EntryRow { + _uid: number; + entryType: EntryType; + npCategory?: string; + otherProjectId?: number; + epcProjectId?: number; + canonicalWbsId?: number; + tealEntryId?: number; + hours: Record; + remark?: string; +} + +interface Props { + entry: EntryRow; + projects: { id: number; name: string }[]; + days: string[]; + dayLabels: string[]; + disabled?: boolean; + onUpdate: (updated: Partial) => void; + onRemove: () => void; +} + +export default function TimesheetEntryRow({ entry, projects, days, dayLabels, disabled, onUpdate, onRemove }: Props) { + const rowTotal = Object.values(entry.hours).reduce((a, b) => a + b, 0); + + function setHour(day: string, val: number | null) { + onUpdate({ hours: { ...entry.hours, [day]: val ?? 0 } }); + } + + return ( + + + {entry.entryType === 'NON_PROJECT' && ( + ({ label: c.label, value: c.value }))} + onChange={(e) => onUpdate({ npCategory: e.value })} + placeholder="카테고리 선택" + disabled={disabled} + style={{ width: '100%' }} + /> + )} + {entry.entryType === 'OTHER_PROJECT' && ( + ({ label: p.name, value: p.id }))} + onChange={(e) => onUpdate({ otherProjectId: e.value })} + placeholder="프로젝트 선택" + disabled={disabled} + style={{ width: '100%' }} + /> + )} + {entry.entryType === 'EPC' && ( + ({ label: p.name, value: p.id }))} + onChange={(e) => onUpdate({ epcProjectId: e.value })} + placeholder="프로젝트 선택" + disabled={disabled} + style={{ width: '100%' }} + /> + )} + + {days.map((day, i) => ( + + setHour(day, e.value ?? 0)} + min={0} + max={16} + step={0.5} + disabled={disabled} + inputStyle={{ width: '60px', textAlign: 'center' }} + minFractionDigits={1} + maxFractionDigits={1} + /> + + ))} + {rowTotal.toFixed(1)} + +