From 64fd9ad3d2aab2e6b9a2a31931fba25aeec9d159 Mon Sep 17 00:00:00 2001 From: hyungi Date: Thu, 16 Oct 2025 12:45:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20BOM=20=EA=B4=80=EB=A6=AC=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EB=8C=80=ED=8F=AD=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20Docker=20=EB=B0=B0=ED=8F=AC=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🎨 UI/UX κ°œμ„ : 데본씽크 μŠ€νƒ€μΌ λͺ¨λ˜ λ””μžμΈ 적용 - πŸ“ μ»΄ν¬λ„ŒνŠΈ ꡬ쑰 κ°œμ„ : 폴더별 체계적 관리 (common/, bom/, materials/) - πŸ”§ BOM 관리 νŽ˜μ΄μ§€ λ¦¬νŒ©ν† λ§: NewMaterialsPage β†’ BOMManagementPage + μΉ΄ν…Œκ³ λ¦¬λ³„ μ»΄ν¬λ„ŒνŠΈ 뢄리 - πŸ’Ύ ꡬ맀신청 κΈ°λŠ₯ κ°œμ„ : μ„ νƒλœ 자재 λΉ„ν™œμ„±ν™”, 제λͺ© νŽΈμ§‘, μ—‘μ…€ λ‹€μš΄λ‘œλ“œ - πŸ“Š 자재 ν‘œμ‹œ κ°œμ„ : νƒ€μž…/μ„œλΈŒνƒ€μž… 컬럼 정리, 상세 정보 볡원 - πŸ› CSS λΉŒλ“œ 였λ₯˜ μˆ˜μ •: NewMaterialsPage.css 문법 였λ₯˜ ν•΄κ²° - πŸ“š λ¬Έμ„œν™”: PAGES_GUIDE.md μΆ”κ°€, README에 Docker μΊμ‹œ 문제 ν•΄κ²° κ°€μ΄λ“œ μΆ”κ°€ - πŸ”„ API κ°œμ„ : ꡬ맀신청 자재 쑰회, 제λͺ© μˆ˜μ • μ—”λ“œν¬μΈνŠΈ μΆ”κ°€ --- RULES.md | 175 +++ backend/app/auth/signup_routes.py | 33 + backend/app/routers/files.py | 183 ++- backend/app/routers/purchase_request.py | 97 +- frontend/PAGES_GUIDE.md | 273 ++++ frontend/README.md | 107 +- frontend/package-lock.json | 311 ++-- frontend/src/App.jsx | 1091 +++----------- frontend/src/components/ErrorBoundary.jsx | 268 ---- frontend/src/components/bom/index.js | 3 + .../bom/materials/BoltMaterialsView.jsx | 460 ++++++ .../bom/materials/FittingMaterialsView.jsx | 666 +++++++++ .../bom/materials/FlangeMaterialsView.jsx | 512 +++++++ .../bom/materials/GasketMaterialsView.jsx | 396 +++++ .../bom/materials/PipeMaterialsView.jsx | 525 +++++++ .../bom/materials/SupportMaterialsView.jsx | 377 +++++ .../bom/materials/ValveMaterialsView.jsx | 403 +++++ .../src/components/bom/materials/index.js | 8 + .../bom/shared/FilterableHeader.jsx | 78 + .../components/bom/shared/MaterialTable.jsx | 161 ++ frontend/src/components/bom/shared/index.js | 3 + .../src/components/common/ErrorBoundary.jsx | 163 ++ frontend/src/components/common/UserMenu.jsx | 219 +++ frontend/src/components/common/index.js | 3 + frontend/src/pages/BOMManagementPage.css | 184 +++ frontend/src/pages/BOMManagementPage.jsx | 451 ++++++ frontend/src/pages/DashboardPage.jsx | 1320 +++++++++++++---- frontend/src/pages/InactiveProjectsPage.jsx | 398 +++++ frontend/src/pages/NewMaterialsPage.css | 2 + frontend/src/pages/NewMaterialsPage.jsx | 15 +- frontend/src/pages/PurchaseRequestPage.jsx | 169 ++- 31 files changed, 7450 insertions(+), 1604 deletions(-) create mode 100644 frontend/PAGES_GUIDE.md delete mode 100644 frontend/src/components/ErrorBoundary.jsx create mode 100644 frontend/src/components/bom/index.js create mode 100644 frontend/src/components/bom/materials/BoltMaterialsView.jsx create mode 100644 frontend/src/components/bom/materials/FittingMaterialsView.jsx create mode 100644 frontend/src/components/bom/materials/FlangeMaterialsView.jsx create mode 100644 frontend/src/components/bom/materials/GasketMaterialsView.jsx create mode 100644 frontend/src/components/bom/materials/PipeMaterialsView.jsx create mode 100644 frontend/src/components/bom/materials/SupportMaterialsView.jsx create mode 100644 frontend/src/components/bom/materials/ValveMaterialsView.jsx create mode 100644 frontend/src/components/bom/materials/index.js create mode 100644 frontend/src/components/bom/shared/FilterableHeader.jsx create mode 100644 frontend/src/components/bom/shared/MaterialTable.jsx create mode 100644 frontend/src/components/bom/shared/index.js create mode 100644 frontend/src/components/common/ErrorBoundary.jsx create mode 100644 frontend/src/components/common/UserMenu.jsx create mode 100644 frontend/src/components/common/index.js create mode 100644 frontend/src/pages/BOMManagementPage.css create mode 100644 frontend/src/pages/BOMManagementPage.jsx create mode 100644 frontend/src/pages/InactiveProjectsPage.jsx diff --git a/RULES.md b/RULES.md index 681a3a6..5309703 100644 --- a/RULES.md +++ b/RULES.md @@ -1255,6 +1255,8 @@ const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5; - μžλ™ μ—λŸ¬ 핸듀링 (검증, DB, 일반 μ˜ˆμ™Έ) ### πŸ“Š κ΅¬ν˜„λœ νŽ˜μ΄μ§€λ“€ + +#### **πŸ“‹ κΈ°μ‘΄ νŽ˜μ΄μ§€λ“€** - MainPage: 메인 λŒ€μ‹œλ³΄λ“œ - JobSelectionPage: ν”„λ‘œμ νŠΈ 선택 - JobRegistrationPage: ν”„λ‘œμ νŠΈ 등둝 @@ -1264,6 +1266,179 @@ const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5; - PurchaseConfirmationPage: ꡬ맀 확인 - RevisionPurchasePage: 리비전별 ꡬ맀 +#### **🎨 μ‹ κ·œ λͺ¨λ˜ UI νŽ˜μ΄μ§€λ“€ (2025.10.16 μΆ”κ°€)** + +##### **DashboardPage.jsx** - ν”„λ‘œμ νŠΈ 쀑심 λŒ€μ‹œλ³΄λ“œ +```jsx +// μœ„μΉ˜: frontend/src/pages/DashboardPage.jsx +// νŠΉμ§•: 데본씽크 μŠ€νƒ€μΌμ˜ λͺ¨λ˜ν•œ λ””μžμΈ +// κΈ°λŠ₯: +// - ν”„λ‘œμ νŠΈ 선택 및 관리 (μΉ΄λ“œ ν˜•νƒœ) +// - κΆŒν•œλ³„ κΈ°λŠ₯ μΉ΄λ“œ (BOM 관리, 자재 관리, ꡬ맀 관리) +// - κ΄€λ¦¬μž μ „μš© κΈ°λŠ₯ (μ‚¬μš©μž 관리, μ‹œμŠ€ν…œ μ„€μ •) +// - μ‹œμŠ€ν…œ ν˜„ν™© λŒ€μ‹œλ³΄λ“œ +// - ν”„λ‘œμ νŠΈ 생성 λͺ¨λ‹¬ + +// λ””μžμΈ νŠΉμ§•: +// - κΈ€λž˜μŠ€λͺ¨ν”Όμ¦˜ 효과 (backdrop-filter: blur(10px)) +// - κ·ΈλΌλ°μ΄μ…˜ λ°°κ²½ 및 λ²„νŠΌ +// - μΉ΄λ“œ ν˜Έλ²„ μ• λ‹ˆλ©”μ΄μ…˜ +// - νƒ€μ΄ν¬κ·Έλž˜ν”Ό 쀑심 λ””μžμΈ (이λͺ¨μ§€ 제거) +// - λ°˜μ‘ν˜• κ·Έλ¦¬λ“œ λ ˆμ΄μ•„μ›ƒ + +// μ£Όμš” κΈ°λŠ₯: +// 1. ν”„λ‘œμ νŠΈ 선택 μ‹œμŠ€ν…œ +// - ν”„λ‘œμ νŠΈ λͺ©λ‘μ„ μΉ΄λ“œ ν˜•νƒœλ‘œ ν‘œμ‹œ +// - μ„ νƒλœ ν”„λ‘œμ νŠΈ ν•˜μ΄λΌμ΄νŠΈ +// - ν”„λ‘œμ νŠΈ 정보 (μ½”λ“œ, 이름, 고객사) ν‘œμ‹œ +// 2. κΆŒν•œ 기반 κΈ°λŠ₯ μ ‘κ·Ό +// - ν”„λ‘œμ νŠΈ 선택 ν›„μ—λ§Œ BOM/자재 관리 μ ‘κ·Ό κ°€λŠ₯ +// - κ΄€λ¦¬μž μ „μš© 메뉴 뢄리 ν‘œμ‹œ +// 3. ν”„λ‘œμ νŠΈ 생성 κΈ°λŠ₯ +// - λͺ¨λ‹¬ ν˜•νƒœμ˜ ν”„λ‘œμ νŠΈ 생성 폼 +// - ν”„λ‘œμ νŠΈ μ½”λ“œ, 이름, 고객사 μž…λ ₯ +``` + +##### **UserMenu.jsx** - μ‚¬μš©μž 메뉴 μ»΄ν¬λ„ŒνŠΈ +```jsx +// μœ„μΉ˜: frontend/src/components/UserMenu.jsx +// νŠΉμ§•: λ“œλ‘­λ‹€μš΄ ν˜•νƒœμ˜ μ‚¬μš©μž 메뉴 +// κΈ°λŠ₯: +// - μ‚¬μš©μž ν”„λ‘œν•„ ν‘œμ‹œ (아바타, 이름, μ—­ν• ) +// - 계정 μ„€μ • 링크 +// - κ΄€λ¦¬μž μ „μš© 메뉴 (κΆŒν•œλ³„ ν‘œμ‹œ) +// - λ‘œκ·Έμ•„μ›ƒ κΈ°λŠ₯ + +// λ””μžμΈ νŠΉμ§•: +// - μ›ν˜• 아바타 (κ·ΈλΌλ°μ΄μ…˜ λ°°κ²½) +// - λ“œλ‘­λ‹€μš΄ μ• λ‹ˆλ©”μ΄μ…˜ +// - ν˜Έλ²„ 효과 +// - 역할별 색상 ꡬ뢄 + +// μ£Όμš” κΈ°λŠ₯: +// 1. μ‚¬μš©μž 정보 ν‘œμ‹œ +// - 이름 첫 κΈ€μžλ‘œ 아바타 생성 +// - μ—­ν•  ν‘œμ‹œ (μ‹œμŠ€ν…œ κ΄€λ¦¬μž, κ΄€λ¦¬μž, μ‚¬μš©μž) +// 2. κΆŒν•œλ³„ 메뉴 +// - κ΄€λ¦¬μž: μ‚¬μš©μž 관리, μ‹œμŠ€ν…œ μ„€μ •, μ‹œμŠ€ν…œ 둜그 +// - 일반 μ‚¬μš©μž: 계정 μ„€μ •λ§Œ +// 3. λ„€λΉ„κ²Œμ΄μ…˜ 연동 +// - onNavigate μ½œλ°±μ„ ν†΅ν•œ νŽ˜μ΄μ§€ 이동 +// - onLogout μ½œλ°±μ„ ν†΅ν•œ λ‘œκ·Έμ•„μ›ƒ 처리 +``` + +#### **🎨 UI/UX λ””μžμΈ μ‹œμŠ€ν…œ** + +##### **색상 νŒ”λ ˆνŠΈ** +```css +/* μ£Όμš” 색상 */ +--primary-gradient: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); +--background-gradient: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); +--glass-background: rgba(255, 255, 255, 0.9); +--text-primary: #0f172a; +--text-secondary: #64748b; +--border-color: rgba(255, 255, 255, 0.2); + +/* 그림자 */ +--shadow-card: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +--shadow-button: 0 4px 14px 0 rgba(59, 130, 246, 0.39); +--shadow-hover: 0 8px 25px 0 rgba(59, 130, 246, 0.5); +``` + +##### **νƒ€μ΄ν¬κ·Έλž˜ν”Ό** +```css +/* 폰트 μ‹œμŠ€ν…œ */ +font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + +/* 제λͺ© */ +--heading-1: 36px, weight: 800, letter-spacing: -0.025em; +--heading-2: 24px, weight: 700, letter-spacing: -0.025em; +--heading-3: 18px, weight: 600; + +/* λ³Έλ¬Έ */ +--body-large: 18px, weight: 400; +--body-medium: 16px, weight: 400; +--body-small: 14px, weight: 400; +--caption: 12px, weight: 400; +``` + +##### **μ»΄ν¬λ„ŒνŠΈ μŠ€νƒ€μΌ** +```css +/* μΉ΄λ“œ */ +.modern-card { + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10px); + border-radius: 20px; + padding: 32px; + box-shadow: var(--shadow-card); + border: 1px solid var(--border-color); + transition: all 0.2s ease; +} + +.modern-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-hover); +} + +/* λ²„νŠΌ */ +.modern-button { + background: var(--primary-gradient); + color: white; + border: none; + border-radius: 12px; + padding: 12px 20px; + font-weight: 600; + box-shadow: var(--shadow-button); + transition: all 0.2s ease; + letter-spacing: 0.025em; +} + +.modern-button:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-hover); +} +``` + +#### **πŸ”§ μ»΄ν¬λ„ŒνŠΈ μ‚¬μš© κ°€μ΄λ“œ** + +##### **DashboardPage μ‚¬μš©λ²•** +```jsx +import DashboardPage from './pages/DashboardPage'; + +// App.jsxμ—μ„œ μ‚¬μš© +case 'dashboard': + return ( + + ); +``` + +##### **UserMenu μ‚¬μš©λ²•** +```jsx +import UserMenu from './components/UserMenu'; + +// ν—€λ”μ—μ„œ μ‚¬μš© + +``` + +#### **πŸ“± λ°˜μ‘ν˜• λ””μžμΈ** +- **λ°μŠ€ν¬ν†±**: 1200px 이상 - 3-4μ—΄ κ·Έλ¦¬λ“œ +- **νƒœλΈ”λ¦Ώ**: 768px-1199px - 2μ—΄ κ·Έλ¦¬λ“œ +- **λͺ¨λ°”일**: 767px μ΄ν•˜ - 1μ—΄ μŠ€νƒ + +#### **β™Ώ μ ‘κ·Όμ„± 고렀사항** +- ν‚€λ³΄λ“œ λ„€λΉ„κ²Œμ΄μ…˜ 지원 +- μΆ©λΆ„ν•œ 색상 λŒ€λΉ„ (WCAG 2.1 AA μ€€μˆ˜) +- 슀크린 리더 ν˜Έν™˜μ„± +- 포컀슀 ν‘œμ‹œ λͺ…ν™•ν™” + --- ## 🌐 μ‹œλ†€λ‘œμ§€ NAS 배포 κ°€μ΄λ“œ ⭐ diff --git a/backend/app/auth/signup_routes.py b/backend/app/auth/signup_routes.py index d2bd4cc..dba1ed7 100644 --- a/backend/app/auth/signup_routes.py +++ b/backend/app/auth/signup_routes.py @@ -176,6 +176,39 @@ async def get_signup_requests( ) +@router.get("/pending-signups/count") +async def get_pending_signups_count( + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 승인 λŒ€κΈ° 쀑인 νšŒμ›κ°€μž… 수 쑰회 (κ΄€λ¦¬μž μ „μš©) + + Returns: + dict: 승인 λŒ€κΈ° 쀑인 μ‚¬μš©μž 수 + """ + try: + # κ΄€λ¦¬μž κΆŒν•œ 확인 + if current_user.get('role') not in ['admin', 'system']: + return {"count": 0} # κ΄€λ¦¬μžκ°€ μ•„λ‹ˆλ©΄ 0 λ°˜ν™˜ + + # 승인 λŒ€κΈ° 쀑인 μ‚¬μš©μž 수 쑰회 + query = text(""" + SELECT COUNT(*) as count + FROM users + WHERE status = 'pending' + """) + + result = db.execute(query).fetchone() + count = result.count if result else 0 + + return {"count": count} + + except Exception as e: + logger.error(f"승인 λŒ€κΈ° νšŒμ›κ°€μž… 수 쑰회 μ‹€νŒ¨: {str(e)}") + return {"count": 0} # 였λ₯˜ μ‹œ 0 λ°˜ν™˜ + + @router.post("/approve-signup/{user_id}") async def approve_signup( user_id: int, diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 2d59752..e5921b4 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -32,6 +32,13 @@ from app.services.revision_comparator import get_revision_comparison router = APIRouter() +class ExcelSaveRequest(BaseModel): + file_id: int + category: str + materials: List[Dict] + filename: str + user_id: Optional[int] = None + def extract_enhanced_material_grade(description: str, original_grade: str, category: str) -> str: """ 원본 μ„€λͺ…μ—μ„œ κ°œμ„ λœ 재질 정보λ₯Ό μΆ”μΆœ @@ -3039,9 +3046,9 @@ async def get_valve_details( except Exception as e: raise HTTPException(status_code=500, detail=f"VALVE 상세 정보 쑰회 μ‹€νŒ¨: {str(e)}") -@router.get("/user-requirements") +@router.get("/{file_id}/user-requirements") async def get_user_requirements( - file_id: Optional[int] = None, + file_id: int, job_no: Optional[str] = None, status: Optional[str] = None, db: Session = Depends(get_db) @@ -3729,4 +3736,174 @@ async def process_missing_drawings( except Exception as e: db.rollback() - raise HTTPException(status_code=500, detail=f"도면 처리 μ‹€νŒ¨: {str(e)}") \ No newline at end of file + raise HTTPException(status_code=500, detail=f"도면 처리 μ‹€νŒ¨: {str(e)}") + +@router.post("/save-excel") +async def save_excel_file( + request: ExcelSaveRequest, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + μ—‘μ…€ νŒŒμΌμ„ μ„œλ²„μ— μ €μž₯ν•˜κ³  메타데이터λ₯Ό 기둝 + """ + try: + # μ—‘μ…€ μ €μž₯ 디렉토리 생성 + excel_dir = Path("uploads/excel_exports") + excel_dir.mkdir(parents=True, exist_ok=True) + + # 파일 경둜 생성 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_filename = f"{request.category}_{timestamp}_{request.filename}" + file_path = excel_dir / safe_filename + + # μ—‘μ…€ 파일 생성 (openpyxl μ‚¬μš©) + import openpyxl + from openpyxl.styles import Font, PatternFill, Alignment + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = request.category + + # 헀더 μ„€μ • + headers = ['TAGNO', 'ν’ˆλͺ©λͺ…', 'μˆ˜λŸ‰', '톡화ꡬ뢄', '단가', '크기', 'μ••λ ₯λ“±κΈ‰', 'μŠ€μΌ€μ€„', + '재질', '상세내역', 'μ‚¬μš©μžμš”κ΅¬', '관리항λͺ©1', '관리항λͺ©2', '관리항λͺ©3', + '관리항λͺ©4', '납기일(YYYY-MM-DD)'] + + # 헀더 μŠ€νƒ€μΌ + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_alignment = Alignment(horizontal="center", vertical="center") + + # 헀더 μž‘μ„± + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + + # 데이터 μž‘μ„± + for row_idx, material in enumerate(request.materials, 2): + # κΈ°λ³Έ 데이터 + data = [ + '', # TAGNO + request.category, # ν’ˆλͺ©λͺ… + material.get('quantity', 0), # μˆ˜λŸ‰ + 'KRW', # 톡화ꡬ뢄 + 1, # 단가 + material.get('size_spec', '-'), # 크기 + '-', # μ••λ ₯λ“±κΈ‰ + material.get('schedule', '-'), # μŠ€μΌ€μ€„ + material.get('full_material_grade', material.get('material_grade', '-')), # 재질 + '-', # 상세내역 + material.get('user_requirement', ''), # μ‚¬μš©μžμš”κ΅¬ + '', '', '', '', # 관리항λͺ©λ“€ + datetime.now().strftime('%Y-%m-%d') # 납기일 + ] + + # 데이터 μž…λ ₯ + for col, value in enumerate(data, 1): + ws.cell(row=row_idx, column=col, value=value) + + # μ—‘μ…€ 파일 μ €μž₯ + wb.save(file_path) + + # λ°μ΄ν„°λ² μ΄μŠ€μ— 메타데이터 μ €μž₯ (ν…Œμ΄λΈ”μ΄ μ—†μœΌλ©΄ λ¬΄μ‹œ) + try: + save_query = text(""" + INSERT INTO excel_exports ( + file_id, category, filename, file_path, + material_count, created_by, created_at + ) VALUES ( + :file_id, :category, :filename, :file_path, + :material_count, :user_id, :created_at + ) + """) + + db.execute(save_query, { + "file_id": request.file_id, + "category": request.category, + "filename": safe_filename, + "file_path": str(file_path), + "material_count": len(request.materials), + "user_id": current_user.get('id'), + "created_at": datetime.now() + }) + + db.commit() + except Exception as db_error: + logger.warning(f"μ—‘μ…€ 메타데이터 μ €μž₯ μ‹€νŒ¨ (νŒŒμΌμ€ μ €μž₯됨): {str(db_error)}") + # 메타데이터 μ €μž₯ μ‹€νŒ¨ν•΄λ„ νŒŒμΌμ€ μ €μž₯λ˜μ—ˆμœΌλ―€λ‘œ 계속 μ§„ν–‰ + + logger.info(f"μ—‘μ…€ 파일 μ €μž₯ μ™„λ£Œ: {safe_filename}") + + return { + "success": True, + "message": "μ—‘μ…€ 파일이 μ„±κ³΅μ μœΌλ‘œ μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", + "filename": safe_filename, + "file_path": str(file_path), + "material_count": len(request.materials) + } + + except Exception as e: + logger.error(f"μ—‘μ…€ 파일 μ €μž₯ μ‹€νŒ¨: {str(e)}") + raise HTTPException(status_code=500, detail=f"μ—‘μ…€ 파일 μ €μž₯ μ‹€νŒ¨: {str(e)}") + +@router.get("/excel-exports") +async def get_excel_exports( + file_id: Optional[int] = None, + category: Optional[str] = None, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + μ €μž₯된 μ—‘μ…€ 파일 λͺ©λ‘ 쑰회 + """ + try: + query = text(""" + SELECT + id, file_id, category, filename, file_path, + material_count, created_by, created_at + FROM excel_exports + WHERE 1=1 + """) + + params = {} + + if file_id: + query = text(str(query) + " AND file_id = :file_id") + params["file_id"] = file_id + + if category: + query = text(str(query) + " AND category = :category") + params["category"] = category + + query = text(str(query) + " ORDER BY created_at DESC") + + result = db.execute(query, params).fetchall() + + exports = [] + for row in result: + exports.append({ + "id": row.id, + "file_id": row.file_id, + "category": row.category, + "filename": row.filename, + "file_path": row.file_path, + "material_count": row.material_count, + "created_by": row.created_by, + "created_at": row.created_at.isoformat() if row.created_at else None + }) + + return { + "success": True, + "exports": exports + } + + except Exception as e: + logger.error(f"μ—‘μ…€ 내보내기 λͺ©λ‘ 쑰회 μ‹€νŒ¨: {str(e)}") + return { + "success": False, + "exports": [], + "message": "μ—‘μ…€ 내보내기 λͺ©λ‘μ„ μ‘°νšŒν•  수 μ—†μŠ΅λ‹ˆλ‹€." + } \ No newline at end of file diff --git a/backend/app/routers/purchase_request.py b/backend/app/routers/purchase_request.py index a94962f..832d7e0 100644 --- a/backend/app/routers/purchase_request.py +++ b/backend/app/routers/purchase_request.py @@ -1,7 +1,7 @@ """ ꡬ맀신청 관리 API """ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Body from fastapi.responses import FileResponse from pydantic import BaseModel from sqlalchemy import text @@ -439,6 +439,101 @@ async def get_request_materials( ) +@router.get("/requested-materials") +async def get_requested_material_ids( + file_id: Optional[int] = None, + job_no: Optional[str] = None, + db: Session = Depends(get_db) +): + """ + κ΅¬λ§€μ‹ μ²­λœ 자재 ID λͺ©λ‘ 쑰회 (BOM νŽ˜μ΄μ§€μ—μ„œ λΉ„ν™œμ„±ν™”μš©) + """ + try: + query = text(""" + SELECT DISTINCT pri.material_id + FROM purchase_request_items pri + JOIN purchase_requests pr ON pri.request_id = pr.request_id + WHERE 1=1 + AND (:file_id IS NULL OR pr.file_id = :file_id) + AND (:job_no IS NULL OR pr.job_no = :job_no) + """) + + results = db.execute(query, { + "file_id": file_id, + "job_no": job_no + }).fetchall() + + material_ids = [row.material_id for row in results] + + return { + "success": True, + "requested_material_ids": material_ids, + "count": len(material_ids) + } + + except Exception as e: + logger.error(f"Failed to get requested material IDs: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"ꡬ맀신청 자재 ID 쑰회 μ‹€νŒ¨: {str(e)}" + ) + + +@router.patch("/{request_id}/title") +async def update_request_title( + request_id: int, + title: str = Body(..., embed=True), + # current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + ꡬ맀신청 제λͺ©(request_no) μ—…λ°μ΄νŠΈ + """ + try: + # ꡬ맀신청 쑴재 확인 + check_query = text(""" + SELECT request_no FROM purchase_requests + WHERE request_id = :request_id + """) + existing = db.execute(check_query, {"request_id": request_id}).fetchone() + + if not existing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="ꡬ맀신청을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€" + ) + + # 제λͺ© μ—…λ°μ΄νŠΈ + update_query = text(""" + UPDATE purchase_requests + SET request_no = :title + WHERE request_id = :request_id + """) + + db.execute(update_query, { + "request_id": request_id, + "title": title + }) + db.commit() + + return { + "success": True, + "message": "ꡬ맀신청 제λͺ©μ΄ μ—…λ°μ΄νŠΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€", + "old_title": existing.request_no, + "new_title": title + } + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"Failed to update request title: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"ꡬ맀신청 제λͺ© μ—…λ°μ΄νŠΈ μ‹€νŒ¨: {str(e)}" + ) + + @router.get("/{request_id}/download-excel") async def download_request_excel( request_id: int, diff --git a/frontend/PAGES_GUIDE.md b/frontend/PAGES_GUIDE.md new file mode 100644 index 0000000..9720989 --- /dev/null +++ b/frontend/PAGES_GUIDE.md @@ -0,0 +1,273 @@ +# ν”„λ‘ νŠΈμ—”λ“œ νŽ˜μ΄μ§€ κ°€μ΄λ“œ + +이 λ¬Έμ„œλŠ” TK-MP ν”„λ‘œμ νŠΈμ˜ ν”„λ‘ νŠΈμ—”λ“œ νŽ˜μ΄μ§€λ“€μ˜ μ—­ν• κ³Ό κΈ°λŠ₯을 μ •λ¦¬ν•œ κ°€μ΄λ“œμž…λ‹ˆλ‹€. + +## πŸ“‹ λͺ©μ°¨ + +- [인증 κ΄€λ ¨ νŽ˜μ΄μ§€](#인증-κ΄€λ ¨-νŽ˜μ΄μ§€) +- [λŒ€μ‹œλ³΄λ“œ 및 메인 νŽ˜μ΄μ§€](#λŒ€μ‹œλ³΄λ“œ-및-메인-νŽ˜μ΄μ§€) +- [ν”„λ‘œμ νŠΈ 관리 νŽ˜μ΄μ§€](#ν”„λ‘œμ νŠΈ-관리-νŽ˜μ΄μ§€) +- [BOM 관리 νŽ˜μ΄μ§€](#bom-관리-νŽ˜μ΄μ§€) +- [ꡬ맀 관리 νŽ˜μ΄μ§€](#ꡬ맀-관리-νŽ˜μ΄μ§€) +- [μ‹œμŠ€ν…œ 관리 νŽ˜μ΄μ§€](#μ‹œμŠ€ν…œ-관리-νŽ˜μ΄μ§€) +- [μ»΄ν¬λ„ŒνŠΈ ꡬ쑰](#μ»΄ν¬λ„ŒνŠΈ-ꡬ쑰) + +--- + +## 인증 κ΄€λ ¨ νŽ˜μ΄μ§€ + +### `LoginPage.jsx` +- **μ—­ν• **: μ‚¬μš©μž 둜그인 νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: + - μ‚¬μš©μž 인증 (이메일/λΉ„λ°€λ²ˆν˜Έ) + - 둜그인 μƒνƒœ 관리 + - 인증 μ‹€νŒ¨ μ‹œ μ—λŸ¬ λ©”μ‹œμ§€ ν‘œμ‹œ +- **λΌμš°νŒ…**: `/login` +- **μ ‘κ·Ό κΆŒν•œ**: λͺ¨λ“  μ‚¬μš©μž (비인증) + +--- + +## λŒ€μ‹œλ³΄λ“œ 및 메인 νŽ˜μ΄μ§€ + +### `DashboardPage.jsx` +- **μ—­ν• **: 메인 λŒ€μ‹œλ³΄λ“œ νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: + - ν”„λ‘œμ νŠΈ 선택 λ“œλ‘­λ‹€μš΄ + - ν”„λ‘œμ νŠΈλ³„ κΈ°λŠ₯ μΉ΄λ“œ (BOM 관리, ꡬ맀신청 관리) + - κ΄€λ¦¬μž μ „μš© κΈ°λŠ₯ (μ‚¬μš©μž 관리, 둜그 관리) + - ν”„λ‘œμ νŠΈ 생성/νŽΈμ§‘/μ‚­μ œ/λΉ„ν™œμ„±ν™” +- **λΌμš°νŒ…**: `/dashboard` +- **μ ‘κ·Ό κΆŒν•œ**: 인증된 μ‚¬μš©μž +- **λ””μžμΈ**: 데본씽크 μŠ€νƒ€μΌ, κΈ€λž˜μŠ€λͺ¨ν”Όμ¦˜ 효과 + +### `MainPage.jsx` +- **μ—­ν• **: 초기 λžœλ”© νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: κΈ°λ³Έ νŽ˜μ΄μ§€ ꡬ쑰 및 λ„€λΉ„κ²Œμ΄μ…˜ +- **λΌμš°νŒ…**: `/` +- **μ ‘κ·Ό κΆŒν•œ**: 인증된 μ‚¬μš©μž + +--- + +## ν”„λ‘œμ νŠΈ 관리 νŽ˜μ΄μ§€ + +### `ProjectsPage.jsx` +- **μ—­ν• **: ν”„λ‘œμ νŠΈ λͺ©λ‘ 및 관리 +- **κΈ°λŠ₯**: + - ν”„λ‘œμ νŠΈ λͺ©λ‘ 쑰회 + - ν”„λ‘œμ νŠΈ 생성/μˆ˜μ •/μ‚­μ œ + - ν”„λ‘œμ νŠΈ μƒνƒœ 관리 +- **λΌμš°νŒ…**: `/projects` +- **μ ‘κ·Ό κΆŒν•œ**: κ΄€λ¦¬μž + +### `InactiveProjectsPage.jsx` +- **μ—­ν• **: λΉ„ν™œμ„±ν™”λœ ν”„λ‘œμ νŠΈ 관리 +- **κΈ°λŠ₯**: + - λΉ„ν™œμ„± ν”„λ‘œμ νŠΈ λͺ©λ‘ 쑰회 + - ν”„λ‘œμ νŠΈ ν™œμ„±ν™”/μ‚­μ œ + - 전체 선택/ν•΄μ œ κΈ°λŠ₯ +- **λΌμš°νŒ…**: `/inactive-projects` +- **μ ‘κ·Ό κΆŒν•œ**: κ΄€λ¦¬μž + +### `JobRegistrationPage.jsx` +- **μ—­ν• **: μƒˆλ‘œμš΄ μž‘μ—…(Job) 등둝 +- **κΈ°λŠ₯**: + - μž‘μ—… 정보 μž…λ ₯ 및 등둝 + - ν”„λ‘œμ νŠΈ μ—°κ²° +- **λΌμš°νŒ…**: `/job-registration` +- **μ ‘κ·Ό κΆŒν•œ**: κ΄€λ¦¬μž + +### `JobSelectionPage.jsx` +- **μ—­ν• **: μž‘μ—… 선택 νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: + - λ“±λ‘λœ μž‘μ—… λͺ©λ‘ 쑰회 + - μž‘μ—… 선택 및 이동 +- **λΌμš°νŒ…**: `/job-selection` +- **μ ‘κ·Ό κΆŒν•œ**: 인증된 μ‚¬μš©μž + +--- + +## BOM 관리 νŽ˜μ΄μ§€ + +### `BOMManagementPage.jsx` +- **μ—­ν• **: BOM(Bill of Materials) 톡합 관리 νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: + - μΉ΄ν…Œκ³ λ¦¬λ³„ 자재 쑰회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT) + - 자재 선택 및 ꡬ맀신청 (μ—‘μ…€ 내보내기) + - κ΅¬λ§€μ‹ μ²­λœ 자재 λΉ„ν™œμ„±ν™” ν‘œμ‹œ + - μ‚¬μš©μž μš”κ΅¬μ‚¬ν•­ μž…λ ₯ + - 리비전 관리 +- **λΌμš°νŒ…**: `/bom-management` +- **μ ‘κ·Ό κΆŒν•œ**: 인증된 μ‚¬μš©μž +- **νŠΉμ§•**: μΉ΄ν…Œκ³ λ¦¬λ³„ μ»΄ν¬λ„ŒνŠΈλ‘œ λΆ„λ¦¬λœ ꡬ쑰 + +### `NewMaterialsPage.jsx` (λ ˆκ±°μ‹œ) +- **μ—­ν• **: κΈ°μ‘΄ 자재 관리 νŽ˜μ΄μ§€ (ν˜„μž¬ λ°±μ—…μš©) +- **μƒνƒœ**: μ‚¬μš© 쀑단, `BOMManagementPage`둜 λŒ€μ²΄λ¨ +- **κΈ°λŠ₯**: 자재 λΆ„λ₯˜, νŽΈμ§‘, 내보내기 (κΈ°μ‘΄ 둜직 보쑴) + +### `BOMStatusPage.jsx` +- **μ—­ν• **: BOM μƒνƒœ 쑰회 νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: + - BOM 파일 μƒνƒœ 확인 + - 처리 μ§„ν–‰λ₯  ν‘œμ‹œ +- **λΌμš°νŒ…**: `/bom-status` +- **μ ‘κ·Ό κΆŒν•œ**: 인증된 μ‚¬μš©μž + +### `BOMWorkspacePage.jsx` +- **μ—­ν• **: BOM μž‘μ—… 곡간 +- **κΈ°λŠ₯**: + - BOM 파일 μ—…λ‘œλ“œ 및 처리 + - 자재 λΆ„λ₯˜ μž‘μ—… +- **λΌμš°νŒ…**: `/bom-workspace` +- **μ ‘κ·Ό κΆŒν•œ**: 인증된 μ‚¬μš©μž + +--- + +## ꡬ맀 관리 νŽ˜μ΄μ§€ + +### `PurchaseRequestPage.jsx` +- **μ—­ν• **: ꡬ맀신청 관리 νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: + - ꡬ맀신청 λͺ©λ‘ 쑰회 + - ꡬ맀신청 제λͺ© νŽΈμ§‘ (인라인 νŽΈμ§‘) + - 원본 파일 정보 ν‘œμ‹œ + - μ—‘μ…€ 파일 λ‹€μš΄λ‘œλ“œ + - ꡬ맀신청 자재 상세 쑰회 +- **λΌμš°νŒ…**: `/purchase-requests` +- **μ ‘κ·Ό κΆŒν•œ**: 인증된 μ‚¬μš©μž +- **νŠΉμ§•**: BOM νŽ˜μ΄μ§€μ™€ μ—°λ™λœ ꡬ맀 μ›Œν¬ν”Œλ‘œμš° + +### `PurchaseBatchPage.jsx` +- **μ—­ν• **: ꡬ맀 배치 처리 νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: + - λŒ€λŸ‰ ꡬ맀 처리 + - 배치 μž‘μ—… 관리 +- **λΌμš°νŒ…**: `/purchase-batch` +- **μ ‘κ·Ό κΆŒν•œ**: κ΄€λ¦¬μž + +--- + +## μ‹œμŠ€ν…œ 관리 νŽ˜μ΄μ§€ + +### `UserManagementPage.jsx` +- **μ—­ν• **: μ‚¬μš©μž 관리 νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: + - μ‚¬μš©μž λͺ©λ‘ 쑰회 + - μ‚¬μš©μž 생성/μˆ˜μ •/μ‚­μ œ + - κΆŒν•œ 관리 + - μ‚¬μš©μž μƒνƒœ 관리 +- **λΌμš°νŒ…**: `/user-management` +- **μ ‘κ·Ό κΆŒν•œ**: κ΄€λ¦¬μž + +### `SystemSettingsPage.jsx` +- **μ—­ν• **: μ‹œμŠ€ν…œ μ„€μ • νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: + - μ‹œμŠ€ν…œ μ „λ°˜ μ„€μ • 관리 + - ν™˜κ²½ λ³€μˆ˜ μ„€μ • +- **λΌμš°νŒ…**: `/system-settings` +- **μ ‘κ·Ό κΆŒν•œ**: κ΄€λ¦¬μž + +### `SystemSetupPage.jsx` +- **μ—­ν• **: μ‹œμŠ€ν…œ 초기 μ„€μ • νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: + - μ‹œμŠ€ν…œ 초기 ꡬ성 + - κΈ°λ³Έ 데이터 μ„€μ • +- **λΌμš°νŒ…**: `/system-setup` +- **μ ‘κ·Ό κΆŒν•œ**: κ΄€λ¦¬μž + +### `SystemLogsPage.jsx` +- **μ—­ν• **: μ‹œμŠ€ν…œ 둜그 쑰회 νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: + - μ‹œμŠ€ν…œ 둜그 쑰회 + - 둜그 필터링 및 검색 +- **λΌμš°νŒ…**: `/system-logs` +- **μ ‘κ·Ό κΆŒν•œ**: κ΄€λ¦¬μž + +### `LogMonitoringPage.jsx` +- **μ—­ν• **: 둜그 λͺ¨λ‹ˆν„°λ§ νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: + - μ‹€μ‹œκ°„ 둜그 λͺ¨λ‹ˆν„°λ§ + - 둜그 뢄석 및 μ•Œλ¦Ό +- **λΌμš°νŒ…**: `/log-monitoring` +- **μ ‘κ·Ό κΆŒν•œ**: κ΄€λ¦¬μž + +### `AccountSettingsPage.jsx` +- **μ—­ν• **: 개인 계정 μ„€μ • νŽ˜μ΄μ§€ +- **κΈ°λŠ₯**: + - 개인 정보 μˆ˜μ • + - λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ + - 계정 μ„€μ • 관리 +- **λΌμš°νŒ…**: `/account-settings` +- **μ ‘κ·Ό κΆŒν•œ**: 인증된 μ‚¬μš©μž + +--- + +## μ›Œν¬μŠ€νŽ˜μ΄μŠ€ νŽ˜μ΄μ§€ + +### `ProjectWorkspacePage.jsx` +- **μ—­ν• **: ν”„λ‘œμ νŠΈ μž‘μ—… 곡간 +- **κΈ°λŠ₯**: + - ν”„λ‘œμ νŠΈλ³„ μž‘μ—… ν™˜κ²½ + - 파일 관리 및 ν˜‘μ—… 도ꡬ +- **λΌμš°νŒ…**: `/project-workspace` +- **μ ‘κ·Ό κΆŒν•œ**: 인증된 μ‚¬μš©μž + +--- + +## μ»΄ν¬λ„ŒνŠΈ ꡬ쑰 + +### `components/common/` +- **ErrorBoundary.jsx**: μ—λŸ¬ 경계 μ»΄ν¬λ„ŒνŠΈ +- **UserMenu.jsx**: μ‚¬μš©μž λ“œλ‘­λ‹€μš΄ 메뉴 + +### `components/bom/` +- **shared/**: 곡톡 BOM μ»΄ν¬λ„ŒνŠΈ + - `FilterableHeader.jsx`: 필터링 κ°€λŠ₯ν•œ ν…Œμ΄λΈ” 헀더 + - `MaterialTable.jsx`: 자재 ν…Œμ΄λΈ” 곡톡 μ»΄ν¬λ„ŒνŠΈ +- **materials/**: μΉ΄ν…Œκ³ λ¦¬λ³„ 자재 λ·° μ»΄ν¬λ„ŒνŠΈ + - `PipeMaterialsView.jsx`: νŒŒμ΄ν”„ 자재 관리 + - `FittingMaterialsView.jsx`: ν”ΌνŒ… 자재 관리 + - `FlangeMaterialsView.jsx`: ν”Œλžœμ§€ 자재 관리 + - `ValveMaterialsView.jsx`: 밸브 자재 관리 + - `GasketMaterialsView.jsx`: κ°€μŠ€μΌ“ 자재 관리 + - `BoltMaterialsView.jsx`: 볼트 자재 관리 + - `SupportMaterialsView.jsx`: μ„œν¬νŠΈ 자재 관리 + +### 기타 μ»΄ν¬λ„ŒνŠΈ +- **NavigationMenu.jsx**: μ‚¬μ΄λ“œλ°” λ„€λΉ„κ²Œμ΄μ…˜ +- **NavigationBar.jsx**: 상단 λ„€λΉ„κ²Œμ΄μ…˜ λ°” +- **FileUpload.jsx**: 파일 μ—…λ‘œλ“œ μ»΄ν¬λ„ŒνŠΈ +- **ProtectedRoute.jsx**: κΆŒν•œ 기반 라우트 보호 + +--- + +## νŽ˜μ΄μ§€ μΆ”κ°€ μ‹œ κ·œμΉ™ + +1. **μƒˆ νŽ˜μ΄μ§€ 생성 μ‹œ 이 λ¬Έμ„œ μ—…λ°μ΄νŠΈ ν•„μˆ˜** +2. **νŽ˜μ΄μ§€ μ—­ν• κ³Ό κΈ°λŠ₯을 λͺ…ν™•νžˆ λ¬Έμ„œν™”** +3. **λΌμš°νŒ… κ²½λ‘œμ™€ μ ‘κ·Ό κΆŒν•œ λͺ…μ‹œ** +4. **κ΄€λ ¨ μ»΄ν¬λ„ŒνŠΈμ™€μ˜ μ—°κ΄€μ„± μ„€λͺ…** +5. **λ””μžμΈ μ‹œμŠ€ν…œ μ€€μˆ˜ (데본씽크 μŠ€νƒ€μΌ, κΈ€λž˜μŠ€λͺ¨ν”Όμ¦˜)** + +--- + +## λ””μžμΈ μ‹œμŠ€ν…œ + +### 색상 νŒ”λ ˆνŠΈ +- **Primary**: 블루 κ·ΈλΌλ°μ΄μ…˜ (#3b82f6 β†’ #1d4ed8) +- **Background**: κΈ€λž˜μŠ€ 효과 (backdrop-filter: blur) +- **Cards**: 20px λ‘₯κ·Ό λͺ¨μ„œλ¦¬, 그림자 효과 + +### λ°˜μ‘ν˜• λ””μžμΈ +- **Desktop**: 3-4μ—΄ κ·Έλ¦¬λ“œ +- **Tablet**: 2μ—΄ κ·Έλ¦¬λ“œ +- **Mobile**: 1μ—΄ κ·Έλ¦¬λ“œ + +### νƒ€μ΄ν¬κ·Έλž˜ν”Ό +- **Font Family**: Apple μ‹œμŠ€ν…œ 폰트 +- **Weight**: λ‹€μ–‘ν•œ weight ν™œμš© (400, 500, 600, 700) + +--- + +*λ§ˆμ§€λ§‰ μ—…λ°μ΄νŠΈ: 2024-10-16* +*λ‹€μŒ νŽ˜μ΄μ§€ μΆ”κ°€ μ‹œ λ°˜λ“œμ‹œ 이 λ¬Έμ„œλ₯Ό μ—…λ°μ΄νŠΈν•˜μ„Έμš”.* diff --git a/frontend/README.md b/frontend/README.md index d48c6eb..d1df511 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -50,40 +50,95 @@ uvicorn app.main:app --reload frontend/ β”œβ”€β”€ src/ β”‚ β”œβ”€β”€ components/ -β”‚ β”‚ β”œβ”€β”€ Dashboard.jsx # λŒ€μ‹œλ³΄λ“œ -β”‚ β”‚ β”œβ”€β”€ FileUpload.jsx # 파일 μ—…λ‘œλ“œ -β”‚ β”‚ β”œβ”€β”€ MaterialList.jsx # 자재 λͺ©λ‘ -β”‚ β”‚ └── ProjectManager.jsx # ν”„λ‘œμ νŠΈ 관리 -β”‚ β”œβ”€β”€ App.jsx # 메인 μ•± -β”‚ β”œβ”€β”€ main.jsx # μ—”νŠΈλ¦¬ 포인트 -β”‚ └── index.css # μ „μ—­ μŠ€νƒ€μΌ +β”‚ β”‚ β”œβ”€β”€ common/ # 곡톡 μ»΄ν¬λ„ŒνŠΈ +β”‚ β”‚ β”œβ”€β”€ bom/ # BOM κ΄€λ ¨ μ»΄ν¬λ„ŒνŠΈ +β”‚ β”‚ β”‚ β”œβ”€β”€ materials/ # μΉ΄ν…Œκ³ λ¦¬λ³„ 자재 λ·° +β”‚ β”‚ β”‚ └── shared/ # BOM 곡톡 μ»΄ν¬λ„ŒνŠΈ +β”‚ β”‚ └── ... # 기타 μ»΄ν¬λ„ŒνŠΈ +β”‚ β”œβ”€β”€ pages/ # νŽ˜μ΄μ§€ μ»΄ν¬λ„ŒνŠΈ +β”‚ β”‚ β”œβ”€β”€ DashboardPage.jsx # 메인 λŒ€μ‹œλ³΄λ“œ +β”‚ β”‚ β”œβ”€β”€ BOMManagementPage.jsx # BOM 관리 +β”‚ β”‚ β”œβ”€β”€ PurchaseRequestPage.jsx # ꡬ맀신청 관리 +β”‚ β”‚ └── ... # 기타 νŽ˜μ΄μ§€λ“€ +β”‚ β”œβ”€β”€ App.jsx # 메인 μ•± +β”‚ β”œβ”€β”€ main.jsx # μ—”νŠΈλ¦¬ 포인트 +β”‚ └── index.css # μ „μ—­ μŠ€νƒ€μΌ +β”œβ”€β”€ PAGES_GUIDE.md # πŸ“‹ νŽ˜μ΄μ§€ μ—­ν•  κ°€μ΄λ“œ β”œβ”€β”€ package.json └── vite.config.js ``` -## 🎯 μ£Όμš” μ»΄ν¬λ„ŒνŠΈ +## πŸ“‹ νŽ˜μ΄μ§€ κ°€μ΄λ“œ -### Dashboard -- ν”„λ‘œμ νŠΈ 톡계 및 ν˜„ν™© ν‘œμ‹œ -- 졜근 ν™œλ™ λͺ©λ‘ -- μ‹€μ‹œκ°„ 데이터 μ—…λ°μ΄νŠΈ +**λͺ¨λ“  νŽ˜μ΄μ§€μ˜ μ—­ν• κ³Ό κΈ°λŠ₯은 [`PAGES_GUIDE.md`](./PAGES_GUIDE.md)에 μƒμ„Ένžˆ λ¬Έμ„œν™”λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.** -### FileUpload -- λ“œλž˜κ·Έ&λ“œλ‘­ μΈν„°νŽ˜μ΄μŠ€ -- Excel 파일 검증 -- μ—…λ‘œλ“œ μ§„ν–‰λ₯  ν‘œμ‹œ -- 배치 파일 처리 +### πŸ”„ νŽ˜μ΄μ§€ 개발 κ·œμΉ™ -### MaterialList -- νŽ˜μ΄μ§€λ„€μ΄μ…˜μ΄ μžˆλŠ” 데이터 κ·Έλ¦¬λ“œ -- μ‹€μ‹œκ°„ 검색 및 필터링 -- CSV 내보내기 -- μ •λ ¬ 및 컬럼 관리 +1. **μƒˆ νŽ˜μ΄μ§€ 생성 μ‹œ `PAGES_GUIDE.md` μ—…λ°μ΄νŠΈ ν•„μˆ˜** +2. **νŽ˜μ΄μ§€ μ—­ν• , κΈ°λŠ₯, λΌμš°νŒ…, μ ‘κ·Ό κΆŒν•œμ„ λͺ…ν™•νžˆ λ¬Έμ„œν™”** +3. **κ΄€λ ¨ μ»΄ν¬λ„ŒνŠΈμ™€μ˜ μ—°κ΄€μ„± μ„€λͺ…** +4. **λ””μžμΈ μ‹œμŠ€ν…œ μ€€μˆ˜ (데본씽크 μŠ€νƒ€μΌ, κΈ€λž˜μŠ€λͺ¨ν”Όμ¦˜)** -### ProjectManager -- ν”„λ‘œμ νŠΈ CRUD μž‘μ—… -- μΉ΄λ“œ ν˜•νƒœμ˜ ν”„λ‘œμ νŠΈ ν‘œμ‹œ -- λͺ¨λ‹¬ 기반 νŽΈμ§‘ +### ⚠️ **Docker 배포 μ‹œ μ£Όμ˜μ‚¬ν•­** + +**ν”„λ‘ νŠΈμ—”λ“œ 변경사항이 λ°˜μ˜λ˜μ§€ μ•Šμ„ λ•Œ:** + +```bash +# 1. ν”„λ‘ νŠΈμ—”λ“œ μ»¨ν…Œμ΄λ„ˆ μ™„μ „ μž¬λΉŒλ“œ (μΊμ‹œ 문제 ν•΄κ²°) +docker-compose stop frontend +docker-compose rm -f frontend +docker-compose build --no-cache frontend +docker-compose up -d frontend + +# 2. 배포 ν›„ index 파일 버전 확인 +docker exec tk-mp-frontend find /usr/share/nginx/html -name "index-*.js" + +# 3. 둜컬 λΉŒλ“œ 버전과 비ꡐ +ls -la frontend/dist/assets/index-*.js +``` + +**주의:** Docker μ»¨ν…Œμ΄λ„ˆλŠ” 이전 λΉŒλ“œλ₯Ό μΊμ‹œν•  수 μžˆμ–΄ μ΅œμ‹  변경사항이 λ°˜μ˜λ˜μ§€ μ•Šμ„ 수 μžˆμŠ΅λ‹ˆλ‹€. +변경사항이 보이지 μ•ŠμœΌλ©΄ λ°˜λ“œμ‹œ `--no-cache` μ˜΅μ…˜μœΌλ‘œ μž¬λΉŒλ“œν•˜μ„Έμš”. + +### πŸš€ **λΉ λ₯Έ 배포 λͺ…λ Ήμ–΄** + +```bash +# ν”„λ‘ νŠΈμ—”λ“œ λΉ λ₯Έ 재배포 (ν•œ 쀄 λͺ…λ Ήμ–΄) +docker-compose stop frontend && docker-compose rm -f frontend && docker-compose build --no-cache frontend && docker-compose up -d frontend + +# 전체 μ‹œμŠ€ν…œ μž¬μ‹œμž‘ +docker-compose restart + +# νŠΉμ • μ„œλΉ„μŠ€λ§Œ μž¬μ‹œμž‘ +docker-compose restart backend +docker-compose restart frontend +``` + +## 🎯 μ£Όμš” νŽ˜μ΄μ§€ + +### DashboardPage +- ν”„λ‘œμ νŠΈ 선택 λ“œλ‘­λ‹€μš΄ +- ν”„λ‘œμ νŠΈλ³„ κΈ°λŠ₯ μΉ΄λ“œ (BOM 관리, ꡬ맀신청 관리) +- κ΄€λ¦¬μž μ „μš© κΈ°λŠ₯ (μ‚¬μš©μž 관리, 둜그 관리) +- 데본씽크 μŠ€νƒ€μΌ λ””μžμΈ + +### BOMManagementPage +- μΉ΄ν…Œκ³ λ¦¬λ³„ 자재 관리 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT) +- κ΅¬λ§€μ‹ μ²­λœ 자재 λΉ„ν™œμ„±ν™” ν‘œμ‹œ +- μ—‘μ…€ 내보내기 및 μ„œλ²„ μ €μž₯ +- μ‚¬μš©μž μš”κ΅¬μ‚¬ν•­ μž…λ ₯ + +### PurchaseRequestPage +- ꡬ맀신청 λͺ©λ‘ 쑰회 및 관리 +- ꡬ맀신청 제λͺ© 인라인 νŽΈμ§‘ +- 원본 파일 정보 ν‘œμ‹œ +- μ—‘μ…€ 파일 λ‹€μš΄λ‘œλ“œ + +### μΉ΄ν…Œκ³ λ¦¬λ³„ 자재 λ·° μ»΄ν¬λ„ŒνŠΈ +- 각 자재 μΉ΄ν…Œκ³ λ¦¬λ³„ μ „μš© λ·° μ»΄ν¬λ„ŒνŠΈ +- ν†΅μΌλœ ν…Œμ΄λΈ” ν˜•νƒœ UI +- μ •λ ¬, 필터링, 전체 선택 κΈ°λŠ₯ +- κ΅¬λ§€μ‹ μ²­λœ 자재 λΉ„ν™œμ„±ν™” 처리 ## πŸ“± λ°˜μ‘ν˜• λ””μžμΈ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 78229ab..ab8f074 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,20 +34,6 @@ "vite": "^4.5.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -63,9 +49,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", "dev": true, "license": "MIT", "engines": { @@ -73,22 +59,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", - "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", + "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.6", - "@babel/parser": "^7.28.0", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.0", - "@babel/types": "^7.28.0", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -111,13 +97,13 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -166,15 +152,15 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -222,26 +208,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", - "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -283,9 +269,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -306,17 +292,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", - "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", + "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.0", + "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", - "@babel/types": "^7.28.0", + "@babel/types": "^7.28.4", "debug": "^4.3.1" }, "engines": { @@ -324,9 +310,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", - "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -375,9 +361,9 @@ "license": "MIT" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", - "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0" @@ -857,9 +843,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { @@ -958,15 +944,26 @@ "license": "BSD-3-Clause" }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -977,15 +974,15 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1079,7 +1076,7 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/private-theming": { + "node_modules/@mui/private-theming": { "version": "5.17.1", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", @@ -1106,7 +1103,7 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/styled-engine": { + "node_modules/@mui/styled-engine": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", @@ -1139,7 +1136,7 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/system": { + "node_modules/@mui/system": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", @@ -1179,7 +1176,7 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/types": { + "node_modules/@mui/types": { "version": "7.2.24", "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", @@ -1193,7 +1190,7 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/utils": { + "node_modules/@mui/utils": { "version": "5.17.1", "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", @@ -1281,9 +1278,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.19", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", - "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", "dev": true, "license": "MIT" }, @@ -1323,13 +1320,13 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", - "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.20.7" + "@babel/types": "^7.28.2" } }, "node_modules/@types/parse-json": { @@ -1345,9 +1342,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.23", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", - "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1381,16 +1378,16 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", - "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", + "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.19", + "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, @@ -1398,7 +1395,7 @@ "node": "^14.18.0 || >=16.0.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/acorn": { @@ -1663,13 +1660,13 @@ } }, "node_modules/axios": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -1695,6 +1692,16 @@ "dev": true, "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", + "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1707,9 +1714,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", "dev": true, "funding": [ { @@ -1727,9 +1734,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { @@ -1798,9 +1806,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "version": "1.0.30001750", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz", + "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==", "dev": true, "funding": [ { @@ -1849,9 +1857,9 @@ } }, "node_modules/chart.js": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", - "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" @@ -1939,15 +1947,6 @@ "node": ">=10" } }, - "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -2036,9 +2035,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2142,16 +2141,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.182", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.182.tgz", - "integrity": "sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==", + "version": "1.5.237", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", + "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", "dev": true, "license": "ISC" }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -2494,9 +2493,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.20", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", - "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2736,9 +2735,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -2772,9 +2771,9 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -2858,6 +2857,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3353,14 +3362,15 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -3852,9 +3862,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.23", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", + "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", "dev": true, "license": "MIT" }, @@ -4280,9 +4290,9 @@ } }, "node_modules/react-is": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", - "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", "license": "MIT" }, "node_modules/react-refresh": { @@ -5308,6 +5318,15 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2554564..c70cb52 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,10 @@ import React, { useState, useEffect } from 'react'; import SimpleLogin from './SimpleLogin'; +import DashboardPage from './pages/DashboardPage'; +import { UserMenu, ErrorBoundary } from './components/common'; import BOMWorkspacePage from './pages/BOMWorkspacePage'; import NewMaterialsPage from './pages/NewMaterialsPage'; +import BOMManagementPage from './pages/BOMManagementPage'; import SystemSettingsPage from './pages/SystemSettingsPage'; import AccountSettingsPage from './pages/AccountSettingsPage'; import UserManagementPage from './pages/UserManagementPage'; @@ -9,12 +12,13 @@ import PurchaseBatchPage from './pages/PurchaseBatchPage'; import PurchaseRequestPage from './pages/PurchaseRequestPage'; import SystemLogsPage from './pages/SystemLogsPage'; import LogMonitoringPage from './pages/LogMonitoringPage'; -import ErrorBoundary from './components/ErrorBoundary'; +import InactiveProjectsPage from './pages/InactiveProjectsPage'; import errorLogger from './utils/errorLogger'; import api from './api'; import './App.css'; function App() { + // TK-MP BOM Management System v2.0 - Project Selection Dashboard const [isAuthenticated, setIsAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); const [user, setUser] = useState(null); @@ -30,16 +34,15 @@ function App() { const [newProjectName, setNewProjectName] = useState(''); const [newClientName, setNewClientName] = useState(''); const [pendingSignupCount, setPendingSignupCount] = useState(0); + const [inactiveProjects, setInactiveProjects] = useState(new Set()); // 승인 λŒ€κΈ° 쀑인 νšŒμ›κ°€μž… 수 쑰회 const loadPendingSignups = async () => { try { - const response = await api.get('/auth/signup-requests'); - // API 응닡이 { requests: [...], count: ... } ν˜•νƒœ - const pendingCount = response.data.count || 0; - setPendingSignupCount(pendingCount); + const response = await api.get('/auth/pending-signups/count'); + setPendingSignupCount(response.data.count || 0); } catch (error) { - console.error('승인 λŒ€κΈ° 쑰회 μ‹€νŒ¨:', error); + console.error('승인 λŒ€κΈ° νšŒμ›κ°€μž… 수 쑰회 μ‹€νŒ¨:', error); setPendingSignupCount(0); } }; @@ -103,861 +106,131 @@ function App() { }); } + // νŽΈμ§‘ λͺ¨λ“œ μ’…λ£Œ setEditingProject(null); setEditedProjectName(''); + alert('ν”„λ‘œμ νŠΈ 이름이 μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); } } catch (error) { - console.error('ν”„λ‘œμ νŠΈ 이름 μˆ˜μ • μ‹€νŒ¨:', error); - alert('ν”„λ‘œμ νŠΈ 이름 μˆ˜μ •μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'); + console.error('ν”„λ‘œμ νŠΈ μˆ˜μ • μ‹€νŒ¨:', error); + const errorMsg = error.response?.data?.detail || 'ν”„λ‘œμ νŠΈ μˆ˜μ •μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'; + alert(errorMsg); } }; - useEffect(() => { - // μ €μž₯된 토큰 확인 - const token = localStorage.getItem('access_token'); - const userData = localStorage.getItem('user_data'); - - if (token && userData) { - setIsAuthenticated(true); - setUser(JSON.parse(userData)); - loadProjects(); // ν”„λ‘œμ νŠΈ λͺ©λ‘ λ‘œλ“œ + // ν”„λ‘œμ νŠΈ μ‚­μ œ + const deleteProject = async (projectId) => { + if (window.confirm('μ •λ§λ‘œ 이 ν”„λ‘œμ νŠΈλ₯Ό μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?')) { + try { + const response = await api.delete(`/dashboard/projects/${projectId}`); + if (response.data.success) { + alert('ν”„λ‘œμ νŠΈκ°€ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + await loadProjects(); + // λΉ„ν™œμ„± ν”„λ‘œμ νŠΈ λͺ©λ‘μ—μ„œλ„ 제거 + setInactiveProjects(prev => { + const newSet = new Set(prev); + newSet.delete(projectId); + return newSet; + }); + if (selectedProject?.id === projectId) { + setSelectedProject(null); + } + } else { + alert(`ν”„λ‘œμ νŠΈ μ‚­μ œ μ‹€νŒ¨: ${response.data.detail}`); + } + } catch (error) { + console.error('ν”„λ‘œμ νŠΈ μ‚­μ œ μ‹€νŒ¨:', error); + alert(`ν”„λ‘œμ νŠΈ μ‚­μ œ μ‹€νŒ¨: ${error.response?.data?.detail || error.message}`); + } } - - setIsLoading(false); + }; - // 자재 λͺ©λ‘ νŽ˜μ΄μ§€λ‘œ 이동 이벀트 λ¦¬μŠ€λ„ˆ - const handleNavigateToMaterials = (event) => { - const { jobNo, revision, bomName, message, file_id } = event.detail; - navigateToPage('materials', { - jobNo: jobNo, - revision: revision, - bomName: bomName, - message: message, - file_id: file_id // file_id μΆ”κ°€ - }); - }; + // ν”„λ‘œμ νŠΈ ν™œμ„±ν™” + const handleActivateProject = (project) => { + setInactiveProjects(prev => { + const newSet = new Set(prev); + newSet.delete(project.job_no); + return newSet; + }); + }; - window.addEventListener('navigateToMaterials', handleNavigateToMaterials); - - return () => { - window.removeEventListener('navigateToMaterials', handleNavigateToMaterials); + // 인증 μƒνƒœ 확인 + useEffect(() => { + const checkAuth = async () => { + try { + const token = localStorage.getItem('access_token'); + if (token) { + api.defaults.headers.common['Authorization'] = `Bearer ${token}`; + const userResponse = await api.get('/auth/me'); + setUser(userResponse.data.user); + setIsAuthenticated(true); + await loadPendingSignups(); + await loadProjects(); // ν”„λ‘œμ νŠΈ λ‘œλ“œ μΆ”κ°€ + } + } catch (error) { + console.error('인증 확인 μ‹€νŒ¨:', error); + localStorage.removeItem('access_token'); + localStorage.removeItem('user_data'); + setIsAuthenticated(false); + } finally { + setIsLoading(false); + } }; + checkAuth(); }, []); - // μ‚¬μš©μž 메뉴 μ™ΈλΆ€ 클릭 μ‹œ λ‹«κΈ° - useEffect(() => { - const handleClickOutside = (event) => { - if (showUserMenu && !event.target.closest('.user-menu-container')) { - setShowUserMenu(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [showUserMenu]); - - // κ΄€λ¦¬μžμΈ 경우 주기적으둜 승인 λŒ€κΈ° 수 확인 - useEffect(() => { - if ((user?.role === 'admin' || user?.role === 'system') && isAuthenticated) { - // 초기 λ‘œλ“œ - loadPendingSignups(); - - // 30μ΄ˆλ§ˆλ‹€ 확인 - const interval = setInterval(() => { - loadPendingSignups(); - }, 30000); - - return () => clearInterval(interval); - } - }, [user?.role, isAuthenticated]); - - // 둜그인 성곡 μ‹œ 호좜될 ν•¨μˆ˜ - const handleLoginSuccess = () => { - const userData = localStorage.getItem('user_data'); - if (userData) { - const parsedUser = JSON.parse(userData); - setUser(parsedUser); - - // κ΄€λ¦¬μžμΈ 경우 승인 λŒ€κΈ° 수 확인 - if (parsedUser?.role === 'admin' || parsedUser?.role === 'system') { - loadPendingSignups(); - } - } - setIsAuthenticated(true); - }; - - // λ‘œκ·Έμ•„μ›ƒ ν•¨μˆ˜ - const handleLogout = () => { - localStorage.removeItem('access_token'); - localStorage.removeItem('user_data'); - setIsAuthenticated(false); - setUser(null); - setCurrentPage('dashboard'); - }; - - // νŽ˜μ΄μ§€ λ„€λΉ„κ²Œμ΄μ…˜ ν•¨μˆ˜ + // νŽ˜μ΄μ§€ 이동 ν•¨μˆ˜ const navigateToPage = (page, params = {}) => { setCurrentPage(page); setPageParams(params); - }; - - // 핡심 κΈ°λŠ₯만 제곡 - const getCoreFeatures = () => { - return [ - { - id: 'bom', - title: 'πŸ“‹ BOM μ—…λ‘œλ“œ & λΆ„λ₯˜', - description: 'μ—‘μ…€ 파일 μ—…λ‘œλ“œ β†’ μžλ™ λΆ„λ₯˜ β†’ κ²€ν†  β†’ 자재 확인 β†’ ꡬ맀신청 (μ—‘μ…€ 내보내기)', - color: '#4299e1' - }, - { - id: 'purchase-request', - title: 'πŸ“¦ ꡬ맀신청 관리', - description: 'κ΅¬λ§€μ‹ μ²­ν•œ μžμž¬λ“€μ„ κ·Έλ£Ήλ³„λ‘œ μ‘°νšŒν•˜κ³  μ—‘μ…€ μž¬λ‹€μš΄λ‘œλ“œ', - color: '#10b981' - } - ]; - }; - - // κ΄€λ¦¬μž μ „μš© κΈ°λŠ₯ - const getAdminFeatures = () => { - const features = []; - - console.log('getAdminFeatures - Current user:', user); - console.log('getAdminFeatures - User role:', user?.role); - console.log('getAdminFeatures - Pending count:', pendingSignupCount); - console.log('getAdminFeatures - Role check result:', user?.role === 'system' || user?.role === 'admin'); - - // μ‹œμŠ€ν…œ κ΄€λ¦¬μž κΈ°λŠ₯ (system role이 졜고 κΆŒν•œ) - if (user?.role === 'system' || user?.role === 'admin') { - console.log('βœ… μ‹œμŠ€ν…œ κ΄€λ¦¬μž κΈ°λŠ₯ μΆ”κ°€ 쀑...'); - features.push( - { - id: 'user-management', - title: 'πŸ‘₯ μ‚¬μš©μž 관리', - description: '계정 생성, μ—­ν•  λ³€κ²½, νšŒμ›κ°€μž… 승인', - color: '#dc2626', - badge: 'μ‹œμŠ€ν…œ κ΄€λ¦¬μž', - pendingCount: pendingSignupCount - }, - { - id: 'system-logs', - title: 'πŸ“Š μ‹œμŠ€ν…œ 둜그', - description: '둜그인 기둝, μ‹œμŠ€ν…œ 였λ₯˜ 둜그 쑰회', - color: '#7c3aed', - badge: 'μ‹œμŠ€ν…œ κ΄€λ¦¬μž' - } - ); - } - - // 일반 κ΄€λ¦¬μž κΈ°λŠ₯ - if (user?.role === 'manager') { - // 일반 κ΄€λ¦¬μžλŠ” νšŒμ›κ°€μž… 승인만 κ°€λŠ₯ - features.push( - { - id: 'user-management', - title: 'πŸ‘₯ νšŒμ›κ°€μž… 승인', - description: 'μ‹ κ·œ νšŒμ›κ°€μž… 승인 및 κ±°λΆ€', - color: '#dc2626', - badge: 'κ΄€λ¦¬μž', - pendingCount: pendingSignupCount - } - ); - } - - // κ΄€λ¦¬μž 이상 곡톡 κΈ°λŠ₯ - if (user?.role === 'admin' || user?.role === 'manager') { - - features.push( - { - id: 'log-monitoring', - title: 'πŸ“ˆ 둜그 λͺ¨λ‹ˆν„°λ§', - description: 'μ‚¬μš©μž ν™œλ™ 둜그 및 였λ₯˜ λͺ¨λ‹ˆν„°λ§', - color: '#059669', - badge: user?.role === 'system' ? 'μ‹œμŠ€ν…œ κ΄€λ¦¬μž' : 'κ΄€λ¦¬μž' - } - ); - } - - return features; + setShowUserMenu(false); }; // νŽ˜μ΄μ§€ λ Œλ”λ§ ν•¨μˆ˜ const renderCurrentPage = () => { - console.log('ν˜„μž¬ νŽ˜μ΄μ§€:', currentPage, 'νŽ˜μ΄μ§€ νŒŒλΌλ―Έν„°:', pageParams); switch (currentPage) { case 'dashboard': - const coreFeatures = getCoreFeatures(); - const adminFeatures = getAdminFeatures(); - - return ( -
- {/* 상단 헀더 */} -
-
-

