diff --git a/tkeg/api/app/main.py b/tkeg/api/app/main.py index 10bd1b9..af2833c 100644 --- a/tkeg/api/app/main.py +++ b/tkeg/api/app/main.py @@ -104,13 +104,6 @@ try: except ImportError: logger.warning("dashboard 라우터를 찾을 수 없습니다") -# 리비전 관리 라우터 (임시 비활성화) -# try: -# from .routers import revision_management -# app.include_router(revision_management.router, tags=["revision-management"]) -# except ImportError: -# logger.warning("revision_management 라우터를 찾을 수 없습니다") - try: from .routers import tubing app.include_router(tubing.router, prefix="/tubing", tags=["tubing"]) diff --git a/tkeg/api/app/routers/files.py b/tkeg/api/app/routers/files.py index b6c39d1..712e8b1 100644 --- a/tkeg/api/app/routers/files.py +++ b/tkeg/api/app/routers/files.py @@ -201,26 +201,26 @@ async def upload_file( # 🎯 트랜잭션 오류 방지: 완전한 트랜잭션 초기화 # 🔍 디버깅: 업로드 파라미터 로깅 - print(f"🔍 [UPLOAD] job_no: {job_no}, revision: {revision}, parent_file_id: {parent_file_id}") - print(f"🔍 [UPLOAD] bom_name: {bom_name}, filename: {file.filename}") - print(f"🔍 [UPLOAD] revision != 'Rev.0': {revision != 'Rev.0'}") + logger.info(f"[UPLOAD] job_no: {job_no}, revision: {revision}, parent_file_id: {parent_file_id}") + logger.info(f"[UPLOAD] bom_name: {bom_name}, filename: {file.filename}") + logger.info(f"[UPLOAD] revision != 'Rev.0': {revision != 'Rev.0'}") try: # 1. 현재 트랜잭션 완전 롤백 db.rollback() - print("🔄 1단계: 이전 트랜잭션 롤백 완료") + logger.info("1단계: 이전 트랜잭션 롤백 완료") # 2. 세션 상태 초기화 db.close() - print("🔄 2단계: 세션 닫기 완료") + logger.info("2단계: 세션 닫기 완료") # 3. 새 세션 생성 from ..database import get_db db = next(get_db()) - print("🔄 3단계: 새 세션 생성 완료") + logger.info("3단계: 새 세션 생성 완료") except Exception as e: - print(f"⚠️ 트랜잭션 초기화 중 오류: {e}") + logger.warning(f"트랜잭션 초기화 중 오류: {e}") # 오류 발생 시에도 계속 진행 # [변경] BOMParser 사용하여 확장자 검증 @@ -245,10 +245,10 @@ async def upload_file( try: # [변경] BOMParser 사용하여 파일 파싱 (자동 양식 감지 포함) - print(f"🚀 파일 파싱 시작: {file_path}") + logger.info(f"파일 파싱 시작: {file_path}") materials_data = BOMParser.parse_file(str(file_path)) parsed_count = len(materials_data) - print(f"✅ 파싱 완료: {parsed_count}개 자재 추출됨") + logger.info(f"파싱 완료: {parsed_count}개 자재 추출됨") # 신규 자재 카운트 초기화 new_materials_count = 0 @@ -256,7 +256,7 @@ async def upload_file( # 리비전 업로드인 경우만 자동 리비전 생성 및 기존 자재 조회 if parent_file_id is not None: - print(f"🔄 리비전 업로드 감지: parent_file_id={parent_file_id}") + logger.info(f"리비전 업로드 감지: parent_file_id={parent_file_id}") # 부모 파일의 정보 조회 parent_query = text(""" SELECT original_filename, revision, bom_name FROM files @@ -299,10 +299,10 @@ async def upload_file( else: revision = "Rev.1" - print(f"리비전 업로드: {latest_rev} → {revision}") + logger.info(f"리비전 업로드: {latest_rev} -> {revision}") else: revision = "Rev.1" - print(f"첫 번째 리비전: {revision}") + logger.info(f"첫 번째 리비전: {revision}") # 모든 이전 리비전의 누적 자재 목록 조회 (리비전 0부터 현재까지) existing_materials_query = text(""" @@ -327,24 +327,24 @@ async def upload_file( existing_materials_descriptions.add(key) existing_materials_with_quantity[key] = float(row.total_quantity or 0) - print(f"📊 누적 자재 수 (Rev.0~현재): {len(existing_materials_descriptions)}") - print(f"📊 누적 자재 총 수량: {sum(existing_materials_with_quantity.values())}") + logger.info(f"누적 자재 수 (Rev.0~현재): {len(existing_materials_descriptions)}") + logger.info(f"누적 자재 총 수량: {sum(existing_materials_with_quantity.values())}") if len(existing_materials_descriptions) > 0: - print(f"📝 기존 자재 샘플 (처음 3개): {list(existing_materials_descriptions)[:3]}") + logger.info(f"기존 자재 샘플 (처음 3개): {list(existing_materials_descriptions)[:3]}") # 수량이 있는 자재들 확인 quantity_samples = [(k, v) for k, v in list(existing_materials_with_quantity.items())[:3]] - print(f"📊 기존 자재 수량 샘플: {quantity_samples}") + logger.info(f"기존 자재 수량 샘플: {quantity_samples}") else: - print(f"⚠️ 기존 자재가 없습니다! parent_file_id={parent_file_id}의 materials 테이블을 확인하세요.") + logger.warning(f"기존 자재가 없습니다! parent_file_id={parent_file_id}의 materials 테이블을 확인하세요.") # 파일명을 부모와 동일하게 유지 file.filename = parent_file[0] else: # 일반 업로드 (새 BOM) - print(f"일반 업로드 모드: 새 BOM 파일 (Rev.0)") + logger.info(f"일반 업로드 모드: 새 BOM 파일 (Rev.0)") # 파일 정보 저장 (사용자 정보 포함) - print("DB 저장 시작") + logger.info("DB 저장 시작") username = current_user.get('username', 'unknown') user_id = current_user.get('user_id') @@ -370,7 +370,7 @@ async def upload_file( file_id = file_result.fetchone()[0] db.commit() # 파일 레코드 즉시 커밋 - print(f"파일 저장 완료: file_id = {file_id}, uploaded_by = {username}") + logger.info(f"파일 저장 완료: file_id = {file_id}, uploaded_by = {username}") # 🔄 리비전 비교 수행 (RULES.md 코딩 컨벤션 준수) revision_comparison = None @@ -378,23 +378,23 @@ async def upload_file( purchased_materials_map = {} # 구매확정된 자재 매핑 (키 -> 구매확정 정보) if revision != "Rev.0": # 리비전 업로드인 경우만 비교 - print(f"🔍 [DEBUG] 리비전 비교 시작 - revision: {revision}, parent_file_id: {parent_file_id}") + logger.info(f"[DEBUG] 리비전 비교 시작 - revision: {revision}, parent_file_id: {parent_file_id}") try: # 간단한 리비전 비교 로직 (purchase_confirmed 기반) - print(f"🔍 [DEBUG] perform_simple_revision_comparison 호출 중...") + logger.info(f"[DEBUG] perform_simple_revision_comparison 호출 중...") revision_comparison = perform_simple_revision_comparison(db, job_no, parent_file_id, materials_data) - print(f"🔍 [DEBUG] 리비전 비교 완료: {revision_comparison.keys() if revision_comparison else 'None'}") + logger.info(f"[DEBUG] 리비전 비교 완료: {revision_comparison.keys() if revision_comparison else 'None'}") if revision_comparison.get("has_purchased_materials", False): - print(f"📊 간단한 리비전 비교 결과:") - print(f" - 구매확정된 자재: {revision_comparison.get('purchased_count', 0)}개") - print(f" - 미구매 자재: {revision_comparison.get('unpurchased_count', 0)}개") - print(f" - 신규 자재: {revision_comparison.get('new_count', 0)}개") - print(f" - 제외된 구매확정 자재: {revision_comparison.get('excluded_purchased_count', 0)}개") + logger.info(f"간단한 리비전 비교 결과:") + logger.info(f" - 구매확정된 자재: {revision_comparison.get('purchased_count', 0)}개") + logger.info(f" - 미구매 자재: {revision_comparison.get('unpurchased_count', 0)}개") + logger.info(f" - 신규 자재: {revision_comparison.get('new_count', 0)}개") + logger.info(f" - 제외된 구매확정 자재: {revision_comparison.get('excluded_purchased_count', 0)}개") # 신규 및 변경된 자재만 분류 materials_to_classify = revision_comparison.get("materials_to_classify", []) - print(f" - 분류 필요: {len(materials_to_classify)}개") + logger.info(f" - 분류 필요: {len(materials_to_classify)}개") # 🔥 구매확정된 자재 매핑 정보 저장 purchased_materials_map = revision_comparison.get("purchased_materials_map", {}) diff --git a/tkeg/api/app/routers/material_comparison.py b/tkeg/api/app/routers/material_comparison.py index 008808a..4a5608e 100644 --- a/tkeg/api/app/routers/material_comparison.py +++ b/tkeg/api/app/routers/material_comparison.py @@ -5,6 +5,8 @@ - 발주 상태 관리 """ +import logging + from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import text @@ -14,6 +16,8 @@ from datetime import datetime from ..database import get_db +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/materials", tags=["material-comparison"]) @router.post("/compare-revisions") @@ -452,7 +456,7 @@ async def perform_material_comparison( previous_total = previous_item["pipe_details"]["total_length_mm"] length_change = current_total - previous_total modified_item["length_change"] = length_change - print(f"🔢 실제 길이 변화: {current_item['description'][:50]} - 이전:{previous_total:.0f}mm → 현재:{current_total:.0f}mm (변화:{length_change:+.0f}mm)") + logger.info(f"실제 길이 변화: {current_item['description'][:50]} - 이전:{previous_total:.0f}mm -> 현재:{current_total:.0f}mm (변화:{length_change:+.0f}mm)") modified_items.append(modified_item) @@ -587,7 +591,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]: pipe_count = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE') pipe_with_details = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE' and 'pipe_details' in data) - print(f"✅ 자재 처리 완료: 총 {len(materials_dict)}개, 파이프 {pipe_count}개 (길이정보: {pipe_with_details}개)") + logger.info(f"자재 처리 완료: 총 {len(materials_dict)}개, 파이프 {pipe_count}개 (길이정보: {pipe_with_details}개)") return materials_dict diff --git a/tkeg/api/app/routers/purchase_request.py b/tkeg/api/app/routers/purchase_request.py index 648aa93..e11b2f6 100644 --- a/tkeg/api/app/routers/purchase_request.py +++ b/tkeg/api/app/routers/purchase_request.py @@ -49,7 +49,7 @@ async def create_purchase_request( if request_data.material_ids: logger.info(f"🔍 material_ids 샘플: {request_data.material_ids[:5]}") - print(f"🔍 [DEBUG] 구매신청 API 호출됨 - material_ids: {len(request_data.material_ids)}개") + logger.info(f"[DEBUG] 구매신청 API 호출됨 - material_ids: {len(request_data.material_ids)}개") # 구매신청 번호 생성 today = datetime.now().strftime('%Y%m%d') count_query = text(""" @@ -160,8 +160,8 @@ async def create_purchase_request( # 🔥 중요: materials 테이블의 purchase_confirmed 업데이트 if request_data.material_ids: - print(f"🔥 [PURCHASE] purchase_confirmed 업데이트 시작: {len(request_data.material_ids)}개 자재") - print(f"🔥 [PURCHASE] material_ids: {request_data.material_ids[:5]}...") # 처음 5개만 로그 + logger.info(f"[PURCHASE] purchase_confirmed 업데이트 시작: {len(request_data.material_ids)}개 자재") + logger.info(f"[PURCHASE] material_ids: {request_data.material_ids[:5]}...") # 처음 5개만 로그 update_materials_query = text(""" UPDATE materials @@ -176,10 +176,10 @@ async def create_purchase_request( "confirmed_by": current_user.get("username", "system") }) - print(f"🔥 [PURCHASE] UPDATE 결과: {result.rowcount}개 행 업데이트됨") + logger.info(f"[PURCHASE] UPDATE 결과: {result.rowcount}개 행 업데이트됨") logger.info(f"✅ {len(request_data.material_ids)}개 자재의 purchase_confirmed를 true로 업데이트") else: - print(f"⚠️ [PURCHASE] material_ids가 비어있음!") + logger.warning(f"[PURCHASE] material_ids가 비어있음!") db.commit() @@ -330,7 +330,7 @@ async def get_request_materials( data = json.load(f) grouped_materials = data.get("grouped_materials", []) except Exception as e: - print(f"⚠️ JSON 파일 읽기 오류 (무시): {e}") + logger.warning(f"JSON 파일 읽기 오류 (무시): {e}") grouped_materials = [] # 개별 자재 정보 조회 (기존 코드) diff --git a/tkeg/api/app/services/excel_parser.py b/tkeg/api/app/services/excel_parser.py index c75165f..18b0262 100644 --- a/tkeg/api/app/services/excel_parser.py +++ b/tkeg/api/app/services/excel_parser.py @@ -1,3 +1,4 @@ +import logging import pandas as pd import re @@ -6,6 +7,8 @@ import uuid from datetime import datetime from pathlib import Path +logger = logging.getLogger(__name__) + # 허용된 확장자 ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"} @@ -72,7 +75,7 @@ class BOMParser: # 양식 감지 format_type = cls.detect_format(df) - print(f"📋 감지된 BOM 양식: {format_type}") + logger.info(f"감지된 BOM 양식: {format_type}") if format_type == 'INVENTOR': return cls._parse_inventor_bom(df) @@ -109,7 +112,7 @@ class BOMParser: mapped_columns[standard_col] = possible_upper break - print(f"📋 [Standard] 컬럼 매핑 결과: {mapped_columns}") + logger.info(f"[Standard] 컬럼 매핑 결과: {mapped_columns}") materials = [] for index, row in df.iterrows(): @@ -190,7 +193,7 @@ class BOMParser: 헤더: NO., NAME, Q'ty, LENGTH & THICKNESS, WEIGHT, DESCIPTION, REMARK 특징: Size 컬럼 부재, NAME에 주요 정보 포함 """ - print("⚠️ [Inventor] 인벤터 양식 파서를 사용합니다.") + logger.warning("[Inventor] 인벤터 양식 파서를 사용합니다.") # 컬럼명 전처리 (좌우 공백 제거 및 대문자화) df.columns = df.columns.str.strip().str.upper() diff --git a/tkeg/api/app/services/material_grade_extractor.py b/tkeg/api/app/services/material_grade_extractor.py index 275a12a..17bf3f2 100644 --- a/tkeg/api/app/services/material_grade_extractor.py +++ b/tkeg/api/app/services/material_grade_extractor.py @@ -3,9 +3,12 @@ 원본 설명에서 완전한 재질명을 추출하여 축약되지 않은 형태로 제공 """ +import logging import re from typing import Optional, Dict +logger = logging.getLogger(__name__) + def extract_full_material_grade(description: str) -> str: """ 원본 설명에서 전체 재질명 추출 @@ -196,7 +199,7 @@ def update_full_material_grades(db, batch_size: int = 1000) -> Dict: count_query = text("SELECT COUNT(*) FROM materials WHERE full_material_grade IS NULL OR full_material_grade = ''") total_count = db.execute(count_query).scalar() - print(f"📊 업데이트 대상 자재: {total_count}개") + logger.info(f"업데이트 대상 자재: {total_count}개") updated_count = 0 processed_count = 0 @@ -244,7 +247,7 @@ def update_full_material_grades(db, batch_size: int = 1000) -> Dict: db.commit() offset += batch_size - print(f"📈 진행률: {processed_count}/{total_count} ({processed_count/total_count*100:.1f}%)") + logger.info(f"진행률: {processed_count}/{total_count} ({processed_count/total_count*100:.1f}%)") return { "total_processed": processed_count, @@ -254,7 +257,7 @@ def update_full_material_grades(db, batch_size: int = 1000) -> Dict: except Exception as e: db.rollback() - print(f"❌ 업데이트 실패: {str(e)}") + logger.error(f"업데이트 실패: {str(e)}") return { "total_processed": 0, "updated_count": 0, diff --git a/tkeg/api/app/services/material_service.py b/tkeg/api/app/services/material_service.py index d1ecccd..1e1e49b 100644 --- a/tkeg/api/app/services/material_service.py +++ b/tkeg/api/app/services/material_service.py @@ -1,3 +1,4 @@ +import logging from sqlalchemy.orm import Session from sqlalchemy import text @@ -18,6 +19,8 @@ from app.services.structural_classifier import classify_structural from app.services.pipe_classifier import classify_pipe_for_purchase, extract_end_preparation_info from app.services.material_grade_extractor import extract_full_material_grade +logger = logging.getLogger(__name__) + class MaterialService: """자재 처리 및 저장을 담당하는 서비스""" @@ -70,7 +73,7 @@ class MaterialService: if revision_comparison and revision_comparison.get("materials_to_classify"): materials_to_classify = revision_comparison.get("materials_to_classify") - print(f"🔧 자재 분류 및 저장 시작: {len(materials_to_classify)}개") + logger.info(f"자재 분류 및 저장 시작: {len(materials_to_classify)}개") for material_data in materials_to_classify: MaterialService._classify_and_save_single_material( @@ -116,7 +119,7 @@ class MaterialService: new_keys.add(new_key) except Exception as e: - print(f"❌ 변경사항 분석 실패: {e}") + logger.error(f"변경사항 분석 실패: {e}") @staticmethod def _generate_material_key(dwg, line, desc, size, grade): @@ -332,7 +335,7 @@ class MaterialService: def inherit_purchase_requests(db: Session, current_file_id: int, parent_file_id: int): """이전 리비전의 구매신청 정보를 상속합니다.""" try: - print(f"🔄 구매신청 정보 상속 처리 시작...") + logger.info(f"구매신청 정보 상속 처리 시작...") # 1. 이전 리비전에서 그룹별 구매신청 수량 집계 prev_purchase_summary = text(""" @@ -396,14 +399,14 @@ class MaterialService: inherited_count = len(new_materials) if inherited_count > 0: - print(f" ✅ {prev_purchase.original_description[:30]}... (도면: {prev_purchase.drawing_name or 'N/A'}) → {inherited_count}/{purchased_count}개 상속") + logger.info(f" {prev_purchase.original_description[:30]}... (도면: {prev_purchase.drawing_name or 'N/A'}) -> {inherited_count}/{purchased_count}개 상속") # 커밋은 호출하는 쪽에서 일괄 처리하거나 여기서 처리 # db.commit() - print(f"✅ 구매신청 정보 상속 완료") + logger.info(f"구매신청 정보 상속 완료") except Exception as e: - print(f"❌ 구매신청 정보 상속 실패: {str(e)}") + logger.error(f"구매신청 정보 상속 실패: {str(e)}") # 상속 실패는 전체 프로세스를 중단하지 않음 @staticmethod diff --git a/tkeg/api/app/services/spool_manager_v2.py b/tkeg/api/app/services/spool_manager_v2.py deleted file mode 100644 index 2d33836..0000000 --- a/tkeg/api/app/services/spool_manager_v2.py +++ /dev/null @@ -1,229 +0,0 @@ -""" -수정된 스풀 관리 시스템 -도면별 스풀 넘버링 + 에리어는 별도 관리 -""" - -import re -from typing import Dict, List, Optional, Tuple -from datetime import datetime - -# ========== 스풀 넘버링 규칙 ========== -SPOOL_NUMBERING_RULES = { - "SPOOL_NUMBER": { - "sequence": ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", - "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", - "U", "V", "W", "X", "Y", "Z"], - "description": "도면별 스풀 넘버" - }, - "AREA_NUMBER": { - "pattern": r"#(\d{2})", # #01, #02, #03... - "format": "#{:02d}", # 2자리 숫자 - "range": (1, 99), # 01~99 - "description": "물리적 구역 넘버 (별도 관리)" - } -} - -class SpoolManagerV2: - """수정된 스풀 관리 클래스""" - - def __init__(self, project_id: int = None): - self.project_id = project_id - - def generate_spool_identifier(self, dwg_name: str, spool_number: str) -> str: - """ - 스풀 식별자 생성 (도면명 + 스풀넘버) - - Args: - dwg_name: 도면명 (예: "A-1", "B-3") - spool_number: 스풀넘버 (예: "A", "B") - - Returns: - 스풀 식별자 (예: "A-1-A", "B-3-B") - """ - - # 스풀 넘버 포맷 검증 - spool_formatted = self.format_spool_number(spool_number) - - # 조합: {도면명}-{스풀넘버} - return f"{dwg_name}-{spool_formatted}" - - def parse_spool_identifier(self, spool_id: str) -> Dict: - """스풀 식별자 파싱""" - - # 패턴: DWG_NAME-SPOOL_NUMBER - # 예: A-1-A, B-3-B, 1-IAR-3B1D0-0129-N-A - - # 마지막 '-' 기준으로 분리 - parts = spool_id.rsplit('-', 1) - - if len(parts) == 2: - dwg_base = parts[0] - spool_number = parts[1] - - return { - "original_id": spool_id, - "dwg_name": dwg_base, - "spool_number": spool_number, - "is_valid": self.validate_spool_number(spool_number), - "format": "CORRECT" - } - else: - return { - "original_id": spool_id, - "dwg_name": None, - "spool_number": None, - "is_valid": False, - "format": "INVALID" - } - - def format_spool_number(self, spool_input: str) -> str: - """스풀 넘버 포맷팅 및 검증""" - - spool_clean = spool_input.upper().strip() - - # 유효한 스풀 넘버인지 확인 (A-Z 단일 문자) - if re.match(r'^[A-Z]$', spool_clean): - return spool_clean - - raise ValueError(f"유효하지 않은 스풀 넘버: {spool_input} (A-Z 단일 문자만 가능)") - - def validate_spool_number(self, spool_number: str) -> bool: - """스풀 넘버 유효성 검증""" - return bool(re.match(r'^[A-Z]$', spool_number)) - - def get_next_spool_number(self, dwg_name: str, existing_spools: List[str] = None) -> str: - """해당 도면의 다음 사용 가능한 스풀 넘버 추천""" - - if not existing_spools: - return "A" # 첫 번째 스풀 - - # 해당 도면의 기존 스풀들 파싱 - used_spools = set() - - for spool_id in existing_spools: - parsed = self.parse_spool_identifier(spool_id) - if parsed["dwg_name"] == dwg_name and parsed["is_valid"]: - used_spools.add(parsed["spool_number"]) - - # 다음 사용 가능한 넘버 찾기 - sequence = SPOOL_NUMBERING_RULES["SPOOL_NUMBER"]["sequence"] - for spool_num in sequence: - if spool_num not in used_spools: - return spool_num - - raise ValueError(f"도면 {dwg_name}에서 사용 가능한 스풀 넘버가 없습니다") - - def validate_spool_identifier(self, spool_id: str) -> Dict: - """스풀 식별자 전체 유효성 검증""" - - parsed = self.parse_spool_identifier(spool_id) - - validation_result = { - "is_valid": True, - "errors": [], - "warnings": [], - "parsed": parsed - } - - # 도면명 확인 - if not parsed["dwg_name"]: - validation_result["is_valid"] = False - validation_result["errors"].append("도면명이 없습니다") - - # 스풀 넘버 확인 - if not parsed["spool_number"]: - validation_result["is_valid"] = False - validation_result["errors"].append("스풀 넘버가 없습니다") - elif not self.validate_spool_number(parsed["spool_number"]): - validation_result["is_valid"] = False - validation_result["errors"].append("스풀 넘버 형식이 잘못되었습니다 (A-Z 단일 문자)") - - return validation_result - -# ========== 에리어 관리 (별도 시스템) ========== -class AreaManager: - """에리어 관리 클래스 (물리적 구역)""" - - def __init__(self, project_id: int = None): - self.project_id = project_id - - def format_area_number(self, area_input: str) -> str: - """에리어 넘버 포맷팅""" - - # 숫자만 추출 - numbers = re.findall(r'\d+', area_input) - if numbers: - area_num = int(numbers[0]) - if 1 <= area_num <= 99: - return f"#{area_num:02d}" - - raise ValueError(f"유효하지 않은 에리어 넘버: {area_input} (#01-#99)") - - def assign_drawings_to_area(self, area_number: str, drawing_names: List[str]) -> Dict: - """도면들을 에리어에 할당""" - - area_formatted = self.format_area_number(area_number) - - return { - "area_number": area_formatted, - "assigned_drawings": drawing_names, - "assignment_count": len(drawing_names), - "assignment_date": datetime.now().isoformat() - } - -def classify_pipe_with_corrected_spool(dat_file: str, description: str, main_nom: str, - length: float = None, dwg_name: str = None, - spool_number: str = None, area_number: str = None) -> Dict: - """파이프 분류 + 수정된 스풀 정보""" - - # 기본 파이프 분류 - from .pipe_classifier import classify_pipe - pipe_result = classify_pipe(dat_file, description, main_nom, length) - - # 스풀 관리자 생성 - spool_manager = SpoolManagerV2() - area_manager = AreaManager() - - # 스풀 정보 처리 - spool_info = { - "dwg_name": dwg_name, - "spool_number": None, - "spool_identifier": None, - "area_number": None, # 별도 관리 - "manual_input_required": True, - "validation": None - } - - # 스풀 넘버가 제공된 경우 - if dwg_name and spool_number: - try: - spool_identifier = spool_manager.generate_spool_identifier(dwg_name, spool_number) - - spool_info.update({ - "spool_number": spool_manager.format_spool_number(spool_number), - "spool_identifier": spool_identifier, - "manual_input_required": False, - "validation": {"is_valid": True, "errors": []} - }) - - except ValueError as e: - spool_info["validation"] = { - "is_valid": False, - "errors": [str(e)] - } - - # 에리어 정보 처리 (별도) - if area_number: - try: - area_formatted = area_manager.format_area_number(area_number) - spool_info["area_number"] = area_formatted - except ValueError as e: - spool_info["area_validation"] = { - "is_valid": False, - "errors": [str(e)] - } - - # 기존 결과에 스풀 정보 추가 - pipe_result["spool_info"] = spool_info - - return pipe_result diff --git a/tkeg/web/src/App.jsx b/tkeg/web/src/App.jsx index 2d70015..08fb6c0 100644 --- a/tkeg/web/src/App.jsx +++ b/tkeg/web/src/App.jsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from 'react'; import DashboardPage from './pages/dashboard/DashboardPage'; import { UserMenu, ErrorBoundary } from './components/common'; -import NewMaterialsPage from './pages/NewMaterialsPage'; import BOMManagementPage from './pages/BOMManagementPage'; import UnifiedBOMPage from './pages/UnifiedBOMPage'; import SystemSettingsPage from './pages/SystemSettingsPage'; diff --git a/tkeg/web/src/pages/DashboardPage.old.jsx b/tkeg/web/src/pages/DashboardPage.old.jsx deleted file mode 100644 index 5ed50d3..0000000 --- a/tkeg/web/src/pages/DashboardPage.old.jsx +++ /dev/null @@ -1,1119 +0,0 @@ -import React, { useState, useEffect } from 'react'; - -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) => { - const projectId = project.job_no || project.official_project_code || project.id; - const projectName = project.job_name || project.project_name || projectId; - - console.log('🔍 비활성화 요청:', { project, projectId, projectName }); - - if (window.confirm(`"${projectName}" 프로젝트를 비활성화하시겠습니까?`)) { - setInactiveProjects(prev => { - const newSet = new Set([...prev, projectId]); - console.log('📦 비활성화 프로젝트 업데이트:', { prev: Array.from(prev), new: Array.from(newSet) }); - return newSet; - }); - - const selectedProjectId = selectedProject?.job_no || selectedProject?.official_project_code || selectedProject?.id; - if (selectedProjectId === projectId) { - setSelectedProject(null); - } - setShowProjectDropdown(false); - } - }; - - // 프로젝트 활성화 - const handleActivateProject = (project) => { - setInactiveProjects(prev => { - const newSet = new Set(prev); - newSet.delete(project.job_no); - return newSet; - }); - }; - - // 프로젝트 삭제 (드롭다운용) - const handleDeleteProjectFromDropdown = (project, e) => { - e.stopPropagation(); - if (window.confirm(`"${project.job_name || project.job_no}" 프로젝트를 완전히 삭제하시겠습니까?`)) { - deleteProject(project.job_no); - setShowProjectDropdown(false); - } - }; - - // 컴포넌트 마운트 시 프로젝트 로드 - useEffect(() => { - loadProjects(); - }, []); - - 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 => { - const projectId = project.job_no || project.official_project_code || project.id; - return !inactiveProjects.has(projectId); - }) - .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 && ( -
-

