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 ? (