- 🏭 TK-MP BOM 관리 μ‹œμŠ€ν…œ -

-

- {user?.name || user?.username}λ‹˜ ν™˜μ˜ν•©λ‹ˆλ‹€ -

-
- - {/* μ‚¬μš©μž 메뉴 */} -
- - - {/* λ“œλ‘­λ‹€μš΄ 메뉴 */} - {showUserMenu && ( -
-
-
- {user?.name || user?.username} -
-
- {user?.email || '이메일 μ—†μŒ'} -
-
- - - - -
- )} -
-
- - {/* 메인 μ½˜ν…μΈ  */} -
- - {/* ν”„λ‘œμ νŠΈ 관리 */} -
-
-

- πŸ“ ν”„λ‘œμ νŠΈ 관리 -

-
- - {selectedProject && ( - - )} -
-
- - {/* ν”„λ‘œμ νŠΈ 생성 폼 */} - {showCreateProject && ( -
-
- βž• μƒˆ ν”„λ‘œμ νŠΈ 생성 -
-
-
- - setNewProjectCode(e.target.value)} - placeholder="예: J24-004" - style={{ - width: '100%', - padding: '10px 12px', - border: '1px solid #d1d5db', - borderRadius: '6px', - fontSize: '14px' - }} - /> -
-
- - setNewProjectName(e.target.value)} - placeholder="예: μƒˆλ‘œμš΄ ν”„λ‘œμ νŠΈ" - style={{ - width: '100%', - padding: '10px 12px', - border: '1px solid #d1d5db', - borderRadius: '6px', - fontSize: '14px' - }} - /> -
-
- - setNewClientName(e.target.value)} - placeholder="예: ABC μ£Όμ‹νšŒμ‚¬" - style={{ - width: '100%', - padding: '10px 12px', - border: '1px solid #d1d5db', - borderRadius: '6px', - fontSize: '14px' - }} - /> -
-
- - -
-
-
- )} - - - - - - {/* ν”„λ‘œμ νŠΈ 이름 νŽΈμ§‘ 폼 */} - {editingProject && ( -
-
- ν”„λ‘œμ νŠΈ 이름 μˆ˜μ •: {editingProject.official_project_code} -
-
- setEditedProjectName(e.target.value)} - onKeyPress={(e) => { - if (e.key === 'Enter') updateProjectName(editingProject.id); - }} - placeholder="μƒˆ ν”„λ‘œμ νŠΈ 이름" - autoFocus - style={{ - flex: 1, - padding: '10px 12px', - border: '2px solid #3b82f6', - borderRadius: '6px', - fontSize: '14px' - }} - /> - - -
-
- )} -
- - {/* 핡심 κΈ°λŠ₯ - ν”„λ‘œμ νŠΈ 선택 μ‹œλ§Œ ν‘œμ‹œ */} - {selectedProject && ( -
-

- πŸ“‹ BOM 관리 μ›Œν¬ν”Œλ‘œμš° -

-
- {coreFeatures.map((feature) => ( -
{ - e.currentTarget.style.transform = 'translateY(-2px)'; - e.currentTarget.style.boxShadow = '0 8px 12px rgba(0, 0, 0, 0.1)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = 'translateY(0)'; - e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.07)'; - }} - > -

- {feature.title} -

-

- {feature.description} -

- -
- ))} -
-
- )} {/* selectedProject 쑰건문 λ‹«κΈ° */} - - {/* κ΄€λ¦¬μž κΈ°λŠ₯ (ν”„λ‘œμ νŠΈ 선택과 λ¬΄κ΄€ν•˜κ²Œ 항상 ν‘œμ‹œ) */} - {adminFeatures.length > 0 && ( -
-

- βš™οΈ μ‹œμŠ€ν…œ 관리 -

-
- {adminFeatures.map((feature) => ( -
{ - e.currentTarget.style.transform = 'translateY(-2px)'; - e.currentTarget.style.boxShadow = '0 8px 12px rgba(0, 0, 0, 0.1)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = 'translateY(0)'; - e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.07)'; - }} - > -

- {feature.title} - {feature.id === 'user-management' && feature.pendingCount > 0 && ( - - {feature.pendingCount}λͺ… λŒ€κΈ° - - )} -

-

- {feature.description} -

-
- - {feature.badge} μ „μš© - -
- -
- ))} -
-
- )} - - {/* κ°„λ‹¨ν•œ μ‚¬μš©λ²• μ•ˆλ‚΄ */} -
-