- Project Management -

- -
- {/* 통합 BOM 관리 */} -
navigateToPage('unified-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, manage revisions, and classify materials in one unified workspace -

- - {/* 기능 미리보기 */} -
-
- 📤 Upload -
-
- 📊 Revisions -
-
- 📋 Materials -
-
-
- - {/* 구매신청 관리 */} -
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 -

-
- {/* 등록된 프로젝트 */} -
-
- {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; \ No newline at end of file diff --git a/tkeg/web/src/pages/NewMaterialsPage.css b/tkeg/web/src/pages/NewMaterialsPage.css deleted file mode 100644 index 28644b2..0000000 --- a/tkeg/web/src/pages/NewMaterialsPage.css +++ /dev/null @@ -1,1193 +0,0 @@ -/* NewMaterialsPage - DevonThink 스타일 */ - -* { - box-sizing: border-box; -} - -.materials-page { - background: #f8f9fa; - min-height: 100vh; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif; - overflow-x: auto; - min-width: 1400px; /* 페이지 최소 너비 고정 */ -} - -/* 헤더 */ -.materials-header { - background: white; - border-bottom: 1px solid #e5e7eb; - padding: 16px 24px; -} - -.header-center { - display: flex; - align-items: center; - gap: 16px; -} - -.revision-selector { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - background: #f8f9fa; - border: 1px solid #e9ecef; - border-radius: 6px; -} - -.revision-selector label { - font-size: 14px; - font-weight: 500; - color: #495057; - margin: 0; -} - -.revision-dropdown { - padding: 4px 8px; - border: 1px solid #ced4da; - border-radius: 4px; - background: white; - font-size: 14px; - color: #495057; - cursor: pointer; - min-width: 180px; -} - -.revision-dropdown:focus { - outline: none; - 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; -} - -.header-left { - display: flex; - align-items: center; - gap: 16px; -} - -.back-button { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - background: #6366f1; - color: white; - border: none; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; -} - -.back-button:hover { - background: #5558e3; - transform: translateY(-1px); -} - -/* 심플한 뒤로가기 버튼 */ -.back-button-simple { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - background: #86efac; - color: #166534; - border: 1px solid #bbf7d0; - border-radius: 6px; - font-size: 18px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; -} - -.back-button-simple:hover { - background: #6ee7b7; - transform: translateX(-2px); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.header-info { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} - -.materials-header h1 { - font-size: 18px; - font-weight: 600; - color: #1f2937; - margin: 0; -} - -.job-info { - color: #6b7280; - font-size: 13px; - font-weight: 400; -} - -.material-count-inline { - color: #6b7280; - font-size: 12px; - background: #f3f4f6; - padding: 2px 8px; - border-radius: 8px; -} - -.material-count { - color: #6b7280; - font-size: 14px; - background: #f3f4f6; - padding: 4px 12px; - border-radius: 12px; -} - -/* 메인 헤더 */ -.materials-header { - background: white; - padding: 8px 24px; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid #e5e7eb; - min-height: 50px; -} - -.header-left { - display: flex; - align-items: center; - gap: 12px; -} - -/* 카테고리 필터 */ -.category-filters { - background: white; - padding: 12px 24px; - display: flex; - gap: 8px; - align-items: center; - border-bottom: 1px solid #e5e7eb; - overflow-x: auto; -} - -.category-filters::-webkit-scrollbar { - height: 6px; -} - -.category-filters::-webkit-scrollbar-track { - background: #f3f4f6; - border-radius: 3px; -} - -.category-filters::-webkit-scrollbar-thumb { - background: #d1d5db; - border-radius: 3px; -} - -.category-btn { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 14px; - background: white; - border: 1px solid #e5e7eb; - border-radius: 20px; - font-size: 13px; - font-weight: 500; - color: #4b5563; - cursor: pointer; - transition: all 0.2s; - white-space: nowrap; -} - -.category-btn:hover { - background: #f9fafb; - border-color: #d1d5db; -} - -.category-btn.active { - background: #eef2ff; - border-color: #6366f1; - color: #4f46e5; -} - -.category-btn .count { - background: #f3f4f6; - padding: 2px 6px; - border-radius: 10px; - font-size: 11px; - font-weight: 600; - min-width: 20px; - text-align: center; -} - -.category-btn.active .count { - background: #6366f1; - color: white; -} - -/* 액션 바 */ -.action-bar { - background: white; - padding: 12px 24px; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid #e5e7eb; -} - -.selection-info { - font-size: 13px; - color: #6b7280; -} - -.action-buttons { - display: flex; - gap: 8px; -} - -.select-all-btn, -.export-btn { - padding: 6px 14px; - border: none; - border-radius: 6px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; -} - -.select-all-btn { - background: white; - border: 1px solid #e5e7eb; - color: #374151; -} - -.select-all-btn:hover { - background: #f9fafb; - border-color: #d1d5db; -} - -.export-btn { - background: #10b981; - color: white; -} - -.export-btn:hover { - background: #059669; -} - -.export-btn:disabled { - background: #e5e7eb; - color: #9ca3af; - cursor: not-allowed; -} - -/* 자재 테이블 - 엑셀 스타일 */ -.materials-grid { - background: white; - margin: 16px 24px; - overflow-y: auto; - overflow-x: auto; /* 좌우 스크롤 가능하도록 변경 */ - max-height: calc(100vh - 220px); - border: 1px solid #d1d5db; -} - -.detailed-grid-header { - display: grid; - /* 기본 그리드는 사용하지 않음 - 각 카테고리별 전용 클래스 사용 */ - background: #f3f4f6; - border-bottom: 2px solid #9ca3af; - font-size: 11px; - font-weight: 600; - color: #374151; - text-transform: uppercase; - letter-spacing: 0.3px; -} - -.detailed-grid-header > div, -.detailed-grid-header .filterable-header { - text-align: center; - display: flex; - align-items: center; - justify-content: center; - min-height: 32px; - padding: 6px 4px; - border-right: 1px solid #d1d5db; - background: #f3f4f6; -} - -.detailed-grid-header > div:first-child, -.detailed-grid-header .filterable-header:first-child { - border-left: 1px solid #d1d5db; -} - -.detailed-grid-header > div:last-child, -.detailed-grid-header .filterable-header:last-child { - border-right: 1px solid #d1d5db; -} - -/* PIPE 전용 헤더 - 9개 컬럼 */ -.detailed-grid-header.pipe-header { - grid-template-columns: 2% 8% 14% 8% 10% 20% 12% 15% 10% !important; -} - -.detailed-grid-header.pipe-header > div, -.detailed-grid-header.pipe-header .filterable-header { - border-right: 1px solid #d1d5db; -} - -.detailed-grid-header.pipe-header > div:last-child, -.detailed-grid-header.pipe-header .filterable-header:last-child { - border-right: none; -} - -/* PIPE 전용 행 - 9개 컬럼 */ -.detailed-material-row.pipe-row { - grid-template-columns: 1.5% 8.5% 14% 8% 10% 20% 12% 15% 10% !important; -} - -.detailed-material-row.pipe-row .material-cell { - border-right: 1px solid #d1d5db; -} - -.detailed-material-row.pipe-row .material-cell:last-child { - border-right: none; -} - -/* SPECIAL 전용 헤더 - 10개 컬럼 */ -.detailed-grid-header.special-header { - grid-template-columns: 5% 8% 14% 8% 10% 18% 12% 12% 18% 5%; - - -} - -.detailed-grid-header.special-header > div, -.detailed-grid-header.special-header .filterable-header { - border-right: 1px solid #d1d5db; -} - -.detailed-grid-header.special-header > div:last-child, -.detailed-grid-header.special-header .filterable-header:last-child { - border-right: none; -} - -/* SPECIAL 전용 행 - 10개 컬럼 */ -.detailed-material-row.special-row { - grid-template-columns: 5% 8% 14% 8% 10% 18% 12% 12% 18% 5%; - - -} - -/* BOLT 전용 헤더 - 9개 컬럼 */ -.detailed-grid-header.bolt-header { - grid-template-columns: 2% 8% 14% 8% 10% 20% 12% 15% 10%; - - -} - -/* BOLT 전용 행 - 9개 컬럼 */ -.detailed-material-row.bolt-row { - grid-template-columns: 2% 8% 14% 8% 10% 20% 12% 15% 10%; - - -} - -.detailed-material-row.special-row .material-cell { - border-right: 1px solid #d1d5db; -} - -/* BOLT 헤더 테두리 */ -.detailed-grid-header.bolt-header > div, -.detailed-grid-header.bolt-header .filterable-header { - border-right: 1px solid #d1d5db; -} - -.detailed-grid-header.bolt-header > div:last-child, -.detailed-grid-header.bolt-header .filterable-header:last-child { - border-right: none; -} - -/* BOLT 행 테두리 */ -.detailed-material-row.bolt-row .material-cell { - border-right: 1px solid #d1d5db; -} - -.detailed-material-row.bolt-row .material-cell:last-child { - border-right: none; -} - -/* BOLT 타입 배지 */ -.type-badge.bolt { - background: #7c3aed; - color: white; - border: 2px solid #6d28d9; - font-weight: 600; -} - -/* SUPPORT 전용 헤더 - 8개 컬럼 */ -.detailed-grid-header.support-header { - grid-template-columns: 3% 10% 12% 10% 25% 10% 18% 12%; - - -} - -/* SUPPORT 전용 행 - 8개 컬럼 */ -.detailed-material-row.support-row { - grid-template-columns: 3% 10% 12% 10% 25% 10% 18% 12%; - - -} - -/* SUPPORT 헤더 테두리 */ -.detailed-grid-header.support-header > div, -.detailed-grid-header.support-header .filterable-header { - border-right: 1px solid #d1d5db; -} -.detailed-grid-header.support-header > div:last-child, -.detailed-grid-header.support-header .filterable-header:last-child { - border-right: none; -} - -/* SUPPORT 행 테두리 */ -.detailed-material-row.support-row .material-cell { - border-right: 1px solid #d1d5db; -} -.detailed-material-row.support-row .material-cell:last-child { - border-right: none; -} - -/* SUPPORT 타입 배지 */ -.type-badge.support { - background: #059669; - color: white; - border: 2px solid #047857; - font-weight: 600; -} - -/* URETHANE 타입 배지 */ -.type-badge.urethane { - background: #ea580c; - color: white; - border: 2px solid #c2410c; - font-weight: 600; -} - -/* CLAMP 타입 배지 */ -.type-badge.clamp { - background: #0d9488; - color: white; - border: 2px solid #0f766e; - font-weight: 600; -} - -/* SUPPORT 전용 헤더 - 9개 컬럼 */ -.detailed-grid-header.support-header { - grid-template-columns: 2% 8% 16% 8% 10% 18% 12% 15% 10%; - - -} - -/* SUPPORT 전용 행 - 9개 컬럼 */ -.detailed-material-row.support-row { - grid-template-columns: 2% 8% 16% 8% 10% 18% 12% 15% 10%; - - -} - -/* SUPPORT 헤더 테두리 */ -.detailed-grid-header.support-header > div, -.detailed-grid-header.support-header .filterable-header { - border-right: 1px solid #d1d5db; -} -.detailed-grid-header.support-header > div:last-child, -.detailed-grid-header.support-header .filterable-header:last-child { - border-right: none; -} - -/* SUPPORT 행 테두리 */ -.detailed-material-row.support-row .material-cell { - border-right: 1px solid #d1d5db; -} -.detailed-material-row.support-row .material-cell:last-child { - border-right: none; -} - -/* SUPPORT 타입 배지 */ -.type-badge.support { - background: #0891b2; - color: white; - border: 2px solid #0e7490; - font-weight: 600; -} - -.detailed-material-row.special-row .material-cell:last-child { - border-right: none; -} - -/* 플랜지 전용 헤더 - 10개 컬럼 */ -.detailed-grid-header.flange-header { - grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 15% 8% 12% 11.5%; - - -} - -.detailed-grid-header.flange-header > div, -.detailed-grid-header.flange-header .filterable-header { - border-right: 1px solid #d1d5db; -} - -.detailed-grid-header.flange-header > div:last-child, -.detailed-grid-header.flange-header .filterable-header:last-child { - border-right: none; -} - -/* 플랜지 전용 행 - 10개 컬럼 */ -.detailed-material-row.flange-row { - grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 15% 8% 12% 11.5%; - - -} - -.detailed-material-row.flange-row .material-cell { - border-right: 1px solid #d1d5db; -} - -.detailed-material-row.flange-row .material-cell:last-child { - border-right: none; -} - -/* 피팅 전용 헤더 - 10개 컬럼 */ -.detailed-grid-header.fitting-header { - grid-template-columns: 2% 8.5% 16% 7.5% 7.5% 9% 15% 9% 13% 12%; - - -} - -.detailed-grid-header.fitting-header > div, -.detailed-grid-header.fitting-header .filterable-header { - border-right: 1px solid #d1d5db; -} - -.detailed-grid-header.fitting-header > div:last-child, -.detailed-grid-header.fitting-header .filterable-header:last-child { - border-right: none; -} - -/* 피팅 전용 행 - 10개 컬럼 */ -.detailed-material-row.fitting-row { - grid-template-columns: 2% 8.5% 16% 7.5% 7.5% 9% 15% 9% 13% 12%; - - -} - -.detailed-material-row.fitting-row .material-cell { - border-right: 1px solid #d1d5db; -} - -.detailed-material-row.fitting-row .material-cell:last-child { - border-right: none; -} - -/* 밸브 전용 헤더 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */ -.detailed-grid-header.valve-header { - grid-template-columns: 3% 19% 12% 8% 8% 18% 10% 15% 6%; - - -} - -.detailed-grid-header.valve-header > div, -.detailed-grid-header.valve-header .filterable-header { - border-right: 1px solid #d1d5db; -} - -.detailed-grid-header.valve-header > div:last-child, -.detailed-grid-header.valve-header .filterable-header:last-child { - border-right: none; -} - -/* 밸브 전용 행 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */ -.detailed-material-row.valve-row { - grid-template-columns: 3% 19% 12% 8% 8% 18% 10% 15% 6%; - - -} - -.detailed-material-row.valve-row .material-cell { - border-right: 1px solid #d1d5db; -} - -.detailed-material-row.valve-row .material-cell:last-child { - border-right: none; -} - -/* 가스켓 전용 헤더 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */ -.detailed-grid-header.gasket-header { - grid-template-columns: 2% 8% 12% 7% 7% 10% 18% 7% 10% 15% 3%; - - -} - -.detailed-grid-header.gasket-header > div, -.detailed-grid-header.gasket-header .filterable-header { - border-right: 1px solid #d1d5db; -} - -.detailed-grid-header.gasket-header > div:last-child, -.detailed-grid-header.gasket-header .filterable-header:last-child { - border-right: none; -} - -/* 가스켓 전용 행 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */ -.detailed-material-row.gasket-row { - grid-template-columns: 2% 8% 12% 7% 7% 10% 18% 7% 10% 15% 3%; - - -} - -.detailed-material-row.gasket-row .material-cell { - border-right: 1px solid #d1d5db; -} - -.detailed-material-row.gasket-row .material-cell:last-child { - border-right: none; -} - -/* 필터링 가능한 헤더 스타일 */ -.filterable-header { - position: relative; - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - gap: 4px; - padding: 0; - background: transparent; - cursor: pointer; - user-select: none; - transition: all 0.2s ease; - min-height: 40px; -} - -.filterable-header:hover { - background: #f1f5f9; -} - -.header-text { - font-size: 12px; - font-weight: 600; - color: #64748b; - text-transform: uppercase; - letter-spacing: 0.5px; - text-align: center; - white-space: nowrap; - margin: 0; -} - -.header-controls { - display: flex; - gap: 2px; - opacity: 0.7; - flex-shrink: 0; - transition: opacity 0.2s ease; -} - -.filterable-header:hover .header-controls { - opacity: 1; -} - -.sort-btn, .filter-btn { - background: white; - border: 1px solid #e2e8f0; - padding: 1px; - border-radius: 3px; - cursor: pointer; - font-size: 9px; - color: #64748b; - transition: all 0.15s ease; - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; -} - -.sort-btn:hover, .filter-btn:hover { - background: #f8fafc; - border-color: #cbd5e1; - color: #475569; -} - -.sort-btn.active { - background: #3b82f6; - border-color: #3b82f6; - color: white; -} - -.filter-btn.active { - background: #10b981; - border-color: #10b981; - color: white; -} - -/* 필터 드롭다운 */ -.filter-dropdown { - position: absolute; - top: calc(100% + 2px); - left: 0; - right: 0; - background: white; - border: 1px solid #e2e8f0; - border-radius: 8px; - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); - z-index: 1000; - max-height: 240px; - overflow: hidden; - display: flex; - flex-direction: column; - animation: dropdownFadeIn 0.15s ease-out; -} - -@keyframes dropdownFadeIn { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.filter-search { - padding: 8px; - border-bottom: 1px solid #f1f5f9; - background: #fafbfc; - display: flex; - align-items: center; - gap: 6px; -} - -.filter-search input { - flex: 1; - padding: 6px 8px; - border: 1px solid #e2e8f0; - border-radius: 4px; - font-size: 12px; - outline: none; - transition: border-color 0.15s ease; - background: white; - color: #374151; -} - -.filter-search input:focus { - border-color: #3b82f6; -} - -.filter-search input::placeholder { - color: #9ca3af; - font-size: 11px; -} - -.clear-filter-btn { - background: #ef4444; - color: white; - border: none; - padding: 4px 6px; - border-radius: 3px; - cursor: pointer; - font-size: 10px; - font-weight: 500; - transition: background-color 0.15s ease; -} - -.clear-filter-btn:hover { - background: #dc2626; -} - -.filter-options { - flex: 1; - overflow-y: auto; - max-height: 160px; -} - -.filter-option-header { - padding: 4px 10px; - font-size: 10px; - font-weight: 600; - color: #6b7280; - background: #f8fafc; - border-bottom: 1px solid #f1f5f9; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.filter-option { - padding: 4px 10px; - font-size: 11px; - cursor: pointer; - transition: background-color 0.1s ease; - color: #374151; -} - -.filter-option:hover { - background: #f8fafc; -} - -.filter-option-more { - padding: 4px 10px; - font-size: 10px; - color: #9ca3af; - font-style: italic; - text-align: center; - background: #f8fafc; -} - -/* 액션 바 스타일 개선 */ -.filter-info { - font-size: 11px; - color: #6b7280; - margin-left: 8px; -} - -.clear-filters-btn { - background: #f59e0b; - color: white; - border: none; - padding: 8px 12px; - border-radius: 4px; - cursor: pointer; - font-size: 12px; - margin-right: 8px; - transition: background-color 0.2s; -} - -.clear-filters-btn:hover { - background: #d97706; -} - -/* UNCLASSIFIED 전용 헤더 - 5개 컬럼 */ -.detailed-grid-header.unknown-header { - grid-template-columns: 5% 10% 1fr 20% 10%; - - -} - -.detailed-grid-header.unknown-header > div, -.detailed-grid-header.unknown-header .filterable-header { - border-right: 1px solid #d1d5db; -} - -.detailed-grid-header.unknown-header > div:last-child, -.detailed-grid-header.unknown-header .filterable-header:last-child { - border-right: none; -} - -/* UNCLASSIFIED 전용 행 - 5개 컬럼 */ -.detailed-material-row.unknown-row { - grid-template-columns: 5% 10% 1fr 20% 10%; - - -} - -.detailed-material-row.unknown-row .material-cell { - border-right: 1px solid #d1d5db; -} - -.detailed-material-row.unknown-row .material-cell:last-child { - border-right: none; -} - -/* UNCLASSIFIED 설명 셀 스타일 */ -.description-cell { - overflow: visible; - text-overflow: initial; - white-space: normal; - word-break: break-word; -} - -.description-text { - display: block; - overflow: visible; - text-overflow: initial; - white-space: normal; - word-break: break-word; -} - -.detailed-material-row { - display: grid; - /* 기본 그리드는 사용하지 않음 - 각 카테고리별 전용 클래스 사용 */ - border-bottom: 1px solid #d1d5db; - background: white; - transition: background 0.15s; - font-size: 12px; -} - -.detailed-material-row .material-cell { - border-right: 1px solid #d1d5db; - padding: 6px 4px; - display: flex; - align-items: center; - justify-content: center; - min-height: 28px; -} - -.detailed-material-row .material-cell:first-child { - border-left: 1px solid #d1d5db; -} - -.detailed-material-row .material-cell:last-child { - border-right: 1px solid #d1d5db; -} - -.detailed-material-row:hover { - background: #fafbfc; -} - -.detailed-material-row.selected { - background: #f0f9ff; -} - -.material-cell { - overflow: visible !important; - text-overflow: initial !important; - text-align: center; - display: flex; - align-items: center; - justify-content: center; - white-space: normal !important; - word-break: break-word; - min-width: 120px; - max-width: none !important; - min-height: 40px; -} - -.material-cell > * { - margin: 0; - flex-shrink: 0; -} - -/* 사용자 요구사항 입력 필드 */ -.user-req-input { - width: 100%; - padding: 4px 8px; - border: 1px solid #d1d5db; - border-radius: 4px; - font-size: 12px; - outline: none; - text-align: center; - margin: 0; -} - -.user-req-input:focus { - border-color: #3b82f6; - box-shadow: 0 0 0 1px #3b82f6; -} - -.material-cell input[type="checkbox"] { - width: 14px; - height: 14px; - cursor: pointer; - margin: 0; - padding: 0; - flex-shrink: 0; - box-sizing: border-box; -} - -/* 타입 배지 */ -.type-badge { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 3px 8px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.3px; - margin: 0; - flex-shrink: 0; -} - -.type-badge.pipe { - background: #10b981; - color: white; -} - -.type-badge.fitting { - background: #3b82f6; - color: white; -} - -.type-badge.valve { - background: #f59e0b; - color: white; -} - -.type-badge.flange { - background: #8b5cf6; - color: white; -} - -.type-badge.bolt { - background: #ef4444; - color: white; -} - -.type-badge.gasket { - background: #06b6d4; - color: white; -} - -.type-badge.special { - background: #dc2626; - color: white; - border: 2px solid #b91c1c; - font-weight: 700; - box-shadow: 0 2px 4px rgba(220, 38, 38, 0.2); -} - -.type-badge.unknown { - background: #6b7280; - color: white; -} - -.type-badge.instrument { - background: #78716c; - color: white; -} - -/* 텍스트 스타일 */ -.subtype-text, -.size-text, -.material-grade { - color: #1f2937; - font-weight: 500; - white-space: normal !important; - word-break: break-word !important; - overflow: visible !important; - text-overflow: initial !important; - min-width: 300px !important; -} - -/* 입력 필드 */ -.user-req-input { - width: 100%; - padding: 4px 8px; - border: 1px solid #e5e7eb; - border-radius: 4px; - font-size: 12px; - background: #fafbfc; - transition: all 0.2s; -} - -.user-req-input:focus { - outline: none; - border-color: #6366f1; - background: white; -} - -.user-req-input::placeholder { - color: #9ca3af; -} - -/* 수량 정보 */ -.quantity-info { - display: flex; - flex-direction: column; - gap: 2px; -} - -/* 플랜지 압력 정보 */ -.pressure-info { - font-weight: 600; - color: #0066cc; -} - -.quantity-value { - font-weight: 600; - color: #1f2937; - font-size: 14px; -} - -.quantity-details { - font-size: 11px; - color: #9ca3af; -} - -/* 로딩 */ -.loading-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 400px; - background: white; -} - -.loading-spinner { - width: 40px; - height: 40px; - border: 3px solid #f3f4f6; - border-top: 3px solid #6366f1; - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -.loading-container p { - margin-top: 16px; - color: #6b7280; - font-size: 14px; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -/* 스크롤바 스타일 */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: #f3f4f6; -} - -::-webkit-scrollbar-thumb { - background: #d1d5db; - border-radius: 4px; -} - -::-webkit-scrollbar-thumb:hover { - background: #9ca3af; -} - -/* ================================ - 리비전 상태별 스타일 - ================================ */ - -/* 재고품 스타일 (구매신청 후 리비전에서 삭제됨) */ -.detailed-material-row.inventory { - background-color: #fef3c7 !important; /* 연노랑색 배경 */ - border-left: 4px solid #f59e0b !important; /* 주황색 왼쪽 테두리 */ -} - -/* 삭제된 자재 (구매신청 전) - 숨김 처리 */ -.detailed-material-row.deleted-not-purchased { - display: none !important; -} - -/* 변경된 자재 (추가 구매 필요) */ -.detailed-material-row.changed { - border-left: 4px solid #3b82f6 !important; /* 파란색 왼쪽 테두리 */ -} \ No newline at end of file diff --git a/tkeg/web/src/pages/NewMaterialsPage.jsx b/tkeg/web/src/pages/NewMaterialsPage.jsx deleted file mode 100644 index 436512d..0000000 --- a/tkeg/web/src/pages/NewMaterialsPage.jsx +++ /dev/null @@ -1,2849 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { fetchMaterials } from '../api'; -import { exportMaterialsToExcel } from '../utils/excelExport'; -import * as XLSX from 'xlsx'; -import { saveAs } from 'file-saver'; -import api from '../api'; -import './NewMaterialsPage.css'; - -const NewMaterialsPage = ({ - 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 [viewMode, setViewMode] = useState('detailed'); // 'detailed' or 'simple' - const [availableRevisions, setAvailableRevisions] = useState([]); - const [currentRevision, setCurrentRevision] = useState(revision || 'Rev.0'); - - // 사용자 요구사항 상태 관리 - const [userRequirements, setUserRequirements] = useState({}); - // materialId: requirement 형태 - const [savingRequirements, setSavingRequirements] = useState(false); - - // 정렬 및 필터링 상태 - const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); - const [columnFilters, setColumnFilters] = useState({}); - const [showFilterDropdown, setShowFilterDropdown] = useState(null); - - // 구매신청된 자재 ID 관리 - const [purchasedMaterials, setPurchasedMaterials] = useState(new Set()); - - // 같은 BOM의 다른 리비전들 조회 - 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); - console.log('📋 사용 가능한 리비전:', sameBomFiles); - } catch (error) { - console.error('리비전 목록 조회 실패:', error); - } - }; - - // 자재 데이터 로드 - useEffect(() => { - if (fileId) { - loadMaterials(fileId); - loadAvailableRevisions(); - loadUserRequirements(fileId); - } - }, [fileId]); - - // 외부 클릭 시 필터 드롭다운 닫기 - useEffect(() => { - const handleClickOutside = (event) => { - if (!event.target.closest('.filterable-header')) { - setShowFilterDropdown(null); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - // 구매신청된 자재 확인 - const loadPurchasedMaterials = async (jobNo) => { - try { - const response = await api.get('/purchase-request/list', { - params: { job_no: jobNo } - }); - - if (response.data?.requests) { - const allPurchasedIds = new Set(); - - // 모든 구매신청에서 자재 ID 수집 - for (const request of response.data.requests) { - try { - const detailResponse = await api.get(`/purchase-request/${request.request_id}/materials`); - if (detailResponse.data?.materials) { - detailResponse.data.materials.forEach(m => { - if (m.material_ids) { - // 그룹화된 자재의 모든 ID 추가 - m.material_ids.forEach(id => allPurchasedIds.add(id)); - } else if (m.material_id) { - // 개별 자재 ID 추가 - allPurchasedIds.add(m.material_id); - } - }); - } - } catch (err) { - console.error('구매신청 상세 로드 실패:', err); - } - } - - setPurchasedMaterials(allPurchasedIds); - console.log(`✅ 구매신청된 자재 ${allPurchasedIds.size}개 확인`); - } - } catch (error) { - console.error('구매신청 목록 로드 실패:', error); - } - }; - - 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}개 원본 자재 로드 완료`); - - // PIPE 데이터 샘플 확인 - const pipeSample = materialsData.find(m => m.classified_category === 'PIPE'); - if (pipeSample) { - console.log('📊 PIPE 샘플 데이터:', pipeSample); - console.log('📊 pipe_details:', pipeSample.pipe_details); - } - - // 같은 사양끼리 그룹화 (PIPE는 특별 처리) - const groupedMap = new Map(); - - materialsData.forEach(material => { - let groupKey; - - if (material.classified_category === 'PIPE') { - // PIPE는 길이를 제외한 사양으로 그룹화 - // 예: "PIPE 2" SCH40 120mm"과 "PIPE 2" SCH40 60mm"은 같은 그룹 - const descWithoutLength = material.original_description.replace(/\d+(?:\.\d+)?\s*mm/gi, '').trim(); - groupKey = `PIPE|${descWithoutLength}|${material.size_spec || ''}|${material.schedule || ''}|${material.material_grade || ''}`; - } else { - // PIPE가 아닌 경우 기존 방식대로 그룹화 - groupKey = `${material.original_description}|${material.size_spec || ''}|${material.schedule || ''}|${material.material_grade || ''}|${material.classified_category}`; - } - - if (groupedMap.has(groupKey)) { - // 이미 있는 그룹에 추가 - const existing = groupedMap.get(groupKey); - - if (material.classified_category === 'PIPE') { - // PIPE의 경우 - 백엔드에서 이미 그룹화되어 온 경우 처리 - if (material.pipe_details && material.pipe_details.individual_pipes) { - // 백엔드에서 그룹화된 개별 파이프 정보 사용 - const individualPipes = material.pipe_details.individual_pipes; - - individualPipes.forEach(pipe => { - const pipeLength = parseFloat(pipe.length); - const pipeQty = parseFloat(pipe.quantity || 1); - const pipeTotalLength = pipeLength * pipeQty; - - if (existing.pipe_lengths) { - existing.pipe_lengths.push({ length: pipeLength, quantity: pipeQty, totalLength: pipeTotalLength }); - existing.total_length = (existing.total_length || 0) + pipeTotalLength; - } else { - existing.pipe_lengths = [{ length: pipeLength, quantity: pipeQty, totalLength: pipeTotalLength }]; - existing.total_length = pipeTotalLength; - } - }); - - existing.quantity = material.pipe_details.group_total_quantity || material.quantity; - } else { - // 개별 파이프 처리 (백엔드 그룹화 없는 경우) - const qty = parseFloat(material.quantity); - let length = 6000; // 기본값 6m - - if (material.pipe_details && material.pipe_details.length_mm) { - length = parseFloat(material.pipe_details.length_mm); - } - - const totalLength = length * qty; - - if (existing.pipe_lengths) { - existing.pipe_lengths.push({ length, quantity: qty, totalLength }); - existing.total_length = (existing.total_length || 0) + totalLength; - } else { - existing.pipe_lengths = [{ length, quantity: qty, totalLength }]; - existing.total_length = totalLength; - } - - existing.quantity = (parseFloat(existing.quantity) + qty).toFixed(3); - } - - // 6,000mm를 1본으로 계산 - existing.pipe_count = Math.ceil((existing.total_length || 0) / 6000); - } else { - // PIPE가 아닌 경우 수량만 합산 - existing.quantity = (parseFloat(existing.quantity) + parseFloat(material.quantity)).toFixed(3); - } - - existing.grouped_ids = [...existing.grouped_ids, material.id]; - } else { - // 새 그룹 생성 - const newGroup = { - ...material, - grouped_ids: [material.id], // 그룹에 속한 모든 자재 ID 저장 - is_grouped: true - }; - - // PIPE의 경우 길이 정보 초기화 - if (material.classified_category === 'PIPE') { - // 백엔드에서 이미 그룹화된 경우 - if (material.pipe_details && material.pipe_details.individual_pipes) { - newGroup.pipe_lengths = material.pipe_details.individual_pipes.map(pipe => ({ - length: parseFloat(pipe.length), - quantity: parseFloat(pipe.quantity || 1), - totalLength: parseFloat(pipe.length) * parseFloat(pipe.quantity || 1) - })); - newGroup.total_length = material.pipe_details.total_length_mm || - newGroup.pipe_lengths.reduce((sum, p) => sum + p.totalLength, 0); - newGroup.quantity = material.pipe_details.group_total_quantity || material.quantity; - } else { - // 개별 파이프 - const qty = parseFloat(material.quantity); - let length = 6000; // 기본값 6m - - if (material.pipe_details && material.pipe_details.length_mm) { - length = parseFloat(material.pipe_details.length_mm); - } - - const totalLength = length * qty; - newGroup.pipe_lengths = [{ length, quantity: qty, totalLength }]; - newGroup.total_length = totalLength; - } - - newGroup.pipe_count = Math.ceil(newGroup.total_length / 6000); // 6,000mm를 1본으로 계산 - } - - groupedMap.set(groupKey, newGroup); - } - }); - - const groupedMaterials = Array.from(groupedMap.values()); - console.log(`📦 ${groupedMaterials.length}개 그룹으로 집계 완료`); - - // 파이프 데이터 검증 - const pipes = groupedMaterials.filter(m => m.classified_category === 'PIPE'); - if (pipes.length > 0) { - console.log('📊 파이프 데이터 샘플:', pipes[0]); - } - - setMaterials(groupedMaterials); - } - } catch (error) { - console.error('❌ 자재 로딩 실패:', error); - setMaterials([]); - } finally { - setLoading(false); - } - }; - - // 사용자 요구사항 로드 - const loadUserRequirements = async (id) => { - try { - console.log('🔍 사용자 요구사항 로딩 중...', { file_id: id }); - - const response = await api.get('/files/user-requirements', { - params: { file_id: parseInt(id) } - }); - - if (response.data && Array.isArray(response.data)) { - const requirements = {}; - console.log('📦 API 응답 데이터:', response.data); - response.data.forEach(req => { - // material_id를 키로 사용하여 요구사항 저장 - if (req.material_id) { - requirements[req.material_id] = req.requirement_description || req.requirement_title || ''; - console.log(`📥 로드된 요구사항: 자재 ID ${req.material_id} = "${requirements[req.material_id]}"`); - } else { - console.warn('⚠️ material_id가 없는 요구사항:', req); - } - }); - - console.log('🔄 setUserRequirements 호출 전 상태:', userRequirements); - setUserRequirements(requirements); - console.log('🔄 setUserRequirements 호출 후 새로운 상태:', requirements); - console.log('✅ 사용자 요구사항 로드 완료:', Object.keys(requirements).length, '개'); - - // 상태 업데이트 확인을 위한 지연된 로그 - setTimeout(() => { - console.log('⏰ 1초 후 실제 userRequirements 상태:', userRequirements); - }, 1000); - } - } catch (error) { - console.error('❌ 사용자 요구사항 로딩 실패:', error); - setUserRequirements({}); - } - }; - - // 사용자 요구사항 저장 - const saveUserRequirements = async () => { - try { - setSavingRequirements(true); - - // 강제 테스트: userRequirements가 비어있으면 첫 번째 자재에 테스트 데이터 추가 - let currentRequirements = { ...userRequirements }; - if (Object.keys(currentRequirements).length === 0 && materials.length > 0) { - const firstMaterialId = materials[0].id; - currentRequirements[firstMaterialId] = '강제 테스트 요구사항'; - setUserRequirements(currentRequirements); - console.log('⚠️ 테스트 데이터 강제 추가:', currentRequirements); - } - - // 디버깅: 현재 userRequirements 상태 확인 - console.log('💾 저장 시작 - 현재 userRequirements:', currentRequirements); - console.log('💾 저장 시작 - userRequirements 키 개수:', Object.keys(currentRequirements).length); - - console.log('💾 사용자 요구사항 저장 중...', userRequirements); - console.log('📋 전체 userRequirements 객체:', Object.keys(userRequirements).length, '개'); - - // 요구사항이 있는 자재들만 저장 - const requirementsToSave = Object.entries(currentRequirements) - .filter(([materialId, requirement]) => { - const hasValue = requirement && requirement.trim() && requirement.trim().length > 0; - console.log(`🔍 자재 ID ${materialId}: "${requirement}" (길이: ${requirement ? requirement.length : 0}) -> ${hasValue ? '저장' : '제외'}`); - return hasValue; - }) - .map(([materialId, requirement]) => ({ - material_id: parseInt(materialId), - file_id: parseInt(fileId), - requirement_type: 'CUSTOM_SPEC', - requirement_title: '사용자 요구사항', - requirement_description: requirement.trim(), - priority: 'NORMAL' - })); - - console.log('📝 저장할 요구사항 개수:', requirementsToSave.length); - - if (requirementsToSave.length === 0) { - alert('저장할 요구사항이 없습니다.'); - return; - } - - // 기존 요구사항 삭제 후 새로 저장 - console.log('🗑️ 기존 요구사항 삭제 중...', { file_id: parseInt(fileId) }); - console.log('🌐 API Base URL:', api.defaults.baseURL); - console.log('🔑 Authorization Header:', api.defaults.headers.Authorization); - - try { - const deleteResponse = await api.delete(`/files/user-requirements`, { - params: { file_id: parseInt(fileId) } - }); - console.log('✅ 기존 요구사항 삭제 완료:', deleteResponse.data); - } catch (deleteError) { - console.error('❌ 기존 요구사항 삭제 실패:', deleteError); - console.error('❌ 삭제 에러 상세:', deleteError.response?.data); - console.error('❌ 삭제 에러 전체:', deleteError); - // 삭제 실패해도 계속 진행 - } - - // 새 요구사항들 저장 - console.log('🚀 API 호출 시작 - 저장할 데이터:', requirementsToSave); - for (const req of requirementsToSave) { - console.log('🚀 개별 API 호출:', req); - try { - const response = await api.post('/files/user-requirements', req); - console.log('✅ API 응답:', response.data); - } catch (apiError) { - console.error('❌ API 호출 실패:', apiError); - console.error('❌ API 에러 상세:', apiError.response?.data); - throw apiError; - } - } - - alert(`${requirementsToSave.length}개의 사용자 요구사항이 저장되었습니다.`); - console.log('✅ 사용자 요구사항 저장 완료'); - - // 저장 후 다시 로드하여 최신 상태 반영 - console.log('🔄 저장 완료 후 다시 로드 시작...'); - await loadUserRequirements(fileId); - console.log('🔄 저장 완료 후 다시 로드 완료!'); - - } catch (error) { - console.error('❌ 사용자 요구사항 저장 실패:', error); - alert('사용자 요구사항 저장에 실패했습니다: ' + (error.response?.data?.detail || error.message)); - } finally { - setSavingRequirements(false); - } - }; - - // 사용자 요구사항 입력 핸들러 - const handleUserRequirementChange = (materialId, value) => { - console.log(`📝 사용자 요구사항 입력: 자재 ID ${materialId} = "${value}"`); - setUserRequirements(prev => { - const updated = { - ...prev, - [materialId]: value - }; - console.log('🔄 업데이트된 userRequirements:', updated); - return updated; - }); - }; - - // 카테고리별 자재 수 계산 - const getCategoryCounts = () => { - const counts = {}; - materials.forEach(material => { - const category = material.classified_category || 'UNCLASSIFIED'; - counts[category] = (counts[category] || 0) + 1; - }); - return counts; - }; - - // 파이프 구매 수량 계산 함수 - const calculatePipePurchase = (material) => { - // 백엔드에서 이미 그룹핑된 데이터 사용 - const totalLength = material.pipe_details?.total_length_mm || 0; - const pipeCount = material.pipe_details?.pipe_count || material.quantity || 0; - - // 절단 손실: 각 단관마다 2mm - const cuttingLoss = pipeCount * 2; - - // 총 필요 길이 - const requiredLength = totalLength + cuttingLoss; - - // 6M(6000mm) 단위로 구매 본수 계산 - const purchaseCount = Math.ceil(requiredLength / 6000); - - return { - pipeCount, // 단관 개수 - totalLength, // 총 BOM 길이 - cuttingLoss, // 절단 손실 - requiredLength, // 필요 길이 - purchaseCount // 구매 본수 - }; - }; - - // 카테고리 표시명 매핑 - const getCategoryDisplayName = (category) => { - const categoryMap = { - 'SPECIAL': 'SPECIAL', - 'U_BOLT': 'SUPPORT', - 'SUPPORT': 'SUPPORT', - 'PIPE': 'PIPE', - 'FITTING': 'FITTING', - 'FLANGE': 'FLANGE', - 'VALVE': 'VALVE', - 'BOLT': 'BOLT', - 'GASKET': 'GASKET', - 'INSTRUMENT': 'INSTRUMENT', - 'UNCLASSIFIED': 'UNCLASSIFIED' - }; - return categoryMap[category] || category; - }; - - // 니플 끝단 정보 추출 - const extractNippleEndInfo = (description) => { - const descUpper = description.toUpperCase(); - - // 니플 끝단 패턴들 - 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 - }; - - for (const [pattern, display] of Object.entries(endPatterns)) { - if (descUpper.includes(pattern)) { - return display; - } - } - - return ''; - }; - - // 볼트 추가요구사항 추출 - const extractBoltAdditionalRequirements = (description) => { - const descUpper = description.toUpperCase(); - const additionalReqs = []; - - // 표면처리 패턴들 (원본 영어 약어 사용) - const surfaceTreatments = { - 'ELEC.GALV': 'ELEC.GALV', - 'ELEC GALV': 'ELEC.GALV', - 'GALVANIZED': 'GALVANIZED', - 'GALV': 'GALV', - 'HOT DIP GALV': 'HDG', - 'HDG': 'HDG', - 'ZINC PLATED': 'ZINC PLATED', - 'ZINC': 'ZINC', - 'STAINLESS': 'STAINLESS', - 'SS': 'SS' - }; - - // 표면처리 확인 - for (const [pattern, treatment] of Object.entries(surfaceTreatments)) { - if (descUpper.includes(pattern)) { - additionalReqs.push(treatment); - } - } - - // 중복 제거 - const uniqueReqs = [...new Set(additionalReqs)]; - return uniqueReqs.join(', '); - }; - - // 자재 정보 파싱 - const parseMaterialInfo = (material) => { - const category = material.classified_category; - - if (category === 'PIPE') { - const calc = calculatePipePurchase(material); - - return { - type: 'PIPE', - subtype: material.pipe_details?.manufacturing_method || 'SMLS', - size: material.size_spec || '-', - schedule: material.pipe_details?.schedule || '-', - grade: material.full_material_grade || material.material_grade || '-', // 전체 재질명 우선 사용 - quantity: calc.purchaseCount, - unit: '본', - details: calc - }; - } else if (category === 'FITTING') { - 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')) { - // 기존 데이터의 TEE RED 패턴 - displayType = 'TEE REDUCING'; - } else if (description.toUpperCase().includes('RED CONC')) { - // 기존 데이터의 RED CONC 패턴 - displayType = 'REDUCER CONC'; - } else if (description.toUpperCase().includes('RED ECC')) { - // 기존 데이터의 RED ECC 패턴 - displayType = 'REDUCER ECC'; - } else if (description.toUpperCase().includes('CAP')) { - // CAP: 연결 방식 표시 (예: CAP, NPT(F), 3000LB, ASTM A105) - 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')) { - // PLUG: 타입과 연결 방식 표시 (예: HEX.PLUG, NPT(M), 6000LB, ASTM A105) - 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') { - // 티: 타입과 연결 방식 - const teeType = fittingSubtype === 'EQUAL' ? 'EQ' : fittingSubtype === 'REDUCING' ? 'RED' : ''; - const connection = description.includes('SW') ? 'SW' : description.includes('BW') ? 'BW' : ''; - displayType = `TEE ${teeType} ${connection}`.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') { - // 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: - oletDisplayName = 'OLET'; - } - - displayType = oletDisplayName; - } else if (!displayType) { - // 기타 피팅 타입 - displayType = fittingType || 'FITTING'; - } - - // 압력 등급과 스케줄 추출 - let pressure = '-'; - let schedule = '-'; - - // 압력 등급 찾기 (3000LB, 6000LB 등) - const pressureMatch = description.match(/(\d+)LB/i); - if (pressureMatch) { - pressure = `${pressureMatch[1]}LB`; - } - - // 스케줄 표시 (분리 스케줄 지원) - if (hasDifferentSchedules && mainSchedule && redSchedule) { - // 분리 스케줄: "SCH 40 x SCH 80" - schedule = `${mainSchedule} x ${redSchedule}`; - } else if (mainSchedule && mainSchedule !== 'UNKNOWN') { - // 단일 스케줄: "SCH 40" - schedule = mainSchedule; - } else if (description.includes('SCH')) { - // 기존 데이터에서 분리 스케줄 패턴 확인 - const separatedSchMatch = description.match(/SCH\s*(\d+[A-Z]*)\s*[xX×]\s*SCH\s*(\d+[A-Z]*)/i); - if (separatedSchMatch) { - // 분리 스케줄 발견: "SCH 40 x SCH 80" - schedule = `SCH ${separatedSchMatch[1]} x SCH ${separatedSchMatch[2]}`; - } else { - // 단일 스케줄 - const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i); - if (schMatch) { - schedule = `SCH ${schMatch[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 - }; - } else if (category === 'VALVE') { - const valveDetails = material.valve_details || {}; - const description = material.original_description || ''; - - // 밸브 타입 파싱 (GATE, BALL, CHECK, GLOBE 등) - 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'; - } - - // 연결 방식 파싱 (FLG, SW, THRD 등) - 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', - 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 - }; - } else if (category === 'FLANGE') { - 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', - 'BL': 'BLIND FLANGE', - 'BLIND': 'BLIND FLANGE', - 'RED': 'REDUCING 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': 'FULL FACE', - 'FULL_FACE': 'FULL FACE', - 'RTJ': 'RING JOINT', - 'RING_JOINT': 'RING JOINT', - 'MALE': 'MALE', - 'FEMALE': 'FEMALE' - }; - - // 백엔드에서 제공된 타입 정보 - const rawFlangeType = flangeDetails.flange_type || ''; - const rawFacingType = flangeDetails.facing_type || ''; - - // 풀네임으로 변환 - let displayType = flangeTypeMap[rawFlangeType] || rawFlangeType || '-'; - let facingType = facingTypeMap[rawFacingType] || rawFacingType || '-'; - - // 백엔드 데이터가 없으면 description에서 추출 - if (displayType === '-') { - const upperDesc = description.toUpperCase(); - if (upperDesc.includes('WN') || upperDesc.includes('WELD NECK')) { - displayType = 'WELD NECK FLANGE'; - } else if (upperDesc.includes('SO') || upperDesc.includes('SLIP ON')) { - displayType = 'SLIP ON FLANGE'; - } else if (upperDesc.includes('SW') || upperDesc.includes('SOCKET')) { - displayType = 'SOCKET WELD FLANGE'; - } else if (upperDesc.includes('BLIND') || upperDesc.includes('BL')) { - displayType = 'BLIND FLANGE'; - } else if (upperDesc.includes('REDUCING') || upperDesc.includes('RED')) { - displayType = 'REDUCING FLANGE'; - } else if (upperDesc.includes('ORIFICE')) { - displayType = 'ORIFICE FLANGE'; - } else if (upperDesc.includes('SPECTACLE')) { - displayType = 'SPECTACLE BLIND'; - } else if (upperDesc.includes('PADDLE')) { - displayType = 'PADDLE BLIND'; - } else if (upperDesc.includes('SPACER')) { - displayType = 'SPACER'; - } else { - displayType = 'FLANGE'; - } - } - - // 끝단처리 정보가 없으면 description에서 추출 - if (facingType === '-') { - const upperDesc = description.toUpperCase(); - if (upperDesc.includes(' RF') || upperDesc.includes('RAISED')) { - facingType = 'RAISED FACE'; - } else if (upperDesc.includes(' FF') || upperDesc.includes('FULL FACE')) { - facingType = 'FULL FACE'; - } else if (upperDesc.includes('RTJ') || upperDesc.includes('RING')) { - facingType = 'RING 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]}`; - } - } - - return { - type: 'FLANGE', - subtype: displayType, // 풀네임 플랜지 타입 - facing: facingType, // 새로 추가: 끝단처리 정보 - size: material.size_spec || '-', - pressure: flangeDetails.pressure_rating || '-', - schedule: schedule, - grade: material.full_material_grade || material.material_grade || '-', - quantity: Math.round(material.quantity || 0), - unit: '개', - isFlange: true // 플랜지 구분용 플래그 - }; - } else if (category === 'BOLT') { - 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 || '-', - schedule: boltLength, // 길이 정보 - grade: boltGrade, - additionalReq: additionalReq, // 추가요구사항 - quantity: purchaseQty, - unit: 'SETS' - }; - } else if (category === 'GASKET') { - const qty = Math.round(material.quantity || 0); - const purchaseQty = Math.ceil(qty / 5) * 5; // 5의 배수 - - // original_description에서 재질 정보 파싱 - 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, - materialStructure: materialStructure, - materialDetail: materialDetail, - thickness: thickness, - quantity: purchaseQty, - unit: '개', - isGasket: true - }; - } else if (category === 'SUPPORT' || category === 'U_BOLT') { - 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 || '-', - description: material.original_description || '-', - grade: material.full_material_grade || material.material_grade || '-', - additionalReq: '-', - quantity: Math.round(material.quantity || 0), - unit: '개', - isSupport: true - }; - } else if (category === 'SPECIAL') { - // SPECIAL 카테고리: 크기/스케줄/재질을 제외한 나머지를 타입으로 표시 - const desc = material.original_description || ''; - - // 크기, 스케줄, 재질 패턴 제거하여 타입 추출 - let typeText = desc; - - // 재질 제거 (ASTM A105 등) - typeText = typeText.replace(/ASTM\s+[A-Z0-9]+/gi, '').trim(); - typeText = typeText.replace(/[A-Z]{2,}\s+[A-Z0-9]+/g, '').trim(); - - // 크기 제거 (1", 2", 3" 등) - typeText = typeText.replace(/\d+["']\s*/g, '').trim(); - - // 스케줄 제거 (SCH 40, 150LB 등) - typeText = typeText.replace(/SCH\s*\d+[A-Z]*/gi, '').trim(); - typeText = typeText.replace(/\d+LB/gi, '').trim(); - - // 쉼표 정리 - typeText = typeText.replace(/,\s*,/g, ',').replace(/^,\s*|,\s*$/g, '').trim(); - - return { - type: 'SPECIAL', - subtype: typeText || desc, - size: material.main_nom || material.size_inch || material.size_spec || '-', - schedule: '-', - grade: material.full_material_grade || material.material_grade || '-', - drawingNo: material.drawing_name || material.line_no || material.dwg_name || 'N/A', - additionalReq: '-', - quantity: Math.round(material.quantity || 0), - unit: '개', - isSpecial: true - }; - } else if (category === 'UNCLASSIFIED') { - return { - type: 'UNCLASSIFIED', - description: material.original_description || 'Unclassified Item', - quantity: Math.round(material.quantity || 0), - unit: '개', - isUnknown: true - }; - } else { - return { - type: category || 'UNCLASSIFIED', - subtype: '-', - size: material.size_spec || '-', - schedule: '-', - grade: material.material_grade || '-', - quantity: Math.round(material.quantity || 0), - unit: '개' - }; - } - }; - - // 정렬 함수 - const handleSort = (key) => { - let direction = 'asc'; - if (sortConfig.key === key && sortConfig.direction === 'asc') { - direction = 'desc'; - } - setSortConfig({ key, direction }); - }; - - // 필터 함수 - const handleFilter = (column, value) => { - setColumnFilters(prev => ({ - ...prev, - [column]: value - })); - }; - - // 필터 초기화 - const clearFilter = (column) => { - setColumnFilters(prev => { - const newFilters = { ...prev }; - delete newFilters[column]; - return newFilters; - }); - }; - - // 모든 필터 초기화 - const clearAllFilters = () => { - setColumnFilters({}); - setSortConfig({ key: null, direction: 'asc' }); - }; - - // 자재 정보를 미리 계산하여 캐싱 (성능 최적화) - const parsedMaterialsMap = React.useMemo(() => { - const map = new Map(); - materials.forEach(material => { - map.set(material.id, parseMaterialInfo(material)); - }); - return map; - }, [materials]); - - // parseMaterialInfo를 캐시된 버전으로 교체 - const getParsedInfo = (material) => { - return parsedMaterialsMap.get(material.id) || parseMaterialInfo(material); - }; - - // 필터링된 자재 목록 - const filteredMaterials = materials - .filter(material => { - // 카테고리 필터 - if (material.classified_category !== selectedCategory) { - return false; - } - - // 컬럼 필터 적용 - for (const [column, filterValue] of Object.entries(columnFilters)) { - if (!filterValue) continue; - - const info = getParsedInfo(material); - let materialValue = ''; - - switch (column) { - case 'type': - materialValue = info.type || ''; - break; - case 'subtype': - materialValue = info.subtype || ''; - break; - case 'size': - materialValue = info.size || ''; - break; - case 'schedule': - materialValue = info.schedule || ''; - break; - case 'grade': - materialValue = info.grade || ''; - break; - case 'quantity': - materialValue = info.quantity?.toString() || ''; - break; - default: - materialValue = material[column]?.toString() || ''; - } - - if (!materialValue.toLowerCase().includes(filterValue.toLowerCase())) { - return false; - } - } - - return true; - }) - .sort((a, b) => { - if (!sortConfig.key) return 0; - - const aInfo = parseMaterialInfo(a); - const bInfo = parseMaterialInfo(b); - - let aValue, bValue; - - switch (sortConfig.key) { - case 'type': - aValue = aInfo.type || ''; - bValue = bInfo.type || ''; - break; - case 'subtype': - aValue = aInfo.subtype || ''; - bValue = bInfo.subtype || ''; - break; - case 'size': - aValue = aInfo.size || ''; - bValue = bInfo.size || ''; - break; - case 'schedule': - aValue = aInfo.schedule || ''; - bValue = bInfo.schedule || ''; - break; - case 'grade': - aValue = aInfo.grade || ''; - bValue = bInfo.grade || ''; - break; - case 'quantity': - aValue = aInfo.quantity || 0; - bValue = bInfo.quantity || 0; - break; - default: - aValue = a[sortConfig.key] || ''; - bValue = b[sortConfig.key] || ''; - } - - // 숫자 비교 - if (typeof aValue === 'number' && typeof bValue === 'number') { - return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue; - } - - // 문자열 비교 - const comparison = aValue.toString().localeCompare(bValue.toString()); - return sortConfig.direction === 'asc' ? comparison : -comparison; - }); - - // 카테고리 색상 (제거 - CSS에서 처리) - - // 전체 선택/해제 (구매신청된 항목 제외) - const toggleAllSelection = () => { - // 구매신청되지 않은 항목들만 필터링 - const selectableMaterials = filteredMaterials.filter(material => { - const isPurchased = material.grouped_ids ? - material.grouped_ids.some(id => purchasedMaterials.has(id)) : - purchasedMaterials.has(material.id); - return !isPurchased; - }); - - // 현재 선택 가능한 모든 항목이 선택되어 있는지 확인 - const allSelectableSelected = selectableMaterials.every(m => selectedMaterials.has(m.id)); - - if (allSelectableSelected) { - // 전체 해제 - setSelectedMaterials(new Set()); - } else { - // 구매신청되지 않은 항목만 선택 - setSelectedMaterials(new Set(selectableMaterials.map(m => m.id))); - } - }; - - // 개별 선택 - const toggleMaterialSelection = (id) => { - const newSelection = new Set(selectedMaterials); - if (newSelection.has(id)) { - newSelection.delete(id); - } else { - newSelection.add(id); - } - setSelectedMaterials(newSelection); - }; - - // 자재 상태별 CSS 클래스 생성 - const getMaterialRowClasses = (material, baseClass) => { - const classes = [baseClass]; - - if (selectedMaterials.has(material.id)) { - classes.push('selected'); - } - - // 구매신청 여부 확인 - const isPurchased = material.grouped_ids ? - material.grouped_ids.some(id => purchasedMaterials.has(id)) : - purchasedMaterials.has(material.id); - - if (isPurchased) { - classes.push('purchased'); - } - - // 리비전 상태 - if (material.revision_status === 'inventory') { - classes.push('inventory'); - } else if (material.revision_status === 'deleted_not_purchased') { - classes.push('deleted-not-purchased'); - } else if (material.revision_status === 'changed') { - classes.push('changed'); - } - - return classes.join(' '); - }; - - // 리비전 상태 배지 렌더링 - const renderRevisionBadge = (material) => { - if (material.revision_status === 'inventory') { - return ( - - 재고품 - - ); - } else if (material.revision_status === 'changed') { - return ( - - 변경됨 - - ); - } - return null; - }; - - // 필터 헤더 컴포넌트 - const FilterableHeader = ({ children, sortKey, filterKey, className = "" }) => { - const uniqueValues = React.useMemo(() => { - const values = new Set(); - - // 현재 선택된 카테고리의 자재들만 필터링 (최대 200개만 처리하여 성능 개선) - const categoryMaterials = materials - .filter(material => material.classified_category === selectedCategory) - .slice(0, 200); - - categoryMaterials.forEach(material => { - const info = getParsedInfo(material); - let value = ''; - - switch (filterKey) { - case 'type': - value = info.type || ''; - break; - case 'subtype': - value = info.subtype || ''; - break; - case 'size': - value = info.size || ''; - break; - case 'schedule': - value = info.schedule || ''; - break; - case 'grade': - value = info.grade || ''; - break; - case 'quantity': - value = info.quantity?.toString() || ''; - break; - default: - value = material[filterKey]?.toString() || ''; - } - - if (value) values.add(value); - }); - - return Array.from(values).sort(); - }, [materials, filterKey, selectedCategory]); - - return ( -
- {children} -
- {/* 정렬 버튼 */} - - - {/* 필터 버튼 */} - -
- - {/* 필터 드롭다운 */} - {showFilterDropdown === filterKey && ( -
-
- handleFilter(filterKey, e.target.value)} - autoFocus - /> - {columnFilters[filterKey] && ( - - )} -
- -
-
값 목록:
- {uniqueValues.slice(0, 20).map(value => ( -
{ - handleFilter(filterKey, value); - setShowFilterDropdown(null); - }} - > - {value} -
- ))} - {uniqueValues.length > 20 && ( -
- +{uniqueValues.length - 20}개 더... -
- )} -
-
- )} -
- ); - }; - - // 엑셀 내보내기 - 개선된 버전 사용 - const exportToExcel = async () => { - try { - // 내보낼 데이터 결정 - let dataToExport; - - if (selectedMaterials.size > 0) { - // 선택된 항목만 내보내기 - dataToExport = materials.filter(m => selectedMaterials.has(m.id)); - } else { - // 선택된 항목이 없으면 현재 보이는 카테고리의 구매신청되지 않은 자재만 - dataToExport = filteredMaterials.filter(material => { - const isPurchased = material.grouped_ids ? - material.grouped_ids.some(id => purchasedMaterials.has(id)) : - purchasedMaterials.has(material.id); - return !isPurchased; - }); - } - - // 그룹화된 자재의 경우 모든 ID를 포함 - const allMaterialIds = []; - const expandedMaterialsData = []; - - dataToExport.forEach(material => { - if (material.grouped_ids && material.grouped_ids.length > 0) { - // 그룹화된 자재의 모든 ID 추가 - allMaterialIds.push(...material.grouped_ids); - // 각 ID에 대해 동일한 데이터 복사 - material.grouped_ids.forEach(id => { - expandedMaterialsData.push({ - ...material, - material_id: id, - id: id - }); - }); - } else { - // 그룹화되지 않은 자재 - allMaterialIds.push(material.id); - expandedMaterialsData.push(material); - } - }); - - console.log('📊 엑셀 내보내기:', dataToExport.length, '개 그룹, ', allMaterialIds.length, '개 실제 자재'); - console.log('🔍 전송할 자재 ID들:', allMaterialIds); - console.log('📦 그룹화 정보:', dataToExport.map(m => ({ - id: m.id, - grouped_ids: m.grouped_ids, - description: m.original_description - }))); - - // 사용자 요구사항을 자재에 추가 - const dataWithRequirements = dataToExport.map(material => ({ - ...material, - user_requirement: userRequirements[material.id] || '' - })); - - // 1단계: 엑셀 파일 생성 - const timestamp = new Date().toISOString().split('T')[0]; - const fileName = `${jobNo}_${selectedCategory}_${timestamp}.xlsx`; - - // 엑셀 파일명 설정 - const excelFileName = fileName; - - // 2단계: 생성된 엑셀을 서버에 업로드 (구매신청과 함께) - // 서버에 구매신청 생성 - try { - // 그룹화된 데이터를 구매신청 형식으로 변환 - const groupedMaterialsForRequest = dataToExport.map(material => { - if (material.classified_category === 'PIPE' && material.total_length) { - // PIPE의 경우 길이 정보 포함 - return { - group_key: `${material.original_description}|${material.size_spec}|${material.schedule}|${material.material_grade}`, - material_ids: material.grouped_ids || [material.id], - description: material.original_description, - category: material.classified_category, - size: material.size_inch || material.size_spec, - schedule: material.schedule, - material_grade: material.material_grade || material.full_material_grade, - quantity: material.quantity, - unit: 'm', // 파이프는 미터 단위 - total_length: material.total_length, - pipe_lengths: material.pipe_lengths, - user_requirement: userRequirements[material.id] || '' - }; - } else { - // 다른 자재들 - return { - group_key: `${material.original_description}|${material.size_spec}|${material.schedule}|${material.material_grade}`, - material_ids: material.grouped_ids || [material.id], - description: material.original_description, - category: material.classified_category, - size: material.size_inch || material.size_spec, - schedule: material.schedule, - material_grade: material.material_grade || material.full_material_grade, - quantity: material.quantity, - unit: material.unit || '개', - user_requirement: userRequirements[material.id] || '' - }; - } - }); - - const response = await api.post('/purchase-request/create', { - file_id: fileId, - job_no: jobNo, - category: selectedCategory, - material_ids: allMaterialIds, // 그룹화된 모든 ID 전송 - materials_data: expandedMaterialsData.map(m => ({ // 확장된 데이터 전송 - material_id: m.id, - description: m.original_description, - category: m.classified_category, - size: m.size_inch || m.size_spec, - schedule: m.schedule, - material_grade: m.material_grade || m.full_material_grade, - quantity: m.quantity, - unit: m.unit, - user_requirement: userRequirements[m.id] || '' - })), - grouped_materials: groupedMaterialsForRequest // 그룹화 정보 추가 전송 - }); - - if (response.data.success) { - console.log(`✅ 구매신청 완료: ${response.data.request_no}`); - console.log(`📊 구매신청된 자재 ID: ${allMaterialIds.length}개`, allMaterialIds); - alert(`구매신청 ${response.data.request_no}이 생성되었습니다.\n구매신청 관리 페이지에서 확인하세요.`); - - // 구매신청된 자재 ID를 즉시 purchasedMaterials에 추가 - setPurchasedMaterials(prev => { - const newSet = new Set(prev); - allMaterialIds.forEach(id => newSet.add(id)); - console.log(`📦 구매신청 자재 추가: 기존 ${prev.size}개 → 신규 ${newSet.size}개`); - return newSet; - }); - - // 선택된 항목 초기화 - setSelectedMaterials(new Set()); - - // 페이지 새로고침하여 신청된 자재 숨기기 - console.log('🔄 구매신청 후 자재 목록 새로고침 시작'); - await loadMaterials(fileId); - console.log('✅ 자재 목록 새로고침 완료'); - } - } catch (error) { - console.error('구매신청 생성 실패:', error); - // 실패해도 엑셀은 내보내기 - } - - // 엑셀 내보내기 (한 번만 실행) - const additionalInfo = { - filename: filename || bomName, - jobNo: jobNo, - revision: currentRevision, - uploadDate: new Date().toLocaleDateString() - }; - - exportMaterialsToExcel(dataWithRequirements, excelFileName, additionalInfo); - - console.log('✅ 엑셀 내보내기 성공'); - } catch (error) { - console.error('❌ 엑셀 내보내기 실패:', error); - alert('엑셀 내보내기에 실패했습니다.'); - } - }; - - if (loading) { - return ( -
-
-

