feat: React 18 프론트엔드 추가 및 프로젝트 구조 정리
- wtm-frontend → wtm-frontend-vue 이름 변경 - wtm-frontend-react 추가 (React 18 + PrimeReact + Zustand) - 동일한 모듈 구조 및 API 연동 (Vue 버전과 기능 동일) - Vue:5173 / React:5174 포트 분리 - 개발자 가이드에 React 프론트엔드 안내 추가 - .gitignore: Claude/OMC, 문서 생성 스크립트, package-lock 제외 - 불필요 파일 git 추적 제거 (.omc, generate_*.py, regenerate_*.py) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
12
.gitignore
벤더링됨
12
.gitignore
벤더링됨
@@ -12,7 +12,6 @@ out/
|
|||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
wtm-frontend/node_modules/
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -22,9 +21,10 @@ Thumbs.db
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
# Claude/OMC
|
# Claude/OMC/AI
|
||||||
.claude/
|
.claude/
|
||||||
.omc/
|
.omc/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
@@ -32,3 +32,11 @@ logs/
|
|||||||
|
|
||||||
# Plans
|
# Plans
|
||||||
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
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
바이너리 파일은 표시되지 않습니다.
@@ -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.config.WbxAutoConfiguration
|
||||||
kr.co.accura.wbx.spring.admin.AdminAutoConfiguration
|
kr.co.accura.wbx.spring.admin.AdminAutoConfiguration
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ spring:
|
|||||||
minimum-idle: 5
|
minimum-idle: 5
|
||||||
connection-timeout: 30000
|
connection-timeout: 30000
|
||||||
|
|
||||||
data:
|
# Redis (선택 — 미설정 시 인메모리 캐시 자동 사용)
|
||||||
redis:
|
# data:
|
||||||
host: localhost
|
# redis:
|
||||||
port: 6379
|
# host: localhost
|
||||||
|
# port: 6379
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ spring:
|
|||||||
flyway:
|
flyway:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
data:
|
|
||||||
redis:
|
|
||||||
host: localhost
|
|
||||||
port: 6379
|
|
||||||
|
|
||||||
wbx:
|
wbx:
|
||||||
spring:
|
spring:
|
||||||
jwt:
|
jwt:
|
||||||
|
|||||||
바이너리 파일은 표시되지 않습니다.
바이너리 파일은 표시되지 않습니다.
바이너리 파일은 표시되지 않습니다.
바이너리 파일은 표시되지 않습니다.
바이너리 파일은 표시되지 않습니다.
바이너리 파일은 표시되지 않습니다.
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -120,7 +120,46 @@
|
|||||||
GRANT ALL ON mos.* TO 'jsh'@'%';
|
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/swagger-ui → WTM API 문서
|
||||||
http://localhost:8081/admin/login → WTM 관리 콘솔
|
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 없이)
|
[방법 B] DB 직접 설치 (Docker 없이)
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
@@ -228,7 +278,7 @@
|
|||||||
[환경별 수정]
|
[환경별 수정]
|
||||||
SPRING_PROFILES_ACTIVE 프로필 조합 (아래 참고)
|
SPRING_PROFILES_ACTIVE 프로필 조합 (아래 참고)
|
||||||
DB_HOST / DB_PORT DB 서버 주소
|
DB_HOST / DB_PORT DB 서버 주소
|
||||||
CORS_ORIGINS 프론트엔드 URL
|
CORS_ORIGINS 프론트엔드 URL (Vue: 5173, React: 5174)
|
||||||
|
|
||||||
[프로필 조합 예시]
|
[프로필 조합 예시]
|
||||||
로컬 개발 (MySQL) : local,mysql
|
로컬 개발 (MySQL) : local,mysql
|
||||||
@@ -250,7 +300,8 @@
|
|||||||
| 3306 | MySQL | 기본 DB (mysql 프로필) |
|
| 3306 | MySQL | 기본 DB (mysql 프로필) |
|
||||||
| 5432 | PostgreSQL | 대안 DB (postgresql 프로필) |
|
| 5432 | PostgreSQL | 대안 DB (postgresql 프로필) |
|
||||||
| 6379 | Redis | Embedded Redis 자동 구동 (별도 설치 불필요)|
|
| 6379 | Redis | Embedded Redis 자동 구동 (별도 설치 불필요)|
|
||||||
| 5173 | wtm-frontend | Vue 3 개발 서버 |
|
| 5173 | wtm-frontend-vue | Vue 3 개발 서버 |
|
||||||
|
| 5174 | wtm-frontend-react| React 18 개발 서버 |
|
||||||
| 8001 | WBX FastAPI | 선택, 그룹웨어 동시 운영 시 |
|
| 8001 | WBX FastAPI | 선택, 그룹웨어 동시 운영 시 |
|
||||||
+--------+------------------+------------------------------------------+
|
+--------+------------------+------------------------------------------+
|
||||||
|
|
||||||
@@ -285,6 +336,19 @@
|
|||||||
A: netstat -ano | findstr :8080 으로 프로세스 확인 후 종료
|
A: netstat -ano | findstr :8080 으로 프로세스 확인 후 종료
|
||||||
또는 application.yml에서 server.port 변경
|
또는 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. 참고 문서
|
8. 참고 문서
|
||||||
|
|||||||
13
wtm-frontend-react/index.html
일반 파일
13
wtm-frontend-react/index.html
일반 파일
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>WTM - Work Time Manager</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/app/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
wtm-frontend-react/package.json
일반 파일
38
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { router } from './router';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
}
|
||||||
21
wtm-frontend-react/src/app/main.tsx
일반 파일
21
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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<PrimeReactProvider value={primeReactConfig}>
|
||||||
|
<App />
|
||||||
|
</PrimeReactProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -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 (
|
||||||
|
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><ProgressSpinner /></div>}>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
// Auth routes (no layout)
|
||||||
|
{ path: '/login', element: <SuspenseWrapper><LoginView /></SuspenseWrapper> },
|
||||||
|
{ path: '/forgot-password', element: <SuspenseWrapper><ForgotPasswordView /></SuspenseWrapper> },
|
||||||
|
|
||||||
|
// Protected routes (with layout)
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: (
|
||||||
|
<AuthGuard>
|
||||||
|
<AppLayout />
|
||||||
|
</AuthGuard>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||||
|
{ path: 'dashboard', element: <SuspenseWrapper><DashboardView /></SuspenseWrapper> },
|
||||||
|
{ path: 'change-password', element: <SuspenseWrapper><ChangePasswordView /></SuspenseWrapper> },
|
||||||
|
{ path: 'timesheets', element: <SuspenseWrapper><TimesheetWeekView /></SuspenseWrapper> },
|
||||||
|
{ path: 'timesheets/history', element: <SuspenseWrapper><TimesheetHistoryView /></SuspenseWrapper> },
|
||||||
|
{ path: 'timesheets/upload', element: <SuspenseWrapper><TimesheetUploadView /></SuspenseWrapper> },
|
||||||
|
{ path: 'approvals', element: <SuspenseWrapper><ApprovalPendingView /></SuspenseWrapper> },
|
||||||
|
{ path: 'approvals/history', element: <SuspenseWrapper><ApprovalHistoryView /></SuspenseWrapper> },
|
||||||
|
{ path: 'projects', element: <SuspenseWrapper><ProjectListView /></SuspenseWrapper> },
|
||||||
|
{ path: 'projects/:id', element: <SuspenseWrapper><ProjectDetailView /></SuspenseWrapper> },
|
||||||
|
{ path: 'wbs', element: <SuspenseWrapper><WbsTreeView /></SuspenseWrapper> },
|
||||||
|
{ path: 'teal', element: <SuspenseWrapper><TealListView /></SuspenseWrapper> },
|
||||||
|
{ path: 'reports', element: <SuspenseWrapper><ReportView /></SuspenseWrapper> },
|
||||||
|
{ path: 'users', element: <SuspenseWrapper><UserListView /></SuspenseWrapper> },
|
||||||
|
{ path: 'users/:id', element: <SuspenseWrapper><UserDetailView /></SuspenseWrapper> },
|
||||||
|
{ path: 'settings', element: <SuspenseWrapper><SettingsView /></SuspenseWrapper> },
|
||||||
|
{ path: '*', element: <NotFoundView /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
이전 너비: | 높이: | 크기: 296 B 이후 너비: | 높이: | 크기: 296 B |
@@ -0,0 +1 @@
|
|||||||
|
// PrimeReact theme overrides
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="app-layout">
|
||||||
|
{isMobile && sidebarVisible && (
|
||||||
|
<div className="app-layout__overlay" onClick={() => setSidebarVisible(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AppSidebar visible={sidebarVisible} collapsed={sidebarCollapsed && !isMobile} mobile={isMobile} />
|
||||||
|
|
||||||
|
<div className="app-layout__main" style={{ marginLeft: contentMarginLeft }}>
|
||||||
|
<AppTopbar onToggleSidebar={toggleSidebar} />
|
||||||
|
<main className="app-layout__content">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<aside className={classNames} style={{ width: sidebarWidth }}>
|
||||||
|
<div className="app-sidebar__header">
|
||||||
|
<img src={logo} alt="WTM" className="app-sidebar__logo" />
|
||||||
|
{!collapsed && <span className="app-sidebar__title">WTM</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="app-sidebar__nav">
|
||||||
|
<PanelMenu model={filteredMenu} className="app-sidebar__menu" />
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Menu>(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 (
|
||||||
|
<header className="app-topbar">
|
||||||
|
<div className="app-topbar__left">
|
||||||
|
<Button icon="pi pi-bars" text rounded severity="secondary" onClick={onToggleSidebar} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="app-topbar__right">
|
||||||
|
<Button icon="pi pi-bell" text rounded severity="secondary" className="app-topbar__notify-btn" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
onClick={(e) => userMenu.current?.toggle(e)}
|
||||||
|
className="app-topbar__user-btn"
|
||||||
|
>
|
||||||
|
<Avatar label={currentUser?.fullName?.charAt(0) ?? '?'} shape="circle" size="normal" />
|
||||||
|
<span className="app-topbar__username">{currentUser?.fullName}</span>
|
||||||
|
</Button>
|
||||||
|
<Menu model={userMenuItems} popup ref={userMenu} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<ProgressSpinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authService.isAuthenticated()) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
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<T extends Record<string, any>>({
|
||||||
|
value,
|
||||||
|
loading = false,
|
||||||
|
totalRecords,
|
||||||
|
dataKey = 'id',
|
||||||
|
globalFilterFields,
|
||||||
|
paginator = true,
|
||||||
|
rowsPerPage = PAGINATION.defaultPageSize,
|
||||||
|
emptyMessage = '데이터가 없습니다.',
|
||||||
|
toolbarLeft,
|
||||||
|
toolbarRight,
|
||||||
|
onRowSelect,
|
||||||
|
onPage,
|
||||||
|
children,
|
||||||
|
}: Props<T>) {
|
||||||
|
const [globalFilter, setGlobalFilter] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="crud-table">
|
||||||
|
<div className="crud-table__toolbar">
|
||||||
|
<div className="crud-table__toolbar-left">{toolbarLeft}</div>
|
||||||
|
<div className="crud-table__toolbar-right">
|
||||||
|
{toolbarRight}
|
||||||
|
{globalFilterFields && globalFilterFields.length > 0 && (
|
||||||
|
<span className="p-input-icon-left">
|
||||||
|
<i className="pi pi-search" />
|
||||||
|
<InputText
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
placeholder="검색..."
|
||||||
|
size={1}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
value={value as any} // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
loading={loading}
|
||||||
|
dataKey={dataKey}
|
||||||
|
paginator={paginator}
|
||||||
|
rows={rowsPerPage}
|
||||||
|
rowsPerPageOptions={[...PAGINATION.pageSizeOptions]}
|
||||||
|
totalRecords={totalRecords}
|
||||||
|
globalFilter={globalFilter}
|
||||||
|
globalFilterFields={globalFilterFields}
|
||||||
|
removableSort
|
||||||
|
stripedRows
|
||||||
|
showGridlines
|
||||||
|
size="small"
|
||||||
|
className="crud-table__datatable"
|
||||||
|
onRowSelect={(e) => onRowSelect?.(e.data as T)}
|
||||||
|
onPage={onPage}
|
||||||
|
emptyMessage={
|
||||||
|
<div className="crud-table__empty">
|
||||||
|
<i className="pi pi-inbox" style={{ fontSize: '2rem' }} />
|
||||||
|
<p>{emptyMessage}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = (
|
||||||
|
<div className="form-dialog__footer">
|
||||||
|
<Button label={cancelLabel} severity="secondary" text disabled={loading} onClick={onHide} />
|
||||||
|
<Button label={submitLabel} loading={loading} onClick={onSubmit} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
visible={visible}
|
||||||
|
header={title}
|
||||||
|
style={{ width, maxWidth: '95vw' }}
|
||||||
|
modal
|
||||||
|
closable={!loading}
|
||||||
|
draggable={false}
|
||||||
|
onHide={onHide}
|
||||||
|
footer={footer}
|
||||||
|
>
|
||||||
|
<div className="form-dialog__body">{children}</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="page-header">
|
||||||
|
<div className="page-header__text">
|
||||||
|
<h1 className="page-header__title">{title}</h1>
|
||||||
|
{subtitle && <p className="page-header__subtitle">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="page-header__actions">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function NotFoundView() {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '4rem 1rem' }}>
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>페이지를 찾을 수 없습니다.</p>
|
||||||
|
<Link to="/dashboard">홈으로 이동</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, { label: string; severity: string }> = {
|
||||||
|
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<string, { label: string; severity: string }> = {
|
||||||
|
ACTIVE: { label: '진행중', severity: 'success' },
|
||||||
|
CLOSED: { label: '종료', severity: 'secondary' },
|
||||||
|
HOLD: { label: '보류', severity: 'warning' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Entry types
|
||||||
|
export const ENTRY_TYPES: Record<string, { label: string; icon: string }> = {
|
||||||
|
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'],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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<string, unknown>) => api.get(`${BASE}/history`, { params }),
|
||||||
|
getOverdue: () => api.get(`${BASE}/overdue`),
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Record<string, unknown>[]>([]);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
|
const [dateFrom, setDateFrom] = useState<Date | null>(null);
|
||||||
|
const [dateTo, setDateTo] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>[] ?? data as Record<string, unknown>[]))
|
||||||
|
.catch(() => setHistory([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [statusFilter, dateFrom, dateTo]);
|
||||||
|
|
||||||
|
const statusOptions = Object.entries(TIMESHEET_STATUS).map(([key, val]) => ({ label: val.label, value: key }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="결재 이력" />
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<Calendar value={dateFrom} onChange={(e) => setDateFrom(e.value as Date)} placeholder="시작일" dateFormat="yy-mm-dd" />
|
||||||
|
<Calendar value={dateTo} onChange={(e) => setDateTo(e.value as Date)} placeholder="종료일" dateFormat="yy-mm-dd" />
|
||||||
|
<Dropdown value={statusFilter} options={statusOptions} onChange={(e) => setStatusFilter(e.value)} placeholder="상태" showClear />
|
||||||
|
</div>
|
||||||
|
<BaseCrudTable value={history} loading={loading}>
|
||||||
|
<Column field="requesterName" header="요청자" sortable />
|
||||||
|
<Column field="projectName" header="프로젝트" sortable />
|
||||||
|
<Column field="weekStartDate" header="주 시작일" sortable />
|
||||||
|
<Column field="totalHours" header="시수" body={(row) => `${row.totalHours}h`} />
|
||||||
|
<Column field="status" header="상태" body={(row) => {
|
||||||
|
const s = TIMESHEET_STATUS[row.status as string];
|
||||||
|
return <Tag value={s?.label ?? (row.status as string)} severity={(s?.severity ?? 'secondary') as 'success' | 'info' | 'warning' | 'danger' | 'secondary'} />;
|
||||||
|
}} />
|
||||||
|
<Column field="approvedAt" header="처리일" sortable />
|
||||||
|
</BaseCrudTable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Approval[]>([]);
|
||||||
|
const [selected, setSelected] = useState<Approval[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="결재 대기" />
|
||||||
|
<BaseCrudTable
|
||||||
|
value={approvals}
|
||||||
|
loading={loading}
|
||||||
|
selectionMode="checkbox"
|
||||||
|
toolbarLeft={
|
||||||
|
<Button label="일괄 승인" icon="pi pi-check-circle" size="small" disabled={selected.length === 0} onClick={batchApprove} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Column selectionMode="multiple" headerStyle={{ width: '3em' }} />
|
||||||
|
<Column field="requesterName" header="요청자" sortable />
|
||||||
|
<Column field="projectName" header="프로젝트" sortable />
|
||||||
|
<Column field="weekStartDate" header="주 시작일" sortable />
|
||||||
|
<Column field="totalHours" header="시수" body={(row) => `${row.totalHours}h`} sortable />
|
||||||
|
<Column field="status" header="상태" body={(row) => {
|
||||||
|
const s = TIMESHEET_STATUS[row.status];
|
||||||
|
return <Tag value={s?.label ?? row.status} severity={(s?.severity ?? 'secondary') as 'success' | 'info' | 'warning' | 'danger' | 'secondary'} />;
|
||||||
|
}} />
|
||||||
|
<Column header="액션" body={(row) => (
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||||
|
<Button icon="pi pi-check" severity="success" text size="small" onClick={() => handleApprove(row.id)} />
|
||||||
|
<Button icon="pi pi-times" severity="danger" text size="small" onClick={() => handleReject(row.id)} />
|
||||||
|
</div>
|
||||||
|
)} />
|
||||||
|
</BaseCrudTable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
fetchMe: () => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUser(u: Record<string, unknown>): 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<AuthState>((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<string, unknown>) });
|
||||||
|
} finally {
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchMe: async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await authApi.me();
|
||||||
|
set({ currentUser: mapUser(data as unknown as Record<string, unknown>) });
|
||||||
|
} 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 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="비밀번호 변경" />
|
||||||
|
<Card style={{ maxWidth: '480px' }}>
|
||||||
|
{message && <Message severity={message.severity} text={message.text} style={{ width: '100%', marginBottom: '1rem' }} />}
|
||||||
|
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label">현재 비밀번호</label>
|
||||||
|
<Password value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} feedback={false} toggleMask inputStyle={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label">새 비밀번호</label>
|
||||||
|
<Password value={newPassword} onChange={(e) => setNewPassword(e.target.value)} feedback={false} toggleMask inputStyle={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label">새 비밀번호 확인</label>
|
||||||
|
<Password value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} feedback={false} toggleMask inputStyle={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" label="변경" icon="pi pi-check" loading={loading} />
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Card } from 'primereact/card';
|
||||||
|
|
||||||
|
export default function ForgotPasswordView() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', padding: '1rem' }}>
|
||||||
|
<Card title="비밀번호 찾기" style={{ width: '100%', maxWidth: '420px' }}>
|
||||||
|
<p style={{ color: 'var(--p-text-muted-color)' }}>비밀번호 찾기 기능은 준비 중입니다.</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="login-page">
|
||||||
|
<Card className="login-page__card">
|
||||||
|
<div className="login-page__content">
|
||||||
|
<div className="login-page__logo">
|
||||||
|
<i className="pi pi-clock" style={{ fontSize: '2.5rem', color: 'var(--p-primary-color)' }} />
|
||||||
|
<h1 className="login-page__title">WTM</h1>
|
||||||
|
<p className="login-page__subtitle">Work Time Manager</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Message severity="error" text={error} style={{ width: '100%' }} />}
|
||||||
|
|
||||||
|
<form className="login-page__form" onSubmit={onLogin}>
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label">이메일</label>
|
||||||
|
<InputText
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
type="email"
|
||||||
|
placeholder="user@hanwha.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label">비밀번호</label>
|
||||||
|
<Password
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="비밀번호 입력"
|
||||||
|
feedback={false}
|
||||||
|
toggleMask
|
||||||
|
inputStyle={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
label="로그인"
|
||||||
|
icon="pi pi-sign-in"
|
||||||
|
loading={loading}
|
||||||
|
className="login-page__submit"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="login-page__links">
|
||||||
|
<Link to="/forgot-password" className="login-page__link">
|
||||||
|
비밀번호 찾기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`),
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface DashboardStat {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
icon: string;
|
||||||
|
trend?: number;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DashboardStat[]>(defaultStats);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [weeklyHoursData, setWeeklyHoursData] = useState<any>(null);
|
||||||
|
const [pendingApprovals, setPendingApprovals] = useState<Record<string, unknown>[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="대시보드" subtitle="금주 시수 현황 및 결재 현황" />
|
||||||
|
<div className="dashboard-view__loading"><ProgressSpinner /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard-view">
|
||||||
|
<BasePageHeader title="대시보드" subtitle="금주 시수 현황 및 결재 현황" />
|
||||||
|
|
||||||
|
<div className="dashboard-view__stats">
|
||||||
|
{stats.map((stat, idx) => (
|
||||||
|
<Card key={idx} className="dashboard-view__stat-card">
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card__icon"><i className={stat.icon} /></div>
|
||||||
|
<div className="stat-card__info">
|
||||||
|
<span className="stat-card__value">{stat.value}</span>
|
||||||
|
<span className="stat-card__label">{stat.label}</span>
|
||||||
|
</div>
|
||||||
|
{stat.trend != null && (
|
||||||
|
<div className={`stat-card__trend ${stat.trend > 0 ? 'stat-card__trend--up' : stat.trend < 0 ? 'stat-card__trend--down' : ''}`}>
|
||||||
|
<i className={stat.trend > 0 ? 'pi pi-arrow-up' : stat.trend < 0 ? 'pi pi-arrow-down' : 'pi pi-minus'} />
|
||||||
|
{Math.abs(stat.trend)}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-view__grid">
|
||||||
|
<Card title="금주 시수 현황" className="dashboard-view__chart-card">
|
||||||
|
<div className="dashboard-view__chart-wrapper">
|
||||||
|
{weeklyHoursData && <Chart type="bar" data={weeklyHoursData as object} options={chartOptions} />}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="결재 대기 목록" className="dashboard-view__approvals-card">
|
||||||
|
<DataTable value={pendingApprovals} rows={5} paginator={pendingApprovals.length > 5} size="small" stripedRows
|
||||||
|
emptyMessage={<div style={{ textAlign: 'center', padding: '1rem', color: 'var(--p-text-muted-color)' }}>결재 대기 건이 없습니다.</div>}
|
||||||
|
>
|
||||||
|
<Column field="requesterName" header="요청자" />
|
||||||
|
<Column field="projectName" header="프로젝트" />
|
||||||
|
<Column field="totalHours" header="시수" body={(row) => `${row.totalHours}h`} />
|
||||||
|
<Column field="status" header="상태" body={(row) => <Tag value={statusLabel(row.status)} severity={statusSeverity(row.status)} />} />
|
||||||
|
</DataTable>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Project>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<BaseFormDialog visible={visible} onHide={onHide} title={project ? '프로젝트 수정' : '프로젝트 생성'} loading={loading} onSubmit={handleSubmit}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="col-6 form-field">
|
||||||
|
<label className="form-field__label form-field__label--required">코드</label>
|
||||||
|
<InputText value={code} onChange={(e) => setCode(e.target.value)} disabled={!!project} />
|
||||||
|
</div>
|
||||||
|
<div className="col-6 form-field">
|
||||||
|
<label className="form-field__label form-field__label--required">프로젝트명</label>
|
||||||
|
<InputText value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-6 form-field">
|
||||||
|
<label className="form-field__label">유형</label>
|
||||||
|
<Dropdown value={type} options={typeOptions} onChange={(e) => setType(e.value)} placeholder="선택" />
|
||||||
|
</div>
|
||||||
|
<div className="col-6 form-field">
|
||||||
|
<label className="form-field__label">상태</label>
|
||||||
|
<Dropdown value={status} options={statusOptions} onChange={(e) => setStatus(e.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-12 form-field">
|
||||||
|
<label className="form-field__label">설명</label>
|
||||||
|
<InputTextarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseFormDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import api from '@/core/api/axios';
|
||||||
|
|
||||||
|
const BASE = '/api/wtm/projects';
|
||||||
|
|
||||||
|
export const projectService = {
|
||||||
|
getAll: (params?: Record<string, unknown>) => 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`),
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Card } from 'primereact/card';
|
||||||
|
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||||
|
|
||||||
|
export default function ProjectDetailView() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="프로젝트 상세" />
|
||||||
|
<Card>
|
||||||
|
<p style={{ color: 'var(--p-text-muted-color)' }}>프로젝트 상세 페이지는 준비 중입니다.</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Project[]>([]);
|
||||||
|
const [dialogVisible, setDialogVisible] = useState(false);
|
||||||
|
const [editProject, setEditProject] = useState<Project | null>(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<Project>) {
|
||||||
|
if (editProject) {
|
||||||
|
await projectService.update(editProject.id, data);
|
||||||
|
} else {
|
||||||
|
await projectService.create(data);
|
||||||
|
}
|
||||||
|
setDialogVisible(false);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="프로젝트 목록" actions={<Button label="프로젝트 생성" icon="pi pi-plus" size="small" onClick={openCreate} />} />
|
||||||
|
<BaseCrudTable value={projects} loading={loading} globalFilterFields={['code', 'name']}
|
||||||
|
onRowSelect={(row) => navigate(`/projects/${row.id}`)}>
|
||||||
|
<Column field="code" header="코드" sortable />
|
||||||
|
<Column field="name" header="프로젝트명" sortable />
|
||||||
|
<Column field="type" header="유형" sortable />
|
||||||
|
<Column field="status" header="상태" body={(row) => {
|
||||||
|
const s = PROJECT_STATUS[row.status];
|
||||||
|
return <Tag value={s?.label ?? row.status} severity={(s?.severity ?? 'secondary') as 'success' | 'warning' | 'secondary'} />;
|
||||||
|
}} sortable />
|
||||||
|
<Column field="managerName" header="PM" />
|
||||||
|
<Column header="" body={(row) => <Button icon="pi pi-pencil" text size="small" onClick={() => openEdit(row)} />} style={{ width: '4rem' }} />
|
||||||
|
</BaseCrudTable>
|
||||||
|
|
||||||
|
<ProjectFormDialog visible={dialogVisible} onHide={() => setDialogVisible(false)} project={editProject} onSave={onSave} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import api from '@/core/api/axios';
|
||||||
|
|
||||||
|
const BASE = '/api/wtm/reports';
|
||||||
|
|
||||||
|
export const reportService = {
|
||||||
|
getProjectHours: (params: Record<string, unknown>) => api.get(`${BASE}/project-hours`, { params }),
|
||||||
|
exportProjectHours: (params: Record<string, unknown>) => api.get(`${BASE}/project-hours/export`, { params, responseType: 'blob' }),
|
||||||
|
getWbsHours: (params: Record<string, unknown>) => api.get(`${BASE}/wbs-hours`, { params }),
|
||||||
|
exportWbsHours: (params: Record<string, unknown>) => api.get(`${BASE}/wbs-hours/export`, { params, responseType: 'blob' }),
|
||||||
|
getPhaseRatio: (params: Record<string, unknown>) => api.get(`${BASE}/phase-ratio`, { params }),
|
||||||
|
getNpRatio: (params: Record<string, unknown>) => api.get(`${BASE}/np-ratio`, { params }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface ReportFilter {
|
||||||
|
projectId?: number;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
groupBy?: string;
|
||||||
|
wbsLevel?: number;
|
||||||
|
}
|
||||||
@@ -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<number | null>(null);
|
||||||
|
const [dateFrom, setDateFrom] = useState<Date | null>(null);
|
||||||
|
const [dateTo, setDateTo] = useState<Date | null>(null);
|
||||||
|
const [groupBy, setGroupBy] = useState('project');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||||
|
|
||||||
|
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<string, unknown> = { 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<string, unknown>[] ?? []))
|
||||||
|
.catch(() => setData([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportExcel() {
|
||||||
|
const params: Record<string, unknown> = { 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 (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="리포트" />
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label">프로젝트</label>
|
||||||
|
<Dropdown value={projectId} options={projects.map((p) => ({ label: p.name, value: p.id }))} onChange={(e) => setProjectId(e.value)} placeholder="전체" showClear />
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label">시작일</label>
|
||||||
|
<Calendar value={dateFrom} onChange={(e) => setDateFrom(e.value as Date)} dateFormat="yy-mm-dd" />
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label">종료일</label>
|
||||||
|
<Calendar value={dateTo} onChange={(e) => setDateTo(e.value as Date)} dateFormat="yy-mm-dd" />
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label">그룹</label>
|
||||||
|
<Dropdown value={groupBy} options={groupByOptions} onChange={(e) => setGroupBy(e.value)} />
|
||||||
|
</div>
|
||||||
|
<Button label="조회" icon="pi pi-search" onClick={search} loading={loading} />
|
||||||
|
<Button label="Excel" icon="pi pi-file-excel" severity="success" outlined onClick={exportExcel} disabled={data.length === 0} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{data.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ height: '300px' }}>
|
||||||
|
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<DataTable value={data} size="small" stripedRows showGridlines paginator rows={20}>
|
||||||
|
<Column field="label" header="구분" sortable body={(row) => row.label ?? row.name ?? '-'} />
|
||||||
|
<Column field="totalHours" header="총 시수" sortable body={(row) => `${row.totalHours ?? row.hours ?? 0}h`} />
|
||||||
|
<Column field="userCount" header="인원" sortable />
|
||||||
|
</DataTable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<OverheadType>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<BaseFormDialog visible={visible} onHide={onHide} title={overheadType ? 'Overhead Type 수정' : 'Overhead Type 추가'} loading={loading} onSubmit={handleSubmit}>
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label form-field__label--required">코드</label>
|
||||||
|
<InputText value={code} onChange={(e) => setCode(e.target.value)} disabled={!!overheadType} />
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label form-field__label--required">이름</label>
|
||||||
|
<InputText value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{overheadType && (
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label">활성 상태</label>
|
||||||
|
<InputSwitch checked={isActive} onChange={(e) => setIsActive(e.value ?? false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</BaseFormDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<OverheadType[]>([]);
|
||||||
|
const [workRule, setWorkRule] = useState<WorkRule>({ id: 0, minDailyHours: 8, maxWeeklyHours: 52 });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [dialogVisible, setDialogVisible] = useState(false);
|
||||||
|
const [editOt, setEditOt] = useState<OverheadType | null>(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<OverheadType>) {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="시스템 설정" />
|
||||||
|
<TabView>
|
||||||
|
<TabPanel header="Overhead Types">
|
||||||
|
<BaseCrudTable value={overheadTypes} loading={loading}
|
||||||
|
toolbarLeft={<Button label="추가" icon="pi pi-plus" size="small" onClick={openCreate} />}>
|
||||||
|
<Column field="code" header="코드" sortable />
|
||||||
|
<Column field="name" header="이름" sortable />
|
||||||
|
<Column field="isActive" header="상태" body={(row) => <Tag value={row.isActive ? '활성' : '비활성'} severity={row.isActive ? 'success' : 'secondary'} />} />
|
||||||
|
<Column header="" body={(row) => <Button icon="pi pi-pencil" text size="small" onClick={() => openEdit(row)} />} style={{ width: '4rem' }} />
|
||||||
|
</BaseCrudTable>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel header="Work Rules">
|
||||||
|
<Card>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="col-4 form-field">
|
||||||
|
<label className="form-field__label">최소 일일 시수</label>
|
||||||
|
<InputNumber value={workRule.minDailyHours} onValueChange={(e) => setWorkRule({ ...workRule, minDailyHours: e.value ?? 0 })} min={0} max={24} suffix="h" />
|
||||||
|
</div>
|
||||||
|
<div className="col-4 form-field">
|
||||||
|
<label className="form-field__label">최대 주간 시수</label>
|
||||||
|
<InputNumber value={workRule.maxWeeklyHours} onValueChange={(e) => setWorkRule({ ...workRule, maxWeeklyHours: e.value ?? 0 })} min={0} max={168} suffix="h" />
|
||||||
|
</div>
|
||||||
|
<div className="col-4 form-field">
|
||||||
|
<label className="form-field__label">Location</label>
|
||||||
|
<span style={{ padding: '0.5rem 0' }}>{workRule.location ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<Button label="저장" icon="pi pi-save" loading={savingRule} onClick={saveWorkRule} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</TabPanel>
|
||||||
|
</TabView>
|
||||||
|
|
||||||
|
<OverheadTypeDialog visible={dialogVisible} onHide={() => setDialogVisible(false)} overheadType={editOt} onSave={onSaveOt} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TealUploadDialog({ visible, onHide, onUpload }: Props) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [effectiveDate, setEffectiveDate] = useState<Date | null>(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 (
|
||||||
|
<BaseFormDialog visible={visible} onHide={onHide} title="TEAL 업로드" loading={loading} onSubmit={handleSubmit} submitLabel="업로드">
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label form-field__label--required">TEAL 파일</label>
|
||||||
|
<FileUpload mode="basic" accept=".xls,.xlsx,.csv" maxFileSize={10000000} onSelect={onSelect} chooseLabel="파일 선택" auto={false} />
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label form-field__label--required">적용일</label>
|
||||||
|
<Calendar value={effectiveDate} onChange={(e) => setEffectiveDate(e.value as Date)} dateFormat="yy-mm-dd" />
|
||||||
|
</div>
|
||||||
|
</BaseFormDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`),
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<number | null>(null);
|
||||||
|
const [versions, setVersions] = useState<TealVersion[]>([]);
|
||||||
|
const [selectedVersionId, setSelectedVersionId] = useState<number | null>(null);
|
||||||
|
const [entries, setEntries] = useState<TealEntry[]>([]);
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="TEAL 관리" actions={<Button label="TEAL 업로드" icon="pi pi-upload" size="small" disabled={!selectedProjectId} onClick={() => setUploadVisible(true)} />} />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<Dropdown value={selectedProjectId} options={projects.map((p) => ({ label: `${p.code} - ${p.name}`, value: p.id }))} onChange={(e) => { setSelectedProjectId(e.value); setSelectedVersionId(null); }} placeholder="프로젝트 선택" style={{ minWidth: '250px' }} />
|
||||||
|
<Dropdown value={selectedVersionId} options={versions.map((v) => ({ label: `v${v.versionNumber} (${v.effectiveDate})`, value: v.id }))} onChange={(e) => setSelectedVersionId(e.value)} placeholder="버전 선택" disabled={!selectedProjectId} />
|
||||||
|
{versions.find((v) => v.id === selectedVersionId) && (
|
||||||
|
<Tag value={versions.find((v) => v.id === selectedVersionId)?.status ?? ''} severity={versions.find((v) => v.id === selectedVersionId)?.status === 'ACTIVE' ? 'success' : 'secondary'} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BaseCrudTable value={entries} loading={loading} globalFilterFields={['activityCode', 'activityName', 'discipline']}>
|
||||||
|
<Column field="activityCode" header="Activity Code" sortable />
|
||||||
|
<Column field="activityName" header="Activity Name" sortable />
|
||||||
|
<Column field="discipline" header="Discipline" sortable />
|
||||||
|
</BaseCrudTable>
|
||||||
|
|
||||||
|
<TealUploadDialog visible={uploadVisible} onHide={() => setUploadVisible(false)} onUpload={handleUpload} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, number>;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entry: EntryRow;
|
||||||
|
projects: { id: number; name: string }[];
|
||||||
|
days: string[];
|
||||||
|
dayLabels: string[];
|
||||||
|
disabled?: boolean;
|
||||||
|
onUpdate: (updated: Partial<EntryRow>) => 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 (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{entry.entryType === 'NON_PROJECT' && (
|
||||||
|
<Dropdown
|
||||||
|
value={entry.npCategory}
|
||||||
|
options={NP_CATEGORIES.map((c) => ({ label: c.label, value: c.value }))}
|
||||||
|
onChange={(e) => onUpdate({ npCategory: e.value })}
|
||||||
|
placeholder="카테고리 선택"
|
||||||
|
disabled={disabled}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{entry.entryType === 'OTHER_PROJECT' && (
|
||||||
|
<Dropdown
|
||||||
|
value={entry.otherProjectId}
|
||||||
|
options={projects.map((p) => ({ label: p.name, value: p.id }))}
|
||||||
|
onChange={(e) => onUpdate({ otherProjectId: e.value })}
|
||||||
|
placeholder="프로젝트 선택"
|
||||||
|
disabled={disabled}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{entry.entryType === 'EPC' && (
|
||||||
|
<Dropdown
|
||||||
|
value={entry.epcProjectId}
|
||||||
|
options={projects.map((p) => ({ label: p.name, value: p.id }))}
|
||||||
|
onChange={(e) => onUpdate({ epcProjectId: e.value })}
|
||||||
|
placeholder="프로젝트 선택"
|
||||||
|
disabled={disabled}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{days.map((day, i) => (
|
||||||
|
<td key={dayLabels[i]} style={{ textAlign: 'center' }}>
|
||||||
|
<InputNumber
|
||||||
|
value={entry.hours[day] ?? 0}
|
||||||
|
onValueChange={(e) => setHour(day, e.value ?? 0)}
|
||||||
|
min={0}
|
||||||
|
max={16}
|
||||||
|
step={0.5}
|
||||||
|
disabled={disabled}
|
||||||
|
inputStyle={{ width: '60px', textAlign: 'center' }}
|
||||||
|
minFractionDigits={1}
|
||||||
|
maxFractionDigits={1}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td style={{ textAlign: 'center', fontWeight: 600 }}>{rowTotal.toFixed(1)}</td>
|
||||||
|
<td style={{ textAlign: 'center' }}>
|
||||||
|
<Button icon="pi pi-times" text rounded severity="danger" size="small" disabled={disabled} onClick={onRemove} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import api from '@/core/api/axios';
|
||||||
|
|
||||||
|
const BASE = '/api/wtm/timesheets';
|
||||||
|
|
||||||
|
export const timesheetService = {
|
||||||
|
getWeekly: (weekStart: string) => api.get(`${BASE}/week`, { params: { weekStart } }),
|
||||||
|
saveEntry: (tsId: number, entry: unknown) => api.post(`${BASE}/${tsId}/entries`, entry),
|
||||||
|
saveBatch: (tsId: number, entries: unknown[]) => api.put(`${BASE}/${tsId}/entries/batch`, { entries }),
|
||||||
|
deleteEntry: (tsId: number, entryId: number) => api.delete(`${BASE}/${tsId}/entries/${entryId}`),
|
||||||
|
submit: (tsId: number) => api.post(`${BASE}/${tsId}/submit`),
|
||||||
|
getHistory: (params: Record<string, unknown>) => api.get(`${BASE}/history`, { params }),
|
||||||
|
upload: (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
return api.post(`${BASE}/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||||
|
},
|
||||||
|
downloadTemplate: () => api.get(`${BASE}/template`, { responseType: 'blob' }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type TimesheetStatus = 'DRAFT' | 'SUBMITTED' | 'DL_APPROVED' | 'APPROVED' | 'REJECTED';
|
||||||
|
export type EntryType = 'NON_PROJECT' | 'OTHER_PROJECT' | 'EPC';
|
||||||
|
|
||||||
|
export interface TimesheetEntry {
|
||||||
|
id?: number;
|
||||||
|
entryType: EntryType;
|
||||||
|
entryDate: string;
|
||||||
|
hours: number;
|
||||||
|
npCategory?: string;
|
||||||
|
otherProjectId?: number;
|
||||||
|
epcProjectId?: number;
|
||||||
|
canonicalWbsId?: number;
|
||||||
|
tealEntryId?: number;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Timesheet {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
weekStartDate: string;
|
||||||
|
weekEndDate: string;
|
||||||
|
status: TimesheetStatus;
|
||||||
|
totalHours: number;
|
||||||
|
submittedAt?: string;
|
||||||
|
entries: TimesheetEntry[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Column } from 'primereact/column';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
|
import { Calendar } from 'primereact/calendar';
|
||||||
|
import BaseCrudTable from '@/core/components/BaseCrudTable';
|
||||||
|
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||||
|
import { timesheetService } from '../timesheet.service';
|
||||||
|
import { TIMESHEET_STATUS } from '@/core/constants/app.constants';
|
||||||
|
|
||||||
|
export default function TimesheetHistoryView() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [history, setHistory] = useState<Record<string, unknown>[]>([]);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
|
const [dateFrom, setDateFrom] = useState<Date | null>(null);
|
||||||
|
const [dateTo, setDateTo] = useState<Date | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
if (statusFilter) params.status = statusFilter;
|
||||||
|
if (dateFrom) params.from = dateFrom.toISOString().slice(0, 10);
|
||||||
|
if (dateTo) params.to = dateTo.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
timesheetService.getHistory(params)
|
||||||
|
.then(({ data }) => setHistory((data as { items?: unknown[] }).items as Record<string, unknown>[] ?? data as Record<string, unknown>[]))
|
||||||
|
.catch(() => setHistory([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [statusFilter, dateFrom, dateTo]);
|
||||||
|
|
||||||
|
const statusOptions = Object.entries(TIMESHEET_STATUS).map(([key, val]) => ({ label: val.label, value: key }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="시수 이력" />
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<Calendar value={dateFrom} onChange={(e) => setDateFrom(e.value as Date)} placeholder="시작일" dateFormat="yy-mm-dd" />
|
||||||
|
<Calendar value={dateTo} onChange={(e) => setDateTo(e.value as Date)} placeholder="종료일" dateFormat="yy-mm-dd" />
|
||||||
|
<Dropdown value={statusFilter} options={statusOptions} onChange={(e) => setStatusFilter(e.value)} placeholder="상태" showClear />
|
||||||
|
</div>
|
||||||
|
<BaseCrudTable value={history} loading={loading} rowsPerPage={10}>
|
||||||
|
<Column field="weekStartDate" header="시작일" sortable />
|
||||||
|
<Column field="weekEndDate" header="종료일" sortable />
|
||||||
|
<Column field="totalHours" header="총 시수" body={(row) => `${row.totalHours}h`} sortable />
|
||||||
|
<Column field="status" header="상태" body={(row) => {
|
||||||
|
const s = TIMESHEET_STATUS[row.status as string];
|
||||||
|
return <Tag value={s?.label ?? row.status} severity={(s?.severity ?? 'secondary') as 'success' | 'info' | 'warning' | 'danger' | 'secondary'} />;
|
||||||
|
}} />
|
||||||
|
<Column field="submittedAt" header="제출일" sortable />
|
||||||
|
<Column header="" body={(row) => <a onClick={() => navigate(`/timesheets?week=${row.weekStartDate}`)} style={{ cursor: 'pointer', color: 'var(--p-primary-color)' }}>상세</a>} />
|
||||||
|
</BaseCrudTable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Card } from 'primereact/card';
|
||||||
|
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||||
|
|
||||||
|
export default function TimesheetUploadView() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="Excel 업로드" />
|
||||||
|
<Card>
|
||||||
|
<p style={{ color: 'var(--p-text-muted-color)' }}>Excel 업로드 기능은 준비 중입니다.</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
@use '@/assets/styles/variables' as *;
|
||||||
|
|
||||||
|
.timesheet-week-view {
|
||||||
|
&__week-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-sm;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
padding: $space-xs $space-sm;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
background: var(--p-surface-100);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
tfoot td {
|
||||||
|
background: var(--p-surface-50);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__summary {
|
||||||
|
margin-top: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__summary-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__warnings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-xs;
|
||||||
|
margin-top: $space-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import { Calendar } from 'primereact/calendar';
|
||||||
|
import { TabView, TabPanel } from 'primereact/tabview';
|
||||||
|
import { Card } from 'primereact/card';
|
||||||
|
import { Message } from 'primereact/message';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
|
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||||
|
import TimesheetEntryRow from '../components/TimesheetEntryRow';
|
||||||
|
import { timesheetService } from '../timesheet.service';
|
||||||
|
import { projectService } from '@/modules/project/project.service';
|
||||||
|
import { TIMESHEET_RULES, TIMESHEET_STATUS, ENTRY_TYPES } from '@/core/constants/app.constants';
|
||||||
|
import type { Timesheet, TimesheetEntry, EntryType } from '../timesheet.types';
|
||||||
|
import './TimesheetWeekView.scss';
|
||||||
|
|
||||||
|
interface EntryRow {
|
||||||
|
_uid: number;
|
||||||
|
entryType: EntryType;
|
||||||
|
npCategory?: string;
|
||||||
|
otherProjectId?: number;
|
||||||
|
epcProjectId?: number;
|
||||||
|
canonicalWbsId?: number;
|
||||||
|
tealEntryId?: number;
|
||||||
|
hours: Record<string, number>;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uidCounter = 0;
|
||||||
|
|
||||||
|
function getMonday(d: Date): Date {
|
||||||
|
const date = new Date(d);
|
||||||
|
const day = date.getDay();
|
||||||
|
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
|
date.setDate(diff);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
function formatDate(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
function addDays(d: Date, n: number): Date {
|
||||||
|
const r = new Date(d);
|
||||||
|
r.setDate(r.getDate() + n);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entriesToRows(entries: TimesheetEntry[]): EntryRow[] {
|
||||||
|
const grouped = new Map<string, EntryRow>();
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = `${e.entryType}-${e.npCategory ?? ''}-${e.otherProjectId ?? ''}-${e.epcProjectId ?? ''}-${e.canonicalWbsId ?? ''}-${e.tealEntryId ?? ''}`;
|
||||||
|
if (!grouped.has(key)) {
|
||||||
|
grouped.set(key, {
|
||||||
|
_uid: ++uidCounter,
|
||||||
|
entryType: e.entryType,
|
||||||
|
npCategory: e.npCategory,
|
||||||
|
otherProjectId: e.otherProjectId,
|
||||||
|
epcProjectId: e.epcProjectId,
|
||||||
|
canonicalWbsId: e.canonicalWbsId,
|
||||||
|
tealEntryId: e.tealEntryId,
|
||||||
|
hours: {},
|
||||||
|
remark: e.remark,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
grouped.get(key)!.hours[e.entryDate] = e.hours;
|
||||||
|
}
|
||||||
|
return Array.from(grouped.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimesheetWeekView() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [weekStart, setWeekStart] = useState<Date>(getMonday(new Date()));
|
||||||
|
const [timesheet, setTimesheet] = useState<Timesheet | null>(null);
|
||||||
|
const [entryRows, setEntryRows] = useState<EntryRow[]>([]);
|
||||||
|
const [projects, setProjects] = useState<{ id: number; name: string }[]>([]);
|
||||||
|
|
||||||
|
const weekEnd = useMemo(() => addDays(weekStart, 5), [weekStart]);
|
||||||
|
const weekLabel = `${formatDate(weekStart)} ~ ${formatDate(weekEnd)}`;
|
||||||
|
const days = useMemo(() => Array.from({ length: 6 }, (_, i) => formatDate(addDays(weekStart, i))), [weekStart]);
|
||||||
|
const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
const totalHours = useMemo(() =>
|
||||||
|
entryRows.reduce((sum, row) => sum + Object.values(row.hours).reduce((a, b) => a + b, 0), 0),
|
||||||
|
[entryRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dailyTotals = useMemo(() => {
|
||||||
|
const totals: Record<string, number> = {};
|
||||||
|
for (const d of days) {
|
||||||
|
totals[d] = entryRows.reduce((sum, row) => sum + (row.hours[d] ?? 0), 0);
|
||||||
|
}
|
||||||
|
return totals;
|
||||||
|
}, [entryRows, days]);
|
||||||
|
|
||||||
|
const warnings = useMemo(() => {
|
||||||
|
const msgs: string[] = [];
|
||||||
|
for (const [date, total] of Object.entries(dailyTotals)) {
|
||||||
|
if (total > TIMESHEET_RULES.warnDailyHours) msgs.push(`${date}: 일 ${total}시간 입력 - 기준(${TIMESHEET_RULES.defaultDailyHours}h) 초과`);
|
||||||
|
if (total > TIMESHEET_RULES.maxDailyHours) msgs.push(`${date}: 일 최대 ${TIMESHEET_RULES.maxDailyHours}시간 초과!`);
|
||||||
|
}
|
||||||
|
if (totalHours > TIMESHEET_RULES.maxWeeklyHours) msgs.push(`주간 합계 ${totalHours}시간 - 최대 ${TIMESHEET_RULES.maxWeeklyHours}h 초과!`);
|
||||||
|
return msgs;
|
||||||
|
}, [dailyTotals, totalHours]);
|
||||||
|
|
||||||
|
const isEditable = !timesheet || timesheet.status === 'DRAFT' || timesheet.status === 'REJECTED';
|
||||||
|
const statusInfo = timesheet ? TIMESHEET_STATUS[timesheet.status] : null;
|
||||||
|
|
||||||
|
const loadWeek = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await timesheetService.getWeekly(formatDate(weekStart));
|
||||||
|
const ts = data as Timesheet;
|
||||||
|
setTimesheet(ts);
|
||||||
|
setEntryRows(entriesToRows(ts.entries ?? []));
|
||||||
|
} catch {
|
||||||
|
setTimesheet(null);
|
||||||
|
setEntryRows([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [weekStart]);
|
||||||
|
|
||||||
|
useEffect(() => { loadWeek(); }, [loadWeek]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
projectService.getMy()
|
||||||
|
.then(({ data }) => setProjects((data as { items?: unknown[] }).items as { id: number; name: string }[] ?? data as { id: number; name: string }[]))
|
||||||
|
.catch(() => setProjects([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function rowsToEntries() {
|
||||||
|
const entries: unknown[] = [];
|
||||||
|
for (const row of entryRows) {
|
||||||
|
for (const [date, hours] of Object.entries(row.hours)) {
|
||||||
|
if (hours > 0) entries.push({ entryType: row.entryType, entryDate: date, hours, npCategory: row.npCategory, otherProjectId: row.otherProjectId, epcProjectId: row.epcProjectId, canonicalWbsId: row.canonicalWbsId, tealEntryId: row.tealEntryId, remark: row.remark });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow(type: EntryType) {
|
||||||
|
setEntryRows((prev) => [...prev, { _uid: ++uidCounter, entryType: type, hours: {} }]);
|
||||||
|
}
|
||||||
|
function removeRow(uid: number) {
|
||||||
|
setEntryRows((prev) => prev.filter((r) => r._uid !== uid));
|
||||||
|
}
|
||||||
|
function updateRow(uid: number, updated: Partial<EntryRow>) {
|
||||||
|
setEntryRows((prev) => prev.map((r) => r._uid === uid ? { ...r, ...updated } : r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraft() {
|
||||||
|
if (!timesheet) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await timesheetService.saveBatch(timesheet.id, rowsToEntries());
|
||||||
|
await loadWeek();
|
||||||
|
} catch { /* handled */ }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTimesheet() {
|
||||||
|
if (!timesheet) return;
|
||||||
|
if (warnings.some((w) => w.includes('초과!'))) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await timesheetService.saveBatch(timesheet.id, rowsToEntries());
|
||||||
|
await timesheetService.submit(timesheet.id);
|
||||||
|
await loadWeek();
|
||||||
|
} catch { /* handled */ }
|
||||||
|
finally { setSubmitting(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabTypes: EntryType[] = ['NON_PROJECT', 'OTHER_PROJECT', 'EPC'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="timesheet-week-view">
|
||||||
|
<BasePageHeader title="시수 입력" subtitle={weekLabel}
|
||||||
|
actions={statusInfo ? <Tag value={statusInfo.label} severity={statusInfo.severity as 'success' | 'info' | 'warning' | 'danger' | 'secondary'} /> : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="timesheet-week-view__week-picker">
|
||||||
|
<Button icon="pi pi-chevron-left" text rounded onClick={() => setWeekStart(addDays(weekStart, -7))} />
|
||||||
|
<Calendar value={weekStart} onChange={(e) => e.value && setWeekStart(getMonday(e.value as Date))} dateFormat="yy-mm-dd" style={{ width: '160px' }} />
|
||||||
|
<Button icon="pi pi-chevron-right" text rounded onClick={() => setWeekStart(addDays(weekStart, 7))} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><ProgressSpinner /></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TabView>
|
||||||
|
{tabTypes.map((tabKey) => (
|
||||||
|
<TabPanel key={tabKey} header={ENTRY_TYPES[tabKey]?.label ?? tabKey}>
|
||||||
|
<div className="timesheet-week-view__table-wrapper">
|
||||||
|
<table className="timesheet-week-view__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ minWidth: 200 }}>{tabKey === 'NON_PROJECT' ? '카테고리' : tabKey === 'OTHER_PROJECT' ? '프로젝트' : '프로젝트 / WBS / TEAL'}</th>
|
||||||
|
{dayLabels.map((label, i) => (
|
||||||
|
<th key={label} style={{ width: 80, textAlign: 'center' }}>{label}<br /><small>{days[i]?.slice(5)}</small></th>
|
||||||
|
))}
|
||||||
|
<th style={{ width: 70, textAlign: 'center' }}>합계</th>
|
||||||
|
<th style={{ width: 50 }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entryRows.filter((r) => r.entryType === tabKey).map((row) => (
|
||||||
|
<TimesheetEntryRow key={row._uid} entry={row} projects={projects} days={days} dayLabels={dayLabels} disabled={!isEditable}
|
||||||
|
onUpdate={(u) => updateRow(row._uid, u)} onRemove={() => removeRow(row._uid)} />
|
||||||
|
))}
|
||||||
|
{entryRows.filter((r) => r.entryType === tabKey).length === 0 && (
|
||||||
|
<tr><td colSpan={dayLabels.length + 3} style={{ textAlign: 'center', padding: '1.5rem', color: 'var(--p-text-muted-color)' }}>항목이 없습니다. 아래 버튼으로 추가하세요.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td><strong>소계</strong></td>
|
||||||
|
{days.map((day) => (<td key={day} style={{ textAlign: 'center' }}>{dailyTotals[day]?.toFixed(1) ?? '0.0'}</td>))}
|
||||||
|
<td style={{ textAlign: 'center' }}><strong>{totalHours.toFixed(1)}h</strong></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<Button label="+ 행 추가" text size="small" disabled={!isEditable} onClick={() => addRow(tabKey)} />
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
))}
|
||||||
|
</TabView>
|
||||||
|
|
||||||
|
<Card className="timesheet-week-view__summary">
|
||||||
|
<div className="timesheet-week-view__summary-row">
|
||||||
|
<span>주간 합계: <strong>{totalHours.toFixed(1)}</strong> / {TIMESHEET_RULES.maxWeeklyHours}h</span>
|
||||||
|
<div className="timesheet-week-view__summary-actions">
|
||||||
|
<Button label="임시 저장" severity="secondary" icon="pi pi-save" loading={saving} disabled={!isEditable} onClick={saveDraft} />
|
||||||
|
<Button label="제출 (결재 요청)" icon="pi pi-send" loading={submitting} disabled={!isEditable} onClick={submitTimesheet} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<div className="timesheet-week-view__warnings">
|
||||||
|
{warnings.map((w, i) => <Message key={i} severity={w.includes('초과!') ? 'error' : 'warn'} text={w} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { InputText } from 'primereact/inputtext';
|
||||||
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
|
import { MultiSelect } from 'primereact/multiselect';
|
||||||
|
import { InputSwitch } from 'primereact/inputswitch';
|
||||||
|
import BaseFormDialog from '@/core/components/BaseFormDialog';
|
||||||
|
import { ROLES } from '@/core/constants/app.constants';
|
||||||
|
import type { User } from '../user.types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
onHide: () => void;
|
||||||
|
user: User | null;
|
||||||
|
onSave: (data: Partial<User>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISCIPLINE_OPTIONS = [
|
||||||
|
'Piping', 'Electrical', 'Instrument', 'Civil', 'Structural',
|
||||||
|
'Mechanical', 'Process', 'HSE', 'QA/QC', 'Other',
|
||||||
|
].map((d) => ({ label: d, value: d }));
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = Object.values(ROLES).map((r) => ({ label: r, value: r }));
|
||||||
|
|
||||||
|
export default function UserFormDialog({ visible, onHide, user, onSave }: Props) {
|
||||||
|
const [fullName, setFullName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [employeeId, setEmployeeId] = useState('');
|
||||||
|
const [department, setDepartment] = useState('');
|
||||||
|
const [discipline, setDiscipline] = useState('');
|
||||||
|
const [roles, setRoles] = useState<string[]>([]);
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setFullName(user.fullName);
|
||||||
|
setEmail(user.email);
|
||||||
|
setEmployeeId(user.employeeId ?? '');
|
||||||
|
setDepartment(user.department ?? '');
|
||||||
|
setDiscipline(user.discipline ?? '');
|
||||||
|
setRoles(user.roles);
|
||||||
|
setIsActive(user.isActive);
|
||||||
|
} else {
|
||||||
|
setFullName(''); setEmail(''); setEmployeeId(''); setDepartment('');
|
||||||
|
setDiscipline(''); setRoles([]); setIsActive(true);
|
||||||
|
}
|
||||||
|
}, [user, visible]);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onSave({ fullName, email, employeeId, department, discipline, roles, isActive });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseFormDialog visible={visible} onHide={onHide} title={user ? '사용자 수정' : '사용자 생성'} loading={loading} onSubmit={handleSubmit}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="col-6 form-field">
|
||||||
|
<label className="form-field__label form-field__label--required">이름</label>
|
||||||
|
<InputText value={fullName} onChange={(e) => setFullName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-6 form-field">
|
||||||
|
<label className="form-field__label form-field__label--required">이메일</label>
|
||||||
|
<InputText value={email} onChange={(e) => setEmail(e.target.value)} type="email" />
|
||||||
|
</div>
|
||||||
|
<div className="col-6 form-field">
|
||||||
|
<label className="form-field__label">사번</label>
|
||||||
|
<InputText value={employeeId} onChange={(e) => setEmployeeId(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-6 form-field">
|
||||||
|
<label className="form-field__label">부서</label>
|
||||||
|
<InputText value={department} onChange={(e) => setDepartment(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-6 form-field">
|
||||||
|
<label className="form-field__label">Discipline</label>
|
||||||
|
<Dropdown value={discipline} options={DISCIPLINE_OPTIONS} onChange={(e) => setDiscipline(e.value)} placeholder="선택" showClear />
|
||||||
|
</div>
|
||||||
|
<div className="col-6 form-field">
|
||||||
|
<label className="form-field__label">역할</label>
|
||||||
|
<MultiSelect value={roles} options={ROLE_OPTIONS} onChange={(e) => setRoles(e.value)} placeholder="선택" />
|
||||||
|
</div>
|
||||||
|
{user && (
|
||||||
|
<div className="col-6 form-field">
|
||||||
|
<label className="form-field__label">활성 상태</label>
|
||||||
|
<InputSwitch checked={isActive} onChange={(e) => setIsActive(e.value ?? false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</BaseFormDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import api from '@/core/api/axios';
|
||||||
|
|
||||||
|
const BASE = '/api/wtm/users';
|
||||||
|
|
||||||
|
export const userService = {
|
||||||
|
getAll: (params?: Record<string, unknown>) => api.get(BASE, { params }),
|
||||||
|
getById: (id: number) => api.get(`${BASE}/${id}`),
|
||||||
|
update: (id: number, data: unknown) => api.put(`${BASE}/${id}`, data),
|
||||||
|
updateRoles: (id: number, roles: string[]) => api.put(`${BASE}/${id}/roles`, { roles }),
|
||||||
|
uploadInternal: (file: File) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
return api.post(`${BASE}/upload/internal`, fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||||
|
},
|
||||||
|
uploadSubcontractor: (file: File) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
return api.post(`${BASE}/upload/subcontractor`, fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||||
|
},
|
||||||
|
downloadTemplate: () => api.get(`${BASE}/template`, { responseType: 'blob' }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
username?: string;
|
||||||
|
fullName: string;
|
||||||
|
employeeId?: string;
|
||||||
|
department?: string;
|
||||||
|
discipline?: string;
|
||||||
|
location?: string;
|
||||||
|
roles: string[];
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Card } from 'primereact/card';
|
||||||
|
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||||
|
|
||||||
|
export default function UserDetailView() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="사용자 상세" />
|
||||||
|
<Card>
|
||||||
|
<p style={{ color: 'var(--p-text-muted-color)' }}>사용자 상세 페이지는 준비 중입니다.</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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 UserFormDialog from '../components/UserFormDialog';
|
||||||
|
import { userService } from '../user.service';
|
||||||
|
import type { User } from '../user.types';
|
||||||
|
|
||||||
|
const ROLE_SEVERITY: Record<string, string> = {
|
||||||
|
SA: 'danger', PM: 'warning', PCM: 'info', DL: 'success', PTK: 'secondary', USER: 'contrast',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UserListView() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [dialogVisible, setDialogVisible] = useState(false);
|
||||||
|
const [editUser, setEditUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
userService.getAll()
|
||||||
|
.then(({ data }) => setUsers((data as { items?: User[] }).items ?? data as User[]))
|
||||||
|
.catch(() => setUsers([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
function openEdit(u: User) {
|
||||||
|
setEditUser(u);
|
||||||
|
setDialogVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave(data: Partial<User>) {
|
||||||
|
if (editUser) {
|
||||||
|
await userService.update(editUser.id, data);
|
||||||
|
}
|
||||||
|
setDialogVisible(false);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="사용자 관리" />
|
||||||
|
<BaseCrudTable value={users} loading={loading} globalFilterFields={['fullName', 'email', 'employeeId', 'department']}>
|
||||||
|
<Column field="fullName" header="이름" sortable />
|
||||||
|
<Column field="email" header="이메일" sortable />
|
||||||
|
<Column field="employeeId" header="사번" sortable />
|
||||||
|
<Column field="department" header="부서" sortable />
|
||||||
|
<Column field="discipline" header="Discipline" sortable />
|
||||||
|
<Column field="roles" header="역할" body={(row) => (
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
||||||
|
{(row.roles as string[]).map((r: string) => (
|
||||||
|
<Tag key={r} value={r} severity={(ROLE_SEVERITY[r] ?? 'secondary') as 'danger' | 'warning' | 'info' | 'success' | 'secondary'} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)} />
|
||||||
|
<Column field="isActive" header="상태" body={(row) => (
|
||||||
|
<Tag value={row.isActive ? '활성' : '비활성'} severity={row.isActive ? 'success' : 'secondary'} />
|
||||||
|
)} />
|
||||||
|
<Column header="" body={(row) => <Button icon="pi pi-pencil" text size="small" onClick={() => openEdit(row)} />} style={{ width: '4rem' }} />
|
||||||
|
</BaseCrudTable>
|
||||||
|
|
||||||
|
<UserFormDialog visible={dialogVisible} onHide={() => setDialogVisible(false)} user={editUser} onSave={onSave} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WbsUploadDialog({ visible, onHide, onUpload }: Props) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [effectiveDate, setEffectiveDate] = useState<Date | null>(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 (
|
||||||
|
<BaseFormDialog visible={visible} onHide={onHide} title="P6 WBS 업로드" loading={loading} onSubmit={handleSubmit} submitLabel="업로드">
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label form-field__label--required">WBS 파일</label>
|
||||||
|
<FileUpload mode="basic" accept=".xml,.xer" maxFileSize={10000000} onSelect={onSelect} chooseLabel="파일 선택" auto={false} />
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-field__label form-field__label--required">적용일</label>
|
||||||
|
<Calendar value={effectiveDate} onChange={(e) => setEffectiveDate(e.value as Date)} dateFormat="yy-mm-dd" />
|
||||||
|
</div>
|
||||||
|
</BaseFormDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import { Tree } from 'primereact/tree';
|
||||||
|
import type { TreeNode } from 'primereact/treenode';
|
||||||
|
import { Card } from 'primereact/card';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
|
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||||
|
import WbsUploadDialog from '../components/WbsUploadDialog';
|
||||||
|
import { wbsService } from '../wbs.service';
|
||||||
|
import { projectService } from '@/modules/project/project.service';
|
||||||
|
import type { WbsVersion, WbsNode } from '../wbs.types';
|
||||||
|
|
||||||
|
function nodesToTreeNodes(nodes: WbsNode[]): TreeNode[] {
|
||||||
|
return nodes.map((n) => ({
|
||||||
|
key: String(n.id),
|
||||||
|
label: `${n.code} - ${n.name}`,
|
||||||
|
children: n.children ? nodesToTreeNodes(n.children) : [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WbsTreeView() {
|
||||||
|
const [projects, setProjects] = useState<{ id: number; name: string; code: string }[]>([]);
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null);
|
||||||
|
const [versions, setVersions] = useState<WbsVersion[]>([]);
|
||||||
|
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
|
||||||
|
const [treeNodes, setTreeNodes] = useState<TreeNode[]>([]);
|
||||||
|
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([]); return; }
|
||||||
|
wbsService.getVersions(selectedProjectId)
|
||||||
|
.then(({ data }) => setVersions(data as WbsVersion[]))
|
||||||
|
.catch(() => setVersions([]));
|
||||||
|
}, [selectedProjectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProjectId || !selectedVersion) { setTreeNodes([]); return; }
|
||||||
|
setLoading(true);
|
||||||
|
wbsService.getVersionNodes(selectedProjectId, selectedVersion)
|
||||||
|
.then(({ data }) => setTreeNodes(nodesToTreeNodes(data as WbsNode[])))
|
||||||
|
.catch(() => setTreeNodes([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [selectedProjectId, selectedVersion]);
|
||||||
|
|
||||||
|
async function handleUpload(file: File, effectiveDate: string) {
|
||||||
|
if (!selectedProjectId) return;
|
||||||
|
await wbsService.upload(selectedProjectId, file, effectiveDate);
|
||||||
|
setUploadVisible(false);
|
||||||
|
const { data } = await wbsService.getVersions(selectedProjectId);
|
||||||
|
setVersions(data as WbsVersion[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BasePageHeader title="WBS 관리" actions={<Button label="P6 업로드" icon="pi pi-upload" size="small" disabled={!selectedProjectId} onClick={() => setUploadVisible(true)} />} />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<Dropdown value={selectedProjectId} options={projects.map((p) => ({ label: `${p.code} - ${p.name}`, value: p.id }))} onChange={(e) => { setSelectedProjectId(e.value); setSelectedVersion(null); }} placeholder="프로젝트 선택" style={{ minWidth: '250px' }} />
|
||||||
|
<Dropdown value={selectedVersion} options={versions.map((v) => ({ label: `v${v.versionNumber} (${v.effectiveDate})`, value: v.versionNumber }))} onChange={(e) => setSelectedVersion(e.value)} placeholder="버전 선택" disabled={!selectedProjectId} />
|
||||||
|
{versions.find((v) => v.versionNumber === selectedVersion) && (
|
||||||
|
<Tag value={versions.find((v) => v.versionNumber === selectedVersion)?.status ?? ''} severity={versions.find((v) => v.versionNumber === selectedVersion)?.status === 'ACTIVE' ? 'success' : 'secondary'} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><ProgressSpinner /></div>
|
||||||
|
) : treeNodes.length > 0 ? (
|
||||||
|
<Tree value={treeNodes} />
|
||||||
|
) : (
|
||||||
|
<p style={{ textAlign: 'center', color: 'var(--p-text-muted-color)', padding: '2rem' }}>프로젝트와 버전을 선택하세요.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<WbsUploadDialog visible={uploadVisible} onHide={() => setUploadVisible(false)} onUpload={handleUpload} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import api from '@/core/api/axios';
|
||||||
|
|
||||||
|
export const wbsService = {
|
||||||
|
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}/wbs/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||||
|
},
|
||||||
|
getVersions: (projectId: number) => api.get(`/api/wtm/projects/${projectId}/wbs/versions`),
|
||||||
|
getVersionNodes: (projectId: number, version: number) => api.get(`/api/wtm/projects/${projectId}/wbs/versions/${version}`),
|
||||||
|
activateVersion: (projectId: number, version: number) => api.post(`/api/wtm/projects/${projectId}/wbs/versions/${version}/activate`),
|
||||||
|
getCanonicalWbs: (projectId: number) => api.get(`/api/wtm/projects/${projectId}/canonical-wbs`),
|
||||||
|
compareVersions: (projectId: number, v1: number, v2: number) => api.get(`/api/wtm/projects/${projectId}/wbs/compare`, { params: { v1, v2 } }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export interface WbsNode {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
parentId?: number;
|
||||||
|
children?: WbsNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WbsVersion {
|
||||||
|
id: number;
|
||||||
|
projectId: number;
|
||||||
|
versionNumber: number;
|
||||||
|
effectiveDate: string;
|
||||||
|
status: string;
|
||||||
|
nodeCount?: number;
|
||||||
|
}
|
||||||
11
wtm-frontend-react/src/vite-env.d.ts
벤더링됨
일반 파일
11
wtm-frontend-react/src/vite-env.d.ts
벤더링됨
일반 파일
@@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL: string;
|
||||||
|
readonly VITE_APP_TITLE: string;
|
||||||
|
readonly VITE_APP_ENV: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
25
wtm-frontend-react/tsconfig.app.json
일반 파일
25
wtm-frontend-react/tsconfig.app.json
일반 파일
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/vite-env.d.ts","./src/app/app.tsx","./src/app/main.tsx","./src/app/router.tsx","./src/core/api/api.types.ts","./src/core/api/axios.ts","./src/core/auth/auth.service.ts","./src/core/auth/auth.types.ts","./src/core/components/applayout.tsx","./src/core/components/appsidebar.tsx","./src/core/components/apptopbar.tsx","./src/core/components/authguard.tsx","./src/core/components/basecrudtable.tsx","./src/core/components/baseformdialog.tsx","./src/core/components/basepageheader.tsx","./src/core/components/notfoundview.tsx","./src/core/constants/app.constants.ts","./src/core/hooks/usecurrentuser.ts","./src/modules/approval/approval.service.ts","./src/modules/approval/approval.types.ts","./src/modules/approval/views/approvalhistoryview.tsx","./src/modules/approval/views/approvalpendingview.tsx","./src/modules/auth/auth.service.ts","./src/modules/auth/auth.store.ts","./src/modules/auth/auth.types.ts","./src/modules/auth/views/changepasswordview.tsx","./src/modules/auth/views/forgotpasswordview.tsx","./src/modules/auth/views/loginview.tsx","./src/modules/dashboard/dashboard.service.ts","./src/modules/dashboard/dashboard.types.ts","./src/modules/dashboard/views/dashboardview.tsx","./src/modules/project/project.service.ts","./src/modules/project/project.types.ts","./src/modules/project/components/projectformdialog.tsx","./src/modules/project/views/projectdetailview.tsx","./src/modules/project/views/projectlistview.tsx","./src/modules/report/report.service.ts","./src/modules/report/report.types.ts","./src/modules/report/views/reportview.tsx","./src/modules/settings/settings.service.ts","./src/modules/settings/settings.types.ts","./src/modules/settings/components/overheadtypedialog.tsx","./src/modules/settings/views/settingsview.tsx","./src/modules/teal/teal.service.ts","./src/modules/teal/teal.types.ts","./src/modules/teal/components/tealuploaddialog.tsx","./src/modules/teal/views/teallistview.tsx","./src/modules/timesheet/timesheet.service.ts","./src/modules/timesheet/timesheet.types.ts","./src/modules/timesheet/components/timesheetentryrow.tsx","./src/modules/timesheet/views/timesheethistoryview.tsx","./src/modules/timesheet/views/timesheetuploadview.tsx","./src/modules/timesheet/views/timesheetweekview.tsx","./src/modules/user/user.service.ts","./src/modules/user/user.types.ts","./src/modules/user/components/userformdialog.tsx","./src/modules/user/views/userdetailview.tsx","./src/modules/user/views/userlistview.tsx","./src/modules/wbs/wbs.service.ts","./src/modules/wbs/wbs.types.ts","./src/modules/wbs/components/wbsuploaddialog.tsx","./src/modules/wbs/views/wbstreeview.tsx"],"version":"5.6.3"}
|
||||||
이 Diff에서 너무 많은 파일이 변경되어 일부 파일이 표시되지 않습니다 더 보기
새 Issue에서 참조
사용자 차단