- πŸ“– κ°„λ‹¨ν•œ μ‚¬μš©λ²• -

-
-
- 1 - BOM μ—…λ‘œλ“œ -
- β†’ -
- 2 - μžλ™ λΆ„λ₯˜ -
- β†’ -
- 3 - μ—‘μ…€ 내보내기 -
-
-
- )} - {/* adminFeatures 쑰건문 λ‹«κΈ° */} -
-
- ); + return ( + + ); case 'bom': return ( - navigateToPage('dashboard')} /> ); - case 'materials': return ( - ); - case 'purchase-batch': return ( ); - case 'purchase-request': return ( ); - case 'system-settings': return ( - ); - case 'account-settings': return ( - { @@ -1005,83 +273,136 @@ function App() { }} /> ); - case 'user-management': return ( - ); - case 'system-logs': return ( - ); - case 'log-monitoring': return ( - ); - + case 'inactive-projects': + return ( + + ); default: return ( -
-

νŽ˜μ΄μ§€λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€

- +
+
+
❓
+
μ•Œ 수 μ—†λŠ” νŽ˜μ΄μ§€μž…λ‹ˆλ‹€.
+ +
); } }; - // λ‘œλ”© 쀑 - if (isLoading) { - return ( -
-
-
πŸ”„
-
λ‘œλ”© 쀑...
-
-
- ); - } + const handleLogout = () => { + localStorage.removeItem('access_token'); + localStorage.removeItem('user_data'); + setIsAuthenticated(false); + setUser(null); + setCurrentPage('dashboard'); + window.location.reload(); + }; - // λ‘œκ·ΈμΈν•˜μ§€ μ•Šμ€ 경우 - if (!isAuthenticated) { - return ; - } + const handleLoginSuccess = (userData) => { + setIsAuthenticated(true); + setUser(userData); + setIsLoading(false); + loadPendingSignups(); + loadProjects(); + }; - // 메인 μ• ν”Œλ¦¬μΌ€μ΄μ…˜ return ( -
- {renderCurrentPage()} -
+ {isLoading ? ( +
+
+
πŸ”„
+
λ‘œλ”© 쀑...
+
+
+ ) : !isAuthenticated ? ( + + ) : ( +
+ {/* 상단 헀더 */} +
+
+

+ TK-MP BOM Management System +

+

+ {user?.name || user?.username}λ‹˜ ν™˜μ˜ν•©λ‹ˆλ‹€ +

+
+ + {/* μ‚¬μš©μž 메뉴 */} + +
+ + {/* νŽ˜μ΄μ§€ 컨텐츠 */} + {renderCurrentPage()} +
+ )}
); } diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx deleted file mode 100644 index baf94b3..0000000 --- a/frontend/src/components/ErrorBoundary.jsx +++ /dev/null @@ -1,268 +0,0 @@ -import React from 'react'; -import errorLogger from '../utils/errorLogger'; - -class ErrorBoundary extends React.Component { - constructor(props) { - super(props); - this.state = { - hasError: false, - error: null, - errorInfo: null - }; - } - - static getDerivedStateFromError(error) { - // λ‹€μŒ λ Œλ”λ§μ—μ„œ 폴백 UIκ°€ 보이도둝 μƒνƒœλ₯Ό μ—…λ°μ΄νŠΈν•©λ‹ˆλ‹€. - return { hasError: true }; - } - - componentDidCatch(error, errorInfo) { - // 였λ₯˜ 정보λ₯Ό μƒνƒœμ— μ €μž₯ - this.setState({ - error: error, - errorInfo: errorInfo - }); - - // 였λ₯˜ λ‘œκΉ… - errorLogger.logError({ - type: 'react_error_boundary', - message: error.message, - stack: error.stack, - componentStack: errorInfo.componentStack, - timestamp: new Date().toISOString(), - url: window.location.href, - props: this.props.errorContext || {} - }); - - console.error('ErrorBoundary caught an error:', error, errorInfo); - } - - handleReload = () => { - window.location.reload(); - }; - - handleGoHome = () => { - window.location.href = '/'; - }; - - handleReportError = () => { - const errorReport = { - error: this.state.error?.message, - stack: this.state.error?.stack, - componentStack: this.state.errorInfo?.componentStack, - url: window.location.href, - timestamp: new Date().toISOString(), - userAgent: navigator.userAgent - }; - - // 였λ₯˜ λ³΄κ³ μ„œλ₯Ό ν΄λ¦½λ³΄λ“œμ— 볡사 - navigator.clipboard.writeText(JSON.stringify(errorReport, null, 2)) - .then(() => { - alert('였λ₯˜ 정보가 ν΄λ¦½λ³΄λ“œμ— λ³΅μ‚¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); - }) - .catch(() => { - // ν΄λ¦½λ³΄λ“œ 볡사 μ‹€νŒ¨ μ‹œ ν…μŠ€νŠΈ μ˜μ—­μ— ν‘œμ‹œ - const textarea = document.createElement('textarea'); - textarea.value = JSON.stringify(errorReport, null, 2); - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - alert('였λ₯˜ 정보가 ν΄λ¦½λ³΄λ“œμ— λ³΅μ‚¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); - }); - }; - - render() { - if (this.state.hasError) { - return ( -
-
-
- 😡 -
- -

- μ•—! λ¬Έμ œκ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€ -

- -

- μ˜ˆμƒμΉ˜ λͺ»ν•œ 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.
- 이 λ¬Έμ œλŠ” μžλ™μœΌλ‘œ κ°œλ°œνŒ€μ— λ³΄κ³ λ˜μ—ˆμŠ΅λ‹ˆλ‹€. -

- -
- - - - - -
- - {/* 개발 ν™˜κ²½μ—μ„œλ§Œ 상세 였λ₯˜ 정보 ν‘œμ‹œ */} - {process.env.NODE_ENV === 'development' && this.state.error && ( -
- - 개발자 정보 (ν΄λ¦­ν•˜μ—¬ 펼치기) - - -
- 였λ₯˜ λ©”μ‹œμ§€: -
-                                        {this.state.error.message}
-                                    
-
- -
- μŠ€νƒ 트레이슀: -
-                                        {this.state.error.stack}
-                                    
-
- - {this.state.errorInfo?.componentStack && ( -
- μ»΄ν¬λ„ŒνŠΈ μŠ€νƒ: -
-                                            {this.state.errorInfo.componentStack}
-                                        
-
- )} -
- )} - -
- πŸ’‘ 도움말: λ¬Έμ œκ°€ 계속 λ°œμƒν•˜λ©΄ νŽ˜μ΄μ§€λ₯Ό μƒˆλ‘œκ³ μΉ¨ν•˜κ±°λ‚˜ - λΈŒλΌμš°μ € μΊμ‹œλ₯Ό μ‚­μ œν•΄λ³΄μ„Έμš”. -
-
-
- ); - } - - return this.props.children; - } -} - -export default ErrorBoundary; diff --git a/frontend/src/components/bom/index.js b/frontend/src/components/bom/index.js new file mode 100644 index 0000000..e30a8b0 --- /dev/null +++ b/frontend/src/components/bom/index.js @@ -0,0 +1,3 @@ +// BOM Components +export * from './materials'; +export * from './shared'; diff --git a/frontend/src/components/bom/materials/BoltMaterialsView.jsx b/frontend/src/components/bom/materials/BoltMaterialsView.jsx new file mode 100644 index 0000000..8c3d1f3 --- /dev/null +++ b/frontend/src/components/bom/materials/BoltMaterialsView.jsx @@ -0,0 +1,460 @@ +import React, { useState } from 'react'; +import { exportMaterialsToExcel } from '../../../utils/excelExport'; +import api from '../../../api'; +import { FilterableHeader } from '../shared'; + +const BoltMaterialsView = ({ + materials, + selectedMaterials, + setSelectedMaterials, + userRequirements, + setUserRequirements, + purchasedMaterials, + fileId, + user +}) => { + const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); + const [columnFilters, setColumnFilters] = useState({}); + const [showFilterDropdown, setShowFilterDropdown] = useState(null); + // 볼트 μΆ”κ°€μš”κ΅¬μ‚¬ν•­ μΆ”μΆœ ν•¨μˆ˜ + const extractBoltAdditionalRequirements = (description) => { + const additionalReqs = []; + + // ν‘œλ©΄μ²˜λ¦¬ νŒ¨ν„΄ 확인 + const surfacePatterns = { + 'ELEC.GALV': 'ELEC.GALV', + 'ELEC GALV': 'ELEC.GALV', + 'GALVANIZED': 'GALVANIZED', + 'GALV': 'GALV', + 'HOT DIP GALV': 'HDG', + 'HDG': 'HDG', + 'ZINC PLATED': 'ZINC PLATED', + 'ZINC': 'ZINC', + 'PLAIN': 'PLAIN' + }; + + for (const [pattern, treatment] of Object.entries(surfacePatterns)) { + if (description.includes(pattern)) { + additionalReqs.push(treatment); + break; // 첫 번째 맀치만 μ‚¬μš© + } + } + + return additionalReqs.join(', ') || '-'; + }; + + const parseBoltInfo = (material) => { + const qty = Math.round(material.quantity || 0); + const safetyQty = Math.ceil(qty * 1.05); // 5% μ—¬μœ μœ¨ + const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수 + + // 볼트 상세 정보 μš°μ„  μ‚¬μš© + const boltDetails = material.bolt_details || {}; + + // 길이 정보 (bolt_details μš°μ„ , μ—†μœΌλ©΄ 원본 μ„€λͺ…μ—μ„œ μΆ”μΆœ) + let boltLength = '-'; + if (boltDetails.length && boltDetails.length !== '-') { + boltLength = boltDetails.length; + } else { + // 원본 μ„€λͺ…μ—μ„œ 길이 μΆ”μΆœ + const description = material.original_description || ''; + const lengthPatterns = [ + /(\d+(?:\.\d+)?)\s*LG/i, // 75 LG, 90.0000 LG + /(\d+(?:\.\d+)?)\s*mm/i, // 50mm + /(\d+(?:\.\d+)?)\s*MM/i, // 50MM + /LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 ν˜•νƒœ + ]; + + for (const pattern of lengthPatterns) { + const match = description.match(pattern); + if (match) { + let lengthValue = match[1]; + // μ†Œμˆ˜μ  제거 (145.0000 β†’ 145) + if (lengthValue.includes('.') && lengthValue.endsWith('.0000')) { + lengthValue = lengthValue.split('.')[0]; + } else if (lengthValue.includes('.') && /\.0+$/.test(lengthValue)) { + lengthValue = lengthValue.split('.')[0]; + } + boltLength = `${lengthValue}mm`; + break; + } + } + } + + // 재질 정보 (bolt_details μš°μ„ , μ—†μœΌλ©΄ κΈ°λ³Έ ν•„λ“œ μ‚¬μš©) + let boltGrade = '-'; + if (boltDetails.material_standard && boltDetails.material_grade) { + // bolt_detailsμ—μ„œ μ™„μ „ν•œ 재질 정보 ꡬ성 + if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== boltDetails.material_standard) { + boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`; + } else { + boltGrade = boltDetails.material_standard; + } + } else if (material.full_material_grade && material.full_material_grade !== '-') { + boltGrade = material.full_material_grade; + } else if (material.material_grade && material.material_grade !== '-') { + boltGrade = material.material_grade; + } + + // 볼트 νƒ€μž… (PSV_BOLT, LT_BOLT λ“±) + let boltSubtype = 'BOLT_GENERAL'; + if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN') { + boltSubtype = boltDetails.bolt_type; + } else { + // 원본 μ„€λͺ…μ—μ„œ 특수 볼트 νƒ€μž… μΆ”μΆœ + const description = material.original_description || ''; + const upperDesc = description.toUpperCase(); + if (upperDesc.includes('PSV')) { + boltSubtype = 'PSV_BOLT'; + } else if (upperDesc.includes('LT')) { + boltSubtype = 'LT_BOLT'; + } else if (upperDesc.includes('CK')) { + boltSubtype = 'CK_BOLT'; + } + } + + // μΆ”κ°€μš”κ΅¬μ‚¬ν•­ μΆ”μΆœ (ELEC.GALV λ“±) + const additionalReq = extractBoltAdditionalRequirements(material.original_description || ''); + + return { + type: 'BOLT', + subtype: boltSubtype, + size: material.size_spec || material.main_nom || '-', + pressure: '-', // λ³ΌνŠΈλŠ” μ••λ ₯ λ“±κΈ‰ μ—†μŒ + schedule: boltLength, // 길이 정보 + grade: boltGrade, + additionalReq: additionalReq, // μΆ”κ°€μš”κ΅¬μ‚¬ν•­ + quantity: purchaseQty, + unit: 'SETS' + }; + }; + + // μ •λ ¬ 처리 + const handleSort = (key) => { + let direction = 'asc'; + if (sortConfig.key === key && sortConfig.direction === 'asc') { + direction = 'desc'; + } + setSortConfig({ key, direction }); + }; + + // ν•„ν„°λ§λœ 및 μ •λ ¬λœ 자재 λͺ©λ‘ + const getFilteredAndSortedMaterials = () => { + let filtered = materials.filter(material => { + return Object.entries(columnFilters).every(([key, filterValue]) => { + if (!filterValue) return true; + const info = parseBoltInfo(material); + const value = info[key]?.toString().toLowerCase() || ''; + return value.includes(filterValue.toLowerCase()); + }); + }); + + if (sortConfig.key) { + filtered.sort((a, b) => { + const aInfo = parseBoltInfo(a); + const bInfo = parseBoltInfo(b); + const aValue = aInfo[sortConfig.key] || ''; + const bValue = bInfo[sortConfig.key] || ''; + + if (sortConfig.direction === 'asc') { + return aValue > bValue ? 1 : -1; + } else { + return aValue < bValue ? 1 : -1; + } + }); + } + + return filtered; + }; + + // 전체 선택/ν•΄μ œ (κ΅¬λ§€μ‹ μ²­λœ 자재 μ œμ™Έ) + const handleSelectAll = () => { + const filteredMaterials = getFilteredAndSortedMaterials(); + const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); + + if (selectedMaterials.size === selectableMaterials.length) { + setSelectedMaterials(new Set()); + } else { + setSelectedMaterials(new Set(selectableMaterials.map(m => m.id))); + } + }; + + // κ°œλ³„ 선택 (κ΅¬λ§€μ‹ μ²­λœ μžμž¬λŠ” 선택 λΆˆκ°€) + const handleMaterialSelect = (materialId) => { + if (purchasedMaterials.has(materialId)) { + return; // κ΅¬λ§€μ‹ μ²­λœ μžμž¬λŠ” 선택 λΆˆκ°€ + } + + const newSelected = new Set(selectedMaterials); + if (newSelected.has(materialId)) { + newSelected.delete(materialId); + } else { + newSelected.add(materialId); + } + setSelectedMaterials(newSelected); + }; + + // μ—‘μ…€ 내보내기 + const handleExportToExcel = async () => { + const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id)); + if (selectedMaterialsData.length === 0) { + alert('내보낼 자재λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”.'); + return; + } + + const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const excelFileName = `BOLT_Materials_${timestamp}.xlsx`; + + const dataWithRequirements = selectedMaterialsData.map(material => ({ + ...material, + user_requirement: userRequirements[material.id] || '' + })); + + try { + await api.post('/files/save-excel', { + file_id: fileId, + category: 'BOLT', + materials: dataWithRequirements, + filename: excelFileName, + user_id: user?.id + }); + + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'BOLT', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + + alert('μ—‘μ…€ 파일이 μƒμ„±λ˜κ³  μ„œλ²„μ— μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + } catch (error) { + console.error('μ—‘μ…€ μ €μž₯ μ‹€νŒ¨:', error); + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'BOLT', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + } + }; + + const filteredMaterials = getFilteredAndSortedMaterials(); + + return ( +
+ {/* 헀더 */} +
+
+