자재 목록을 불러오는 중...

-
- ); - } - - const categoryCounts = getCategoryCounts(); - - return ( -
- {/* 헤더 */} -
-
- -
-

자재 목록

- {jobNo && ( - - {jobNo} - {bomName} - - )} - - 총 {materials.length}개 자재 ({currentRevision}) - -
-
-
- {availableRevisions.length > 1 && ( -
- - -
- )} -
-
- -
-
- - {/* 카테고리 필터 */} -
- {/* SPECIAL 카테고리 우선 표시 */} - - - {Object.entries(categoryCounts).filter(([category]) => category !== 'SPECIAL').map(([category, count]) => ( - - ))} -
- - {/* 액션 바 */} -
-
- {selectedMaterials.size}개 중 {filteredMaterials.length}개 선택 - {Object.keys(columnFilters).length > 0 && ( - - (필터 {Object.keys(columnFilters).length}개 적용됨) - - )} -
-
- {(Object.keys(columnFilters).length > 0 || sortConfig.key) && ( - - )} - - - - -
-
- - {/* 구매신청 이력 표시 */} - {exportHistory.length > 0 && ( -
- ✅ 최근 구매신청: {new Date(exportHistory[0].export_date).toLocaleString()} - ({exportHistory[0].category} {exportHistory[0].material_count}개) - {exportHistory.length > 1 && ` 외 ${exportHistory.length - 1}건`} -
- )} - - {/* 자재 테이블 */} -
- {/* SPECIAL 전용 헤더 */} - {selectedCategory === 'SPECIAL' ? ( -
-
선택
- 종류 - 타입 - 크기 - 스케줄 - 재질 -
도면번호
-
추가요구
-
사용자요구
- 수량 -
- ) : selectedCategory === 'FLANGE' ? ( -
-
선택
- 종류 - 타입 - 끝단처리 - 크기 -
압력(파운드)
- 스케줄 - 재질 -
추가요구
-
사용자요구
- 수량 -
- ) : selectedCategory === 'FITTING' ? ( -
-
선택
- 종류 - 타입/상세 - 크기 -
압력
- 스케줄 - 재질 -
추가요구
-
사용자요구
- 수량 -
- ) : selectedCategory === 'GASKET' ? ( -
-
선택
-
종류
-
타입
-
크기
-
압력
-
재질
-
상세내역
-
두께
-
추가요구
-
사용자요구
-
수량
-
- ) : selectedCategory === 'VALVE' ? ( -
-
선택
-
타입
-
연결방식
-
크기
-
압력
-
재질
-
추가요구
-
사용자요구
-
수량
-
- ) : selectedCategory === 'BOLT' ? ( -
-
선택
- 종류 - 타입 - 크기 - 길이 - 재질 -
추가요구
-
사용자요구
- 수량 -
- ) : selectedCategory === 'SUPPORT' || selectedCategory === 'U_BOLT' ? ( -
-
선택
- 종류 - 타입 - 크기 - 디스크립션 -
추가요구
-
사용자요구
- 수량 -
- ) : selectedCategory === 'UNCLASSIFIED' ? ( -
-
선택
-
종류
-
설명
-
사용자요구
-
수량
-
- ) : ( -
-
선택
- 종류 - 타입 - 크기 - 스케줄 - 재질 -
추가요구
-
사용자요구
- 수량 -
- )} - - {filteredMaterials.map((material) => { - const info = getParsedInfo(material); - - if (material.classified_category === 'SPECIAL') { - // SPECIAL 카테고리 (10개 컬럼) - // 구매신청 여부 확인 - const isPurchased = material.grouped_ids ? - material.grouped_ids.some(id => purchasedMaterials.has(id)) : - purchasedMaterials.has(material.id); - - return ( -
- {/* 선택 */} -
- toggleMaterialSelection(material.id)} - disabled={isPurchased} - title={isPurchased ? '이미 구매신청된 자재입니다' : ''} - /> -
- - {/* 종류 */} -
- - SPECIAL - -
- - {/* 타입 */} -
- {info.subtype || material.description} -
- - {/* 크기 */} -
- {info.size || material.main_nom} -
- - {/* 스케줄 */} -
- {info.schedule} -
- - {/* 재질 */} -
- {info.grade || material.full_material_grade} -
- - {/* 도면번호 */} -
- {info.drawingNo || material.drawing_name || material.line_no || material.dwg_name || 'N/A'} -
- - {/* 추가요구 */} -
- {material.additional_requirements || '-'} -
- - {/* 사용자요구 */} -
- handleUserRequirementChange(material.id, e.target.value)} - /> -
- - {/* 수량 */} -
- {isPurchased && ( - - 구매신청완료 - - )} - - {info.quantity || material.quantity || 1}개 - -
-
- ); - } - if (material.classified_category === 'PIPE') { - // PIPE 또는 카테고리 없는 경우 (기본 9개 컬럼) - // 구매신청 여부 확인 - const isPurchased = material.grouped_ids ? - material.grouped_ids.some(id => purchasedMaterials.has(id)) : - purchasedMaterials.has(material.id); - - return ( -
- {/* 선택 */} -
- toggleMaterialSelection(material.id)} - disabled={isPurchased} - title={isPurchased ? '이미 구매신청된 자재입니다' : ''} - /> -
- - {/* 종류 */} -
- - {info.type || 'N/A'} - -
- - {/* 타입 */} -
- {info.subtype} -
- - {/* 크기 */} -
- {info.size} -
- - {/* 스케줄 */} -
- {info.schedule} -
- - {/* 재질 */} -
- {info.grade} -
- - {/* 추가요구 */} -
- {info.additionalReq || '-'} -
- - {/* 사용자요구 */} -
- handleUserRequirementChange(material.id, e.target.value)} - /> -
- - {/* 수량 */} -
-
- {isPurchased && ( - - 구매신청완료 - - )} - - {(() => { - // 총 길이와 개수 계산 - let totalLengthMm = material.total_length || 0; - let totalCount = 0; - - if (material.pipe_lengths && material.pipe_lengths.length > 0) { - // pipe_lengths 배열에서 총 개수 계산 - totalCount = material.pipe_lengths.reduce((sum, p) => sum + parseFloat(p.quantity || 0), 0); - } else if (material.grouped_ids && material.grouped_ids.length > 0) { - totalCount = material.grouped_ids.length; - if (!totalLengthMm) { - totalLengthMm = totalCount * 6000; - } - } else { - totalCount = parseFloat(material.quantity) || 1; - if (!totalLengthMm) { - totalLengthMm = totalCount * 6000; - } - } - - // 6,000mm를 1본으로 계산 - const pipeCount = Math.ceil(totalLengthMm / 6000); - - // 형식: 2본(11,000mm/40개) - return `${pipeCount}본(${totalLengthMm.toLocaleString()}mm/${totalCount}개)`; - })()} - -
-
-
- ); - } - - if (material.classified_category === 'FITTING') { - // 구매신청 여부 확인 - const isPurchased = material.grouped_ids ? - material.grouped_ids.some(id => purchasedMaterials.has(id)) : - purchasedMaterials.has(material.id); - - return ( -
- {/* 선택 */} -
- toggleMaterialSelection(material.id)} - disabled={isPurchased} - title={isPurchased ? '이미 구매신청된 자재입니다' : ''} - /> -
- - {/* 종류 */} -
- - {info.type} - -
- - {/* 타입/상세 */} -
- {info.subtype} -
- - {/* 크기 */} -
- {info.size} -
- - {/* 압력 */} -
- {info.pressure} -
- - {/* 스케줄 */} -
- {info.schedule} -
- - {/* 재질 */} -
- {info.grade} -
- - {/* 추가요구 */} -
- {info.additionalReq || '-'} -
- - {/* 사용자요구 */} -
- { - console.log('🎯 입력 이벤트 발생!', material.id, e.target.value); - handleUserRequirementChange(material.id, e.target.value); - }} - /> -
- - {/* 수량 */} -
- {isPurchased && ( - - 구매신청완료 - - )} - - {info.quantity} {info.unit} - -
-
- ); - } - - if (material.classified_category === 'VALVE') { - // 구매신청 여부 확인 - const isPurchased = material.grouped_ids ? - material.grouped_ids.some(id => purchasedMaterials.has(id)) : - purchasedMaterials.has(material.id); - - return ( -
- {/* 선택 */} -
- toggleMaterialSelection(material.id)} - disabled={isPurchased} - title={isPurchased ? '이미 구매신청된 자재입니다' : ''} - /> -
- - {/* 타입 */} -
- {info.valveType} -
- - {/* 연결방식 */} -
- {info.connectionType} -
- - {/* 크기 */} -
- {info.size} -
- - {/* 압력 */} -
- {info.pressure} -
- - {/* 재질 */} -
- {info.grade} -
- - {/* 추가요구 */} -
- {info.additionalReq || '-'} -
- - {/* 사용자요구 */} -
- { - console.log('🎯 입력 이벤트 발생!', material.id, e.target.value); - handleUserRequirementChange(material.id, e.target.value); - }} - /> -
- - {/* 수량 */} -
- {isPurchased && ( - - 구매신청완료 - - )} - - {info.quantity} {info.unit} - -
-
- ); - } - - if (material.classified_category === 'FLANGE') { - // 구매신청 여부 확인 - const isPurchased = material.grouped_ids ? - material.grouped_ids.some(id => purchasedMaterials.has(id)) : - purchasedMaterials.has(material.id); - - return ( -
- {/* 선택 */} -
- toggleMaterialSelection(material.id)} - disabled={isPurchased} - title={isPurchased ? '이미 구매신청된 자재입니다' : ''} - /> -
- - {/* 종류 */} -
- - {info.type} - -
- - {/* 타입 */} -
- {info.subtype} -
- - {/* 끝단처리 (플랜지 전용) */} -
- {info.facing || '-'} -
- - {/* 크기 */} -
- {info.size} -
- - {/* 압력(파운드) */} -
- {info.pressure} -
- - {/* 스케줄 */} -
- {info.schedule} -
- - {/* 재질 */} -
- {info.grade} -
- - {/* 추가요구 */} -
- {info.additionalReq || '-'} -
- - {/* 사용자요구 */} -
- { - console.log('🎯 입력 이벤트 발생!', material.id, e.target.value); - handleUserRequirementChange(material.id, e.target.value); - }} - /> -
- - {/* 수량 */} -
- {isPurchased && ( - - 구매신청완료 - - )} - - {info.quantity} {info.unit} - -
-
- ); - } - - if (material.classified_category === 'UNCLASSIFIED') { - // 구매신청 여부 확인 - const isPurchased = material.grouped_ids ? - material.grouped_ids.some(id => purchasedMaterials.has(id)) : - purchasedMaterials.has(material.id); - - return ( -
- {/* 선택 */} -
- toggleMaterialSelection(material.id)} - disabled={isPurchased} - title={isPurchased ? '이미 구매신청된 자재입니다' : ''} - /> -
- - {/* 종류 */} -
- - {info.type} - -
- - {/* 설명 */} -
- - {info.description} - -
- - {/* 사용자요구 */} -
- { - console.log('🎯 입력 이벤트 발생!', material.id, e.target.value); - handleUserRequirementChange(material.id, e.target.value); - }} - /> -
- - {/* 수량 */} -
- {isPurchased && ( - - 구매신청완료 - - )} -
- - {info.quantity} {info.unit} - -
-
-
- ); - } - - if (material.classified_category === 'GASKET') { - // 구매신청 여부 확인 - const isPurchased = material.grouped_ids ? - material.grouped_ids.some(id => purchasedMaterials.has(id)) : - purchasedMaterials.has(material.id); - - return ( -
- {/* 선택 */} -
- toggleMaterialSelection(material.id)} - disabled={isPurchased} - title={isPurchased ? '이미 구매신청된 자재입니다' : ''} - /> -
- - {/* 종류 */} -
- - {info.type} - -
- - {/* 타입 */} -
- {info.subtype} -
- - {/* 크기 */} -
- {info.size} -
- - {/* 압력 */} -
- {info.pressure} -
- - {/* 재질 */} -
- {info.materialStructure} -
- - {/* 상세내역 */} -
- {info.materialDetail} -
- - {/* 두께 */} -
- {info.thickness} -
- - {/* 추가요구 */} -
- {info.additionalReq || '-'} -
- - {/* 사용자요구 */} -
- { - console.log('🎯 입력 이벤트 발생!', material.id, e.target.value); - handleUserRequirementChange(material.id, e.target.value); - }} - /> -
- - {/* 수량 */} -
- {isPurchased && ( - - 구매신청완료 - - )} -
- - {info.quantity} {info.unit} - -
-
-
- ); - } - - if (material.classified_category === 'BOLT') { - // BOLT 카테고리 (9개 컬럼, 길이 표시) - // 구매신청 여부 확인 - const isPurchased = material.grouped_ids ? - material.grouped_ids.some(id => purchasedMaterials.has(id)) : - purchasedMaterials.has(material.id); - - return ( -
- {/* 선택 */} -
- toggleMaterialSelection(material.id)} - disabled={isPurchased} - title={isPurchased ? '이미 구매신청된 자재입니다' : ''} - /> -
- - {/* 종류 */} -
- - BOLT - -
- - {/* 타입 */} -
- {info.subtype || 'BOLT_GENERAL'} -
- - {/* 크기 */} -
- {info.size || material.main_nom} -
- - {/* 길이 (스케줄 대신) */} -
- {info.schedule || '-'} -
- - {/* 재질 */} -
- {info.grade || material.full_material_grade} -
- - {/* 추가요구 */} -
- {info.additionalReq || '-'} -
- - {/* 사용자요구 */} -
- handleUserRequirementChange(material.id, e.target.value)} - /> -
- - {/* 수량 */} -
- {isPurchased && ( - - 구매신청완료 - - )} - {info.quantity || material.quantity || 1} {info.unit || 'SETS'} -
-
- ); - } - - if (material.classified_category === 'SUPPORT' || material.classified_category === 'U_BOLT') { - // SUPPORT 카테고리 - 자재 타입별 다른 표시 - const desc = material.original_description || ''; - const isUrethaneBlock = desc.includes('URETHANE') || desc.includes('BLOCK SHOE') || desc.includes('우레탄'); - const isClamp = desc.includes('CLAMP') || desc.includes('클램프'); - - // 구매신청 여부 확인 - const isPurchased = material.grouped_ids ? - material.grouped_ids.some(id => purchasedMaterials.has(id)) : - purchasedMaterials.has(material.id); - - let badgeType = 'support'; - let badgeText = 'SUPPORT'; - let subtypeText = ''; - - if (isUrethaneBlock) { - subtypeText = '우레탄블럭슈'; - } else if (isClamp) { - subtypeText = '클램프'; - } else { - subtypeText = '유볼트'; - } - - return ( -
- {/* 선택 */} -
- toggleMaterialSelection(material.id)} - /> -
- - {/* 종류 */} -
- - {badgeText} - -
- - {/* 타입 */} -
- {subtypeText} -
- - {/* 크기 */} -
- - {material.main_nom || material.size_inch || info.size || '-'} - -
- - {/* 디스크립션 (재질정보 포함) */} -
- - {material.original_description || '-'} - -
- - {/* 추가요구 */} -
- {info.additionalReq || '-'} -
- - {/* 사용자요구 */} -
- handleUserRequirementChange(material.id, e.target.value)} - /> -
- - {/* 수량 */} -
- {info.quantity || material.quantity || 1} {info.unit || 'SETS'} -
-
- ); - } - - // 위에서 처리되지 않은 모든 자재는 기본 9개 컬럼으로 렌더링 - // (예: 아직 전용 뷰가 없는 자재) - return ( -
- {/* This is a fallback view, adjust columns as needed */} -
toggleMaterialSelection(material.id)} />
-
{info.type || 'N/A'}
-
{info.subtype || material.original_description}
-
{info.size}
-
{info.schedule}
-
{info.grade}
-
-
-
handleUserRequirementChange(material.id, e.target.value)} />
-
{info.quantity} {info.unit}
-
- ); - })} -
-
- ); -}; - -export default NewMaterialsPage; -