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;
-