+ Bolt Materials +

+

+ {filteredMaterials.length} items β€’ {selectedMaterials.size} selected +

+
+ +
+ + + +
+
+ + {/* ν…Œμ΄λΈ” */} +
+ {/* 헀더 */} +
+
+ { + const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); + return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0; + })()} + onChange={handleSelectAll} + style={{ cursor: 'pointer' }} + /> +
+ Type + Size + Pressure + Length + Material Grade + Quantity +
Unit
+
User Requirement
+
+ + {/* 데이터 ν–‰λ“€ */} +
+ {filteredMaterials.map((material, index) => { + const info = parseBoltInfo(material); + const isSelected = selectedMaterials.has(material.id); + const isPurchased = purchasedMaterials.has(material.id); + + return ( +
{ + if (!isSelected && !isPurchased) { + e.target.style.background = '#f8fafc'; + } + }} + onMouseLeave={(e) => { + if (!isSelected && !isPurchased) { + e.target.style.background = 'white'; + } + }} + > +
+ handleMaterialSelect(material.id)} + disabled={isPurchased} + style={{ + cursor: isPurchased ? 'not-allowed' : 'pointer', + opacity: isPurchased ? 0.5 : 1 + }} + /> +
+
+ {info.subtype} + {isPurchased && ( + + PURCHASED + + )} +
+
+ {info.size} +
+
+ {info.pressure} +
+
+ {info.schedule} +
+
+ {info.grade} +
+
+ {info.quantity} +
+
+ {info.unit} +
+
+ setUserRequirements({ + ...userRequirements, + [material.id]: e.target.value + })} + placeholder="Enter requirement..." + style={{ + width: '100%', + padding: '6px 8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px' + }} + /> +
+
+ ); + })} +
+
+ + {filteredMaterials.length === 0 && ( +
+
πŸ”©
+
+ No Bolt Materials Found +
+
+ {Object.keys(columnFilters).some(key => columnFilters[key]) + ? 'Try adjusting your filters' + : 'No bolt materials available in this BOM'} +
+
+ )} +
+ ); +}; + +export default BoltMaterialsView; diff --git a/frontend/src/components/bom/materials/FittingMaterialsView.jsx b/frontend/src/components/bom/materials/FittingMaterialsView.jsx new file mode 100644 index 0000000..5c3fd5b --- /dev/null +++ b/frontend/src/components/bom/materials/FittingMaterialsView.jsx @@ -0,0 +1,666 @@ +import React, { useState } from 'react'; +import { exportMaterialsToExcel } from '../../../utils/excelExport'; +import api from '../../../api'; +import { FilterableHeader, MaterialTable } from '../shared'; + +const FittingMaterialsView = ({ + materials, + selectedMaterials, + setSelectedMaterials, + userRequirements, + setUserRequirements, + purchasedMaterials, + fileId, + user, + onNavigate +}) => { + const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); + const [columnFilters, setColumnFilters] = useState({}); + const [showFilterDropdown, setShowFilterDropdown] = useState(null); + + // λ‹ˆν”Œ 끝단 정보 μΆ”μΆœ (κΈ°μ‘΄ 둜직 볡원) + const extractNippleEndInfo = (description) => { + const descUpper = description.toUpperCase(); + + // λ‹ˆν”Œ 끝단 νŒ¨ν„΄λ“€ (κΈ°μ‘΄ NewMaterialsPage와 동일) + const endPatterns = { + 'PBE': 'PBE', // Plain Both End + 'BBE': 'BBE', // Bevel Both End + 'POE': 'POE', // Plain One End + 'BOE': 'BOE', // Bevel One End + 'TOE': 'TOE', // Thread One End + 'SW X NPT': 'SWΓ—NPT', // Socket Weld x NPT + 'SW X SW': 'SWΓ—SW', // Socket Weld x Socket Weld + 'NPT X NPT': 'NPTΓ—NPT', // NPT x NPT + 'BOTH END THREADED': 'B.E.T', + 'B.E.T': 'B.E.T', + 'ONE END THREADED': 'O.E.T', + 'O.E.T': 'O.E.T', + 'THREADED': 'THD' + }; + + for (const [pattern, display] of Object.entries(endPatterns)) { + if (descUpper.includes(pattern)) { + return display; + } + } + + return ''; + }; + + // ν”ΌνŒ… 정보 νŒŒμ‹± (κΈ°μ‘΄ 상세 둜직 볡원) + const parseFittingInfo = (material) => { + const fittingDetails = material.fitting_details || {}; + const classificationDetails = material.classification_details || {}; + + // κ°œμ„ λœ λΆ„λ₯˜κΈ° κ²°κ³Ό μš°μ„  μ‚¬μš© + const fittingTypeInfo = classificationDetails.fitting_type || {}; + const scheduleInfo = classificationDetails.schedule_info || {}; + + // κΈ°μ‘΄ ν•„λ“œμ™€ μƒˆ ν•„λ“œ 톡합 + const fittingType = fittingTypeInfo.type || fittingDetails.fitting_type || ''; + const fittingSubtype = fittingTypeInfo.subtype || fittingDetails.fitting_subtype || ''; + const mainSchedule = scheduleInfo.main_schedule || fittingDetails.schedule || ''; + const redSchedule = scheduleInfo.red_schedule || ''; + const hasDifferentSchedules = scheduleInfo.has_different_schedules || false; + + const description = material.original_description || ''; + + // ν”ΌνŒ… νƒ€μž…λ³„ 상세 ν‘œμ‹œ + let displayType = ''; + + // κ°œμ„ λœ λΆ„λ₯˜κΈ° κ²°κ³Ό μš°μ„  ν‘œμ‹œ + if (fittingType === 'TEE' && fittingSubtype === 'REDUCING') { + displayType = 'TEE REDUCING'; + } else if (fittingType === 'REDUCER' && fittingSubtype === 'CONCENTRIC') { + displayType = 'REDUCER CONC'; + } else if (fittingType === 'REDUCER' && fittingSubtype === 'ECCENTRIC') { + displayType = 'REDUCER ECC'; + } else if (description.toUpperCase().includes('TEE RED')) { + displayType = 'TEE REDUCING'; + } else if (description.toUpperCase().includes('RED CONC')) { + displayType = 'REDUCER CONC'; + } else if (description.toUpperCase().includes('RED ECC')) { + displayType = 'REDUCER ECC'; + } else if (description.toUpperCase().includes('CAP')) { + if (description.includes('NPT(F)')) { + displayType = 'CAP NPT(F)'; + } else if (description.includes('SW')) { + displayType = 'CAP SW'; + } else if (description.includes('BW')) { + displayType = 'CAP BW'; + } else { + displayType = 'CAP'; + } + } else if (description.toUpperCase().includes('PLUG')) { + if (description.toUpperCase().includes('HEX')) { + if (description.includes('NPT(M)')) { + displayType = 'HEX PLUG NPT(M)'; + } else { + displayType = 'HEX PLUG'; + } + } else if (description.includes('NPT(M)')) { + displayType = 'PLUG NPT(M)'; + } else if (description.includes('NPT')) { + displayType = 'PLUG NPT'; + } else { + displayType = 'PLUG'; + } + } else if (fittingType === 'NIPPLE') { + const length = fittingDetails.length_mm || fittingDetails.avg_length_mm; + const endInfo = extractNippleEndInfo(description); + + let nippleType = 'NIPPLE'; + if (length) nippleType += ` ${length}mm`; + if (endInfo) nippleType += ` ${endInfo}`; + + displayType = nippleType; + } else if (fittingType === 'ELBOW') { + let elbowDetails = []; + + // 각도 정보 μΆ”μΆœ + if (fittingSubtype.includes('90DEG') || description.includes('90') || description.includes('90Β°')) { + elbowDetails.push('90Β°'); + } else if (fittingSubtype.includes('45DEG') || description.includes('45') || description.includes('45Β°')) { + elbowDetails.push('45Β°'); + } + + // 반경 정보 μΆ”μΆœ (Long Radius / Short Radius) + if (fittingSubtype.includes('LONG_RADIUS') || description.toUpperCase().includes('LR') || description.toUpperCase().includes('LONG RADIUS')) { + elbowDetails.push('LR'); + } else if (fittingSubtype.includes('SHORT_RADIUS') || description.toUpperCase().includes('SR') || description.toUpperCase().includes('SHORT RADIUS')) { + elbowDetails.push('SR'); + } + + // μ—°κ²° 방식 + if (description.includes('SW')) { + elbowDetails.push('SW'); + } else if (description.includes('BW')) { + elbowDetails.push('BW'); + } + + // κΈ°λ³Έκ°’ μ„€μ • (각도가 μ—†μœΌλ©΄ 90λ„λ‘œ κ°€μ •) + if (!elbowDetails.some(detail => detail.includes('Β°'))) { + elbowDetails.unshift('90Β°'); + } + + displayType = `ELBOW ${elbowDetails.join(' ')}`.trim(); + } else if (fittingType === 'TEE') { + // TEE νƒ€μž…κ³Ό μ—°κ²° 방식 상세 ν‘œμ‹œ + let teeDetails = []; + + // λ“±κ²½/μΆ•μ†Œ νƒ€μž… + if (fittingSubtype === 'EQUAL' || description.toUpperCase().includes('TEE EQ')) { + teeDetails.push('EQ'); + } else if (fittingSubtype === 'REDUCING' || description.toUpperCase().includes('TEE RED')) { + teeDetails.push('RED'); + } + + // μ—°κ²° 방식 + if (description.includes('SW')) { + teeDetails.push('SW'); + } else if (description.includes('BW')) { + teeDetails.push('BW'); + } + + displayType = `TEE ${teeDetails.join(' ')}`.trim(); + } else if (fittingType === 'REDUCER') { + const reducerType = fittingSubtype === 'CONCENTRIC' ? 'CONC' : fittingSubtype === 'ECCENTRIC' ? 'ECC' : ''; + const sizes = fittingDetails.reduced_size ? `${material.size_spec}Γ—${fittingDetails.reduced_size}` : material.size_spec; + displayType = `RED ${reducerType} ${sizes}`.trim(); + } else if (fittingType === 'SWAGE') { + const swageType = fittingSubtype || ''; + displayType = `SWAGE ${swageType}`.trim(); + } else if (fittingType === 'OLET') { + const oletSubtype = fittingSubtype || ''; + let oletDisplayName = ''; + + // λ°±μ—”λ“œ λΆ„λ₯˜κΈ° κ²°κ³Ό μš°μ„  μ‚¬μš© + switch (oletSubtype) { + case 'SOCKOLET': + oletDisplayName = 'SOCK-O-LET'; + break; + case 'WELDOLET': + oletDisplayName = 'WELD-O-LET'; + break; + case 'ELLOLET': + oletDisplayName = 'ELL-O-LET'; + break; + case 'THREADOLET': + oletDisplayName = 'THREAD-O-LET'; + break; + case 'ELBOLET': + oletDisplayName = 'ELB-O-LET'; + break; + case 'NIPOLET': + oletDisplayName = 'NIP-O-LET'; + break; + case 'COUPOLET': + oletDisplayName = 'COUP-O-LET'; + break; + default: + // λ°±μ—”λ“œ λΆ„λ₯˜κ°€ μ—†μœΌλ©΄ descriptionμ—μ„œ 직접 μΆ”μΆœ + const upperDesc = description.toUpperCase(); + if (upperDesc.includes('SOCK-O-LET') || upperDesc.includes('SOCKOLET')) { + oletDisplayName = 'SOCK-O-LET'; + } else if (upperDesc.includes('WELD-O-LET') || upperDesc.includes('WELDOLET')) { + oletDisplayName = 'WELD-O-LET'; + } else if (upperDesc.includes('ELL-O-LET') || upperDesc.includes('ELLOLET')) { + oletDisplayName = 'ELL-O-LET'; + } else if (upperDesc.includes('THREAD-O-LET') || upperDesc.includes('THREADOLET')) { + oletDisplayName = 'THREAD-O-LET'; + } else if (upperDesc.includes('ELB-O-LET') || upperDesc.includes('ELBOLET')) { + oletDisplayName = 'ELB-O-LET'; + } else if (upperDesc.includes('NIP-O-LET') || upperDesc.includes('NIPOLET')) { + oletDisplayName = 'NIP-O-LET'; + } else if (upperDesc.includes('COUP-O-LET') || upperDesc.includes('COUPOLET')) { + oletDisplayName = 'COUP-O-LET'; + } else { + oletDisplayName = 'OLET'; + } + } + + displayType = oletDisplayName; + } else if (!displayType) { + displayType = fittingType || 'FITTING'; + } + + // μ••λ ₯ λ“±κΈ‰κ³Ό μŠ€μΌ€μ€„ μΆ”μΆœ (κΈ°μ‘΄ NewMaterialsPage 둜직) + let pressure = '-'; + let schedule = '-'; + + // μ••λ ₯ λ“±κΈ‰ μ°ΎκΈ° (3000LB, 6000LB λ“±) - μ†ŒμΌ“μ›°λ“œ ν”ΌνŒ…μ— 특히 μ€‘μš” + const pressureMatch = description.match(/(\d+)LB/i); + if (pressureMatch) { + pressure = `${pressureMatch[1]}LB`; + } + + // μ†ŒμΌ“μ›°λ“œ ν”ΌνŒ…μ˜ 경우 μ••λ ₯ 등급이 더 μ€‘μš”ν•¨ + if (description.includes('SW') && !pressureMatch) { + // SW ν”ΌνŒ…μΈλ° μ••λ ₯이 λͺ…μ‹œλ˜μ§€ μ•Šμ€ 경우 κΈ°λ³Έκ°’ μ„€μ • + if (description.includes('3000') || description.includes('3K')) { + pressure = '3000LB'; + } else if (description.includes('6000') || description.includes('6K')) { + pressure = '6000LB'; + } + } + + // μŠ€μΌ€μ€„ ν‘œμ‹œ (뢄리 μŠ€μΌ€μ€„ 지원) + if (hasDifferentSchedules && mainSchedule && redSchedule) { + schedule = `${mainSchedule}Γ—${redSchedule}`; + } else if (mainSchedule) { + schedule = mainSchedule; + } else { + // Descriptionμ—μ„œ μŠ€μΌ€μ€„ μΆ”μΆœ + const scheduleMatch = description.match(/SCH\s*(\d+[A-Z]*)/i); + if (scheduleMatch) { + schedule = `SCH ${scheduleMatch[1]}`; + } + } + + return { + type: 'FITTING', + subtype: displayType, + size: material.size_spec || '-', + pressure: pressure, + schedule: schedule, + grade: material.full_material_grade || material.material_grade || '-', + quantity: Math.round(material.quantity || 0), + unit: '개', + isFitting: true + }; + }; + + // μ •λ ¬ 처리 + const handleSort = (key) => { + let direction = 'asc'; + if (sortConfig.key === key && sortConfig.direction === 'asc') { + direction = 'desc'; + } + setSortConfig({ key, direction }); + }; + + // ν•„ν„°λ§λœ 및 μ •λ ¬λœ 자재 λͺ©λ‘ + const getFilteredAndSortedMaterials = () => { + let filtered = materials.filter(material => { + return Object.entries(columnFilters).every(([key, filterValue]) => { + if (!filterValue) return true; + const info = parseFittingInfo(material); + const value = info[key]?.toString().toLowerCase() || ''; + return value.includes(filterValue.toLowerCase()); + }); + }); + + if (sortConfig.key) { + filtered.sort((a, b) => { + const aInfo = parseFittingInfo(a); + const bInfo = parseFittingInfo(b); + const aValue = aInfo[sortConfig.key] || ''; + const bValue = bInfo[sortConfig.key] || ''; + + if (sortConfig.direction === 'asc') { + return aValue > bValue ? 1 : -1; + } else { + return aValue < bValue ? 1 : -1; + } + }); + } + + return filtered; + }; + + // 전체 선택/ν•΄μ œ (κ΅¬λ§€μ‹ μ²­λœ 자재 μ œμ™Έ) + const handleSelectAll = () => { + const filteredMaterials = getFilteredAndSortedMaterials(); + const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); + + if (selectedMaterials.size === selectableMaterials.length) { + setSelectedMaterials(new Set()); + } else { + setSelectedMaterials(new Set(selectableMaterials.map(m => m.id))); + } + }; + + // κ°œλ³„ 선택 (κ΅¬λ§€μ‹ μ²­λœ μžμž¬λŠ” 선택 λΆˆκ°€) + const handleMaterialSelect = (materialId) => { + if (purchasedMaterials.has(materialId)) { + return; // κ΅¬λ§€μ‹ μ²­λœ μžμž¬λŠ” 선택 λΆˆκ°€ + } + + const newSelected = new Set(selectedMaterials); + if (newSelected.has(materialId)) { + newSelected.delete(materialId); + } else { + newSelected.add(materialId); + } + setSelectedMaterials(newSelected); + }; + + // μ—‘μ…€ 내보내기 + const handleExportToExcel = async () => { + const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id)); + if (selectedMaterialsData.length === 0) { + alert('내보낼 자재λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”.'); + return; + } + + const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const excelFileName = `FITTING_Materials_${timestamp}.xlsx`; + + const dataWithRequirements = selectedMaterialsData.map(material => ({ + ...material, + user_requirement: userRequirements[material.id] || '' + })); + + try { + await api.post('/files/save-excel', { + file_id: fileId, + category: 'FITTING', + materials: dataWithRequirements, + filename: excelFileName, + user_id: user?.id + }); + + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'FITTING', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + + alert('μ—‘μ…€ 파일이 μƒμ„±λ˜κ³  μ„œλ²„μ— μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + } catch (error) { + console.error('μ—‘μ…€ μ €μž₯ μ‹€νŒ¨:', error); + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'FITTING', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + } + }; + + const filteredMaterials = getFilteredAndSortedMaterials(); + + // ν•„ν„° 헀더 μ»΄ν¬λ„ŒνŠΈ + const FilterableHeader = ({ sortKey, filterKey, children }) => ( +
+
+ handleSort(sortKey)} + style={{ cursor: 'pointer', flex: 1 }} + > + {children} + {sortConfig.key === sortKey && ( + + {sortConfig.direction === 'asc' ? '↑' : '↓'} + + )} + + +
+ {showFilterDropdown === filterKey && ( +
+ setColumnFilters({ + ...columnFilters, + [filterKey]: e.target.value + })} + style={{ + width: '100%', + padding: '4px 8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px' + }} + autoFocus + /> +
+ )} +
+ ); + + return ( +
+ {/* 헀더 */} +
+
+

+ Fitting Materials +

+

+ {filteredMaterials.length} items β€’ {selectedMaterials.size} selected +

+
+ +
+ + + +
+
+ + {/* ν…Œμ΄λΈ” */} +
+ {/* 헀더 */} +
+
+ { + const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); + return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0; + })()} + onChange={handleSelectAll} + style={{ cursor: 'pointer' }} + /> +
+
Type
+
Size
+
Pressure
+
Schedule
+
Material Grade
+
Quantity
+
Unit
+
User Requirement
+
+ + {/* 데이터 ν–‰λ“€ */} +
+ {filteredMaterials.map((material, index) => { + const info = parseFittingInfo(material); + const isSelected = selectedMaterials.has(material.id); + const isPurchased = purchasedMaterials.has(material.id); + + return ( +
{ + if (!isSelected && !isPurchased) { + e.target.style.background = '#f8fafc'; + } + }} + onMouseLeave={(e) => { + if (!isSelected && !isPurchased) { + e.target.style.background = 'white'; + } + }} + > +
+ handleMaterialSelect(material.id)} + disabled={isPurchased} + style={{ + cursor: isPurchased ? 'not-allowed' : 'pointer', + opacity: isPurchased ? 0.5 : 1 + }} + /> +
+
+ {info.subtype} + {isPurchased && ( + + PURCHASED + + )} +
+
+ {info.size} +
+
+ {info.pressure} +
+
+ {info.schedule} +
+
+ {info.grade} +
+
+ {info.quantity} +
+
+ {info.unit} +
+
+ setUserRequirements({ + ...userRequirements, + [material.id]: e.target.value + })} + placeholder="Enter requirement..." + style={{ + width: '100%', + padding: '6px 8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px' + }} + /> +
+
+ ); + })} +
+
+ + {filteredMaterials.length === 0 && ( +
+
βš™οΈ
+
+ No Fitting Materials Found +
+
+ {Object.keys(columnFilters).some(key => columnFilters[key]) + ? 'Try adjusting your filters' + : 'No fitting materials available in this BOM'} +
+
+ )} +
+ ); +}; + +export default FittingMaterialsView; diff --git a/frontend/src/components/bom/materials/FlangeMaterialsView.jsx b/frontend/src/components/bom/materials/FlangeMaterialsView.jsx new file mode 100644 index 0000000..e9f550f --- /dev/null +++ b/frontend/src/components/bom/materials/FlangeMaterialsView.jsx @@ -0,0 +1,512 @@ +import React, { useState } from 'react'; +import { exportMaterialsToExcel } from '../../../utils/excelExport'; +import api from '../../../api'; +import { FilterableHeader, MaterialTable } from '../shared'; + +const FlangeMaterialsView = ({ + materials, + selectedMaterials, + setSelectedMaterials, + userRequirements, + setUserRequirements, + purchasedMaterials, + fileId, + user, + onNavigate +}) => { + const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); + const [columnFilters, setColumnFilters] = useState({}); + const [showFilterDropdown, setShowFilterDropdown] = useState(null); + + // ν”Œλžœμ§€ 정보 νŒŒμ‹± + const parseFlangeInfo = (material) => { + const description = material.original_description || ''; + const flangeDetails = material.flange_details || {}; + + const flangeTypeMap = { + 'WN': 'WELD NECK FLANGE', + 'WELD_NECK': 'WELD NECK FLANGE', + 'SO': 'SLIP ON FLANGE', + 'SLIP_ON': 'SLIP ON FLANGE', + 'SW': 'SOCKET WELD FLANGE', + 'SOCKET_WELD': 'SOCKET WELD FLANGE', + 'BLIND': 'BLIND FLANGE', + 'REDUCING': 'REDUCING FLANGE', + 'ORIFICE': 'ORIFICE FLANGE', + 'SPECTACLE': 'SPECTACLE BLIND', + 'PADDLE': 'PADDLE BLIND', + 'SPACER': 'SPACER' + }; + + const facingTypeMap = { + 'RF': 'RAISED FACE', + 'RAISED_FACE': 'RAISED FACE', + 'FF': 'FLAT FACE', + 'FLAT_FACE': 'FLAT FACE', + 'RTJ': 'RING TYPE JOINT', + 'RING_TYPE_JOINT': 'RING TYPE JOINT' + }; + + const rawFlangeType = flangeDetails.flange_type || ''; + const rawFacingType = flangeDetails.facing_type || ''; + + let displayType = flangeTypeMap[rawFlangeType] || rawFlangeType || '-'; + let facingType = facingTypeMap[rawFacingType] || rawFacingType || '-'; + + // Descriptionμ—μ„œ μΆ”μΆœ + if (displayType === '-') { + const desc = description.toUpperCase(); + if (desc.includes('ORIFICE')) { + displayType = 'ORIFICE FLANGE'; + } else if (desc.includes('SPECTACLE')) { + displayType = 'SPECTACLE BLIND'; + } else if (desc.includes('PADDLE')) { + displayType = 'PADDLE BLIND'; + } else if (desc.includes('SPACER')) { + displayType = 'SPACER'; + } else if (desc.includes('REDUCING') || desc.includes('RED')) { + displayType = 'REDUCING FLANGE'; + } else if (desc.includes('BLIND')) { + displayType = 'BLIND FLANGE'; + } else if (desc.includes('WN')) { + displayType = 'WELD NECK FLANGE'; + } else if (desc.includes('SO')) { + displayType = 'SLIP ON FLANGE'; + } else if (desc.includes('SW')) { + displayType = 'SOCKET WELD FLANGE'; + } else { + displayType = 'FLANGE'; + } + } + + if (facingType === '-') { + const desc = description.toUpperCase(); + if (desc.includes('RF')) { + facingType = 'RAISED FACE'; + } else if (desc.includes('FF')) { + facingType = 'FLAT FACE'; + } else if (desc.includes('RTJ')) { + facingType = 'RING TYPE JOINT'; + } + } + + // 원본 μ„€λͺ…μ—μ„œ μŠ€μΌ€μ€„ μΆ”μΆœ + let schedule = '-'; + const upperDesc = description.toUpperCase(); + + // SCH 40, SCH 80 λ“±μ˜ νŒ¨ν„΄ μ°ΎκΈ° + if (upperDesc.includes('SCH')) { + const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i); + if (schMatch && schMatch[1]) { + schedule = `SCH ${schMatch[1]}`; + } + } + + // μ••λ ₯ λ“±κΈ‰ μΆ”μΆœ + let pressure = '-'; + const pressureMatch = description.match(/(\d+)LB/i); + if (pressureMatch) { + pressure = `${pressureMatch[1]}LB`; + } + + return { + type: 'FLANGE', + subtype: displayType, // ν’€λ„€μž„ ν”Œλžœμ§€ νƒ€μž… + facing: facingType, // μƒˆλ‘œ μΆ”κ°€: λλ‹¨μ²˜λ¦¬ 정보 + size: material.size_spec || '-', + pressure: flangeDetails.pressure_rating || pressure, + schedule: schedule, + grade: material.full_material_grade || material.material_grade || '-', + quantity: Math.round(material.quantity || 0), + unit: '개', + isFlange: true // ν”Œλžœμ§€ κ΅¬λΆ„μš© ν”Œλž˜κ·Έ + }; + }; + + // μ •λ ¬ 처리 + const handleSort = (key) => { + let direction = 'asc'; + if (sortConfig.key === key && sortConfig.direction === 'asc') { + direction = 'desc'; + } + setSortConfig({ key, direction }); + }; + + // ν•„ν„°λ§λœ 및 μ •λ ¬λœ 자재 λͺ©λ‘ + const getFilteredAndSortedMaterials = () => { + let filtered = materials.filter(material => { + return Object.entries(columnFilters).every(([key, filterValue]) => { + if (!filterValue) return true; + const info = parseFlangeInfo(material); + const value = info[key]?.toString().toLowerCase() || ''; + return value.includes(filterValue.toLowerCase()); + }); + }); + + if (sortConfig.key) { + filtered.sort((a, b) => { + const aInfo = parseFlangeInfo(a); + const bInfo = parseFlangeInfo(b); + const aValue = aInfo[sortConfig.key] || ''; + const bValue = bInfo[sortConfig.key] || ''; + + if (sortConfig.direction === 'asc') { + return aValue > bValue ? 1 : -1; + } else { + return aValue < bValue ? 1 : -1; + } + }); + } + + return filtered; + }; + + // 전체 선택/ν•΄μ œ + const handleSelectAll = () => { + const filteredMaterials = getFilteredAndSortedMaterials(); + if (selectedMaterials.size === filteredMaterials.length) { + setSelectedMaterials(new Set()); + } else { + setSelectedMaterials(new Set(filteredMaterials.map(m => m.id))); + } + }; + + // κ°œλ³„ 선택 + const handleMaterialSelect = (materialId) => { + const newSelected = new Set(selectedMaterials); + if (newSelected.has(materialId)) { + newSelected.delete(materialId); + } else { + newSelected.add(materialId); + } + setSelectedMaterials(newSelected); + }; + + // μ—‘μ…€ 내보내기 + const handleExportToExcel = async () => { + const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id)); + if (selectedMaterialsData.length === 0) { + alert('내보낼 자재λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”.'); + return; + } + + const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const excelFileName = `FLANGE_Materials_${timestamp}.xlsx`; + + const dataWithRequirements = selectedMaterialsData.map(material => ({ + ...material, + user_requirement: userRequirements[material.id] || '' + })); + + try { + await api.post('/files/save-excel', { + file_id: fileId, + category: 'FLANGE', + materials: dataWithRequirements, + filename: excelFileName, + user_id: user?.id + }); + + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'FLANGE', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + + alert('μ—‘μ…€ 파일이 μƒμ„±λ˜κ³  μ„œλ²„μ— μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + } catch (error) { + console.error('μ—‘μ…€ μ €μž₯ μ‹€νŒ¨:', error); + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'FLANGE', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + } + }; + + const filteredMaterials = getFilteredAndSortedMaterials(); + + // ν•„ν„° 헀더 μ»΄ν¬λ„ŒνŠΈ + const FilterableHeader = ({ sortKey, filterKey, children }) => ( +
+
+ handleSort(sortKey)} + style={{ cursor: 'pointer', flex: 1 }} + > + {children} + {sortConfig.key === sortKey && ( + + {sortConfig.direction === 'asc' ? '↑' : '↓'} + + )} + + +
+ {showFilterDropdown === filterKey && ( +
+ setColumnFilters({ + ...columnFilters, + [filterKey]: e.target.value + })} + style={{ + width: '100%', + padding: '4px 8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px' + }} + autoFocus + /> +
+ )} +
+ ); + + return ( +
+ {/* 헀더 */} +
+
+

+ Flange Materials +

+

+ {filteredMaterials.length} items β€’ {selectedMaterials.size} selected +

+
+ +
+ + + +
+
+ + {/* ν…Œμ΄λΈ” */} +
+ {/* 헀더 */} +
+
+ 0} + onChange={handleSelectAll} + style={{ cursor: 'pointer' }} + /> +
+ Type + Facing + Size + Pressure + Schedule + Material Grade + Quantity +
Unit
+
User Requirement
+
+ + {/* 데이터 ν–‰λ“€ */} +
+ {filteredMaterials.map((material, index) => { + const info = parseFlangeInfo(material); + const isSelected = selectedMaterials.has(material.id); + const isPurchased = purchasedMaterials.has(material.id); + + return ( +
{ + if (!isSelected && !isPurchased) { + e.target.style.background = '#f8fafc'; + } + }} + onMouseLeave={(e) => { + if (!isSelected && !isPurchased) { + e.target.style.background = 'white'; + } + }} + > +
+ handleMaterialSelect(material.id)} + style={{ cursor: 'pointer' }} + /> +
+
+ FLANGE + {isPurchased && ( + + PURCHASED + + )} +
+
+ {info.subtype} +
+
+ {info.facing} +
+
+ {info.size} +
+
+ {info.pressure} +
+
+ {info.schedule} +
+
+ {info.grade} +
+
+ {info.quantity} +
+
+ {info.unit} +
+
+ setUserRequirements({ + ...userRequirements, + [material.id]: e.target.value + })} + placeholder="Enter requirement..." + style={{ + width: '100%', + padding: '6px 8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px' + }} + /> +
+
+ ); + })} +
+
+ + {filteredMaterials.length === 0 && ( +
+
πŸ”©
+
+ No Flange Materials Found +
+
+ {Object.keys(columnFilters).some(key => columnFilters[key]) + ? 'Try adjusting your filters' + : 'No flange materials available in this BOM'} +
+
+ )} +
+ ); +}; + +export default FlangeMaterialsView; diff --git a/frontend/src/components/bom/materials/GasketMaterialsView.jsx b/frontend/src/components/bom/materials/GasketMaterialsView.jsx new file mode 100644 index 0000000..13725ec --- /dev/null +++ b/frontend/src/components/bom/materials/GasketMaterialsView.jsx @@ -0,0 +1,396 @@ +import React, { useState } from 'react'; +import { exportMaterialsToExcel } from '../../../utils/excelExport'; +import api from '../../../api'; +import { FilterableHeader } from '../shared'; + +const GasketMaterialsView = ({ + materials, + selectedMaterials, + setSelectedMaterials, + userRequirements, + setUserRequirements, + purchasedMaterials, + fileId, + user +}) => { + const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); + const [columnFilters, setColumnFilters] = useState({}); + const [showFilterDropdown, setShowFilterDropdown] = useState(null); + + const parseGasketInfo = (material) => { + const qty = Math.round(material.quantity || 0); + const purchaseQty = Math.ceil(qty * 1.05 / 5) * 5; // 5% μ—¬μœ μœ¨ + 5의 배수 + + // original_descriptionμ—μ„œ 재질 정보 νŒŒμ‹± (κΈ°μ‘΄ NewMaterialsPage와 동일) + const description = material.original_description || ''; + let materialStructure = '-'; // H/F/I/O λΆ€λΆ„ + let materialDetail = '-'; // SS304/GRAPHITE/CS/CS λΆ€λΆ„ + + // H/F/I/O와 재질 상세 정보 μΆ”μΆœ + const materialMatch = description.match(/H\/F\/I\/O\s+(.+?)(?:,|$)/); + if (materialMatch) { + materialStructure = 'H/F/I/O'; + materialDetail = materialMatch[1].trim(); + // λ‘κ»˜ 정보 제거 (별도 μΆ”μΆœ) + materialDetail = materialDetail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim(); + } + + // μ••λ ₯ 정보 μΆ”μΆœ + let pressure = '-'; + const pressureMatch = description.match(/(\d+LB)/); + if (pressureMatch) { + pressure = pressureMatch[1]; + } + + // λ‘κ»˜ 정보 μΆ”μΆœ + let thickness = '-'; + const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i); + if (thicknessMatch) { + thickness = thicknessMatch[1] + 'mm'; + } + + return { + type: 'GASKET', + subtype: 'SWG', // 항상 SWG둜 ν‘œμ‹œ + size: material.size_spec || '-', + pressure: pressure, + schedule: thickness, // λ‘κ»˜λ₯Ό schedule 열에 ν‘œμ‹œ + materialStructure: materialStructure, + materialDetail: materialDetail, + thickness: thickness, + grade: materialDetail, // 재질 상세λ₯Ό grade둜 ν‘œμ‹œ + quantity: purchaseQty, + unit: '개', + isGasket: true + }; + }; + + // μ •λ ¬ 처리 + const handleSort = (key) => { + let direction = 'asc'; + if (sortConfig.key === key && sortConfig.direction === 'asc') { + direction = 'desc'; + } + setSortConfig({ key, direction }); + }; + + // ν•„ν„°λ§λœ 및 μ •λ ¬λœ 자재 λͺ©λ‘ + const getFilteredAndSortedMaterials = () => { + let filtered = materials.filter(material => { + return Object.entries(columnFilters).every(([key, filterValue]) => { + if (!filterValue) return true; + const info = parseGasketInfo(material); + const value = info[key]?.toString().toLowerCase() || ''; + return value.includes(filterValue.toLowerCase()); + }); + }); + + if (sortConfig.key) { + filtered.sort((a, b) => { + const aInfo = parseGasketInfo(a); + const bInfo = parseGasketInfo(b); + const aValue = aInfo[sortConfig.key] || ''; + const bValue = bInfo[sortConfig.key] || ''; + + if (sortConfig.direction === 'asc') { + return aValue > bValue ? 1 : -1; + } else { + return aValue < bValue ? 1 : -1; + } + }); + } + + return filtered; + }; + + // 전체 선택/ν•΄μ œ (κ΅¬λ§€μ‹ μ²­λœ 자재 μ œμ™Έ) + const handleSelectAll = () => { + const filteredMaterials = getFilteredAndSortedMaterials(); + const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); + + if (selectedMaterials.size === selectableMaterials.length) { + setSelectedMaterials(new Set()); + } else { + setSelectedMaterials(new Set(selectableMaterials.map(m => m.id))); + } + }; + + // κ°œλ³„ 선택 (κ΅¬λ§€μ‹ μ²­λœ μžμž¬λŠ” 선택 λΆˆκ°€) + const handleMaterialSelect = (materialId) => { + if (purchasedMaterials.has(materialId)) { + return; // κ΅¬λ§€μ‹ μ²­λœ μžμž¬λŠ” 선택 λΆˆκ°€ + } + + const newSelected = new Set(selectedMaterials); + if (newSelected.has(materialId)) { + newSelected.delete(materialId); + } else { + newSelected.add(materialId); + } + setSelectedMaterials(newSelected); + }; + + // μ—‘μ…€ 내보내기 + const handleExportToExcel = async () => { + const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id)); + if (selectedMaterialsData.length === 0) { + alert('내보낼 자재λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”.'); + return; + } + + const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const excelFileName = `GASKET_Materials_${timestamp}.xlsx`; + + const dataWithRequirements = selectedMaterialsData.map(material => ({ + ...material, + user_requirement: userRequirements[material.id] || '' + })); + + try { + await api.post('/files/save-excel', { + file_id: fileId, + category: 'GASKET', + materials: dataWithRequirements, + filename: excelFileName, + user_id: user?.id + }); + + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'GASKET', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + + alert('μ—‘μ…€ 파일이 μƒμ„±λ˜κ³  μ„œλ²„μ— μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + } catch (error) { + console.error('μ—‘μ…€ μ €μž₯ μ‹€νŒ¨:', error); + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'GASKET', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + } + }; + + const filteredMaterials = getFilteredAndSortedMaterials(); + + return ( +
+ {/* 헀더 */} +
+
+

+ Gasket Materials +

+

+ {filteredMaterials.length} items β€’ {selectedMaterials.size} selected +

+
+ +
+ + + +
+
+ + {/* ν…Œμ΄λΈ” */} +
+ {/* 헀더 */} +
+
+ { + const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); + return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0; + })()} + onChange={handleSelectAll} + style={{ cursor: 'pointer' }} + /> +
+ Type + Size + Pressure + Thickness + Material Grade + Quantity +
Unit
+
User Requirement
+
+ + {/* 데이터 ν–‰λ“€ */} +
+ {filteredMaterials.map((material, index) => { + const info = parseGasketInfo(material); + const isSelected = selectedMaterials.has(material.id); + const isPurchased = purchasedMaterials.has(material.id); + + return ( +
{ + if (!isSelected && !isPurchased) { + e.target.style.background = '#f8fafc'; + } + }} + onMouseLeave={(e) => { + if (!isSelected && !isPurchased) { + e.target.style.background = 'white'; + } + }} + > +
+ handleMaterialSelect(material.id)} + disabled={isPurchased} + style={{ + cursor: isPurchased ? 'not-allowed' : 'pointer', + opacity: isPurchased ? 0.5 : 1 + }} + /> +
+
+ {info.subtype} + {isPurchased && ( + + PURCHASED + + )} +
+
+ {info.size} +
+
+ {info.pressure} +
+
+ {info.schedule} +
+
+ {info.grade} +
+
+ {info.quantity} +
+
+ {info.unit} +
+
+ setUserRequirements({ + ...userRequirements, + [material.id]: e.target.value + })} + placeholder="Enter requirement..." + style={{ + width: '100%', + padding: '6px 8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px' + }} + /> +
+
+ ); + })} +
+
+ + {filteredMaterials.length === 0 && ( +
+
β­•
+
+ No Gasket Materials Found +
+
+ {Object.keys(columnFilters).some(key => columnFilters[key]) + ? 'Try adjusting your filters' + : 'No gasket materials available in this BOM'} +
+
+ )} +
+ ); +}; + +export default GasketMaterialsView; diff --git a/frontend/src/components/bom/materials/PipeMaterialsView.jsx b/frontend/src/components/bom/materials/PipeMaterialsView.jsx new file mode 100644 index 0000000..742524a --- /dev/null +++ b/frontend/src/components/bom/materials/PipeMaterialsView.jsx @@ -0,0 +1,525 @@ +import React, { useState } from 'react'; +import { exportMaterialsToExcel } from '../../../utils/excelExport'; +import api from '../../../api'; +import { FilterableHeader, MaterialTable } from '../shared'; + +const PipeMaterialsView = ({ + materials, + selectedMaterials, + setSelectedMaterials, + userRequirements, + setUserRequirements, + purchasedMaterials, + fileId, + user, + onNavigate +}) => { + const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); + const [columnFilters, setColumnFilters] = useState({}); + const [showFilterDropdown, setShowFilterDropdown] = useState(null); + + // νŒŒμ΄ν”„ ꡬ맀 μˆ˜λŸ‰ 계산 (κΈ°μ‘΄ 둜직 볡원) + const calculatePipePurchase = (material) => { + const pipeDetails = material.pipe_details || {}; + const totalLength = pipeDetails.length || material.length || 0; + const standardLength = 6; // ν‘œμ€€ 6M + + const purchaseCount = Math.ceil(totalLength / standardLength); + const totalPurchaseLength = purchaseCount * standardLength; + const wasteLength = totalPurchaseLength - totalLength; + const wastePercentage = totalLength > 0 ? (wasteLength / totalLength * 100) : 0; + + return { + totalLength, + standardLength, + purchaseCount, + totalPurchaseLength, + wasteLength, + wastePercentage + }; + }; + + // νŒŒμ΄ν”„ 정보 νŒŒμ‹± (κΈ°μ‘΄ 상세 둜직 볡원) + const parsePipeInfo = (material) => { + const calc = calculatePipePurchase(material); + const pipeDetails = material.pipe_details || {}; + + return { + type: 'PIPE', + subtype: pipeDetails.manufacturing_method || 'SMLS', + size: material.size_spec || '-', + schedule: pipeDetails.schedule || material.schedule || '-', + grade: material.full_material_grade || material.material_grade || '-', + length: calc.totalLength, + quantity: calc.purchaseCount, + unit: 'λ³Έ', + details: calc, + isPipe: true + }; + }; + + // μ •λ ¬ 처리 + const handleSort = (key) => { + let direction = 'asc'; + if (sortConfig.key === key && sortConfig.direction === 'asc') { + direction = 'desc'; + } + setSortConfig({ key, direction }); + }; + + // ν•„ν„°λ§λœ 및 μ •λ ¬λœ 자재 λͺ©λ‘ + const getFilteredAndSortedMaterials = () => { + let filtered = materials.filter(material => { + return Object.entries(columnFilters).every(([key, filterValue]) => { + if (!filterValue) return true; + const info = parsePipeInfo(material); + const value = info[key]?.toString().toLowerCase() || ''; + return value.includes(filterValue.toLowerCase()); + }); + }); + + if (sortConfig.key) { + filtered.sort((a, b) => { + const aInfo = parsePipeInfo(a); + const bInfo = parsePipeInfo(b); + const aValue = aInfo[sortConfig.key] || ''; + const bValue = bInfo[sortConfig.key] || ''; + + if (sortConfig.direction === 'asc') { + return aValue > bValue ? 1 : -1; + } else { + return aValue < bValue ? 1 : -1; + } + }); + } + + return filtered; + }; + + // 전체 선택/ν•΄μ œ + const handleSelectAll = () => { + const filteredMaterials = getFilteredAndSortedMaterials(); + if (selectedMaterials.size === filteredMaterials.length) { + setSelectedMaterials(new Set()); + } else { + setSelectedMaterials(new Set(filteredMaterials.map(m => m.id))); + } + }; + + // κ°œλ³„ 선택 + const handleMaterialSelect = (materialId) => { + const newSelected = new Set(selectedMaterials); + if (newSelected.has(materialId)) { + newSelected.delete(materialId); + } else { + newSelected.add(materialId); + } + setSelectedMaterials(newSelected); + }; + + // μ—‘μ…€ 내보내기 + const handleExportToExcel = async () => { + const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id)); + if (selectedMaterialsData.length === 0) { + alert('내보낼 자재λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”.'); + return; + } + + const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const excelFileName = `PIPE_Materials_${timestamp}.xlsx`; + + // μ‚¬μš©μž μš”κ΅¬μ‚¬ν•­ 포함 + const dataWithRequirements = selectedMaterialsData.map(material => ({ + ...material, + user_requirement: userRequirements[material.id] || '' + })); + + try { + // μ„œλ²„μ— μ—‘μ…€ 파일 μ €μž₯ μš”μ²­ + await api.post('/files/save-excel', { + file_id: fileId, + category: 'PIPE', + materials: dataWithRequirements, + filename: excelFileName, + user_id: user?.id + }); + + // ν΄λΌμ΄μ–ΈνŠΈμ—μ„œ λ‹€μš΄λ‘œλ“œ + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'PIPE', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + + alert('μ—‘μ…€ 파일이 μƒμ„±λ˜κ³  μ„œλ²„μ— μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + } catch (error) { + console.error('μ—‘μ…€ μ €μž₯ μ‹€νŒ¨:', error); + // μ‹€νŒ¨ν•΄λ„ λ‹€μš΄λ‘œλ“œλŠ” μ§„ν–‰ + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'PIPE', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + } + }; + + const filteredMaterials = getFilteredAndSortedMaterials(); + + // ν•„ν„° 헀더 μ»΄ν¬λ„ŒνŠΈ + const FilterableHeader = ({ sortKey, filterKey, children }) => ( +
+
+ handleSort(sortKey)} + style={{ cursor: 'pointer', flex: 1 }} + > + {children} + {sortConfig.key === sortKey && ( + + {sortConfig.direction === 'asc' ? '↑' : '↓'} + + )} + + +
+ {showFilterDropdown === filterKey && ( +
+ setColumnFilters({ + ...columnFilters, + [filterKey]: e.target.value + })} + style={{ + width: '100%', + padding: '4px 8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px' + }} + autoFocus + /> +
+ )} +
+ ); + + return ( +
+ {/* 헀더 */} +
+
+

+ Pipe Materials +

+

+ {filteredMaterials.length} items β€’ {selectedMaterials.size} selected +

+
+ +
+ + + +
+
+ + {/* ν…Œμ΄λΈ” */} +
+ {/* 헀더 */} +
+
+ 0} + onChange={handleSelectAll} + style={{ cursor: 'pointer' }} + /> +
+ + Type + + + Subtype + + + Size + + + Schedule + + + Material Grade + + + Length (M) + + + Quantity + +
Unit
+
User Requirement
+
+ + {/* 데이터 ν–‰λ“€ */} +
+ {filteredMaterials.map((material, index) => { + const info = parsePipeInfo(material); + const isSelected = selectedMaterials.has(material.id); + const isPurchased = purchasedMaterials.has(material.id); + + return ( +
{ + if (!isSelected && !isPurchased) { + e.target.style.background = '#f8fafc'; + } + }} + onMouseLeave={(e) => { + if (!isSelected && !isPurchased) { + e.target.style.background = 'white'; + } + }} + > +
+ handleMaterialSelect(material.id)} + style={{ cursor: 'pointer' }} + /> +
+
+ PIPE + {isPurchased && ( + + PURCHASED + + )} +
+
+ {info.subtype} +
+
+ {info.size} +
+
+ {info.schedule} +
+
+ {info.grade} +
+
+ {info.length.toFixed(2)} +
+
+ {info.quantity} +
+
+ {info.unit} +
+
+ setUserRequirements({ + ...userRequirements, + [material.id]: e.target.value + })} + placeholder="Enter requirement..." + style={{ + width: '100%', + padding: '6px 8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px' + }} + /> +
+
+ ); + })} +
+
+ + {filteredMaterials.length === 0 && ( +
+
πŸ”§
+
+ No Pipe Materials Found +
+
+ {Object.keys(columnFilters).some(key => columnFilters[key]) + ? 'Try adjusting your filters' + : 'No pipe materials available in this BOM'} +
+
+ )} +
+ ); +}; + +export default PipeMaterialsView; diff --git a/frontend/src/components/bom/materials/SupportMaterialsView.jsx b/frontend/src/components/bom/materials/SupportMaterialsView.jsx new file mode 100644 index 0000000..7e4fd51 --- /dev/null +++ b/frontend/src/components/bom/materials/SupportMaterialsView.jsx @@ -0,0 +1,377 @@ +import React, { useState } from 'react'; +import { exportMaterialsToExcel } from '../../../utils/excelExport'; +import api from '../../../api'; +import { FilterableHeader } from '../shared'; + +const SupportMaterialsView = ({ + materials, + selectedMaterials, + setSelectedMaterials, + userRequirements, + setUserRequirements, + purchasedMaterials, + fileId, + user +}) => { + const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); + const [columnFilters, setColumnFilters] = useState({}); + const [showFilterDropdown, setShowFilterDropdown] = useState(null); + + const parseSupportInfo = (material) => { + const desc = material.original_description || ''; + const isUrethaneBlock = desc.includes('URETHANE') || desc.includes('BLOCK SHOE') || desc.includes('μš°λ ˆνƒ„'); + const isClamp = desc.includes('CLAMP') || desc.includes('ν΄λž¨ν”„'); + + let subtypeText = ''; + if (isUrethaneBlock) { + subtypeText = 'μš°λ ˆνƒ„λΈ”λŸ­μŠˆ'; + } else if (isClamp) { + subtypeText = 'ν΄λž¨ν”„'; + } else { + subtypeText = '유볼트'; + } + + return { + type: 'SUPPORT', + subtype: subtypeText, + size: material.main_nom || material.size_inch || material.size_spec || '-', + pressure: '-', // μ„œν¬νŠΈλŠ” μ••λ ₯ λ“±κΈ‰ μ—†μŒ + schedule: '-', // μ„œν¬νŠΈλŠ” μŠ€μΌ€μ€„ μ—†μŒ + description: material.original_description || '-', + grade: material.full_material_grade || material.material_grade || '-', + additionalReq: '-', + quantity: Math.round(material.quantity || 0), + unit: '개', + isSupport: true + }; + }; + + // μ •λ ¬ 처리 + const handleSort = (key) => { + let direction = 'asc'; + if (sortConfig.key === key && sortConfig.direction === 'asc') { + direction = 'desc'; + } + setSortConfig({ key, direction }); + }; + + // ν•„ν„°λ§λœ 및 μ •λ ¬λœ 자재 λͺ©λ‘ + const getFilteredAndSortedMaterials = () => { + let filtered = materials.filter(material => { + return Object.entries(columnFilters).every(([key, filterValue]) => { + if (!filterValue) return true; + const info = parseSupportInfo(material); + const value = info[key]?.toString().toLowerCase() || ''; + return value.includes(filterValue.toLowerCase()); + }); + }); + + if (sortConfig.key) { + filtered.sort((a, b) => { + const aInfo = parseSupportInfo(a); + const bInfo = parseSupportInfo(b); + const aValue = aInfo[sortConfig.key] || ''; + const bValue = bInfo[sortConfig.key] || ''; + + if (sortConfig.direction === 'asc') { + return aValue > bValue ? 1 : -1; + } else { + return aValue < bValue ? 1 : -1; + } + }); + } + + return filtered; + }; + + // 전체 선택/ν•΄μ œ (κ΅¬λ§€μ‹ μ²­λœ 자재 μ œμ™Έ) + const handleSelectAll = () => { + const filteredMaterials = getFilteredAndSortedMaterials(); + const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); + + if (selectedMaterials.size === selectableMaterials.length) { + setSelectedMaterials(new Set()); + } else { + setSelectedMaterials(new Set(selectableMaterials.map(m => m.id))); + } + }; + + // κ°œλ³„ 선택 (κ΅¬λ§€μ‹ μ²­λœ μžμž¬λŠ” 선택 λΆˆκ°€) + const handleMaterialSelect = (materialId) => { + if (purchasedMaterials.has(materialId)) { + return; // κ΅¬λ§€μ‹ μ²­λœ μžμž¬λŠ” 선택 λΆˆκ°€ + } + + const newSelected = new Set(selectedMaterials); + if (newSelected.has(materialId)) { + newSelected.delete(materialId); + } else { + newSelected.add(materialId); + } + setSelectedMaterials(newSelected); + }; + + // μ—‘μ…€ 내보내기 + const handleExportToExcel = async () => { + const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id)); + if (selectedMaterialsData.length === 0) { + alert('내보낼 자재λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”.'); + return; + } + + const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const excelFileName = `SUPPORT_Materials_${timestamp}.xlsx`; + + const dataWithRequirements = selectedMaterialsData.map(material => ({ + ...material, + user_requirement: userRequirements[material.id] || '' + })); + + try { + await api.post('/files/save-excel', { + file_id: fileId, + category: 'SUPPORT', + materials: dataWithRequirements, + filename: excelFileName, + user_id: user?.id + }); + + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'SUPPORT', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + + alert('μ—‘μ…€ 파일이 μƒμ„±λ˜κ³  μ„œλ²„μ— μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + } catch (error) { + console.error('μ—‘μ…€ μ €μž₯ μ‹€νŒ¨:', error); + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'SUPPORT', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + } + }; + + const filteredMaterials = getFilteredAndSortedMaterials(); + + return ( +
+ {/* 헀더 */} +
+
+

+ Support Materials +

+

+ {filteredMaterials.length} items β€’ {selectedMaterials.size} selected +

+
+ +
+ + + +
+
+ + {/* ν…Œμ΄λΈ” */} +
+ {/* 헀더 */} +
+
+ { + const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); + return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0; + })()} + onChange={handleSelectAll} + style={{ cursor: 'pointer' }} + /> +
+ Type + Size + Pressure + Schedule + Material Grade + Quantity +
Unit
+
User Requirement
+
+ + {/* 데이터 ν–‰λ“€ */} +
+ {filteredMaterials.map((material, index) => { + const info = parseSupportInfo(material); + const isSelected = selectedMaterials.has(material.id); + const isPurchased = purchasedMaterials.has(material.id); + + return ( +
{ + if (!isSelected && !isPurchased) { + e.target.style.background = '#f8fafc'; + } + }} + onMouseLeave={(e) => { + if (!isSelected && !isPurchased) { + e.target.style.background = 'white'; + } + }} + > +
+ handleMaterialSelect(material.id)} + disabled={isPurchased} + style={{ + cursor: isPurchased ? 'not-allowed' : 'pointer', + opacity: isPurchased ? 0.5 : 1 + }} + /> +
+
+ {info.subtype} + {isPurchased && ( + + PURCHASED + + )} +
+
+ {info.size} +
+
+ {info.pressure} +
+
+ {info.schedule} +
+
+ {info.grade} +
+
+ {info.quantity} +
+
+ {info.unit} +
+
+ setUserRequirements({ + ...userRequirements, + [material.id]: e.target.value + })} + placeholder="Enter requirement..." + style={{ + width: '100%', + padding: '6px 8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px' + }} + /> +
+
+ ); + })} +
+
+ + {filteredMaterials.length === 0 && ( +
+
πŸ—οΈ
+
+ No Support Materials Found +
+
+ {Object.keys(columnFilters).some(key => columnFilters[key]) + ? 'Try adjusting your filters' + : 'No support materials available in this BOM'} +
+
+ )} +
+ ); +}; + +export default SupportMaterialsView; diff --git a/frontend/src/components/bom/materials/ValveMaterialsView.jsx b/frontend/src/components/bom/materials/ValveMaterialsView.jsx new file mode 100644 index 0000000..f9a0ee8 --- /dev/null +++ b/frontend/src/components/bom/materials/ValveMaterialsView.jsx @@ -0,0 +1,403 @@ +import React, { useState } from 'react'; +import { exportMaterialsToExcel } from '../../../utils/excelExport'; +import api from '../../../api'; +import { FilterableHeader } from '../shared'; + +const ValveMaterialsView = ({ + materials, + selectedMaterials, + setSelectedMaterials, + userRequirements, + setUserRequirements, + purchasedMaterials, + fileId, + user +}) => { + const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); + const [columnFilters, setColumnFilters] = useState({}); + const [showFilterDropdown, setShowFilterDropdown] = useState(null); + + const parseValveInfo = (material) => { + const valveDetails = material.valve_details || {}; + const description = material.original_description || ''; + + // 밸브 νƒ€μž… νŒŒμ‹± (GATE, BALL, CHECK, GLOBE λ“±) - κΈ°μ‘΄ NewMaterialsPage와 동일 + let valveType = valveDetails.valve_type || ''; + if (!valveType && description) { + if (description.includes('GATE')) valveType = 'GATE'; + else if (description.includes('BALL')) valveType = 'BALL'; + else if (description.includes('CHECK')) valveType = 'CHECK'; + else if (description.includes('GLOBE')) valveType = 'GLOBE'; + else if (description.includes('BUTTERFLY')) valveType = 'BUTTERFLY'; + else if (description.includes('NEEDLE')) valveType = 'NEEDLE'; + else if (description.includes('RELIEF')) valveType = 'RELIEF'; + } + + // μ—°κ²° 방식 νŒŒμ‹± (FLG, SW, THRD λ“±) - κΈ°μ‘΄ NewMaterialsPage와 동일 + let connectionType = ''; + if (description.includes('FLG')) { + connectionType = 'FLG'; + } else if (description.includes('SW X THRD')) { + connectionType = 'SWΓ—THRD'; + } else if (description.includes('SW')) { + connectionType = 'SW'; + } else if (description.includes('THRD')) { + connectionType = 'THRD'; + } else if (description.includes('BW')) { + connectionType = 'BW'; + } + + // μ••λ ₯ λ“±κΈ‰ νŒŒμ‹± + let pressure = '-'; + const pressureMatch = description.match(/(\d+)LB/i); + if (pressureMatch) { + pressure = `${pressureMatch[1]}LB`; + } + + // μŠ€μΌ€μ€„μ€ λ°ΈλΈŒμ—λŠ” 일반적으둜 μ—†μŒ (κΈ°λ³Έκ°’) + let schedule = '-'; + + return { + type: 'VALVE', + subtype: `${valveType} ${connectionType}`.trim() || 'VALVE', // νƒ€μž…κ³Ό 연결방식 κ²°ν•© + valveType: valveType, + connectionType: connectionType, + size: material.size_spec || '-', + pressure: pressure, + schedule: schedule, + grade: material.material_grade || '-', + quantity: Math.round(material.quantity || 0), + unit: '개', + isValve: true + }; + }; + + // μ •λ ¬ 처리 + const handleSort = (key) => { + let direction = 'asc'; + if (sortConfig.key === key && sortConfig.direction === 'asc') { + direction = 'desc'; + } + setSortConfig({ key, direction }); + }; + + // ν•„ν„°λ§λœ 및 μ •λ ¬λœ 자재 λͺ©λ‘ + const getFilteredAndSortedMaterials = () => { + let filtered = materials.filter(material => { + return Object.entries(columnFilters).every(([key, filterValue]) => { + if (!filterValue) return true; + const info = parseValveInfo(material); + const value = info[key]?.toString().toLowerCase() || ''; + return value.includes(filterValue.toLowerCase()); + }); + }); + + if (sortConfig.key) { + filtered.sort((a, b) => { + const aInfo = parseValveInfo(a); + const bInfo = parseValveInfo(b); + const aValue = aInfo[sortConfig.key] || ''; + const bValue = bInfo[sortConfig.key] || ''; + + if (sortConfig.direction === 'asc') { + return aValue > bValue ? 1 : -1; + } else { + return aValue < bValue ? 1 : -1; + } + }); + } + + return filtered; + }; + + // 전체 선택/ν•΄μ œ (κ΅¬λ§€μ‹ μ²­λœ 자재 μ œμ™Έ) + const handleSelectAll = () => { + const filteredMaterials = getFilteredAndSortedMaterials(); + const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); + + if (selectedMaterials.size === selectableMaterials.length) { + setSelectedMaterials(new Set()); + } else { + setSelectedMaterials(new Set(selectableMaterials.map(m => m.id))); + } + }; + + // κ°œλ³„ 선택 (κ΅¬λ§€μ‹ μ²­λœ μžμž¬λŠ” 선택 λΆˆκ°€) + const handleMaterialSelect = (materialId) => { + if (purchasedMaterials.has(materialId)) { + return; // κ΅¬λ§€μ‹ μ²­λœ μžμž¬λŠ” 선택 λΆˆκ°€ + } + + const newSelected = new Set(selectedMaterials); + if (newSelected.has(materialId)) { + newSelected.delete(materialId); + } else { + newSelected.add(materialId); + } + setSelectedMaterials(newSelected); + }; + + // μ—‘μ…€ 내보내기 + const handleExportToExcel = async () => { + const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id)); + if (selectedMaterialsData.length === 0) { + alert('내보낼 자재λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”.'); + return; + } + + const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const excelFileName = `VALVE_Materials_${timestamp}.xlsx`; + + const dataWithRequirements = selectedMaterialsData.map(material => ({ + ...material, + user_requirement: userRequirements[material.id] || '' + })); + + try { + await api.post('/files/save-excel', { + file_id: fileId, + category: 'VALVE', + materials: dataWithRequirements, + filename: excelFileName, + user_id: user?.id + }); + + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'VALVE', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + + alert('μ—‘μ…€ 파일이 μƒμ„±λ˜κ³  μ„œλ²„μ— μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + } catch (error) { + console.error('μ—‘μ…€ μ €μž₯ μ‹€νŒ¨:', error); + exportMaterialsToExcel(dataWithRequirements, excelFileName, { + category: 'VALVE', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + } + }; + + const filteredMaterials = getFilteredAndSortedMaterials(); + + return ( +
+ {/* 헀더 */} +
+
+

+ Valve Materials +

+

+ {filteredMaterials.length} items β€’ {selectedMaterials.size} selected +

+
+ +
+ + + +
+
+ + {/* ν…Œμ΄λΈ” */} +
+ {/* 헀더 */} +
+
+ { + const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); + return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0; + })()} + onChange={handleSelectAll} + style={{ cursor: 'pointer' }} + /> +
+ Type + Size + Pressure + Schedule + Material Grade + Quantity +
Unit
+
User Requirement
+
+ + {/* 데이터 ν–‰λ“€ */} +
+ {filteredMaterials.map((material, index) => { + const info = parseValveInfo(material); + const isSelected = selectedMaterials.has(material.id); + const isPurchased = purchasedMaterials.has(material.id); + + return ( +
{ + if (!isSelected && !isPurchased) { + e.target.style.background = '#f8fafc'; + } + }} + onMouseLeave={(e) => { + if (!isSelected && !isPurchased) { + e.target.style.background = 'white'; + } + }} + > +
+ handleMaterialSelect(material.id)} + disabled={isPurchased} + style={{ + cursor: isPurchased ? 'not-allowed' : 'pointer', + opacity: isPurchased ? 0.5 : 1 + }} + /> +
+
+ {info.subtype} + {isPurchased && ( + + PURCHASED + + )} +
+
+ {info.size} +
+
+ {info.pressure} +
+
+ {info.schedule} +
+
+ {info.grade} +
+
+ {info.quantity} +
+
+ {info.unit} +
+
+ setUserRequirements({ + ...userRequirements, + [material.id]: e.target.value + })} + placeholder="Enter requirement..." + style={{ + width: '100%', + padding: '6px 8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px' + }} + /> +
+
+ ); + })} +
+
+ + {filteredMaterials.length === 0 && ( +
+
🚰
+
+ No Valve Materials Found +
+
+ {Object.keys(columnFilters).some(key => columnFilters[key]) + ? 'Try adjusting your filters' + : 'No valve materials available in this BOM'} +
+
+ )} +
+ ); +}; + +export default ValveMaterialsView; diff --git a/frontend/src/components/bom/materials/index.js b/frontend/src/components/bom/materials/index.js new file mode 100644 index 0000000..bec7cda --- /dev/null +++ b/frontend/src/components/bom/materials/index.js @@ -0,0 +1,8 @@ +// BOM Materials Components +export { default as PipeMaterialsView } from './PipeMaterialsView'; +export { default as FittingMaterialsView } from './FittingMaterialsView'; +export { default as FlangeMaterialsView } from './FlangeMaterialsView'; +export { default as ValveMaterialsView } from './ValveMaterialsView'; +export { default as GasketMaterialsView } from './GasketMaterialsView'; +export { default as BoltMaterialsView } from './BoltMaterialsView'; +export { default as SupportMaterialsView } from './SupportMaterialsView'; diff --git a/frontend/src/components/bom/shared/FilterableHeader.jsx b/frontend/src/components/bom/shared/FilterableHeader.jsx new file mode 100644 index 0000000..a0a28dc --- /dev/null +++ b/frontend/src/components/bom/shared/FilterableHeader.jsx @@ -0,0 +1,78 @@ +import React from 'react'; + +const FilterableHeader = ({ + sortKey, + filterKey, + children, + sortConfig, + onSort, + columnFilters, + onFilterChange, + showFilterDropdown, + setShowFilterDropdown +}) => { + return ( +
+
+ onSort(sortKey)} + style={{ cursor: 'pointer', flex: 1 }} + > + {children} + {sortConfig.key === sortKey && ( + + {sortConfig.direction === 'asc' ? '↑' : '↓'} + + )} + + +
+ {showFilterDropdown === filterKey && ( +
+ onFilterChange({ + ...columnFilters, + [filterKey]: e.target.value + })} + style={{ + width: '100%', + padding: '4px 8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px' + }} + autoFocus + /> +
+ )} +
+ ); +}; + +export default FilterableHeader; diff --git a/frontend/src/components/bom/shared/MaterialTable.jsx b/frontend/src/components/bom/shared/MaterialTable.jsx new file mode 100644 index 0000000..2a4ccb6 --- /dev/null +++ b/frontend/src/components/bom/shared/MaterialTable.jsx @@ -0,0 +1,161 @@ +import React from 'react'; + +const MaterialTable = ({ + children, + className = '', + style = {} +}) => { + return ( +
+ {children} +
+ ); +}; + +const MaterialTableHeader = ({ + children, + gridColumns, + className = '' +}) => { + return ( +
+ {children} +
+ ); +}; + +const MaterialTableBody = ({ + children, + maxHeight = '600px', + className = '' +}) => { + return ( +
+ {children} +
+ ); +}; + +const MaterialTableRow = ({ + children, + gridColumns, + isSelected = false, + isPurchased = false, + isLast = false, + onClick, + className = '' +}) => { + return ( +
{ + if (!isSelected && !isPurchased && !onClick) { + e.target.style.background = '#f8fafc'; + } + }} + onMouseLeave={(e) => { + if (!isSelected && !isPurchased && !onClick) { + e.target.style.background = 'white'; + } + }} + > + {children} +
+ ); +}; + +const MaterialTableCell = ({ + children, + align = 'left', + fontWeight = 'normal', + color = '#1f2937', + className = '' +}) => { + return ( +
+ {children} +
+ ); +}; + +const MaterialTableEmpty = ({ + icon = 'πŸ“¦', + title = 'No Materials Found', + message = 'No materials available', + className = '' +}) => { + return ( +
+
{icon}
+
+ {title} +
+
+ {message} +
+
+ ); +}; + +// 볡합 μ»΄ν¬λ„ŒνŠΈλ‘œ export +MaterialTable.Header = MaterialTableHeader; +MaterialTable.Body = MaterialTableBody; +MaterialTable.Row = MaterialTableRow; +MaterialTable.Cell = MaterialTableCell; +MaterialTable.Empty = MaterialTableEmpty; + +export default MaterialTable; diff --git a/frontend/src/components/bom/shared/index.js b/frontend/src/components/bom/shared/index.js new file mode 100644 index 0000000..c78a86c --- /dev/null +++ b/frontend/src/components/bom/shared/index.js @@ -0,0 +1,3 @@ +// BOM Shared Components +export { default as FilterableHeader } from './FilterableHeader'; +export { default as MaterialTable } from './MaterialTable'; diff --git a/frontend/src/components/common/ErrorBoundary.jsx b/frontend/src/components/common/ErrorBoundary.jsx new file mode 100644 index 0000000..3164135 --- /dev/null +++ b/frontend/src/components/common/ErrorBoundary.jsx @@ -0,0 +1,163 @@ +import React from 'react'; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null, errorInfo: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + componentDidCatch(error, errorInfo) { + this.setState({ + error: error, + errorInfo: errorInfo + }); + + // μ—λŸ¬ λ‘œκΉ… + console.error('ErrorBoundary caught an error:', error, errorInfo); + + // μ—λŸ¬ μ»¨ν…μŠ€νŠΈ 정보 λ‘œκΉ… + if (this.props.errorContext) { + console.error('Error context:', this.props.errorContext); + } + } + + render() { + if (this.state.hasError) { + return ( +
+
+
⚠️
+ +

+ Something went wrong +

+ +

+ An unexpected error occurred. Please try refreshing the page or contact support if the problem persists. +

+ +
+ + + +
+ + {/* 개발 ν™˜κ²½μ—μ„œλ§Œ μ—λŸ¬ 상세 정보 ν‘œμ‹œ */} + {process.env.NODE_ENV === 'development' && this.state.error && ( +
+ + Error Details (Development) + +
+                  {this.state.error && this.state.error.toString()}
+                  
+ {this.state.errorInfo.componentStack} +
+
+ )} +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/frontend/src/components/common/UserMenu.jsx b/frontend/src/components/common/UserMenu.jsx new file mode 100644 index 0000000..13773c5 --- /dev/null +++ b/frontend/src/components/common/UserMenu.jsx @@ -0,0 +1,219 @@ +import React, { useState } from 'react'; + +const UserMenu = ({ user, onNavigate, onLogout }) => { + const [showUserMenu, setShowUserMenu] = useState(false); + + return ( +
+ + + {/* λ“œλ‘­λ‹€μš΄ 메뉴 */} + {showUserMenu && ( +
+
+
+
+ {user?.name || user?.username} +
+
+ {user?.email || '이메일 μ—†μŒ'} +
+
+ + + + {user?.role === 'admin' && ( + <> + + + + + + + )} + +
+ +
+
+
+ )} +
+ ); +}; + +export default UserMenu; diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js new file mode 100644 index 0000000..b9dfc88 --- /dev/null +++ b/frontend/src/components/common/index.js @@ -0,0 +1,3 @@ +// Common Components +export { default as UserMenu } from './UserMenu'; +export { default as ErrorBoundary } from './ErrorBoundary'; diff --git a/frontend/src/pages/BOMManagementPage.css b/frontend/src/pages/BOMManagementPage.css new file mode 100644 index 0000000..fefc743 --- /dev/null +++ b/frontend/src/pages/BOMManagementPage.css @@ -0,0 +1,184 @@ +/* BOM Management Page Styles */ + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.bom-management-page { + padding: 40px; + background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); + min-height: 100vh; +} + +.bom-header-card { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 20px; + padding: 32px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + border: 1px solid rgba(255, 255, 255, 0.2); + margin-bottom: 40px; +} + +.bom-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 32px; +} + +.bom-stat-card { + padding: 20px; + border-radius: 12px; + text-align: center; + transition: transform 0.2s ease; +} + +.bom-stat-card:hover { + transform: translateY(-2px); +} + +.bom-stat-number { + font-size: 32px; + font-weight: 700; + margin-bottom: 4px; +} + +.bom-stat-label { + font-size: 14px; + font-weight: 500; +} + +.bom-category-tabs { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 20px; + padding: 24px 32px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + border: 1px solid rgba(255, 255, 255, 0.2); + margin-bottom: 40px; +} + +.bom-category-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 16px; +} + +.bom-category-button { + border-radius: 12px; + padding: 16px 12px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all 0.2s ease; + text-align: center; + border: none; + outline: none; +} + +.bom-category-button:hover { + transform: translateY(-1px); +} + +.bom-category-icon { + font-size: 20px; + margin-bottom: 8px; +} + +.bom-category-count { + font-size: 12px; + opacity: 0.8; + font-weight: 500; +} + +.bom-content-card { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 20px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + border: 1px solid rgba(255, 255, 255, 0.2); + overflow: hidden; +} + +.bom-loading { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); +} + +.bom-loading-spinner { + width: 60px; + height: 60px; + border: 4px solid #e2e8f0; + border-top: 4px solid #3b82f6; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 20px; +} + +.bom-loading-text { + font-size: 18px; + color: #64748b; + font-weight: 600; +} + +.bom-error { + padding: 60px; + text-align: center; + color: #dc2626; +} + +.bom-error-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.bom-error-title { + font-size: 18px; + font-weight: 600; + margin-bottom: 8px; +} + +.bom-error-message { + font-size: 14px; +} + +/* λ°˜μ‘ν˜• λ””μžμΈ */ +@media (max-width: 768px) { + .bom-management-page { + padding: 20px; + } + + .bom-header-card { + padding: 24px; + } + + .bom-stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: 16px; + } + + .bom-category-grid { + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + .bom-category-button { + padding: 12px 8px; + font-size: 12px; + } +} + +@media (max-width: 480px) { + .bom-stats-grid { + grid-template-columns: 1fr; + } + + .bom-category-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/pages/BOMManagementPage.jsx b/frontend/src/pages/BOMManagementPage.jsx new file mode 100644 index 0000000..49bd179 --- /dev/null +++ b/frontend/src/pages/BOMManagementPage.jsx @@ -0,0 +1,451 @@ +import React, { useState, useEffect } from 'react'; +import { fetchMaterials } from '../api'; +import api from '../api'; +import { + PipeMaterialsView, + FittingMaterialsView, + FlangeMaterialsView, + ValveMaterialsView, + GasketMaterialsView, + BoltMaterialsView, + SupportMaterialsView +} from '../components/bom'; +import './BOMManagementPage.css'; + +const BOMManagementPage = ({ + onNavigate, + selectedProject, + fileId, + jobNo, + bomName, + revision, + filename, + user +}) => { + const [materials, setMaterials] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedCategory, setSelectedCategory] = useState('PIPE'); + const [selectedMaterials, setSelectedMaterials] = useState(new Set()); + const [exportHistory, setExportHistory] = useState([]); + const [availableRevisions, setAvailableRevisions] = useState([]); + const [currentRevision, setCurrentRevision] = useState(revision || 'Rev.0'); + const [userRequirements, setUserRequirements] = useState({}); + const [purchasedMaterials, setPurchasedMaterials] = useState(new Set()); + const [error, setError] = useState(null); + + // μΉ΄ν…Œκ³ λ¦¬ μ •μ˜ + const categories = [ + { key: 'PIPE', label: 'Pipes', icon: 'πŸ”§', color: '#3b82f6' }, + { key: 'FITTING', label: 'Fittings', icon: 'βš™οΈ', color: '#10b981' }, + { key: 'FLANGE', label: 'Flanges', icon: 'πŸ”©', color: '#f59e0b' }, + { key: 'VALVE', label: 'Valves', icon: '🚰', color: '#ef4444' }, + { key: 'GASKET', label: 'Gaskets', icon: 'β­•', color: '#8b5cf6' }, + { key: 'BOLT', label: 'Bolts', icon: 'πŸ”©', color: '#6b7280' }, + { key: 'SUPPORT', label: 'Supports', icon: 'πŸ—οΈ', color: '#f97316' } + ]; + + // 자료 λ‘œλ“œ ν•¨μˆ˜λ“€ + const loadMaterials = async (id) => { + try { + setLoading(true); + console.log('πŸ” 자재 데이터 λ‘œλ”© 쀑...', { + file_id: id, + selectedProject: selectedProject?.job_no || selectedProject?.official_project_code, + jobNo + }); + + // κ΅¬λ§€μ‹ μ²­λœ 자재 λ¨Όμ € 확인 + const projectJobNo = selectedProject?.job_no || selectedProject?.official_project_code || jobNo; + await loadPurchasedMaterials(projectJobNo); + + const response = await fetchMaterials({ + file_id: parseInt(id), + limit: 10000, + exclude_requested: false, + job_no: projectJobNo + }); + + if (response.data?.materials) { + const materialsData = response.data.materials; + console.log(`βœ… ${materialsData.length}개 원본 자재 λ‘œλ“œ μ™„λ£Œ`); + setMaterials(materialsData); + setError(null); + } else { + console.warn('⚠️ 자재 데이터가 μ—†μŠ΅λ‹ˆλ‹€:', response.data); + setMaterials([]); + } + } catch (error) { + console.error('자재 λ‘œλ“œ μ‹€νŒ¨:', error); + setError('자재 λ‘œλ“œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'); + } finally { + setLoading(false); + } + }; + + const loadAvailableRevisions = async () => { + try { + const response = await api.get('/files/', { + params: { job_no: jobNo } + }); + + const allFiles = Array.isArray(response.data) ? response.data : response.data?.files || []; + const sameBomFiles = allFiles.filter(file => + (file.bom_name || file.original_filename) === bomName + ); + + sameBomFiles.sort((a, b) => { + const revA = parseInt(a.revision?.replace('Rev.', '') || '0'); + const revB = parseInt(b.revision?.replace('Rev.', '') || '0'); + return revB - revA; + }); + + setAvailableRevisions(sameBomFiles); + } catch (error) { + console.error('리비전 λͺ©λ‘ 쑰회 μ‹€νŒ¨:', error); + } + }; + + const loadPurchasedMaterials = async (jobNo) => { + try { + // μƒˆλ‘œμš΄ API둜 κ΅¬λ§€μ‹ μ²­λœ 자재 ID λͺ©λ‘ 쑰회 + const response = await api.get('/purchase-request/requested-materials', { + params: { + job_no: jobNo, + file_id: fileId + } + }); + + if (response.data?.requested_material_ids) { + const purchasedIds = new Set(response.data.requested_material_ids); + setPurchasedMaterials(purchasedIds); + console.log(`βœ… ${purchasedIds.size}개 κ΅¬λ§€μ‹ μ²­λœ 자재 ID λ‘œλ“œ μ™„λ£Œ`); + } + } catch (error) { + console.error('ꡬ맀신청 자재 쑰회 μ‹€νŒ¨:', error); + } + }; + + const loadUserRequirements = async (fileId) => { + try { + const response = await api.get(`/files/${fileId}/user-requirements`); + if (response.data?.requirements) { + const reqMap = {}; + response.data.requirements.forEach(req => { + reqMap[req.material_id] = req.requirement; + }); + setUserRequirements(reqMap); + } + } catch (error) { + console.error('μ‚¬μš©μž μš”κ΅¬μ‚¬ν•­ λ‘œλ“œ μ‹€νŒ¨:', error); + } + }; + + // 초기 λ‘œλ“œ + useEffect(() => { + if (fileId) { + loadMaterials(fileId); + loadAvailableRevisions(); + loadUserRequirements(fileId); + } + }, [fileId]); + + // μΉ΄ν…Œκ³ λ¦¬λ³„ 자재 필터링 + const getCategoryMaterials = (category) => { + return materials.filter(material => + material.classified_category === category || + material.category === category + ); + }; + + // μΉ΄ν…Œκ³ λ¦¬λ³„ μ»΄ν¬λ„ŒνŠΈ λ Œλ”λ§ + const renderCategoryView = () => { + const categoryMaterials = getCategoryMaterials(selectedCategory); + const commonProps = { + materials: categoryMaterials, + selectedMaterials, + setSelectedMaterials, + userRequirements, + setUserRequirements, + purchasedMaterials, + fileId, + user, + onNavigate + }; + + switch (selectedCategory) { + case 'PIPE': + return ; + case 'FITTING': + return ; + case 'FLANGE': + return ; + case 'VALVE': + return ; + case 'GASKET': + return ; + case 'BOLT': + return ; + case 'SUPPORT': + return ; + default: + return
μΉ΄ν…Œκ³ λ¦¬λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”.
; + } + }; + + if (loading) { + return ( +
+
+
+
+ Loading Materials... +
+
+
+ ); + } + + return ( +
+ {/* 헀더 μ„Ήμ…˜ */} +
+
+
+

+ BOM Materials Management +

+

+ {bomName} - {currentRevision} | Project: {selectedProject?.job_name || jobNo} +

+
+ +
+ + {/* 톡계 정보 */} +
+
+
+ {materials.length} +
+
+ Total Materials +
+
+ +
+
+ {getCategoryMaterials(selectedCategory).length} +
+
+ {selectedCategory} Items +
+
+ +
+
+ {selectedMaterials.size} +
+
+ Selected +
+
+ +
+
+ {purchasedMaterials.size} +
+
+ Purchased +
+
+
+
+ + {/* μΉ΄ν…Œκ³ λ¦¬ νƒ­ */} +
+
+ {categories.map((category) => { + const isActive = selectedCategory === category.key; + const count = getCategoryMaterials(category.key).length; + + return ( + + ); + })} +
+
+ + {/* μΉ΄ν…Œκ³ λ¦¬λ³„ 컨텐츠 */} +
+ {error ? ( +
+
⚠️
+
+ Error Loading Materials +
+
+ {error} +
+
+ ) : ( + renderCategoryView() + )} +
+
+ ); +}; + +export default BOMManagementPage; diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 71bdeeb..58ff764 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -1,286 +1,1066 @@ import React, { useState, useEffect } from 'react'; -const DashboardPage = ({ user }) => { - const [stats, setStats] = useState({ - totalProjects: 0, - activeProjects: 0, - completedProjects: 0, - totalMaterials: 0, - pendingQuotes: 0, - recentActivities: [] +const DashboardPage = ({ + user, + projects, + pendingSignupCount, + navigateToPage, + loadProjects, + createProject, + updateProjectName, + deleteProject, + editingProject, + setEditingProject, + editedProjectName, + setEditedProjectName, + showCreateProject, + setShowCreateProject, + newProjectCode, + setNewProjectCode, + newProjectName, + setNewProjectName, + newClientName, + setNewClientName, + inactiveProjects, + setInactiveProjects, +}) => { + const [selectedProject, setSelectedProject] = useState(null); + const [showProjectDropdown, setShowProjectDropdown] = useState(false); + + // ν”„λ‘œμ νŠΈ 생성 λͺ¨λ‹¬ λ‹«κΈ° + const handleCloseCreateProject = () => { + setShowCreateProject(false); + setNewProjectCode(''); + setNewProjectName(''); + setNewClientName(''); + }; + + // ν”„λ‘œμ νŠΈ 선택 처리 + const handleProjectSelect = (project) => { + setSelectedProject(project); + setShowProjectDropdown(false); + }; + + // ν”„λ‘œμ νŠΈ λΉ„ν™œμ„±ν™” + const handleDeactivateProject = (project) => { + if (window.confirm(`"${project.job_name || project.job_no}" ν”„λ‘œμ νŠΈλ₯Ό λΉ„ν™œμ„±ν™”ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?`)) { + setInactiveProjects(prev => new Set([...prev, project.job_no])); + if (selectedProject?.job_no === project.job_no) { + setSelectedProject(null); + } + setShowProjectDropdown(false); + } + }; + + // ν”„λ‘œμ νŠΈ ν™œμ„±ν™” + const handleActivateProject = (project) => { + setInactiveProjects(prev => { + const newSet = new Set(prev); + newSet.delete(project.job_no); + return newSet; }); + }; - useEffect(() => { - // μ‹€μ œλ‘œλŠ” APIμ—μ„œ 데이터λ₯Ό κ°€μ Έμ˜¬ μ˜ˆμ • - // ν˜„μž¬λŠ” 더미 데이터 μ‚¬μš© - setStats({ - totalProjects: 25, - activeProjects: 8, - completedProjects: 17, - totalMaterials: 1250, - pendingQuotes: 3, - recentActivities: [ - { id: 1, type: 'project', message: '냉동기 ν”„λ‘œμ νŠΈ #2024-001 생성됨', time: '2μ‹œκ°„ μ „' }, - { id: 2, type: 'bom', message: 'BOG μ‹œμŠ€ν…œ BOM μ—…λ°μ΄νŠΈλ¨', time: '4μ‹œκ°„ μ „' }, - { id: 3, type: 'quote', message: 'λ‹€μ΄μ•„ν”„λžŒ νŽŒν”„ κ²¬μ μ„œ 승인됨', time: '6μ‹œκ°„ μ „' }, - { id: 4, type: 'material', message: 'μŠ€ν…ŒμΈλ¦¬μŠ€ νŒŒμ΄ν”„ 재고 λΆ€μ‘± μ•Œλ¦Ό', time: '1일 μ „' }, - { id: 5, type: 'shipment', message: 'λ“œλΌμ΄μ–΄ μ‹œμŠ€ν…œ μΆœν•˜ μ™„λ£Œ', time: '2일 μ „' } - ] - }); - }, []); + // ν”„λ‘œμ νŠΈ μ‚­μ œ (λ“œλ‘­λ‹€μš΄μš©) + const handleDeleteProjectFromDropdown = (project, e) => { + e.stopPropagation(); + if (window.confirm(`"${project.job_name || project.job_no}" ν”„λ‘œμ νŠΈλ₯Ό μ™„μ „νžˆ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?`)) { + deleteProject(project.job_no); + setShowProjectDropdown(false); + } + }; - const getActivityIcon = (type) => { - const icons = { - project: 'πŸ“‹', - bom: 'πŸ”§', - quote: 'πŸ’°', - material: 'πŸ“¦', - shipment: '🚚' - }; - return icons[type] || 'πŸ“Œ'; - }; + // μ»΄ν¬λ„ŒνŠΈ 마운트 μ‹œ ν”„λ‘œμ νŠΈ λ‘œλ“œ + useEffect(() => { + loadProjects(); + }, []); - const StatCard = ({ title, value, icon, color = '#667eea' }) => ( + return ( +
+ {/* λŒ€μ‹œλ³΄λ“œ 헀더 */} +
+

+ Dashboard +

+

+ TK-MP BOM Management System v2.0 - Project Selection Interface +

+
+ + {/* ν”„λ‘œμ νŠΈ 선택 μ„Ήμ…˜ */} +
+
+
+

+ Project Selection +

+

+ Choose a project to access BOM and purchase management +

+
+
+ + + + + +
+
+ + {/* ν”„λ‘œμ νŠΈ λ“œλ‘­λ‹€μš΄ */} +
+ + + {/* λ“œλ‘­λ‹€μš΄ 메뉴 */} + {showProjectDropdown && ( +
+ {projects.length === 0 ? ( +
+ No projects available. Create a new one! +
+ ) : ( + projects + .filter(project => !inactiveProjects.has(project.job_no)) + .map((project) => ( +
+
handleProjectSelect(project)} + style={{ + padding: '16px 20px', + cursor: 'pointer', + flex: 1 + }} + onMouseEnter={(e) => e.target.closest('div').style.background = '#f8fafc'} + onMouseLeave={(e) => e.target.closest('div').style.background = 'white'} + > +
+ {project.job_name || project.job_no} +
+
+ Code: {project.job_no} | Client: {project.client_name || 'N/A'} +
+
+ + {/* ν”„λ‘œμ νŠΈ 관리 λ²„νŠΌλ“€ */} +
+ + + +
+
+ )) + )} +
+ )} +
+
+ + {/* ν”„λ‘œμ νŠΈκ°€ μ„ νƒλœ 경우 - ν”„λ‘œμ νŠΈ κ΄€λ ¨ 메뉴 */} + {selectedProject && (
{ - e.currentTarget.style.transform = 'translateY(-2px)'; - e.currentTarget.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.15)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = 'translateY(0)'; - e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)'; + background: 'rgba(255, 255, 255, 0.9)', + backdropFilter: 'blur(10px)', + borderRadius: '20px', + padding: '32px', + boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', + border: '1px solid rgba(255, 255, 255, 0.2)', + marginBottom: '40px', + position: 'relative', + zIndex: 1 }}> -
-
-
- {title} -
-
- {value} -
-
-
- {icon} -
+

+ Project Management +

+ +
+ {/* BOM 관리 */} +
navigateToPage('bom', { selectedProject })} + style={{ + background: 'white', + borderRadius: '16px', + padding: '32px', + boxShadow: '0 8px 20px rgba(0, 0, 0, 0.08)', + border: '1px solid #e2e8f0', + cursor: 'pointer', + transition: 'all 0.3s ease', + textAlign: 'center' + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'translateY(-5px)'; + e.currentTarget.style.boxShadow = '0 12px 30px rgba(0, 0, 0, 0.15)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.08)'; + }} + > +
+ BOM +
+

+ BOM Management +

+

+ Upload and manage Bill of Materials files. Classify materials and generate reports. +

-
- ); - return ( + {/* ꡬ맀신청 관리 */} +
navigateToPage('purchase-request', { selectedProject })} + style={{ + background: 'white', + borderRadius: '16px', + padding: '32px', + boxShadow: '0 8px 20px rgba(0, 0, 0, 0.08)', + border: '1px solid #e2e8f0', + cursor: 'pointer', + transition: 'all 0.3s ease', + textAlign: 'center' + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'translateY(-5px)'; + e.currentTarget.style.boxShadow = '0 12px 30px rgba(0, 0, 0, 0.15)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.08)'; + }} + > +
+ REQ +
+

+ Purchase Request Management +

+

+ Manage purchase requests and export materials to Excel for procurement. +

+
+
+
+ )} + + {/* κ΄€λ¦¬μž 메뉴 (Admin μ΄μƒλ§Œ ν‘œμ‹œ) */} + {user?.role === 'admin' && ( +
+

+ System Administration +

+ +
+ {/* μ‚¬μš©μž 관리 */} +
navigateToPage('user-management')} + style={{ + background: 'white', + borderRadius: '16px', + padding: '24px', + boxShadow: '0 8px 20px rgba(0, 0, 0, 0.08)', + border: '1px solid #e2e8f0', + cursor: 'pointer', + transition: 'all 0.3s ease', + textAlign: 'center' + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'translateY(-3px)'; + e.currentTarget.style.boxShadow = '0 12px 25px rgba(0, 0, 0, 0.12)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.08)'; + }} + > +
+ USER +
+

+ User Management +

+

+ Manage user accounts and permissions +

+ {pendingSignupCount > 0 && ( +
+ {pendingSignupCount} pending +
+ )} +
+ + {/* μ‹œμŠ€ν…œ μ„€μ • */} +
navigateToPage('system-settings')} + style={{ + background: 'white', + borderRadius: '16px', + padding: '24px', + boxShadow: '0 8px 20px rgba(0, 0, 0, 0.08)', + border: '1px solid #e2e8f0', + cursor: 'pointer', + transition: 'all 0.3s ease', + textAlign: 'center' + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'translateY(-3px)'; + e.currentTarget.style.boxShadow = '0 12px 25px rgba(0, 0, 0, 0.12)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.08)'; + }} + > +
+ SYS +
+

+ System Settings +

+

+ Configure system preferences and settings +

+
+ + {/* μ‹œμŠ€ν…œ 둜그 */} +
navigateToPage('system-logs')} + style={{ + background: 'white', + borderRadius: '16px', + padding: '24px', + boxShadow: '0 8px 20px rgba(0, 0, 0, 0.08)', + border: '1px solid #e2e8f0', + cursor: 'pointer', + transition: 'all 0.3s ease', + textAlign: 'center' + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'translateY(-3px)'; + e.currentTarget.style.boxShadow = '0 12px 25px rgba(0, 0, 0, 0.12)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.08)'; + }} + > +
+ LOG +
+

+ System Logs +

+

+ View system activity and error logs +

+
+ + {/* 둜그 λͺ¨λ‹ˆν„°λ§ */} +
navigateToPage('log-monitoring')} + style={{ + background: 'white', + borderRadius: '16px', + padding: '24px', + boxShadow: '0 8px 20px rgba(0, 0, 0, 0.08)', + border: '1px solid #e2e8f0', + cursor: 'pointer', + transition: 'all 0.3s ease', + textAlign: 'center' + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'translateY(-3px)'; + e.currentTarget.style.boxShadow = '0 12px 25px rgba(0, 0, 0, 0.12)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.08)'; + }} + > +
+ MON +
+

+ Log Monitoring +

+

+ Real-time system monitoring and alerts +

+
+
+
+ )} + + {/* μ‹œμŠ€ν…œ ν˜„ν™© μ„Ήμ…˜ */} +
+

+ System Overview +

-
- {/* 헀더 */} -
-

- μ•ˆλ…•ν•˜μ„Έμš”, {user?.name}λ‹˜! πŸ‘‹ -

-

- μ˜€λŠ˜λ„ TK-MP μ‹œμŠ€ν…œκ³Ό ν•¨κ»˜ 효율적인 업무λ₯Ό μ‹œμž‘ν•΄λ³΄μ„Έμš”. -

-
- - {/* 톡계 μΉ΄λ“œλ“€ */} -
- - - - - -
- -
- {/* 졜근 ν™œλ™ */} -
-

- πŸ“ˆ 졜근 ν™œλ™ -

-
- {stats.recentActivities.map(activity => ( -
- - {getActivityIcon(activity.type)} - -
-
- {activity.message} -
-
- {activity.time} -
-
-
- ))} -
-
- - {/* λΉ λ₯Έ μž‘μ—… */} -
-

- ⚑ λΉ λ₯Έ μž‘μ—… -

-
- {[ - { title: 'μƒˆ ν”„λ‘œμ νŠΈ 등둝', icon: 'βž•', color: '#667eea' }, - { title: 'BOM μ—…λ‘œλ“œ', icon: 'πŸ“€', color: '#48bb78' }, - { title: 'κ²¬μ μ„œ μž‘μ„±', icon: 'πŸ“', color: '#ed8936' }, - { title: '자재 검색', icon: 'πŸ”', color: '#38b2ac' } - ].map((action, index) => ( - - ))} -
-
-
+ {/* λ“±λ‘λœ ν”„λ‘œμ νŠΈ */} +
+
+ {projects.length || 0}
+
Registered Projects
+
+ {/* μ„ νƒλœ ν”„λ‘œμ νŠΈ */} +
+
+ {selectedProject ? '1' : '0'} +
+
Selected Project
+
+ {/* ν˜„μž¬ κΆŒν•œ */} +
+
+ {user?.role === 'admin' ? 'Admin' : 'User'} +
+
Current Role
+
+ {/* μ‹œμŠ€ν…œ μƒνƒœ */} +
+
+ Active +
+
System Status
+
- ); +
+ + {/* ν”„λ‘œμ νŠΈ 생성 λͺ¨λ‹¬ */} + {showCreateProject && ( +
+
+

+ Create New Project +

+
+ + setNewProjectCode(e.target.value)} + style={{ + width: '100%', + padding: '12px', + borderRadius: '8px', + border: '1px solid #cbd5e1', + fontSize: '16px', + boxSizing: 'border-box' + }} + placeholder="e.g., J24-001" + /> +
+
+ + setNewProjectName(e.target.value)} + style={{ + width: '100%', + padding: '12px', + borderRadius: '8px', + border: '1px solid #cbd5e1', + fontSize: '16px', + boxSizing: 'border-box' + }} + placeholder="e.g., Ulsan SK Energy Expansion" + /> +
+
+ + setNewClientName(e.target.value)} + style={{ + width: '100%', + padding: '12px', + borderRadius: '8px', + border: '1px solid #cbd5e1', + fontSize: '16px', + boxSizing: 'border-box' + }} + placeholder="e.g., Samsung Engineering" + /> +
+
+ + +
+
+
+ )} +
+ ); }; -export default DashboardPage; - - - - - - - - - - - - - - - - - - - - - - +export default DashboardPage; \ No newline at end of file diff --git a/frontend/src/pages/InactiveProjectsPage.jsx b/frontend/src/pages/InactiveProjectsPage.jsx new file mode 100644 index 0000000..b67d3b4 --- /dev/null +++ b/frontend/src/pages/InactiveProjectsPage.jsx @@ -0,0 +1,398 @@ +import React, { useState } from 'react'; + +const InactiveProjectsPage = ({ + onNavigate, + user, + projects, + inactiveProjects, + onActivateProject, + onDeleteProject +}) => { + const [selectedProjects, setSelectedProjects] = useState(new Set()); + + // λΉ„ν™œμ„± ν”„λ‘œμ νŠΈ λͺ©λ‘ 필터링 + const inactiveProjectList = projects.filter(project => + inactiveProjects.has(project.job_no) + ); + + // ν”„λ‘œμ νŠΈ 선택/ν•΄μ œ + const handleProjectSelect = (projectNo) => { + setSelectedProjects(prev => { + const newSet = new Set(prev); + if (newSet.has(projectNo)) { + newSet.delete(projectNo); + } else { + newSet.add(projectNo); + } + return newSet; + }); + }; + + // 전체 선택/ν•΄μ œ + const handleSelectAll = () => { + if (selectedProjects.size === inactiveProjectList.length) { + setSelectedProjects(new Set()); + } else { + setSelectedProjects(new Set(inactiveProjectList.map(p => p.job_no))); + } + }; + + // μ„ νƒλœ ν”„λ‘œμ νŠΈλ“€ ν™œμ„±ν™” + const handleBulkActivate = () => { + if (selectedProjects.size === 0) { + alert('ν™œμ„±ν™”ν•  ν”„λ‘œμ νŠΈλ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”.'); + return; + } + + if (window.confirm(`μ„ νƒλœ ${selectedProjects.size}개 ν”„λ‘œμ νŠΈλ₯Ό ν™œμ„±ν™”ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?`)) { + selectedProjects.forEach(projectNo => { + const project = projects.find(p => p.job_no === projectNo); + if (project) { + onActivateProject(project); + } + }); + setSelectedProjects(new Set()); + } + }; + + // μ„ νƒλœ ν”„λ‘œμ νŠΈλ“€ μ‚­μ œ + const handleBulkDelete = () => { + if (selectedProjects.size === 0) { + alert('μ‚­μ œν•  ν”„λ‘œμ νŠΈλ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”.'); + return; + } + + if (window.confirm(`μ„ νƒλœ ${selectedProjects.size}개 ν”„λ‘œμ νŠΈλ₯Ό μ™„μ „νžˆ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ? 이 μž‘μ—…μ€ 되돌릴 수 μ—†μŠ΅λ‹ˆλ‹€.`)) { + selectedProjects.forEach(projectNo => { + onDeleteProject(projectNo); + }); + setSelectedProjects(new Set()); + } + }; + + return ( +
+ {/* 헀더 */} +
+
+
+

+ Inactive Projects Management +

+

+ Manage deactivated projects - activate or permanently delete +

+
+ +
+ + {/* 톡계 정보 */} +
+
+
+ {inactiveProjectList.length} +
+
+ Inactive Projects +
+
+ +
+
+ {selectedProjects.size} +
+
+ Selected +
+
+
+ + {/* 일괄 μž‘μ—… λ²„νŠΌλ“€ */} + {inactiveProjectList.length > 0 && ( +
+ + + + + +
+ )} +
+ + {/* λΉ„ν™œμ„± ν”„λ‘œμ νŠΈ λͺ©λ‘ */} +
+

+ Inactive Projects List +

+ + {inactiveProjectList.length === 0 ? ( +
+
πŸ“‚
+
+ No Inactive Projects +
+
+ All projects are currently active +
+
+ ) : ( +
+ {inactiveProjectList.map((project) => ( +
{ + e.target.style.borderColor = '#cbd5e1'; + e.target.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)'; + }} + onMouseLeave={(e) => { + e.target.style.borderColor = '#e2e8f0'; + e.target.style.boxShadow = '0 2px 8px rgba(0,0,0,0.05)'; + }} + > +
+ handleProjectSelect(project.job_no)} + style={{ + width: '18px', + height: '18px', + cursor: 'pointer' + }} + /> + +
+
+ {project.job_name || project.job_no} +
+
+ Code: {project.job_no} | Client: {project.client_name || 'N/A'} +
+
+
+ +
+ + + +
+
+ ))} +
+ )} +
+
+ ); +}; + +export default InactiveProjectsPage; diff --git a/frontend/src/pages/NewMaterialsPage.css b/frontend/src/pages/NewMaterialsPage.css index 11721e7..b36e157 100644 --- a/frontend/src/pages/NewMaterialsPage.css +++ b/frontend/src/pages/NewMaterialsPage.css @@ -58,6 +58,8 @@ border-color: #4299e1; box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.2); } + +.materials-header { display: flex; align-items: center; justify-content: space-between; diff --git a/frontend/src/pages/NewMaterialsPage.jsx b/frontend/src/pages/NewMaterialsPage.jsx index 4cc717c..11f54a8 100644 --- a/frontend/src/pages/NewMaterialsPage.jsx +++ b/frontend/src/pages/NewMaterialsPage.jsx @@ -13,7 +13,8 @@ const NewMaterialsPage = ({ jobNo, bomName, revision, - filename + filename, + user }) => { const [materials, setMaterials] = useState([]); const [loading, setLoading] = useState(true); @@ -127,15 +128,21 @@ const NewMaterialsPage = ({ const loadMaterials = async (id) => { try { setLoading(true); - console.log('πŸ” 자재 데이터 λ‘œλ”© 쀑...', { file_id: id }); + console.log('πŸ” 자재 데이터 λ‘œλ”© 쀑...', { + file_id: id, + selectedProject: selectedProject?.job_no || selectedProject?.official_project_code, + jobNo + }); // κ΅¬λ§€μ‹ μ²­λœ 자재 λ¨Όμ € 확인 - await loadPurchasedMaterials(jobNo); + const projectJobNo = selectedProject?.job_no || selectedProject?.official_project_code || jobNo; + await loadPurchasedMaterials(projectJobNo); const response = await fetchMaterials({ file_id: parseInt(id), limit: 10000, - exclude_requested: false // κ΅¬λ§€μ‹ μ²­λœ μžμž¬λ„ ν¬ν•¨ν•˜μ—¬ ν‘œμ‹œ + exclude_requested: false, // κ΅¬λ§€μ‹ μ²­λœ μžμž¬λ„ ν¬ν•¨ν•˜μ—¬ ν‘œμ‹œ + job_no: projectJobNo // ν”„λ‘œμ νŠΈλ³„ 필터링 μΆ”κ°€ }); if (response.data?.materials) { diff --git a/frontend/src/pages/PurchaseRequestPage.jsx b/frontend/src/pages/PurchaseRequestPage.jsx index 11b3fbf..7267af3 100644 --- a/frontend/src/pages/PurchaseRequestPage.jsx +++ b/frontend/src/pages/PurchaseRequestPage.jsx @@ -8,6 +8,8 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) => const [selectedRequest, setSelectedRequest] = useState(null); const [requestMaterials, setRequestMaterials] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [editingTitle, setEditingTitle] = useState(null); + const [newTitle, setNewTitle] = useState(''); useEffect(() => { loadRequests(); @@ -81,6 +83,45 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) => } }; + const handleEditTitle = (request) => { + setEditingTitle(request.request_id); + setNewTitle(request.request_no); + }; + + const handleSaveTitle = async (requestId) => { + try { + const response = await api.patch(`/purchase-request/${requestId}/title`, { + title: newTitle + }); + + if (response.data.success) { + // λͺ©λ‘μ—μ„œ ν•΄λ‹Ή μš”μ²­μ˜ 제λͺ© μ—…λ°μ΄νŠΈ + setRequests(prev => prev.map(req => + req.request_id === requestId + ? { ...req, request_no: newTitle } + : req + )); + + // μ„ νƒλœ μš”μ²­λ„ μ—…λ°μ΄νŠΈ + if (selectedRequest?.request_id === requestId) { + setSelectedRequest(prev => ({ ...prev, request_no: newTitle })); + } + + setEditingTitle(null); + setNewTitle(''); + console.log('βœ… ꡬ맀신청 제λͺ© μ—…λ°μ΄νŠΈ μ™„λ£Œ'); + } + } catch (error) { + console.error('❌ 제λͺ© μ—…λ°μ΄νŠΈ μ‹€νŒ¨:', error); + alert('제λͺ© μ—…λ°μ΄νŠΈ μ‹€νŒ¨: ' + error.message); + } + }; + + const handleCancelEdit = () => { + setEditingTitle(null); + setNewTitle(''); + }; + return (
@@ -111,7 +152,82 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) => onClick={() => handleRequestSelect(request)} >
- {request.request_no} + {editingTitle === request.request_id ? ( +
e.stopPropagation()}> + setNewTitle(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleSaveTitle(request.request_id); + } else if (e.key === 'Escape') { + handleCancelEdit(); + } + }} + style={{ + width: '200px', + padding: '4px 8px', + border: '1px solid #ddd', + borderRadius: '4px', + fontSize: '14px' + }} + autoFocus + /> + + +
+ ) : ( +
+ {request.request_no} + +
+ )} {new Date(request.requested_at).toLocaleDateString()} @@ -154,6 +270,57 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) =>
+ {/* 원본 파일 정보 */} +
+

+ πŸ“„ 원본 파일 정보 +

+
+
+ 파일λͺ…: + {selectedRequest.original_filename || 'N/A'} +
+
+ ν”„λ‘œμ νŠΈ: + {selectedRequest.job_no} - {selectedRequest.job_name} +
+
+ 신청일: + {new Date(selectedRequest.requested_at).toLocaleString()} +
+
+ μ‹ μ²­μž: + {selectedRequest.requested_by} +
+
+ 자재 μˆ˜λŸ‰: + {selectedRequest.material_count}개 +
+
+ μΉ΄ν…Œκ³ λ¦¬: + {selectedRequest.category || '전체'} +
+
+
+
{/* μ—…λ‘œλ“œ λ‹Ήμ‹œ λΆ„λ₯˜λœ 정보λ₯Ό κ·ΈλŒ€λ‘œ ν‘œμ‹œ */} {requestMaterials.length === 0 ? (