diff --git a/backend/app/main.py b/backend/app/main.py index fbcfa74..17dcfdb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -127,6 +127,14 @@ try: except ImportError as e: logger.warning(f"materials 라우터를 찾을 수 없습니다: {e}") +# 간단한 리비전 관리 라우터 +try: + from .routers import simple_revision + app.include_router(simple_revision.router) + logger.info("simple_revision 라우터 등록 완료") +except ImportError as e: + logger.warning(f"simple_revision 라우터를 찾을 수 없습니다: {e}") + # 파일 관리 API 라우터 등록 (비활성화 - files 라우터와 충돌 방지) # try: # from .api import file_management diff --git a/backend/app/models.py b/backend/app/models.py index 9198c22..de49ae7 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -703,7 +703,8 @@ class PurchaseRequests(Base): category = Column(String(50)) material_count = Column(Integer) excel_file_path = Column(String(500)) - requested_by = Column(Integer, ForeignKey("users.user_id")) + requested_by = Column(Integer) # ForeignKey 제거 + requested_by_username = Column(String(100)) # 사용자명 직접 저장 # 시간 정보 created_at = Column(DateTime, default=datetime.utcnow) @@ -711,7 +712,7 @@ class PurchaseRequests(Base): # 관계 설정 file = relationship("File") - requested_by_user = relationship("User", foreign_keys=[requested_by]) + # requested_by_user relationship 제거 class Jobs(Base): __tablename__ = "jobs" @@ -765,7 +766,7 @@ class ExcelExports(Base): # 관계 설정 file = relationship("File") - exported_by_user = relationship("User", foreign_keys=[exported_by]) + # exported_by_user relationship 제거 class UserActivityLogs(Base): __tablename__ = "user_activity_logs" @@ -777,7 +778,7 @@ class UserActivityLogs(Base): created_at = Column(DateTime, default=datetime.utcnow) # 관계 설정 - user = relationship("User") + # user relationship 제거 class ExcelExportHistory(Base): __tablename__ = "excel_export_history" @@ -785,12 +786,13 @@ class ExcelExportHistory(Base): export_id = Column(String(50), primary_key=True, index=True) file_id = Column(Integer, ForeignKey("files.id")) job_no = Column(String(50)) - exported_by = Column(Integer, ForeignKey("users.user_id")) + exported_by = Column(Integer) # ForeignKey 제거 + exported_by_username = Column(String(100)) # 사용자명 직접 저장 export_date = Column(DateTime, default=datetime.utcnow) # 관계 설정 file = relationship("File") - exported_by_user = relationship("User", foreign_keys=[exported_by]) + # exported_by_user relationship 제거 class ExportedMaterials(Base): __tablename__ = "exported_materials" @@ -817,4 +819,67 @@ class PurchaseStatusHistory(Base): # 관계 설정 material = relationship("Material") - changed_by_user = relationship("User", foreign_keys=[changed_by]) + # changed_by_user relationship 제거 + + +# ========== 간단한 리비전 관리 시스템 ========== + +class SimpleRevisionComparison(Base): + """간단한 리비전 비교 결과 저장""" + __tablename__ = "simple_revision_comparisons" + + id = Column(Integer, primary_key=True, index=True) + job_no = Column(String(50), nullable=False, index=True) + current_file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + previous_file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + category = Column(String(50), nullable=False) # 비교 대상 카테고리 + + # 비교 결과 통계 + added_count = Column(Integer, default=0) + removed_count = Column(Integer, default=0) + changed_count = Column(Integer, default=0) + unchanged_count = Column(Integer, default=0) + + # 구매 상태별 통계 + purchased_affected = Column(Integer, default=0) + unpurchased_affected = Column(Integer, default=0) + inventory_count = Column(Integer, default=0) + + # 메타데이터 + comparison_data = Column(JSON) # 상세 비교 결과 + created_at = Column(DateTime, default=datetime.utcnow) + created_by_username = Column(String(100)) + + # 관계 설정 (간단하게) + current_file = relationship("File", foreign_keys=[current_file_id]) + previous_file = relationship("File", foreign_keys=[previous_file_id]) + + +class SimpleRevisionMaterial(Base): + """간단한 리비전 자재 변경 로그""" + __tablename__ = "simple_revision_materials" + + id = Column(Integer, primary_key=True, index=True) + comparison_id = Column(Integer, ForeignKey("simple_revision_comparisons.id"), nullable=False) + material_id = Column(Integer, ForeignKey("materials.id")) + change_type = Column(String(20), nullable=False) # 'added', 'removed', 'changed', 'quantity_changed' + revision_action = Column(String(30)) # 'maintain', 'additional_purchase', 'inventory', 'delete', 'quantity_update' + + # 수량 변경 정보 + quantity_before = Column(Numeric(10, 3)) + quantity_after = Column(Numeric(10, 3)) + quantity_difference = Column(Numeric(10, 3)) + purchase_status = Column(String(20)) # 'purchased', 'not_purchased' + + created_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 (간단하게) + comparison = relationship("SimpleRevisionComparison") + material = relationship("Material") + + +# ========== 간단한 리비전 시스템 완료 ========== +# 나머지 복잡한 테이블들은 제거하고 필요시 추가 + +# 기존 복잡한 모델들 제거됨 (PipeLengthCalculation, MaterialPurchaseHistory 등) +# 필요시 간단한 구조로 다시 추가 예정 diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 6a5dfa6..3e5be1c 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -367,12 +367,14 @@ async def upload_file( ) # 4. 자재 데이터 처리 + is_revision_upload = parent_file_id is not None or revision != 'Rev.0' processing_result = upload_service.process_materials_data( file_path=file_path, file_id=file_record.id, job_no=job_no, revision=revision, - parent_file_id=parent_file_id + parent_file_id=parent_file_id, + is_revision=is_revision_upload ) # 5. 파일 레코드 업데이트 (파싱된 자재 수) diff --git a/backend/app/routers/simple_revision.py b/backend/app/routers/simple_revision.py new file mode 100644 index 0000000..e8c185f --- /dev/null +++ b/backend/app/routers/simple_revision.py @@ -0,0 +1,278 @@ +""" +간단한 리비전 관리 API +복잡한 dependency 없이 핵심 기능만 제공 +""" + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File as FastAPIFile +from sqlalchemy.orm import Session +from typing import Dict, List, Any, Optional +import json + +from ..database import get_db +from ..services.simple_revision_service import SimpleRevisionService +from ..services.file_upload_service import FileUploadService +from ..models import File, Material +from ..utils.logger import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/simple-revision", tags=["Simple Revision"]) + +@router.post("/compare") +async def compare_revisions( + current_file_id: int, + previous_file_id: int, + category: str, + username: str = "system", + db: Session = Depends(get_db) +): + """ + 두 리비전 간의 자재 비교 + + Args: + current_file_id: 현재 파일 ID + previous_file_id: 이전 파일 ID + category: 비교할 카테고리 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT, SPECIAL, UNCLASSIFIED) + username: 비교 수행자 + """ + try: + service = SimpleRevisionService(db) + result = service.compare_revisions( + current_file_id, previous_file_id, category, username + ) + + return { + "success": True, + "message": f"{category} 카테고리 리비전 비교 완료", + "data": result + } + + except Exception as e: + logger.error(f"리비전 비교 실패: {e}") + raise HTTPException(status_code=500, detail=f"리비전 비교 실패: {str(e)}") + +@router.get("/comparison/{comparison_id}") +async def get_comparison_result( + comparison_id: int, + db: Session = Depends(get_db) +): + """저장된 비교 결과 조회""" + try: + service = SimpleRevisionService(db) + result = service.get_comparison_result(comparison_id) + + if not result: + raise HTTPException(status_code=404, detail="비교 결과를 찾을 수 없습니다") + + return { + "success": True, + "data": result + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"비교 결과 조회 실패: {e}") + raise HTTPException(status_code=500, detail=f"비교 결과 조회 실패: {str(e)}") + +@router.post("/upload-revision") +async def upload_revision_file( + file: UploadFile = FastAPIFile(...), + job_no: str = "default", + previous_file_id: int = None, + username: str = "system", + db: Session = Depends(get_db) +): + """ + 리비전 파일 업로드 및 자동 비교 + + Args: + file: 업로드할 BOM 파일 + job_no: 작업 번호 + previous_file_id: 비교할 이전 파일 ID + username: 업로드 사용자 + """ + try: + # 1. 파일 업로드 + upload_service = FileUploadService(db) + upload_result = await upload_service.process_upload( + file=file, + job_no=job_no, + username=username, + is_revision=True + ) + + current_file_id = upload_result['file_id'] + + # 2. 이전 파일이 지정되지 않은 경우 최신 파일 찾기 + if not previous_file_id: + previous_file = db.query(File).filter( + File.job_no == job_no, + File.id != current_file_id, + File.is_active == True + ).order_by(File.upload_date.desc()).first() + + if previous_file: + previous_file_id = previous_file.id + else: + return { + "success": True, + "message": "첫 번째 파일이므로 비교할 이전 파일이 없습니다", + "data": { + "file_id": current_file_id, + "comparison_results": [] + } + } + + # 3. 각 카테고리별로 자동 비교 수행 + categories = [ + 'PIPE', 'FITTING', 'FLANGE', 'VALVE', + 'GASKET', 'BOLT', 'SUPPORT', 'SPECIAL', 'UNCLASSIFIED' + ] + + revision_service = SimpleRevisionService(db) + comparison_results = [] + + for category in categories: + try: + # 해당 카테고리에 자재가 있는지 확인 + current_materials = db.query(Material).filter( + Material.file_id == current_file_id, + Material.classified_category == category + ).count() + + previous_materials = db.query(Material).filter( + Material.file_id == previous_file_id, + Material.classified_category == category + ).count() + + if current_materials > 0 or previous_materials > 0: + # 비교 수행 + comparison_result = revision_service.compare_revisions( + current_file_id, previous_file_id, category, username + ) + comparison_results.append(comparison_result) + + except Exception as e: + logger.warning(f"{category} 카테고리 비교 실패: {e}") + continue + + return { + "success": True, + "message": f"리비전 파일 업로드 및 비교 완료 ({len(comparison_results)}개 카테고리)", + "data": { + "file_id": current_file_id, + "previous_file_id": previous_file_id, + "comparison_results": comparison_results + } + } + + except Exception as e: + logger.error(f"리비전 업로드 실패: {e}") + raise HTTPException(status_code=500, detail=f"리비전 업로드 실패: {str(e)}") + +@router.get("/categories/{file_id}") +async def get_available_categories( + file_id: int, + db: Session = Depends(get_db) +): + """파일의 사용 가능한 카테고리 목록 조회""" + try: + # 파일에 포함된 카테고리별 자재 수 조회 + categories_query = db.query( + Material.classified_category, + db.func.count(Material.id).label('count') + ).filter( + Material.file_id == file_id + ).group_by(Material.classified_category).all() + + categories = [] + for category, count in categories_query: + if category and count > 0: + categories.append({ + 'category': category, + 'count': count + }) + + return { + "success": True, + "data": { + "file_id": file_id, + "categories": categories + } + } + + except Exception as e: + logger.error(f"카테고리 조회 실패: {e}") + raise HTTPException(status_code=500, detail=f"카테고리 조회 실패: {str(e)}") + +@router.get("/changed-materials/{current_file_id}/{previous_file_id}") +async def get_changed_materials_only( + current_file_id: int, + previous_file_id: int, + categories: str = None, # 쉼표로 구분된 카테고리 목록 + username: str = "system", + db: Session = Depends(get_db) +): + """변경된 자재만 필터링하여 반환""" + try: + service = SimpleRevisionService(db) + + # 카테고리 파싱 + category_list = None + if categories: + category_list = [cat.strip().upper() for cat in categories.split(',')] + + result = service.get_changed_materials_only( + current_file_id, previous_file_id, category_list, username + ) + + return { + "success": True, + "message": f"변경된 자재 조회 완료 ({result['total_changed_materials']}개 변경)", + "data": result + } + + except Exception as e: + logger.error(f"변경된 자재 조회 실패: {e}") + raise HTTPException(status_code=500, detail=f"변경된 자재 조회 실패: {str(e)}") + +@router.get("/history/{job_no}") +async def get_revision_history( + job_no: str, + db: Session = Depends(get_db) +): + """작업 번호별 리비전 히스토리 조회""" + try: + from ..models import SimpleRevisionComparison + + comparisons = db.query(SimpleRevisionComparison).filter( + SimpleRevisionComparison.job_no == job_no + ).order_by(SimpleRevisionComparison.created_at.desc()).all() + + history = [] + for comp in comparisons: + history.append({ + 'comparison_id': comp.id, + 'category': comp.category, + 'created_at': comp.created_at.isoformat(), + 'created_by': comp.created_by_username, + 'summary': { + 'added': comp.added_count, + 'removed': comp.removed_count, + 'changed': comp.changed_count, + 'unchanged': comp.unchanged_count + } + }) + + return { + "success": True, + "data": { + "job_no": job_no, + "history": history + } + } + + except Exception as e: + logger.error(f"리비전 히스토리 조회 실패: {e}") + raise HTTPException(status_code=500, detail=f"리비전 히스토리 조회 실패: {str(e)}") diff --git a/backend/app/services/database_service.py b/backend/app/services/database_service.py index 6c42833..68d2126 100644 --- a/backend/app/services/database_service.py +++ b/backend/app/services/database_service.py @@ -6,7 +6,9 @@ from sqlalchemy.orm import Session from sqlalchemy import text, and_, or_ from typing import List, Dict, Any, Optional, Union -from ..models import Material, File, User, Project +import json +from ..models import Material, File, Project +from ..auth.models import User from ..utils.logger import get_logger from ..utils.error_handlers import ErrorResponse @@ -292,7 +294,7 @@ class DatabaseService: "area_code": material.get("area_code"), "line_no": material.get("line_no"), "classification_confidence": material.get("classification_confidence"), - "classification_details": material.get("classification_details"), + "classification_details": json.dumps(material.get("classification_details", {})) if material.get("classification_details") else None, "revision_status": material.get("revision_status", "new"), "material_hash": material.get("material_hash"), "normalized_description": material.get("normalized_description"), diff --git a/backend/app/services/file_upload_service.py b/backend/app/services/file_upload_service.py index 4e4ec8a..b8eafaa 100644 --- a/backend/app/services/file_upload_service.py +++ b/backend/app/services/file_upload_service.py @@ -5,6 +5,8 @@ import os import shutil +import uuid +from datetime import datetime from pathlib import Path from typing import Dict, List, Any, Optional, Tuple from fastapi import UploadFile, HTTPException @@ -14,9 +16,8 @@ from sqlalchemy import text from ..models import File, Material from ..utils.logger import get_logger from ..utils.file_processor import parse_file_data -from ..utils.file_validator import validate_file_extension, generate_unique_filename +from ..utils.file_validator import file_validator from .database_service import DatabaseService -from .material_classification_service import MaterialClassificationService logger = get_logger(__name__) @@ -25,6 +26,16 @@ ALLOWED_EXTENSIONS = {'.xls', '.xlsx', '.csv'} MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB +def generate_unique_filename(original_filename: str) -> str: + """고유한 파일명 생성""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + unique_id = str(uuid.uuid4())[:8] + stem = Path(original_filename).stem + suffix = Path(original_filename).suffix + + return f"{stem}_{timestamp}_{unique_id}{suffix}" + + class FileUploadService: """파일 업로드 서비스""" @@ -36,7 +47,7 @@ class FileUploadService: def validate_upload_request(self, file: UploadFile, job_no: str) -> None: """업로드 요청 검증""" - if not validate_file_extension(file.filename): + if not file_validator.validate_file_extension(file.filename): raise HTTPException( status_code=400, detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}" @@ -129,7 +140,8 @@ class FileUploadService: file_id: int, job_no: str, revision: str, - parent_file_id: Optional[int] = None + parent_file_id: Optional[int] = None, + is_revision: bool = False ) -> Dict[str, Any]: """자재 데이터 처리""" @@ -189,8 +201,9 @@ class FileUploadService: # 자재 데이터 DB 저장 inserted_count = self.db_service.bulk_insert_materials(processed_materials, file_id) - # 상세 정보 저장 (분류별) - self._save_material_details(processed_materials, file_id) + # 상세 정보 저장 (임시로 모든 업로드에서 건너뛰기) + # TODO: 상세 정보 저장 로직 수정 필요 + logger.info(f"Skipped material details saving (temporarily disabled)") logger.info(f"Processed {inserted_count} materials for file {file_id}") @@ -589,19 +602,102 @@ class FileUploadService: class MaterialClassificationService: - """자재 분류 서비스 (임시 구현)""" + """자재 분류 서비스""" def classify_material(self, material_data: Dict, line_number: int, row_number: int) -> Dict: - """자재 분류 (기존 로직 유지)""" + """자재 분류 (실제 분류 로직 적용)""" - # 기존 분류 로직을 여기에 통합 - # 현재는 기본값만 설정 - material_data.update({ - 'line_number': line_number, - 'row_number': row_number, - 'classified_category': material_data.get('classified_category', 'UNKNOWN'), - 'classification_confidence': material_data.get('classification_confidence', 0.0), - 'classification_details': material_data.get('classification_details', {}) - }) + description = material_data.get('original_description', '') + main_nom = material_data.get('main_nom', '') + red_nom = material_data.get('red_nom', '') + length_value = material_data.get('length', 0) - return material_data + try: + # 1. 통합 분류기로 1차 분류 + from ..services.integrated_classifier import classify_material_integrated + integrated_result = classify_material_integrated(description, main_nom, red_nom, length_value) + + # 2. 제외 대상 확인 + if self._should_exclude_material(description): + classification_result = { + "category": "EXCLUDE", + "overall_confidence": 0.95, + "reason": "제외 대상 자재" + } + else: + # 3. 타입별 상세 분류기 실행 + material_type = integrated_result.get('category', 'UNCLASSIFIED') + + if material_type == "PIPE": + from ..services.pipe_classifier import classify_pipe_for_purchase + classification_result = classify_pipe_for_purchase("", description, main_nom, length_value) + elif material_type == "FITTING": + from ..services.fitting_classifier import classify_fitting + classification_result = classify_fitting("", description, main_nom, red_nom) + elif material_type == "FLANGE": + from ..services.flange_classifier import classify_flange + classification_result = classify_flange("", description, main_nom, red_nom) + elif material_type == "VALVE": + from ..services.valve_classifier import classify_valve + classification_result = classify_valve("", description, main_nom) + elif material_type == "BOLT": + from ..services.bolt_classifier import classify_bolt + classification_result = classify_bolt("", description, main_nom) + elif material_type == "GASKET": + from ..services.gasket_classifier import classify_gasket + classification_result = classify_gasket("", description, main_nom) + elif material_type == "SUPPORT": + from ..services.support_classifier import classify_support + classification_result = classify_support("", description, main_nom) + elif material_type == "SPECIAL": + classification_result = { + "category": "SPECIAL", + "overall_confidence": integrated_result.get('confidence', 0.8), + "reason": integrated_result.get('reason', 'SPECIAL 키워드 발견') + } + else: + classification_result = { + "category": "UNCLASSIFIED", + "overall_confidence": 0.1, + "reason": "분류되지 않은 자재" + } + + # 4. 결과 적용 + final_category = classification_result.get('category', 'UNCLASSIFIED') + if final_category == 'EXCLUDE': + return None # 제외 대상은 None 반환 + + material_data.update({ + 'line_number': line_number, + 'row_number': row_number, + 'classified_category': final_category, + 'classification_confidence': classification_result.get('overall_confidence', 0.0), + 'classification_details': classification_result + }) + + return material_data + + except Exception as e: + logger.warning(f"자재 분류 실패 (line {line_number}): {e}") + # 분류 실패 시 기본값 설정 + material_data.update({ + 'line_number': line_number, + 'row_number': row_number, + 'classified_category': 'UNCLASSIFIED', + 'classification_confidence': 0.0, + 'classification_details': {'error': str(e)} + }) + + return material_data + + def _should_exclude_material(self, description: str) -> bool: + """제외 대상 자재 확인""" + exclude_keywords = [ + 'WELD GAP', '용접갭', '용접 갭', + 'WELDING GAP', 'WELD ALLOWANCE', + 'CUTTING ALLOWANCE', '절단여유', + 'SPARE', '예비품', 'RESERVE' + ] + + desc_upper = description.upper() + return any(keyword.upper() in desc_upper for keyword in exclude_keywords) diff --git a/backend/app/services/simple_revision_service.py b/backend/app/services/simple_revision_service.py new file mode 100644 index 0000000..4c0b480 --- /dev/null +++ b/backend/app/services/simple_revision_service.py @@ -0,0 +1,395 @@ +""" +간단한 리비전 관리 서비스 +복잡한 dependency 없이 핵심 리비전 로직만 구현 +""" + +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ +from typing import Dict, List, Any, Optional, Tuple +import json +from datetime import datetime + +from ..models import Material, File, SimpleRevisionComparison, SimpleRevisionMaterial +from ..utils.logger import get_logger + +logger = get_logger(__name__) + +class SimpleRevisionService: + """간단한 리비전 관리 서비스""" + + def __init__(self, db: Session): + self.db = db + + def compare_revisions(self, current_file_id: int, previous_file_id: int, + category: str, username: str = "system") -> Dict[str, Any]: + """ + 두 리비전 간의 자재 비교 + + Args: + current_file_id: 현재 파일 ID + previous_file_id: 이전 파일 ID + category: 비교할 카테고리 (PIPE, FITTING, FLANGE 등) + username: 비교 수행자 + + Returns: + 비교 결과 딕셔너리 + """ + try: + # 현재 파일과 이전 파일의 자재 조회 + current_materials = self._get_materials_by_category(current_file_id, category) + previous_materials = self._get_materials_by_category(previous_file_id, category) + + # 자재 비교 수행 + comparison_result = self._perform_material_comparison( + current_materials, previous_materials, category + ) + + # 비교 결과 저장 + comparison_record = self._save_comparison_result( + current_file_id, previous_file_id, category, + comparison_result, username + ) + + return { + 'comparison_id': comparison_record.id, + 'category': category, + 'summary': comparison_result['summary'], + 'materials': comparison_result['materials'], + 'revision_actions': comparison_result['revision_actions'] + } + + except Exception as e: + logger.error(f"리비전 비교 실패: {e}") + raise + + def _get_materials_by_category(self, file_id: int, category: str) -> List[Material]: + """파일 ID와 카테고리로 자재 조회""" + return self.db.query(Material).filter( + and_( + Material.file_id == file_id, + or_( + Material.classified_category == category, + Material.category == category + ) + ) + ).all() + + def _perform_material_comparison(self, current_materials: List[Material], + previous_materials: List[Material], + category: str) -> Dict[str, Any]: + """자재 비교 로직 수행""" + + # 자재를 description + size로 그룹화 + current_dict = self._group_materials_by_key(current_materials) + previous_dict = self._group_materials_by_key(previous_materials) + + # 비교 결과 초기화 + added_materials = [] + removed_materials = [] + changed_materials = [] + unchanged_materials = [] + revision_actions = [] + + # 현재 자재 기준으로 비교 (개선된 버전) + for key, current_group in current_dict.items(): + if key in previous_dict: + previous_group = previous_dict[key] + + # 수량 비교 + current_qty = current_group['quantity'] + previous_qty = previous_group['quantity'] + + if current_qty != previous_qty: + # 수량 변경됨 + changed_materials.append({ + 'material': current_group['representative_material'], + 'previous_quantity': previous_qty, + 'current_quantity': current_qty, + 'quantity_difference': current_qty - previous_qty, + 'purchase_confirmed': previous_group['purchase_confirmed'] + }) + + # 리비전 액션 결정 (구매 상태 고려) + action = self._determine_revision_action_enhanced( + current_group, previous_group, category + ) + revision_actions.append(action) + else: + # 수량 동일 + unchanged_materials.append(current_group['representative_material']) + else: + # 새로 추가된 자재 + added_materials.append(current_group['representative_material']) + revision_actions.append({ + 'material_id': current_group['representative_material'].id, + 'action': 'added', + 'description': current_group['representative_material'].original_description, + 'quantity': current_group['quantity'], + 'purchase_status': 'not_purchased', + 'revision_action': 'new_material' + }) + + # 제거된 자재 확인 + for key, previous_group in previous_dict.items(): + if key not in current_dict: + removed_materials.append(previous_group['representative_material']) + revision_actions.append({ + 'material_id': previous_group['representative_material'].id, + 'action': 'removed', + 'description': previous_group['representative_material'].original_description, + 'quantity': previous_group['quantity'], + 'purchase_status': 'purchased' if previous_group['purchase_confirmed'] else 'not_purchased', + 'revision_action': 'deleted_material' + }) + + # 통계 계산 + summary = { + 'added_count': len(added_materials), + 'removed_count': len(removed_materials), + 'changed_count': len(changed_materials), + 'unchanged_count': len(unchanged_materials), + 'total_current': len(current_materials), + 'total_previous': len(previous_materials) + } + + return { + 'summary': summary, + 'materials': { + 'added': added_materials, + 'removed': removed_materials, + 'changed': changed_materials, + 'unchanged': unchanged_materials + }, + 'revision_actions': revision_actions + } + + def _group_materials_by_key(self, materials: List[Material]) -> Dict[str, Dict]: + """자재를 고유 키로 그룹화 (개선된 버전)""" + grouped = {} + for material in materials: + # 더 정교한 고유 키 생성 + # description + drawing + main_nom + red_nom + material_grade + key_parts = [ + material.original_description.strip().upper(), + material.drawing_name or '', + material.main_nom or '', + material.red_nom or '', + material.material_grade or '' + ] + key = "|".join(key_parts) + + if key in grouped: + # 동일한 자재가 있으면 수량 합산 + grouped[key]['quantity'] += float(material.quantity) + grouped[key]['materials'].append(material) + else: + grouped[key] = { + 'key': key, + 'representative_material': material, + 'quantity': float(material.quantity), + 'materials': [material], + 'purchase_confirmed': getattr(material, 'purchase_confirmed', False) + } + + return grouped + + def _determine_revision_action_enhanced(self, current_group: Dict, + previous_group: Dict, category: str) -> Dict[str, Any]: + """개선된 리비전 액션 결정 로직""" + + material = current_group['representative_material'] + current_qty = current_group['quantity'] + previous_qty = previous_group['quantity'] + quantity_diff = current_qty - previous_qty + is_purchased = previous_group['purchase_confirmed'] + + # 리비전 규칙 적용 + if is_purchased: + # 구매된 자재 + if quantity_diff > 0: + action = 'additional_purchase' + description = f"추가 구매 필요: +{quantity_diff}" + status = 'needs_additional_purchase' + elif quantity_diff < 0: + action = 'inventory' + description = f"재고품으로 분류: {abs(quantity_diff)}" + status = 'excess_inventory' + else: + action = 'maintain' + description = "상황 유지" + status = 'no_change' + else: + # 구매 안된 자재 + if quantity_diff > 0: + action = 'quantity_increase' + description = f"수량 증가: +{quantity_diff}" + status = 'quantity_updated' + elif quantity_diff < 0: + action = 'quantity_decrease' + description = f"수량 감소: {quantity_diff}" + status = 'quantity_reduced' + else: + action = 'maintain' + description = "구매 표시 유지" + status = 'purchase_pending' + + return { + 'material_id': material.id, + 'action': action, + 'description': material.original_description, + 'drawing_name': material.drawing_name, + 'previous_quantity': previous_qty, + 'current_quantity': current_qty, + 'quantity_difference': quantity_diff, + 'purchase_status': 'purchased' if is_purchased else 'not_purchased', + 'revision_action': status, + 'action_description': description, + 'category': category + } + + def _determine_revision_action(self, material: Material, previous_qty: float, + current_qty: float, category: str) -> Dict[str, Any]: + """리비전 액션 결정 로직""" + + # 구매 상태 확인 (간단하게 purchase_confirmed 필드 사용) + is_purchased = getattr(material, 'purchase_confirmed', False) + quantity_diff = current_qty - previous_qty + + if is_purchased: + # 구매된 자재 + if quantity_diff > 0: + action = 'additional_purchase' + description = f"추가 구매 필요: {quantity_diff}" + elif quantity_diff < 0: + action = 'inventory' + description = f"재고품으로 분류: {abs(quantity_diff)}" + else: + action = 'maintain' + description = "상황 유지" + else: + # 구매 안된 자재 + if quantity_diff > 0: + action = 'quantity_update' + description = f"수량 증가: {quantity_diff}" + elif quantity_diff < 0: + action = 'quantity_decrease' + description = f"수량 감소: {abs(quantity_diff)}" + else: + action = 'maintain' + description = "구매 표시 유지" + + return { + 'material_id': material.id, + 'action': action, + 'description': material.original_description, + 'previous_quantity': previous_qty, + 'current_quantity': current_qty, + 'quantity_difference': quantity_diff, + 'purchase_status': 'purchased' if is_purchased else 'not_purchased', + 'action_description': description + } + + def _save_comparison_result(self, current_file_id: int, previous_file_id: int, + category: str, comparison_result: Dict[str, Any], + username: str) -> SimpleRevisionComparison: + """비교 결과를 데이터베이스에 저장""" + + # Job No 조회 + current_file = self.db.query(File).filter(File.id == current_file_id).first() + job_no = getattr(current_file, 'job_no', 'unknown') + + # 비교 결과 레코드 생성 + comparison = SimpleRevisionComparison( + job_no=job_no, + current_file_id=current_file_id, + previous_file_id=previous_file_id, + category=category, + added_count=comparison_result['summary']['added_count'], + removed_count=comparison_result['summary']['removed_count'], + changed_count=comparison_result['summary']['changed_count'], + unchanged_count=comparison_result['summary']['unchanged_count'], + comparison_data=comparison_result, + created_by_username=username + ) + + self.db.add(comparison) + self.db.flush() # ID 생성을 위해 flush + + # 개별 자재 변경 로그 저장 + for action in comparison_result['revision_actions']: + material_log = SimpleRevisionMaterial( + comparison_id=comparison.id, + material_id=action['material_id'], + change_type=action['action'], + revision_action=action['action'], + quantity_before=action.get('previous_quantity', 0), + quantity_after=action.get('current_quantity', 0), + quantity_difference=action.get('quantity_difference', 0), + purchase_status=action.get('purchase_status', 'not_purchased') + ) + self.db.add(material_log) + + self.db.commit() + return comparison + + def get_changed_materials_only(self, current_file_id: int, previous_file_id: int, + categories: List[str] = None, username: str = "system") -> Dict[str, Any]: + """변경된 자재만 필터링하여 반환""" + + if not categories: + categories = ['PIPE', 'FITTING', 'FLANGE', 'VALVE', 'GASKET', 'BOLT', 'SUPPORT', 'SPECIAL', 'UNCLASSIFIED'] + + all_changes = {} + total_changes = 0 + + for category in categories: + try: + comparison_result = self.compare_revisions( + current_file_id, previous_file_id, category, username + ) + + # 변경된 자재만 필터링 + changed_materials = [] + for action in comparison_result['revision_actions']: + if action['action'] in ['added', 'removed'] or action.get('quantity_difference', 0) != 0: + changed_materials.append(action) + + if changed_materials: + all_changes[category] = { + 'category': category, + 'changed_count': len(changed_materials), + 'changes': changed_materials, + 'summary': comparison_result['summary'] + } + total_changes += len(changed_materials) + + except Exception as e: + logger.warning(f"Failed to compare {category}: {e}") + continue + + return { + 'total_changed_materials': total_changes, + 'categories_with_changes': list(all_changes.keys()), + 'changes_by_category': all_changes, + 'has_changes': total_changes > 0 + } + + def get_comparison_result(self, comparison_id: int) -> Optional[Dict[str, Any]]: + """저장된 비교 결과 조회""" + comparison = self.db.query(SimpleRevisionComparison).filter( + SimpleRevisionComparison.id == comparison_id + ).first() + + if not comparison: + return None + + # 관련 자재 변경 로그 조회 + material_logs = self.db.query(SimpleRevisionMaterial).filter( + SimpleRevisionMaterial.comparison_id == comparison_id + ).all() + + return { + 'comparison': comparison, + 'material_logs': material_logs, + 'comparison_data': comparison.comparison_data + } diff --git a/backend/app/utils/file_processor.py b/backend/app/utils/file_processor.py index e83aec0..19ca22c 100644 --- a/backend/app/utils/file_processor.py +++ b/backend/app/utils/file_processor.py @@ -10,6 +10,7 @@ import tempfile import os from concurrent.futures import ThreadPoolExecutor import gc +from fastapi import HTTPException from .logger import get_logger from ..config import get_settings @@ -333,3 +334,107 @@ class FileProcessor: # 전역 파일 프로세서 인스턴스 file_processor = FileProcessor() + + +def parse_dataframe(df): + """DataFrame을 파싱하여 자재 데이터로 변환""" + df = df.dropna(how='all') + # 원본 컬럼명 출력 + # 로그 제거 + df.columns = df.columns.str.strip().str.lower() + # 로그 제거 + + column_mapping = { + 'description': ['description', 'item', 'material', '품명', '자재명'], + 'quantity': ['qty', 'quantity', 'ea', '수량'], + 'main_size': ['main_nom', 'nominal_diameter', 'nd', '주배관'], + 'red_size': ['red_nom', 'reduced_diameter', '축소배관'], + 'length': ['length', 'len', '길이'], + 'weight': ['weight', 'wt', '중량'], + 'dwg_name': ['dwg_name', 'drawing', '도면명'], + 'line_num': ['line_num', 'line_number', '라인번호'] + } + + mapped_columns = {} + for standard_col, possible_names in column_mapping.items(): + for possible_name in possible_names: + if possible_name in df.columns: + mapped_columns[standard_col] = possible_name + break + + print(f"📋 엑셀 컬럼 매핑 결과: {mapped_columns}") + print(f"📋 원본 컬럼명들: {list(df.columns)}") + + materials = [] + for index, row in df.iterrows(): + description = str(row.get(mapped_columns.get('description', ''), '')).strip() + + if not description or description.lower() in ['nan', 'none', '']: + continue + + # 수량 처리 + quantity_raw = row.get(mapped_columns.get('quantity', ''), 0) + try: + quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0 + except (ValueError, TypeError): + quantity = 0 + + if quantity <= 0: + continue + + # 길이 처리 + length_raw = row.get(mapped_columns.get('length', ''), 0) + try: + length = float(length_raw) if pd.notna(length_raw) else 0 + except (ValueError, TypeError): + length = 0 + + # 도면명 처리 + dwg_name = str(row.get(mapped_columns.get('dwg_name', ''), '')).strip() + if dwg_name.lower() in ['nan', 'none']: + dwg_name = '' + + # 라인번호 처리 + line_num = str(row.get(mapped_columns.get('line_num', ''), '')).strip() + if line_num.lower() in ['nan', 'none']: + line_num = '' + + # 사이즈 처리 + main_size = str(row.get(mapped_columns.get('main_size', ''), '')).strip() + if main_size.lower() in ['nan', 'none']: + main_size = '' + + red_size = str(row.get(mapped_columns.get('red_size', ''), '')).strip() + if red_size.lower() in ['nan', 'none']: + red_size = '' + + materials.append({ + 'original_description': description, + 'quantity': quantity, + 'unit': 'EA', # 기본 단위 + 'length': length, + 'drawing_name': dwg_name, + 'line_no': line_num, + 'main_nom': main_size, + 'red_nom': red_size, + 'row_number': index + 1 + }) + + return materials + + +def parse_file_data(file_path): + """파일을 파싱하여 자재 데이터 추출""" + file_extension = Path(file_path).suffix.lower() + + try: + if file_extension == ".csv": + df = pd.read_csv(file_path, encoding='utf-8') + elif file_extension in [".xlsx", ".xls"]: + df = pd.read_excel(file_path, sheet_name=0) + else: + raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식") + + return parse_dataframe(df) + except Exception as e: + raise HTTPException(status_code=400, detail=f"파일 파싱 실패: {str(e)}") diff --git a/backend/scripts/docker_migrator.py b/backend/scripts/docker_migrator.py index 6e8fa01..cf8b79a 100644 --- a/backend/scripts/docker_migrator.py +++ b/backend/scripts/docker_migrator.py @@ -51,7 +51,15 @@ class DockerMigrator: if self._check_and_create_detail_tables(cursor): fixes_applied.append("누락된 상세 테이블들 생성") - # 5. 기타 필요한 수정사항들... + # 5. 리비전 관리 테이블들 체크 및 생성 + if self._check_and_create_revision_tables(cursor): + fixes_applied.append("리비전 관리 테이블들 생성") + + # 6. PIPE 관련 테이블들 체크 및 생성 + if self._check_and_create_pipe_tables(cursor): + fixes_applied.append("PIPE 관련 테이블들 생성") + + # 7. 기타 필요한 수정사항들... # 향후 추가될 수 있는 다른 스키마 수정사항들 conn.commit() @@ -382,6 +390,256 @@ class DockerMigrator: print(f"❌ 테이블 확인 실패: {e}") return False + def _check_and_create_revision_tables(self, cursor): + """간단한 리비전 관리 테이블들 체크 및 생성""" + print("🔍 간단한 리비전 관리 테이블들 확인 중...") + + revision_tables = { + 'simple_revision_comparisons': ''' + CREATE TABLE simple_revision_comparisons ( + id SERIAL PRIMARY KEY, + job_no VARCHAR(50) NOT NULL, + current_file_id INTEGER REFERENCES files(id) NOT NULL, + previous_file_id INTEGER REFERENCES files(id) NOT NULL, + category VARCHAR(50) NOT NULL, + added_count INTEGER DEFAULT 0, + removed_count INTEGER DEFAULT 0, + changed_count INTEGER DEFAULT 0, + unchanged_count INTEGER DEFAULT 0, + purchased_affected INTEGER DEFAULT 0, + unpurchased_affected INTEGER DEFAULT 0, + inventory_count INTEGER DEFAULT 0, + comparison_data JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by_username VARCHAR(100) + ); + CREATE INDEX idx_simple_revision_comparisons_job_no ON simple_revision_comparisons(job_no); + CREATE INDEX idx_simple_revision_comparisons_category ON simple_revision_comparisons(category); + ''', + 'simple_revision_materials': ''' + CREATE TABLE simple_revision_materials ( + id SERIAL PRIMARY KEY, + comparison_id INTEGER REFERENCES simple_revision_comparisons(id) NOT NULL, + material_id INTEGER REFERENCES materials(id), + change_type VARCHAR(20) NOT NULL, + revision_action VARCHAR(30), + quantity_before NUMERIC(10,3), + quantity_after NUMERIC(10,3), + quantity_difference NUMERIC(10,3), + purchase_status VARCHAR(20), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX idx_simple_revision_materials_comparison ON simple_revision_materials(comparison_id); + ''', + } + + created_tables = [] + + for table_name, create_sql in revision_tables.items(): + # 테이블 존재 확인 + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = %s + ) + """, (table_name,)) + + result = cursor.fetchone() + if isinstance(result, dict): + table_exists = result.get('exists', False) + else: + table_exists = result[0] if result else False + + if not table_exists: + # 테이블 생성 + cursor.execute(create_sql) + created_tables.append(table_name) + print(f" 🏗️ {table_name} 테이블 생성됨") + + if created_tables: + print(f"✅ {len(created_tables)}개 리비전 관리 테이블 생성 완료") + return True + else: + print("✅ 모든 리비전 관리 테이블이 이미 존재합니다") + return False + + def _check_and_create_pipe_tables(self, cursor): + """PIPE 관련 테이블들 체크 및 생성""" + print("🔍 PIPE 관련 테이블들 확인 중...") + + pipe_tables = { + 'pipe_cutting_plans': ''' + CREATE TABLE pipe_cutting_plans ( + id SERIAL PRIMARY KEY, + job_no VARCHAR(50) NOT NULL, + file_id INTEGER REFERENCES files(id) NOT NULL, + area VARCHAR(20) NOT NULL, + drawing_name VARCHAR(100) NOT NULL, + line_number VARCHAR(50), + pipe_info TEXT, + length NUMERIC(10,3), + end_preparation VARCHAR(20), + status VARCHAR(20) DEFAULT 'draft', + cutting_status VARCHAR(20) DEFAULT 'not_cut', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100) + ); + CREATE INDEX idx_pipe_cutting_plans_job_no ON pipe_cutting_plans(job_no); + CREATE INDEX idx_pipe_cutting_plans_drawing ON pipe_cutting_plans(drawing_name); + ''', + 'pipe_revision_comparisons': ''' + CREATE TABLE pipe_revision_comparisons ( + id SERIAL PRIMARY KEY, + job_no VARCHAR(50) NOT NULL, + current_cutting_plan_id INTEGER REFERENCES pipe_cutting_plans(id), + previous_cutting_plan_id INTEGER REFERENCES pipe_cutting_plans(id), + drawing_name VARCHAR(100) NOT NULL, + has_changes BOOLEAN DEFAULT FALSE, + total_pipes_current INTEGER DEFAULT 0, + total_pipes_previous INTEGER DEFAULT 0, + added_pipes INTEGER DEFAULT 0, + removed_pipes INTEGER DEFAULT 0, + changed_pipes INTEGER DEFAULT 0, + unchanged_pipes INTEGER DEFAULT 0, + comparison_details JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX idx_pipe_revision_comparisons_job_no ON pipe_revision_comparisons(job_no); + ''', + 'pipe_revision_changes': ''' + CREATE TABLE pipe_revision_changes ( + id SERIAL PRIMARY KEY, + comparison_id INTEGER REFERENCES pipe_revision_comparisons(id) NOT NULL, + change_type VARCHAR(20) NOT NULL, + drawing_name VARCHAR(100) NOT NULL, + line_number VARCHAR(50), + pipe_info TEXT, + length NUMERIC(10,3), + end_preparation VARCHAR(20), + old_data JSONB, + new_data JSONB, + cutting_status VARCHAR(20) DEFAULT 'not_cut', + impact_on_material_requirement JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + ''', + 'pipe_drawing_issues': ''' + CREATE TABLE pipe_drawing_issues ( + id SERIAL PRIMARY KEY, + job_no VARCHAR(50) NOT NULL, + snapshot_id INTEGER REFERENCES pipe_issue_snapshots(id), + area VARCHAR(20) NOT NULL, + drawing_name VARCHAR(100) NOT NULL, + issue_description TEXT NOT NULL, + severity VARCHAR(20) DEFAULT 'normal', + status VARCHAR(20) DEFAULT 'open', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + resolved_by VARCHAR(100), + resolved_at TIMESTAMP + ); + CREATE INDEX idx_pipe_drawing_issues_job_no ON pipe_drawing_issues(job_no); + ''', + 'pipe_segment_issues': ''' + CREATE TABLE pipe_segment_issues ( + id SERIAL PRIMARY KEY, + job_no VARCHAR(50) NOT NULL, + snapshot_id INTEGER REFERENCES pipe_issue_snapshots(id), + area VARCHAR(20) NOT NULL, + drawing_name VARCHAR(100) NOT NULL, + line_no VARCHAR(50) NOT NULL, + pipe_info TEXT NOT NULL, + length NUMERIC(10,3) NOT NULL, + issue_description TEXT NOT NULL, + issue_type VARCHAR(30), + severity VARCHAR(20) DEFAULT 'normal', + status VARCHAR(20) DEFAULT 'open', + resolution_action TEXT, + length_adjustment NUMERIC(10,3), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + resolved_by VARCHAR(100), + resolved_at TIMESTAMP + ); + CREATE INDEX idx_pipe_segment_issues_job_no ON pipe_segment_issues(job_no); + ''', + 'pipe_issue_snapshots': ''' + CREATE TABLE pipe_issue_snapshots ( + id SERIAL PRIMARY KEY, + job_no VARCHAR(50) NOT NULL, + snapshot_name VARCHAR(200), + cutting_plan_finalized_at TIMESTAMP NOT NULL, + is_locked BOOLEAN DEFAULT TRUE, + snapshot_data JSONB, + total_segments INTEGER DEFAULT 0, + total_drawings INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + description TEXT + ); + CREATE INDEX idx_pipe_issue_snapshots_job_no ON pipe_issue_snapshots(job_no); + ''' + } + + created_tables = [] + + # pipe_issue_snapshots를 먼저 생성 (다른 테이블들이 참조하므로) + priority_tables = ['pipe_issue_snapshots', 'pipe_cutting_plans'] + + # 우선순위 테이블들 먼저 생성 + for table_name in priority_tables: + if table_name in pipe_tables: + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = %s + ) + """, (table_name,)) + + result = cursor.fetchone() + if isinstance(result, dict): + table_exists = result.get('exists', False) + else: + table_exists = result[0] if result else False + + if not table_exists: + cursor.execute(pipe_tables[table_name]) + created_tables.append(table_name) + print(f" 🏗️ {table_name} 테이블 생성됨") + + # 나머지 테이블들 생성 + for table_name, create_sql in pipe_tables.items(): + if table_name in priority_tables: + continue # 이미 처리됨 + + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = %s + ) + """, (table_name,)) + + result = cursor.fetchone() + if isinstance(result, dict): + table_exists = result.get('exists', False) + else: + table_exists = result[0] if result else False + + if not table_exists: + cursor.execute(create_sql) + created_tables.append(table_name) + print(f" 🏗️ {table_name} 테이블 생성됨") + + if created_tables: + print(f"✅ {len(created_tables)}개 PIPE 관련 테이블 생성 완료") + return True + else: + print("✅ 모든 PIPE 관련 테이블이 이미 존재합니다") + return False + def main(): print("🚀 Docker 환경 마이그레이션 시작") diff --git a/backend/start.sh b/backend/start.sh index 70d876b..8abda73 100644 --- a/backend/start.sh +++ b/backend/start.sh @@ -13,29 +13,40 @@ echo " - DB_PORT: ${DB_PORT:-5432}" echo " - DB_NAME: ${DB_NAME:-tk_mp_bom}" echo " - DB_USER: ${DB_USER:-tkmp_user}" -# 1. Run complete DB migration -echo "Running complete DB migration..." -python scripts/complete_migrate.py +# 1. Run Docker-optimized migration (includes all new revision/PIPE tables) +echo "Running Docker-optimized migration..." +python scripts/docker_migrator.py migration_result=$? -if [ $migration_result -ne 0 ]; then - echo "WARNING: DB migration had some errors. Trying to fix missing tables..." - python scripts/fix_missing_tables.py - fix_result=$? - if [ $fix_result -eq 0 ]; then - echo "SUCCESS: Missing tables fixed" - else - echo "WARNING: Some tables may still be missing but starting server anyway" - fi +if [ $migration_result -eq 0 ]; then + echo "SUCCESS: Docker migration completed successfully" else - echo "SUCCESS: Complete DB migration finished" + echo "WARNING: Docker migration had some issues. Trying fallback methods..." + + # Fallback to legacy migration methods + echo "Running legacy complete migration..." + python scripts/complete_migrate.py + + legacy_result=$? + if [ $legacy_result -ne 0 ]; then + echo "WARNING: Legacy migration had errors. Trying to fix missing tables..." + python scripts/fix_missing_tables.py + fix_result=$? + if [ $fix_result -eq 0 ]; then + echo "SUCCESS: Missing tables fixed" + else + echo "WARNING: Some tables may still be missing but starting server anyway" + fi + else + echo "SUCCESS: Legacy migration finished" + fi fi # Additional safety check for critical tables echo "Verifying critical tables..." python scripts/fix_missing_tables.py -# Complete schema analysis and fix +# Complete schema analysis and fix (for any remaining issues) echo "Running complete schema analysis and fix..." python scripts/analyze_and_fix_schema.py echo "Complete schema analysis completed" diff --git a/frontend/src/components/RevisionUploadDialog.jsx b/frontend/src/components/RevisionUploadDialog.jsx index ce5129b..67eec03 100644 --- a/frontend/src/components/RevisionUploadDialog.jsx +++ b/frontend/src/components/RevisionUploadDialog.jsx @@ -1,14 +1,110 @@ -import React from 'react'; +import React, { useState, useRef } from 'react'; +import api from '../api'; const RevisionUploadDialog = ({ - revisionDialog, - setRevisionDialog, - revisionFile, - setRevisionFile, - handleRevisionUpload, - uploading + isOpen, + onClose, + parentFile, + selectedProject, + onUploadSuccess }) => { - if (!revisionDialog.open) return null; + const [selectedFile, setSelectedFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [error, setError] = useState(''); + const fileInputRef = useRef(null); + + const handleFileSelect = (event) => { + const file = event.target.files[0]; + if (file) { + // 파일 타입 검증 + const allowedTypes = [ + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/csv' + ]; + + if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx?|csv)$/i)) { + setError('Excel 파일(.xlsx, .xls) 또는 CSV 파일만 업로드 가능합니다.'); + return; + } + + setSelectedFile(file); + setError(''); + } + }; + + const handleUpload = async () => { + if (!selectedFile || !parentFile || !selectedProject) { + setError('필수 정보가 누락되었습니다.'); + return; + } + + try { + setUploading(true); + setUploadProgress(0); + setError(''); + + const formData = new FormData(); + formData.append('file', selectedFile); + formData.append('job_no', selectedProject.official_project_code || selectedProject.job_no); + formData.append('parent_file_id', parentFile.id); + formData.append('bom_name', parentFile.bom_name || parentFile.original_filename); + + console.log('🔄 리비전 업로드 시작:', { + fileName: selectedFile.name, + jobNo: selectedProject.official_project_code || selectedProject.job_no, + parentFileId: parentFile.id, + bomName: parentFile.bom_name || parentFile.original_filename + }); + + const response = await api.post('/files/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + }, + onUploadProgress: (progressEvent) => { + const progress = Math.round( + (progressEvent.loaded * 100) / progressEvent.total + ); + setUploadProgress(progress); + } + }); + + if (response.data.success) { + console.log('✅ 리비전 업로드 성공:', response.data); + + // 성공 콜백 호출 + if (onUploadSuccess) { + onUploadSuccess(response.data); + } + + // 다이얼로그 닫기 + handleClose(); + } else { + throw new Error(response.data.message || '리비전 업로드 실패'); + } + + } catch (err) { + console.error('❌ 리비전 업로드 실패:', err); + setError(err.response?.data?.detail || err.message || '리비전 업로드에 실패했습니다.'); + } finally { + setUploading(false); + setUploadProgress(0); + } + }; + + const handleClose = () => { + setSelectedFile(null); + setUploading(false); + setUploadProgress(0); + setError(''); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + onClose(); + }; + + if (!isOpen) return null; return (
-

- 리비전 업로드: {revisionDialog.bomName} -

- - setRevisionFile(e.target.files[0])} - style={{ - width: '100%', - marginBottom: '16px', - padding: '8px' + {/* 헤더 */} +
+
+

+ 📝 New Revision Upload +

+ +
+ + {/* 부모 파일 정보 */} +
+
+ Base BOM File: +
+
+ {parentFile?.bom_name || parentFile?.original_filename} +
+
+ Current Revision: {parentFile?.revision || 'Rev.0'} +
+
+
+ + {/* 파일 선택 */} +
+ + +
- -
+ onClick={() => fileInputRef.current?.click()} + > + + + {selectedFile ? ( +
+
📄
+
+ {selectedFile.name} +
+
+ {Math.round(selectedFile.size / 1024)} KB +
+
+ ) : ( +
+
📁
+
+ Click to select file +
+
+ Excel (.xlsx, .xls) or CSV files only +
+
+ )} +
+
+ + {/* 업로드 진행률 */} + {uploading && ( +
+
+ + Uploading... + + + {uploadProgress}% + +
+
+
+
+
+ )} + + {/* 에러 메시지 */} + {error && ( +
+ {error} +
+ )} + + {/* 버튼들 */} +
+
@@ -79,26 +325,4 @@ const RevisionUploadDialog = ({ ); }; -export default RevisionUploadDialog; - - - - - - - - - - - - - - - - - - - - - - +export default RevisionUploadDialog; \ No newline at end of file diff --git a/frontend/src/components/bom/tabs/BOMFilesTab.jsx b/frontend/src/components/bom/tabs/BOMFilesTab.jsx index 9ed7742..bf765ae 100644 --- a/frontend/src/components/bom/tabs/BOMFilesTab.jsx +++ b/frontend/src/components/bom/tabs/BOMFilesTab.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import api from '../../../api'; +import RevisionUploadDialog from '../../RevisionUploadDialog'; const BOMFilesTab = ({ selectedProject, @@ -13,6 +14,7 @@ const BOMFilesTab = ({ const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [groupedFiles, setGroupedFiles] = useState({}); + const [revisionDialog, setRevisionDialog] = useState({ open: false, parentFile: null }); // BOM 파일 목록 로드 useEffect(() => { @@ -99,10 +101,33 @@ const BOMFilesTab = ({ } }; - // 리비전 업로드 (향후 구현) + // 리비전 업로드 const handleRevisionUpload = (parentFile) => { - // TODO: 리비전 업로드 기능 구현 - alert('리비전 업로드 기능은 향후 구현 예정입니다.'); + console.log('🔄 리비전 업로드 시작:', parentFile); + setRevisionDialog({ open: true, parentFile }); + }; + + // 리비전 업로드 성공 처리 + const handleRevisionUploadSuccess = async (uploadResult) => { + console.log('✅ 리비전 업로드 성공:', uploadResult); + + // BOM 파일 목록 새로고침 + try { + const projectCode = selectedProject.official_project_code || selectedProject.job_no; + const encodedProjectCode = encodeURIComponent(projectCode); + const response = await api.get(`/files/project/${encodedProjectCode}`); + const files = response.data || []; + + setBomFiles(files); + setGroupedFiles(groupFilesByBOM(files)); + + // 성공 메시지 표시 (선택사항) + console.log(`새 리비전 ${uploadResult.revision} 업로드 완료!`); + + } catch (err) { + console.error('파일 목록 새로고침 실패:', err); + setError('파일 목록을 새로고침하는데 실패했습니다.'); + } }; // 날짜 포맷팅 @@ -422,6 +447,15 @@ const BOMFilesTab = ({
+ + {/* 리비전 업로드 다이얼로그 */} + setRevisionDialog({ open: false, parentFile: null })} + parentFile={revisionDialog.parentFile} + selectedProject={selectedProject} + onUploadSuccess={handleRevisionUploadSuccess} + /> ); }; diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js index 6b42994..c4498f9 100644 --- a/frontend/src/components/common/index.js +++ b/frontend/src/components/common/index.js @@ -4,5 +4,5 @@ export { default as ErrorMessage } from './ErrorMessage'; export { default as ConfirmDialog } from './ConfirmDialog'; // 기존 컴포넌트들도 re-export -export { default as UserMenu } from '../UserMenu'; +export { default as UserMenu } from './UserMenu'; export { default as ErrorBoundary } from '../ErrorBoundary'; \ No newline at end of file diff --git a/frontend/src/hooks/useRevisionLogic.js b/frontend/src/hooks/useRevisionLogic.js deleted file mode 100644 index eb3b166..0000000 --- a/frontend/src/hooks/useRevisionLogic.js +++ /dev/null @@ -1,314 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { api } from '../api'; - -/** - * 리비전 로직 처리 훅 - * 구매 상태별 자재 처리 로직 - */ -export const useRevisionLogic = (jobNo, currentFileId, previousFileId = null) => { - const [processingResult, setProcessingResult] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const processRevision = useCallback(async () => { - if (!jobNo || !currentFileId) return; - - try { - setLoading(true); - setError(null); - - const params = new URLSearchParams({ - job_no: jobNo, - file_id: currentFileId - }); - - if (previousFileId) { - params.append('previous_file_id', previousFileId); - } - - const response = await api.post(`/revision-redirect/process-revision/${jobNo}/${currentFileId}?${params}`); - - if (response.data.success) { - setProcessingResult(response.data.data); - } - } catch (err) { - console.error('리비전 처리 실패:', err); - setError(err.message); - } finally { - setLoading(false); - } - }, [jobNo, currentFileId, previousFileId]); - - const applyProcessingResults = useCallback(async (results) => { - try { - setLoading(true); - setError(null); - - const response = await api.post('/revision-material/apply-results', { - processing_results: results - }); - - if (response.data.success) { - return response.data; - } else { - throw new Error(response.data.message || '처리 결과 적용 실패'); - } - } catch (err) { - console.error('처리 결과 적용 실패:', err); - setError(err.message); - throw err; - } finally { - setLoading(false); - } - }, []); - - return { - processingResult, - loading, - error, - processRevision, - applyProcessingResults, - setError - }; -}; - -/** - * 카테고리별 자재 처리 훅 - */ -export const useCategoryMaterialProcessing = (fileId, category) => { - const [materials, setMaterials] = useState([]); - const [processingInfo, setProcessingInfo] = useState({}); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const loadCategoryMaterials = useCallback(async () => { - if (!fileId || !category || category === 'PIPE') return; - - try { - setLoading(true); - setError(null); - - const response = await api.get(`/revision-material/category/${fileId}/${category}`); - - if (response.data.success) { - setMaterials(response.data.data.materials || []); - setProcessingInfo(response.data.data.processing_info || {}); - } - } catch (err) { - console.error('카테고리 자재 조회 실패:', err); - setError(err.message); - } finally { - setLoading(false); - } - }, [fileId, category]); - - const processMaterial = useCallback(async (materialId, action, additionalData = {}) => { - try { - const response = await api.post(`/revision-material/process/${materialId}`, { - action, - ...additionalData - }); - - if (response.data.success) { - // 자재 목록 새로고침 - await loadCategoryMaterials(); - return response.data; - } else { - throw new Error(response.data.message || '자재 처리 실패'); - } - } catch (err) { - console.error('자재 처리 실패:', err); - setError(err.message); - throw err; - } - }, [loadCategoryMaterials]); - - const updateMaterialStatus = useCallback((materialId, newStatus, additionalInfo = {}) => { - setMaterials(prev => - prev.map(material => - material.id === materialId - ? { - ...material, - revision_status: newStatus, - processing_info: { - ...material.processing_info, - ...additionalInfo - } - } - : material - ) - ); - }, []); - - useEffect(() => { - loadCategoryMaterials(); - }, [loadCategoryMaterials]); - - return { - materials, - processingInfo, - loading, - error, - loadCategoryMaterials, - processMaterial, - updateMaterialStatus, - setError - }; -}; - -/** - * 자재 선택 및 일괄 처리 훅 - */ -export const useMaterialSelection = (materials = []) => { - const [selectedMaterials, setSelectedMaterials] = useState(new Set()); - const [selectAll, setSelectAll] = useState(false); - - const toggleMaterial = useCallback((materialId) => { - setSelectedMaterials(prev => { - const newSet = new Set(prev); - if (newSet.has(materialId)) { - newSet.delete(materialId); - } else { - newSet.add(materialId); - } - return newSet; - }); - }, []); - - const toggleSelectAll = useCallback(() => { - if (selectAll) { - setSelectedMaterials(new Set()); - } else { - const selectableMaterials = materials - .filter(material => material.processing_info?.action !== '완료') - .map(material => material.id); - setSelectedMaterials(new Set(selectableMaterials)); - } - setSelectAll(!selectAll); - }, [selectAll, materials]); - - const clearSelection = useCallback(() => { - setSelectedMaterials(new Set()); - setSelectAll(false); - }, []); - - const getSelectedMaterials = useCallback(() => { - return materials.filter(material => selectedMaterials.has(material.id)); - }, [materials, selectedMaterials]); - - const getSelectionSummary = useCallback(() => { - const selected = getSelectedMaterials(); - const byStatus = selected.reduce((acc, material) => { - const status = material.processing_info?.display_status || 'UNKNOWN'; - acc[status] = (acc[status] || 0) + 1; - return acc; - }, {}); - - return { - total: selected.length, - byStatus, - canProcess: selected.length > 0 - }; - }, [getSelectedMaterials]); - - // materials 변경 시 selectAll 상태 업데이트 - useEffect(() => { - const selectableMaterials = materials - .filter(material => material.processing_info?.action !== '완료'); - - if (selectableMaterials.length === 0) { - setSelectAll(false); - } else { - const allSelected = selectableMaterials.every(material => - selectedMaterials.has(material.id) - ); - setSelectAll(allSelected); - } - }, [materials, selectedMaterials]); - - return { - selectedMaterials, - selectAll, - toggleMaterial, - toggleSelectAll, - clearSelection, - getSelectedMaterials, - getSelectionSummary - }; -}; - -/** - * 리비전 처리 상태 추적 훅 - */ -export const useRevisionProcessingStatus = (jobNo, fileId) => { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const loadStatus = useCallback(async () => { - if (!jobNo || !fileId) return; - - try { - setLoading(true); - setError(null); - - const response = await api.get(`/revision-status/${jobNo}/${fileId}`); - - if (response.data.success) { - setStatus(response.data.data); - } - } catch (err) { - console.error('리비전 상태 조회 실패:', err); - setError(err.message); - } finally { - setLoading(false); - } - }, [jobNo, fileId]); - - const updateProcessingProgress = useCallback((category, processed, total) => { - setStatus(prev => { - if (!prev) return prev; - - const newCategoryStatus = { - ...prev.processing_status.category_breakdown[category], - processed, - pending: total - processed - }; - - const newCategoryBreakdown = { - ...prev.processing_status.category_breakdown, - [category]: newCategoryStatus - }; - - // 전체 통계 재계산 - const totalProcessed = Object.values(newCategoryBreakdown) - .reduce((sum, cat) => sum + cat.processed, 0); - const totalMaterials = Object.values(newCategoryBreakdown) - .reduce((sum, cat) => sum + cat.total, 0); - - return { - ...prev, - processing_status: { - ...prev.processing_status, - total_processed: totalProcessed, - pending_processing: totalMaterials - totalProcessed, - completion_percentage: totalMaterials > 0 ? (totalProcessed / totalMaterials * 100) : 0, - category_breakdown: newCategoryBreakdown - } - }; - }); - }, []); - - useEffect(() => { - loadStatus(); - }, [loadStatus]); - - return { - status, - loading, - error, - loadStatus, - updateProcessingProgress, - setError - }; -}; diff --git a/frontend/src/hooks/useRevisionRedirect.js b/frontend/src/hooks/useRevisionRedirect.js deleted file mode 100644 index 7138aa2..0000000 --- a/frontend/src/hooks/useRevisionRedirect.js +++ /dev/null @@ -1,108 +0,0 @@ -import { useState, useEffect } from 'react'; -import { api } from '../api'; - -/** - * 리비전 리다이렉트 훅 - * BOM 페이지 접근 시 리비전 페이지로 리다이렉트 필요성 확인 - */ -export const useRevisionRedirect = (jobNo, fileId, previousFileId = null) => { - const [redirectInfo, setRedirectInfo] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (!jobNo || !fileId) { - setLoading(false); - return; - } - - checkRevisionRedirect(); - }, [jobNo, fileId, previousFileId]); - - const checkRevisionRedirect = async () => { - try { - setLoading(true); - setError(null); - - const params = new URLSearchParams({ - job_no: jobNo, - file_id: fileId - }); - - if (previousFileId) { - params.append('previous_file_id', previousFileId); - } - - const response = await api.get(`/revision-redirect/check/${jobNo}/${fileId}?${params}`); - - if (response.data.success) { - setRedirectInfo(response.data.data); - } - } catch (err) { - console.error('리비전 리다이렉트 확인 실패:', err); - setError(err.message); - // 에러 발생 시 기존 BOM 페이지 사용 - setRedirectInfo({ - should_redirect: false, - reason: '리비전 상태 확인 실패 - 기존 페이지 사용', - redirect_url: null, - processing_summary: null - }); - } finally { - setLoading(false); - } - }; - - return { - redirectInfo, - loading, - error, - refetch: checkRevisionRedirect - }; -}; - -/** - * 리비전 처리 로직 훅 - * 리비전 페이지에서 사용할 상세 처리 결과 조회 - */ -export const useRevisionProcessing = (jobNo, fileId, previousFileId = null) => { - const [processingResult, setProcessingResult] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const processRevision = async () => { - if (!jobNo || !fileId) return; - - try { - setLoading(true); - setError(null); - - const params = new URLSearchParams({ - job_no: jobNo, - file_id: fileId - }); - - if (previousFileId) { - params.append('previous_file_id', previousFileId); - } - - const response = await api.post(`/revision-redirect/process-revision/${jobNo}/${fileId}?${params}`); - - if (response.data.success) { - setProcessingResult(response.data.data); - } - } catch (err) { - console.error('리비전 처리 실패:', err); - setError(err.message); - } finally { - setLoading(false); - } - }; - - return { - processingResult, - loading, - error, - processRevision - }; -}; diff --git a/frontend/src/pages/BOMManagementPage.jsx b/frontend/src/pages/BOMManagementPage.jsx index fb8de56..fd44adf 100644 --- a/frontend/src/pages/BOMManagementPage.jsx +++ b/frontend/src/pages/BOMManagementPage.jsx @@ -34,6 +34,12 @@ const BOMManagementPage = ({ const [userRequirements, setUserRequirements] = useState({}); const [purchasedMaterials, setPurchasedMaterials] = useState(new Set()); const [error, setError] = useState(null); + + // 리비전 관련 상태 + const [isRevisionMode, setIsRevisionMode] = useState(false); + const [revisionData, setRevisionData] = useState(null); + const [previousFileId, setPreviousFileId] = useState(null); + const [changedMaterials, setChangedMaterials] = useState({}); // 자재 업데이트 함수 (브랜드, 사용자 요구사항 등) const updateMaterial = (materialId, updates) => { @@ -155,12 +161,58 @@ const BOMManagementPage = ({ } }; + // 리비전 모드 감지 및 변경된 자재 로드 + const checkAndLoadRevisionData = async () => { + try { + // 현재 job_no의 모든 파일 목록 확인 + const filesResponse = await api.get(`/files/list?job_no=${jobNo}`); + const files = filesResponse.data.files || []; + + if (files.length > 1) { + // 파일이 여러 개 있으면 리비전 모드 활성화 + setIsRevisionMode(true); + + // 파일들을 업로드 날짜순으로 정렬 + const sortedFiles = files.sort((a, b) => new Date(a.upload_date) - new Date(b.upload_date)); + + // 이전 파일 ID 찾기 (현재 파일 이전 버전) + const currentIndex = sortedFiles.findIndex(file => file.id === parseInt(fileId)); + if (currentIndex > 0) { + const previousFile = sortedFiles[currentIndex - 1]; + setPreviousFileId(previousFile.id); + + // 변경된 자재 로드 + await loadChangedMaterials(fileId, previousFile.id); + } + } + } catch (error) { + console.error('리비전 데이터 로드 실패:', error); + // API 오류 시 리비전 모드 비활성화 + setIsRevisionMode(false); + } + }; + + // 변경된 자재 로드 + const loadChangedMaterials = async (currentFileId, previousFileId) => { + try { + const response = await api.get(`/simple-revision/changed-materials/${currentFileId}/${previousFileId}`); + if (response.data.success) { + setChangedMaterials(response.data.data.changes_by_category || {}); + setRevisionData(response.data.data); + console.log('✅ 변경된 자재 로드 완료:', response.data.data); + } + } catch (error) { + console.error('변경된 자재 로드 실패:', error); + } + }; + // 초기 로드 useEffect(() => { if (fileId) { loadMaterials(fileId); loadAvailableRevisions(); loadUserRequirements(fileId); + checkAndLoadRevisionData(); // 리비전 데이터 확인 } }, [fileId]); @@ -180,12 +232,30 @@ const BOMManagementPage = ({ } }, [materials, selectedCategory]); - // 카테고리별 자재 필터링 + // 카테고리별 자재 필터링 (리비전 모드 지원) const getCategoryMaterials = (category) => { - return materials.filter(material => - material.classified_category === category || - material.category === category - ); + if (isRevisionMode && changedMaterials[category]) { + // 리비전 모드: 변경된 자재만 표시 + const changedMaterialIds = changedMaterials[category].changes.map(change => change.material_id); + return materials.filter(material => + (material.classified_category === category || material.category === category) && + changedMaterialIds.includes(material.id) + ); + } else { + // 일반 모드: 모든 자재 표시 + return materials.filter(material => + material.classified_category === category || + material.category === category + ); + } + }; + + // 리비전 액션 정보 가져오기 + const getRevisionAction = (materialId, category) => { + if (!isRevisionMode || !changedMaterials[category]) return null; + + const change = changedMaterials[category].changes.find(c => c.material_id === materialId); + return change || null; }; // 카테고리별 컴포넌트 렌더링 @@ -210,7 +280,11 @@ const BOMManagementPage = ({ fileId, jobNo, user, - onNavigate + onNavigate, + // 리비전 관련 props 추가 + isRevisionMode, + getRevisionAction: (materialId) => getRevisionAction(materialId, selectedCategory), + revisionData }; switch (selectedCategory) { @@ -282,15 +356,32 @@ const BOMManagementPage = ({ }}>
-

- BOM Materials Management -

+
+

+ BOM Materials Management +

+ {isRevisionMode && ( +
+ 📊 Revision Mode +
+ )} +

{bomName} - {currentRevision} | Project: {selectedProject?.job_name || jobNo} + {isRevisionMode && revisionData && ( + + • {revisionData.total_changed_materials} materials changed + + )}

); diff --git a/frontend/src/pages/EnhancedRevisionPage.css b/frontend/src/pages/EnhancedRevisionPage.css deleted file mode 100644 index 54beaca..0000000 --- a/frontend/src/pages/EnhancedRevisionPage.css +++ /dev/null @@ -1,815 +0,0 @@ -/* Enhanced Revision Page - 기존 스타일 통일 */ - -* { - 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; - 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); -} - -.header-center { - display: flex; - align-items: center; - gap: 16px; -} - -/* 메인 콘텐츠 */ -.materials-content { - padding: 24px; -} - -/* 컨트롤 섹션 */ -.control-section { - background: white; - border-radius: 8px; - padding: 20px; - margin-bottom: 20px; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); - border: 1px solid #e5e7eb; -} - -.section-header h3 { - margin: 0 0 16px 0; - color: #1f2937; - font-size: 18px; - font-weight: 600; -} - -.control-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 20px; - align-items: end; -} - -.control-group { - display: flex; - flex-direction: column; -} - -.control-group label { - font-weight: 600; - color: #34495e; - margin-bottom: 8px; - font-size: 0.95em; -} - -.control-group select { - padding: 8px 12px; - border: 1px solid #d1d5db; - border-radius: 6px; - font-size: 14px; - background: white; - color: #374151; - transition: all 0.2s ease; -} - -.control-group select:focus { - outline: none; - border-color: #6366f1; - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); -} - -.control-group select:disabled { - background-color: #f9fafb; - color: #9ca3af; - cursor: not-allowed; -} - -.btn-compare { - padding: 8px 16px; - background: #6366f1; - color: white; - border: none; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; -} - -.btn-compare:hover:not(:disabled) { - background: #5558e3; - transform: translateY(-1px); -} - -.btn-compare:disabled { - background: #9ca3af; - cursor: not-allowed; - transform: none; -} - -/* 메인 콘텐츠 레이아웃 */ -.revision-content { - display: grid; - grid-template-columns: 2fr 1fr; - gap: 25px; -} - -.content-left, .content-right { - display: flex; - flex-direction: column; - gap: 25px; -} - -/* 비교 결과 */ -.comparison-result { - background: white; - border-radius: 8px; - padding: 20px; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); - border: 1px solid #e5e7eb; -} - -.result-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - padding-bottom: 12px; - border-bottom: 1px solid #e5e7eb; -} - -.result-header h3 { - margin: 0; - color: #1f2937; - font-size: 18px; - font-weight: 600; -} - -.btn-apply { - padding: 8px 16px; - background: #10b981; - color: white; - border: none; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; -} - -.btn-apply:hover:not(:disabled) { - background: #059669; - transform: translateY(-1px); -} - -/* 비교 요약 */ -.comparison-summary { - margin-bottom: 30px; -} - -.comparison-summary h3 { - margin: 0 0 20px 0; - color: #2c3e50; - font-size: 1.2em; -} - -.summary-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 20px; -} - -.summary-card { - padding: 20px; - border-radius: 10px; - border-left: 4px solid; -} - -.summary-card.purchased { - background: #e8f5e8; - border-left-color: #27ae60; -} - -.summary-card.unpurchased { - background: #fff3cd; - border-left-color: #ffc107; -} - -.summary-card.changes { - background: #e3f2fd; - border-left-color: #2196f3; -} - -.summary-card h4 { - margin: 0 0 15px 0; - font-size: 1.1em; - color: #2c3e50; -} - -.summary-stats { - display: flex; - flex-wrap: wrap; - gap: 12px; -} - -.stat-item { - padding: 6px 12px; - border-radius: 20px; - font-size: 0.9em; - font-weight: 600; - background: white; - color: #2c3e50; - border: 1px solid #e1e8ed; -} - -.stat-item.increase { - background: #ffebee; - color: #c62828; - border-color: #ffcdd2; -} - -.stat-item.decrease { - background: #e8f5e8; - color: #2e7d32; - border-color: #c8e6c9; -} - -.stat-item.new { - background: #e3f2fd; - color: #1565c0; - border-color: #bbdefb; -} - -.stat-item.deleted { - background: #fce4ec; - color: #ad1457; - border-color: #f8bbd9; -} - -/* 변경사항 상세 */ -.change-details { - margin-top: 20px; -} - -.change-details h3 { - margin: 0 0 20px 0; - color: #2c3e50; - font-size: 1.2em; -} - -.change-section { - margin-bottom: 25px; - padding: 20px; - background: #f8f9fa; - border-radius: 10px; -} - -.change-section h4 { - margin: 0 0 15px 0; - color: #2c3e50; - font-size: 1.1em; -} - -.change-category { - margin-bottom: 20px; -} - -.change-category h5 { - margin: 0 0 12px 0; - color: #34495e; - font-size: 1em; -} - -.material-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.material-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px; - background: white; - border-radius: 8px; - border-left: 3px solid #e1e8ed; -} - -.change-category.additional-purchase .material-item { - border-left-color: #e74c3c; -} - -.change-category.excess-inventory .material-item { - border-left-color: #f39c12; -} - -.change-category.quantity-updated .material-item { - border-left-color: #3498db; -} - -.change-category.quantity-reduced .material-item { - border-left-color: #95a5a6; -} - -.change-category.new-materials .material-item { - border-left-color: #27ae60; -} - -.change-category.deleted-materials .material-item { - border-left-color: #e74c3c; -} - -.material-desc { - flex: 1; - font-weight: 500; - color: #2c3e50; -} - -.quantity-change, .quantity-info { - font-weight: 600; - color: #7f8c8d; - font-size: 0.9em; -} - -.reason { - font-style: italic; - color: #95a5a6; - font-size: 0.85em; -} - -/* PIPE 길이 요약 */ -.pipe-length-summary { - background: white; - border-radius: 12px; - padding: 25px; - box-shadow: 0 2px 10px rgba(0,0,0,0.05); -} - -.summary-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - padding-bottom: 15px; - border-bottom: 2px solid #ecf0f1; -} - -.summary-header h3 { - margin: 0; - color: #2c3e50; - font-size: 1.3em; -} - -.btn-recalculate { - padding: 8px 16px; - background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%); - color: white; - border: none; - border-radius: 6px; - font-size: 0.9em; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; -} - -.btn-recalculate:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 3px 10px rgba(243, 156, 18, 0.3); -} - -.pipe-stats { - display: flex; - gap: 20px; - margin-bottom: 20px; - padding: 15px; - background: #f8f9fa; - border-radius: 8px; -} - -.pipe-stats span { - font-weight: 600; - color: #2c3e50; -} - -.pipe-lines { - display: flex; - flex-direction: column; - gap: 12px; -} - -.pipe-line { - padding: 15px; - border-radius: 8px; - border-left: 4px solid; - background: white; - transition: all 0.2s ease; -} - -.pipe-line:hover { - transform: translateX(5px); - box-shadow: 0 2px 8px rgba(0,0,0,0.1); -} - -.pipe-line.purchased { - border-left-color: #27ae60; - background: #e8f5e8; -} - -.pipe-line.pending { - border-left-color: #f39c12; - background: #fff3cd; -} - -.pipe-line.mixed { - border-left-color: #e74c3c; - background: #ffebee; -} - -.line-info { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} - -.drawing-line { - font-weight: 600; - color: #2c3e50; - font-size: 1.05em; -} - -.material-spec { - font-size: 0.9em; - color: #7f8c8d; -} - -.line-stats { - display: flex; - gap: 15px; - align-items: center; -} - -.line-stats span { - font-size: 0.9em; - color: #34495e; -} - -.status { - padding: 4px 8px; - border-radius: 12px; - font-size: 0.8em; - font-weight: 600; -} - -.status.purchased { - background: #d4edda; - color: #155724; -} - -.status.pending { - background: #fff3cd; - color: #856404; -} - -.status.mixed { - background: #f8d7da; - color: #721c24; -} - -/* 비교 이력 */ -.comparison-history { - background: white; - border-radius: 12px; - padding: 25px; - box-shadow: 0 2px 10px rgba(0,0,0,0.05); -} - -.comparison-history h3 { - margin: 0 0 20px 0; - color: #2c3e50; - font-size: 1.3em; -} - -.history-list { - display: flex; - flex-direction: column; - gap: 15px; -} - -.history-item { - padding: 15px; - border-radius: 8px; - border: 2px solid; - transition: all 0.2s ease; -} - -.history-item.applied { - border-color: #27ae60; - background: #e8f5e8; -} - -.history-item.pending { - border-color: #f39c12; - background: #fff3cd; -} - -.history-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; -} - -.comparison-date { - font-weight: 600; - color: #2c3e50; - font-size: 0.9em; -} - -.status.applied { - background: #d4edda; - color: #155724; - padding: 4px 8px; - border-radius: 12px; - font-size: 0.8em; - font-weight: 600; -} - -.status.pending { - background: #fff3cd; - color: #856404; - padding: 4px 8px; - border-radius: 12px; - font-size: 0.8em; - font-weight: 600; -} - -.history-summary { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 10px; -} - -.history-summary span { - font-size: 0.85em; - color: #7f8c8d; -} - -.btn-apply-small { - padding: 6px 12px; - background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); - color: white; - border: none; - border-radius: 6px; - font-size: 0.8em; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - align-self: flex-start; -} - -.btn-apply-small:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(39, 174, 96, 0.3); -} - -.no-history { - text-align: center; - color: #95a5a6; - font-style: italic; - padding: 40px 20px; -} - -/* 반응형 디자인 */ -@media (max-width: 1200px) { - .revision-content { - grid-template-columns: 1fr; - } - - .control-grid { - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - } -} - -@media (max-width: 768px) { - .enhanced-revision-page { - padding: 15px; - } - - .page-header { - padding: 15px; - } - - .page-header h1 { - font-size: 1.8em; - } - - .revision-controls, - .comparison-result, - .pipe-length-summary, - .comparison-history { - padding: 20px; - } - - .control-grid { - grid-template-columns: 1fr; - } - - .summary-grid { - grid-template-columns: 1fr; - } - - .line-info { - flex-direction: column; - align-items: flex-start; - gap: 5px; - } - - .line-stats { - flex-wrap: wrap; - gap: 10px; - } - - .material-item { - flex-direction: column; - align-items: flex-start; - gap: 8px; - } -} - -/* 카테고리별 자재 관리 섹션 */ -.category-materials-section { - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - padding: 24px; - margin-bottom: 24px; - border: 1px solid #e2e8f0; -} - -.category-materials-section h3 { - margin: 0 0 20px 0; - font-size: 18px; - font-weight: 600; - color: #1e293b; -} - -.category-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 16px; -} - -.category-card { - background: #f8fafc; - border: 2px solid #e2e8f0; - border-radius: 12px; - padding: 20px; - transition: all 0.3s ease; - position: relative; -} - -.category-card:hover { - border-color: #6366f1; - box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15); - transform: translateY(-2px); -} - -.category-card.has-revisions { - background: linear-gradient(135deg, #fef3c7 0%, #fbbf24 100%); - border-color: #f59e0b; -} - -.category-card.has-revisions:hover { - border-color: #d97706; - box-shadow: 0 4px 12px rgba(245, 158, 11, 0.25); -} - -.category-header { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 16px; -} - -.category-icon { - font-size: 24px; - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - background: white; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.category-info h4 { - margin: 0; - font-size: 16px; - font-weight: 600; - color: #1e293b; -} - -.category-desc { - font-size: 14px; - color: #64748b; -} - -.category-stats { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.category-stats .stat-item { - display: flex; - flex-direction: column; - align-items: center; - padding: 8px 12px; - background: white; - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - min-width: 60px; -} - -.category-stats .stat-item.revision { - background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); - color: white; -} - -.category-stats .stat-item.inventory { - background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%); - color: white; -} - -.category-stats .stat-label { - font-size: 12px; - font-weight: 500; - margin-bottom: 2px; -} - -.category-stats .stat-value { - font-size: 18px; - font-weight: 700; -} - -.empty-category { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - color: #9ca3af; - font-size: 14px; - font-style: italic; -} - -/* 카테고리 카드 반응형 */ -@media (max-width: 768px) { - .category-grid { - grid-template-columns: 1fr; - } - - .category-stats { - justify-content: center; - } -} diff --git a/frontend/src/pages/EnhancedRevisionPage.jsx b/frontend/src/pages/EnhancedRevisionPage.jsx deleted file mode 100644 index f9f3764..0000000 --- a/frontend/src/pages/EnhancedRevisionPage.jsx +++ /dev/null @@ -1,683 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../api'; -import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../components/common'; -import FittingRevisionPage from './revision/FittingRevisionPage'; -import FlangeRevisionPage from './revision/FlangeRevisionPage'; -import SpecialRevisionPage from './revision/SpecialRevisionPage'; -import SupportRevisionPage from './revision/SupportRevisionPage'; -import UnclassifiedRevisionPage from './revision/UnclassifiedRevisionPage'; -import ValveRevisionPage from './revision/ValveRevisionPage'; -import GasketRevisionPage from './revision/GasketRevisionPage'; -import BoltRevisionPage from './revision/BoltRevisionPage'; -import PipeCuttingPlanPage from './revision/PipeCuttingPlanPage'; -import './EnhancedRevisionPage.css'; - -const EnhancedRevisionPage = ({ onNavigate, user }) => { - const [jobs, setJobs] = useState([]); - const [selectedJob, setSelectedJob] = useState(''); - const [files, setFiles] = useState([]); - const [currentFile, setCurrentFile] = useState(''); - const [previousFile, setPreviousFile] = useState(''); - const [comparisonResult, setComparisonResult] = useState(null); - const [comparisonHistory, setComparisonHistory] = useState([]); - const [pipeLengthSummary, setPipeLengthSummary] = useState(null); - - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [showApplyDialog, setShowApplyDialog] = useState(false); - const [selectedComparison, setSelectedComparison] = useState(null); - - // 카테고리별 페이지 라우팅 - const [selectedCategory, setSelectedCategory] = useState(''); - const [categoryMaterials, setCategoryMaterials] = useState({}); - - // 작업 목록 조회 - useEffect(() => { - fetchJobs(); - }, []); - - // 선택된 작업의 파일 목록 조회 - useEffect(() => { - if (selectedJob) { - fetchJobFiles(); - fetchComparisonHistory(); - } - }, [selectedJob]); - - // 현재 파일의 PIPE 길이 요약 및 카테고리별 자재 조회 - useEffect(() => { - if (currentFile) { - fetchPipeLengthSummary(); - fetchCategoryMaterials(); - } - }, [currentFile]); - - const fetchJobs = async () => { - try { - const response = await api.get('/dashboard/projects'); - setJobs(response.data.projects || []); - } catch (err) { - setError('작업 목록 조회 실패: ' + err.message); - } - }; - - const fetchJobFiles = async () => { - try { - const response = await api.get(`/files/by-job/${selectedJob}`); - setFiles(response.data || []); - } catch (err) { - setError('파일 목록 조회 실패: ' + err.message); - } - }; - - const fetchComparisonHistory = async () => { - try { - const response = await api.get(`/enhanced-revision/comparison-history/${selectedJob}`); - setComparisonHistory(response.data.data || []); - } catch (err) { - console.error('비교 이력 조회 실패:', err); - } - }; - - const fetchPipeLengthSummary = async () => { - try { - const response = await api.get(`/enhanced-revision/pipe-length-summary/${currentFile}`); - setPipeLengthSummary(response.data.data); - } catch (err) { - console.error('PIPE 길이 요약 조회 실패:', err); - } - }; - - const fetchCategoryMaterials = async () => { - if (!currentFile) return; - - try { - const categories = ['PIPE', 'FITTING', 'FLANGE', 'VALVE', 'GASKET', 'BOLT', 'SUPPORT', 'SPECIAL', 'UNCLASSIFIED']; - const materialStats = {}; - - for (const category of categories) { - try { - const response = await api.get(`/revision-material/category/${currentFile}/${category}`); - materialStats[category] = { - count: response.data.data?.materials?.length || 0, - processing_info: response.data.data?.processing_info || {} - }; - } catch (err) { - console.error(`Failed to fetch ${category} materials:`, err); - materialStats[category] = { count: 0, processing_info: {} }; - } - } - - setCategoryMaterials(materialStats); - } catch (err) { - console.error('카테고리별 자재 조회 실패:', err); - } - }; - - const handleCompareRevisions = async () => { - if (!selectedJob || !currentFile) { - setError('작업과 현재 파일을 선택해주세요.'); - return; - } - - setLoading(true); - setError(''); - - try { - const params = { - job_no: selectedJob, - current_file_id: parseInt(currentFile), - save_comparison: true - }; - - if (previousFile) { - params.previous_file_id = parseInt(previousFile); - } - - const response = await api.post('/enhanced-revision/compare-revisions', null, { params }); - setComparisonResult(response.data.data); - - // 비교 이력 새로고침 - fetchComparisonHistory(); - - } catch (err) { - setError('리비전 비교 실패: ' + err.message); - } finally { - setLoading(false); - } - }; - - const handleApplyChanges = async (comparisonId) => { - setLoading(true); - setError(''); - - try { - const response = await api.post(`/enhanced-revision/apply-revision-changes/${comparisonId}`); - - if (response.data.success) { - alert('리비전 변경사항이 성공적으로 적용되었습니다.'); - fetchComparisonHistory(); - setComparisonResult(null); - } - } catch (err) { - setError('변경사항 적용 실패: ' + err.message); - } finally { - setLoading(false); - setShowApplyDialog(false); - } - }; - - const handleRecalculatePipeLengths = async () => { - if (!currentFile) return; - - setLoading(true); - try { - const response = await api.post(`/enhanced-revision/recalculate-pipe-lengths/${currentFile}`); - - if (response.data.success) { - alert(`PIPE 자재 ${response.data.data.updated_count}개의 길이를 재계산했습니다.`); - fetchPipeLengthSummary(); - } - } catch (err) { - setError('PIPE 길이 재계산 실패: ' + err.message); - } finally { - setLoading(false); - } - }; - - const renderComparisonSummary = (summary) => { - if (!summary) return null; - - return ( -
-

📊 비교 요약

-
-
-

🛒 구매 완료 자재

-
- 유지: {summary.purchased_maintained} - 추가구매: {summary.purchased_increased} - 잉여재고: {summary.purchased_decreased} -
-
-
-

📋 구매 미완료 자재

-
- 유지: {summary.unpurchased_maintained} - 수량증가: {summary.unpurchased_increased} - 수량감소: {summary.unpurchased_decreased} -
-
-
-

🔄 변경사항

-
- 신규: {summary.new_materials} - 삭제: {summary.deleted_materials} -
-
-
-
- ); - }; - - const renderChangeDetails = (changes) => { - if (!changes) return null; - - return ( -
-

📋 상세 변경사항

- - {/* 구매 완료 자재 변경사항 */} - {changes.purchased_materials && ( -
-

🛒 구매 완료 자재

- - {changes.purchased_materials.additional_purchase_needed?.length > 0 && ( -
-
📈 추가 구매 필요
-
- {changes.purchased_materials.additional_purchase_needed.map((item, idx) => ( -
- {item.material.original_description} - - {item.previous_quantity} → {item.current_quantity} - (+{item.additional_needed}) - -
- ))} -
-
- )} - - {changes.purchased_materials.excess_inventory?.length > 0 && ( -
-
📉 잉여 재고
-
- {changes.purchased_materials.excess_inventory.map((item, idx) => ( -
- {item.material.original_description} - - {item.previous_quantity} → {item.current_quantity} - (-{item.excess_quantity}) - -
- ))} -
-
- )} -
- )} - - {/* 구매 미완료 자재 변경사항 */} - {changes.unpurchased_materials && ( -
-

📋 구매 미완료 자재

- - {changes.unpurchased_materials.quantity_updated?.length > 0 && ( -
-
📊 수량 변경
-
- {changes.unpurchased_materials.quantity_updated.map((item, idx) => ( -
- {item.material.original_description} - - {item.previous_quantity} → {item.current_quantity} - -
- ))} -
-
- )} - - {changes.unpurchased_materials.quantity_reduced?.length > 0 && ( -
-
📉 수량 감소
-
- {changes.unpurchased_materials.quantity_reduced.map((item, idx) => ( -
- {item.material.original_description} - - {item.previous_quantity} → {item.current_quantity} - -
- ))} -
-
- )} -
- )} - - {/* 신규/삭제 자재 */} - {(changes.new_materials?.length > 0 || changes.deleted_materials?.length > 0) && ( -
-

🔄 신규/삭제 자재

- - {changes.new_materials?.length > 0 && ( -
-
✅ 신규 자재
-
- {changes.new_materials.map((item, idx) => ( -
- {item.material.original_description} - 수량: {item.material.quantity} -
- ))} -
-
- )} - - {changes.deleted_materials?.length > 0 && ( -
-
❌ 삭제된 자재
-
- {changes.deleted_materials.map((item, idx) => ( -
- {item.material.original_description} - {item.reason} -
- ))} -
-
- )} -
- )} -
- ); - }; - - const renderPipeLengthSummary = () => { - if (!pipeLengthSummary) return null; - - return ( -
-
-

🔧 PIPE 자재 길이 요약

- -
- -
- 총 라인: {pipeLengthSummary.total_lines}개 - 총 길이: {pipeLengthSummary.total_length?.toFixed(2)}m -
- -
- {pipeLengthSummary.pipe_lines?.map((line, idx) => ( -
-
- - {line.drawing_name} - {line.line_no} - - - {line.material_grade} {line.schedule} {line.nominal_size} - -
-
- 길이: {line.total_length?.toFixed(2)}m - 구간: {line.segment_count}개 - - {line.purchase_status === 'purchased' ? '구매완료' : - line.purchase_status === 'pending' ? '구매대기' : '혼재'} - -
-
- ))} -
-
- ); - }; - - // 카테고리별 페이지 렌더링 - if (selectedCategory && currentFile && previousFile) { - const categoryProps = { - jobNo: selectedJob, - fileId: parseInt(currentFile), - previousFileId: parseInt(previousFile), - onNavigate: (page) => { - if (page === 'enhanced-revision') { - setSelectedCategory(''); - } else { - onNavigate(page); - } - }, - user - }; - - switch (selectedCategory) { - case 'FITTING': - return ; - case 'FLANGE': - return ; - case 'SPECIAL': - return ; - case 'SUPPORT': - return ; - case 'UNCLASSIFIED': - return ; - case 'VALVE': - return ; - case 'GASKET': - return ; - case 'BOLT': - return ; - case 'PIPE': - return ; - default: - setSelectedCategory(''); - break; - } - } - - return ( -
- {/* 헤더 */} -
-
- -
-

- 🔄 강화된 리비전 관리 -

- - 구매 상태를 고려한 스마트 리비전 비교 - -
-
-
- - {error && setError('')} />} - - {/* 메인 콘텐츠 */} -
-
-
-

📂 비교 설정

-
-
-
- - -
- -
- - -
- -
- - -
- -
- -
-
-
-
- -
-
- {/* 비교 결과 */} - {comparisonResult && ( -
-
-

📊 비교 결과

- {comparisonResult.comparison_id && ( - - )} -
- - {renderComparisonSummary(comparisonResult.summary)} - {renderChangeDetails(comparisonResult.changes)} -
- )} - - {/* PIPE 길이 요약 */} - {renderPipeLengthSummary()} - - {/* 카테고리별 자재 관리 */} - {currentFile && previousFile && Object.keys(categoryMaterials).length > 0 && ( -
-

📂 카테고리별 리비전 관리

-
- {[ - { key: 'PIPE', name: 'PIPE', icon: '🔧', description: 'Cutting Plan 관리' }, - { key: 'FITTING', name: 'FITTING', icon: '🔧', description: '피팅 자재' }, - { key: 'FLANGE', name: 'FLANGE', icon: '🔩', description: '플랜지 자재' }, - { key: 'VALVE', name: 'VALVE', icon: '🚰', description: '밸브 자재' }, - { key: 'GASKET', name: 'GASKET', icon: '⭕', description: '가스켓 자재' }, - { key: 'BOLT', name: 'BOLT', icon: '🔩', description: '볼트 자재' }, - { key: 'SUPPORT', name: 'SUPPORT', icon: '🏗️', description: '지지대 자재' }, - { key: 'SPECIAL', name: 'SPECIAL', icon: '⭐', description: '특수 자재' }, - { key: 'UNCLASSIFIED', name: 'UNCLASSIFIED', icon: '❓', description: '미분류 자재' } - ].map(category => { - const stats = categoryMaterials[category.key] || { count: 0, processing_info: {} }; - const hasRevisionMaterials = stats.processing_info?.by_status?.REVISION_MATERIAL > 0; - - return ( -
stats.count > 0 && setSelectedCategory(category.key)} - style={{ cursor: stats.count > 0 ? 'pointer' : 'not-allowed' }} - > -
- {category.icon} -
-

{category.name}

- {category.description} -
-
-
-
- 전체 - {stats.count} -
- {hasRevisionMaterials && ( -
- 리비전 - {stats.processing_info.by_status.REVISION_MATERIAL} -
- )} - {stats.processing_info?.by_status?.INVENTORY_MATERIAL > 0 && ( -
- 재고 - {stats.processing_info.by_status.INVENTORY_MATERIAL} -
- )} -
- {stats.count === 0 && ( -
자료 없음
- )} -
- ); - })} -
-
- )} -
- -
- {/* 비교 이력 */} -
-

📋 비교 이력

- {comparisonHistory.length > 0 ? ( -
- {comparisonHistory.map(comp => ( -
-
- - {new Date(comp.comparison_date).toLocaleString()} - - - {comp.is_applied ? '적용완료' : '대기중'} - -
-
- {comp.summary_stats && ( - <> - 구매완료 변경: {comp.summary_stats.purchased_increased + comp.summary_stats.purchased_decreased} - 구매미완료 변경: {comp.summary_stats.unpurchased_increased + comp.summary_stats.unpurchased_decreased} - 신규/삭제: {comp.summary_stats.new_materials + comp.summary_stats.deleted_materials} - - )} -
- {!comp.is_applied && ( - - )} -
- ))} -
- ) : ( -

비교 이력이 없습니다.

- )} -
-
-
- - {/* 적용 확인 다이얼로그 */} - handleApplyChanges(selectedComparison)} - onCancel={() => { - setShowApplyDialog(false); - setSelectedComparison(null); - }} - confirmText="적용" - cancelText="취소" - /> -
- ); -}; - -export default EnhancedRevisionPage; diff --git a/frontend/src/pages/revision/BoltRevisionPage.jsx b/frontend/src/pages/revision/BoltRevisionPage.jsx deleted file mode 100644 index 6371d2b..0000000 --- a/frontend/src/pages/revision/BoltRevisionPage.jsx +++ /dev/null @@ -1,463 +0,0 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { useRevisionLogic } from '../../hooks/useRevisionLogic'; -import { useRevisionComparison } from '../../hooks/useRevisionComparison'; -import { useRevisionStatus } from '../../hooks/useRevisionStatus'; -import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common'; -import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator'; -import './CategoryRevisionPage.css'; - -const BoltRevisionPage = ({ - jobNo, - fileId, - previousFileId, - onNavigate, - user -}) => { - const [selectedMaterials, setSelectedMaterials] = useState(new Set()); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const [actionType, setActionType] = useState(''); - const [searchTerm, setSearchTerm] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); - const [sortBy, setSortBy] = useState('description'); - const [sortOrder, setSortOrder] = useState('asc'); - const [boltTypeFilter, setBoltTypeFilter] = useState('all'); - const [threadTypeFilter, setThreadTypeFilter] = useState('all'); - const [lengthFilter, setLengthFilter] = useState('all'); - - // 리비전 로직 훅 - const { - materials, - loading: materialsLoading, - error: materialsError, - processingInfo, - handleMaterialSelection, - handleBulkAction, - refreshMaterials - } = useRevisionLogic(fileId, 'BOLT'); - - // 리비전 비교 훅 - const { - comparisonResult, - loading: comparisonLoading, - error: comparisonError, - performComparison, - getFilteredComparison - } = useRevisionComparison(fileId, previousFileId); - - // 리비전 상태 훅 - const { - revisionStatus, - loading: statusLoading, - error: statusError, - uploadNewRevision, - navigateToRevision - } = useRevisionStatus(jobNo, fileId); - - // 필터링 및 정렬된 자재 목록 - const filteredAndSortedMaterials = useMemo(() => { - if (!materials) return []; - - let filtered = materials.filter(material => { - const matchesSearch = !searchTerm || - material.description?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.bolt_type?.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesStatus = statusFilter === 'all' || - material.processing_info?.display_status === statusFilter; - - const matchesBoltType = boltTypeFilter === 'all' || - material.bolt_type === boltTypeFilter; - - const matchesThreadType = threadTypeFilter === 'all' || - material.thread_type === threadTypeFilter; - - const matchesLength = lengthFilter === 'all' || - material.bolt_length === lengthFilter; - - return matchesSearch && matchesStatus && matchesBoltType && matchesThreadType && matchesLength; - }); - - // 정렬 - filtered.sort((a, b) => { - let aValue = a[sortBy] || ''; - let bValue = b[sortBy] || ''; - - if (typeof aValue === 'string') { - aValue = aValue.toLowerCase(); - bValue = bValue.toLowerCase(); - } - - if (sortOrder === 'asc') { - return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; - } else { - return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; - } - }); - - return filtered; - }, [materials, searchTerm, statusFilter, boltTypeFilter, threadTypeFilter, lengthFilter, sortBy, sortOrder]); - - // 고유 값들 추출 (필터 옵션용) - const uniqueValues = useMemo(() => { - if (!materials) return { boltTypes: [], threadTypes: [], lengths: [] }; - - const boltTypes = [...new Set(materials.map(m => m.bolt_type).filter(Boolean))]; - const threadTypes = [...new Set(materials.map(m => m.thread_type).filter(Boolean))]; - const lengths = [...new Set(materials.map(m => m.bolt_length).filter(Boolean))]; - - return { boltTypes, threadTypes, lengths }; - }, [materials]); - - // 초기 비교 수행 - useEffect(() => { - if (fileId && previousFileId && !comparisonResult) { - performComparison('BOLT'); - } - }, [fileId, previousFileId, comparisonResult, performComparison]); - - // 자재 선택 처리 - const handleMaterialSelect = (materialId, isSelected) => { - const newSelected = new Set(selectedMaterials); - if (isSelected) { - newSelected.add(materialId); - } else { - newSelected.delete(materialId); - } - setSelectedMaterials(newSelected); - handleMaterialSelection(materialId, isSelected); - }; - - // 전체 선택/해제 - const handleSelectAll = (isSelected) => { - if (isSelected) { - const allIds = new Set(filteredAndSortedMaterials.map(m => m.id)); - setSelectedMaterials(allIds); - filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true)); - } else { - setSelectedMaterials(new Set()); - filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false)); - } - }; - - // 액션 실행 - const executeAction = async (action) => { - setActionType(action); - setShowConfirmDialog(true); - }; - - const confirmAction = async () => { - try { - await handleBulkAction(actionType, Array.from(selectedMaterials)); - setSelectedMaterials(new Set()); - setShowConfirmDialog(false); - await refreshMaterials(); - } catch (error) { - console.error('Action failed:', error); - } - }; - - // 상태별 색상 클래스 - const getStatusClass = (status) => { - switch (status) { - case 'REVISION_MATERIAL': return 'status-revision'; - case 'INVENTORY_MATERIAL': return 'status-inventory'; - case 'DELETED_MATERIAL': return 'status-deleted'; - case 'NEW_MATERIAL': return 'status-new'; - default: return 'status-normal'; - } - }; - - // 수량 표시 (정수로 변환) - const formatQuantity = (quantity) => { - if (quantity === null || quantity === undefined) return '-'; - return Math.round(parseFloat(quantity) || 0).toString(); - }; - - // BOLT 설명 생성 (볼트 타입과 규격 포함) - const generateBoltDescription = (material) => { - const parts = []; - - if (material.bolt_type) parts.push(material.bolt_type); - if (material.thread_size) parts.push(material.thread_size); - if (material.bolt_length) parts.push(`L${material.bolt_length}mm`); - if (material.thread_type) parts.push(material.thread_type); - if (material.material_grade) parts.push(material.material_grade); - - const baseDesc = material.description || material.item_name || 'BOLT'; - - return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc; - }; - - // 볼트 세트 정보 표시 - const formatBoltSet = (material) => { - const parts = []; - if (material.bolt_count) parts.push(`볼트 ${material.bolt_count}개`); - if (material.nut_count) parts.push(`너트 ${material.nut_count}개`); - if (material.washer_count) parts.push(`와셔 ${material.washer_count}개`); - - return parts.length > 0 ? parts.join(' + ') : '-'; - }; - - if (materialsLoading || comparisonLoading || statusLoading) { - return ; - } - - const error = materialsError || comparisonError || statusError; - if (error) { - return window.location.reload()} />; - } - - return ( -
- {/* 헤더 */} -
-
- -
-

🔩 BOLT 리비전 관리

- - 볼트 타입과 나사 규격을 고려한 BOLT 자재 리비전 처리 - -
-
-
- -
-
- - {/* 컨트롤 섹션 */} -
-
-

📊 자재 현황 및 필터

- {processingInfo && ( -
- 전체: {processingInfo.total_materials}개 | - 리비전: {processingInfo.by_status.REVISION_MATERIAL || 0}개 | - 재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0}개 | - 삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}개 -
- )} -
- -
-
- - setSearchTerm(e.target.value)} - /> -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - - -
-
- - {/* 선택된 자재 액션 */} - {selectedMaterials.size > 0 && ( -
- - {selectedMaterials.size}개 선택됨 - -
- - - -
-
- )} -
- - {/* 자재 테이블 */} -
-
-
- 0} - onChange={(e) => handleSelectAll(e.target.checked)} - /> -
-
상태
-
자재명
-
볼트 타입
-
나사 크기
-
길이
-
세트 구성
-
수량
-
단위
-
액션
-
- -
- {filteredAndSortedMaterials.map((material) => ( -
-
- handleMaterialSelect(material.id, e.target.checked)} - /> -
-
- - {material.processing_info?.display_status || 'NORMAL'} - -
-
-
-
{generateBoltDescription(material)}
- {material.processing_info?.notes && ( -
{material.processing_info.notes}
- )} -
-
-
{material.bolt_type || '-'}
-
{material.thread_size || '-'}
-
{material.bolt_length ? `${material.bolt_length}mm` : '-'}
-
{formatBoltSet(material)}
-
- - {formatQuantity(material.quantity)} - - {material.processing_info?.quantity_change && ( - - ({material.processing_info.quantity_change > 0 ? '+' : ''} - {formatQuantity(material.processing_info.quantity_change)}) - - )} -
-
{material.unit || 'SET'}
-
-
- - - -
-
-
- ))} -
- - {filteredAndSortedMaterials.length === 0 && ( -
-

조건에 맞는 BOLT 자재가 없습니다.

-
- )} -
- - {/* 확인 다이얼로그 */} - {showConfirmDialog && ( - setShowConfirmDialog(false)} - /> - )} -
- ); -}; - -export default BoltRevisionPage; diff --git a/frontend/src/pages/revision/CategoryRevisionPage.css b/frontend/src/pages/revision/CategoryRevisionPage.css deleted file mode 100644 index fa29450..0000000 --- a/frontend/src/pages/revision/CategoryRevisionPage.css +++ /dev/null @@ -1,537 +0,0 @@ -/* 카테고리별 리비전 페이지 공통 스타일 */ - -.category-revision-page { - background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); - min-height: 100vh; - padding: 20px; -} - -/* 헤더 스타일 - 기존 materials-page와 통일 */ -.materials-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 20px 24px; - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - margin-bottom: 24px; - border: 1px solid #e2e8f0; -} - -.header-left { - display: flex; - align-items: center; - gap: 16px; -} - -.back-button { - padding: 8px 16px; - background: #f1f5f9; - border: 1px solid #cbd5e1; - border-radius: 8px; - color: #475569; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; -} - -.back-button:hover { - background: #e2e8f0; - border-color: #94a3b8; - color: #334155; -} - -.header-center h1 { - margin: 0 0 4px 0; - font-size: 24px; - font-weight: 700; - color: #1e293b; -} - -.header-subtitle { - color: #64748b; - font-size: 14px; - font-weight: 400; -} - -.header-right { - display: flex; - align-items: center; - gap: 12px; -} - -/* 컨트롤 섹션 */ -.control-section { - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - margin-bottom: 24px; - border: 1px solid #e2e8f0; - overflow: hidden; -} - -.section-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 24px; - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; -} - -.section-header h3 { - margin: 0; - font-size: 16px; - font-weight: 600; - color: #1e293b; -} - -.processing-summary { - font-size: 14px; - color: #64748b; - font-weight: 500; -} - -.control-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 20px; - padding: 20px 24px; -} - -.control-group { - display: flex; - flex-direction: column; - gap: 6px; -} - -.control-group label { - font-size: 14px; - font-weight: 600; - color: #374151; -} - -.control-group input, -.control-group select { - padding: 10px 12px; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 14px; - background: white; - transition: all 0.2s ease; -} - -.control-group input:focus, -.control-group select:focus { - outline: none; - border-color: #6366f1; - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); -} - -/* 선택된 자재 액션 */ -.selected-actions { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 24px; - background: #f0f9ff; - border-top: 1px solid #e2e8f0; -} - -.selected-count { - font-size: 14px; - font-weight: 600; - color: #0369a1; -} - -.action-buttons { - display: flex; - gap: 8px; -} - -.btn-action { - padding: 8px 16px; - border: none; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; -} - -.btn-purchase { - background: #10b981; - color: white; -} - -.btn-purchase:hover { - background: #059669; -} - -.btn-inventory { - background: #f59e0b; - color: white; -} - -.btn-inventory:hover { - background: #d97706; -} - -.btn-delete { - background: #ef4444; - color: white; -} - -.btn-delete:hover { - background: #dc2626; -} - -/* 자재 테이블 */ -.materials-table-container { - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - border: 1px solid #e2e8f0; - overflow: hidden; -} - -.table-header { - display: grid; - grid-template-columns: 50px 100px 2fr 1fr 80px 80px 1fr 1fr 100px; - gap: 12px; - padding: 16px; - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; - font-weight: 600; - font-size: 14px; - color: #374151; -} - -.header-cell { - display: flex; - align-items: center; - justify-content: flex-start; -} - -.checkbox-cell { - justify-content: center; -} - -.table-body { - max-height: 600px; - overflow-y: auto; -} - -.table-row { - display: grid; - grid-template-columns: 50px 100px 2fr 1fr 80px 80px 1fr 1fr 100px; - gap: 12px; - padding: 16px; - border-bottom: 1px solid #f1f5f9; - font-size: 14px; - color: #374151; - transition: all 0.2s ease; - align-items: center; -} - -.table-row:hover { - background: #f8fafc; -} - -.table-row:last-child { - border-bottom: none; -} - -.table-cell { - display: flex; - align-items: center; - justify-content: flex-start; - min-height: 40px; -} - -.table-cell.checkbox-cell { - justify-content: center; -} - -.table-cell.quantity-cell { - flex-direction: column; - align-items: flex-start; - gap: 2px; -} - -/* 상태별 스타일 */ -.table-row.status-revision { - background: #fef3c7; - border-left: 4px solid #f59e0b; -} - -.table-row.status-inventory { - background: #dbeafe; - border-left: 4px solid #3b82f6; -} - -.table-row.status-deleted { - background: #fee2e2; - border-left: 4px solid #ef4444; - opacity: 0.7; -} - -.table-row.status-new { - background: #dcfce7; - border-left: 4px solid #22c55e; -} - -.table-row.status-normal { - background: white; -} - -/* 상태 배지 */ -.status-badge { - padding: 4px 8px; - border-radius: 12px; - font-size: 12px; - font-weight: 500; - text-align: center; - min-width: 80px; -} - -.status-badge.status-revision { - background: #fbbf24; - color: #92400e; -} - -.status-badge.status-inventory { - background: #60a5fa; - color: #1e40af; -} - -.status-badge.status-deleted { - background: #f87171; - color: #991b1b; -} - -.status-badge.status-new { - background: #4ade80; - color: #166534; -} - -.status-badge.status-normal { - background: #e5e7eb; - color: #374151; -} - -/* 자재 정보 */ -.material-info { - display: flex; - flex-direction: column; - gap: 4px; -} - -.material-name { - font-weight: 500; - color: #1f2937; -} - -.material-notes { - font-size: 12px; - color: #6b7280; - font-style: italic; -} - -/* 수량 표시 */ -.quantity-value { - font-weight: 600; - color: #1f2937; -} - -.quantity-change { - font-size: 12px; - color: #6b7280; -} - -/* 액션 버튼 */ -.action-buttons-small { - display: flex; - gap: 4px; -} - -.btn-small { - padding: 4px 8px; - border: 1px solid #d1d5db; - border-radius: 4px; - background: white; - font-size: 12px; - cursor: pointer; - transition: all 0.2s ease; -} - -.btn-small:hover { - background: #f9fafb; - border-color: #9ca3af; -} - -.btn-view { - color: #3b82f6; -} - -.btn-edit { - color: #f59e0b; -} - -/* 빈 상태 */ -.empty-state { - padding: 60px 20px; - text-align: center; - color: #6b7280; -} - -.empty-state p { - margin: 0; - font-size: 16px; -} - -/* 반응형 디자인 */ -@media (max-width: 1400px) { - .table-header, - .table-row { - grid-template-columns: 50px 100px 2fr 1fr 80px 80px 1fr 100px; - } - - .table-header .header-cell:nth-child(7), - .table-row .table-cell:nth-child(7) { - display: none; - } -} - -@media (max-width: 1200px) { - .control-grid { - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - } - - .table-header, - .table-row { - grid-template-columns: 50px 100px 2fr 80px 80px 100px; - } - - .table-header .header-cell:nth-child(4), - .table-row .table-cell:nth-child(4) { - display: none; - } -} - -@media (max-width: 768px) { - .category-revision-page { - padding: 16px; - } - - .materials-header { - flex-direction: column; - gap: 16px; - align-items: stretch; - } - - .header-left { - flex-direction: column; - gap: 12px; - align-items: stretch; - } - - .control-grid { - grid-template-columns: 1fr; - } - - .selected-actions { - flex-direction: column; - gap: 12px; - align-items: stretch; - } - - .action-buttons { - justify-content: stretch; - } - - .btn-action { - flex: 1; - } - - .table-header, - .table-row { - grid-template-columns: 1fr; - gap: 8px; - } - - .table-header { - display: none; - } - - .table-row { - flex-direction: column; - align-items: stretch; - padding: 16px; - border: 1px solid #e5e7eb; - border-radius: 8px; - margin-bottom: 8px; - } - - .table-cell { - justify-content: space-between; - padding: 4px 0; - border-bottom: 1px solid #f3f4f6; - } - - .table-cell:last-child { - border-bottom: none; - } - - .table-cell::before { - content: attr(data-label); - font-weight: 600; - color: #6b7280; - min-width: 80px; - } - - .checkbox-cell::before { - content: "선택"; - } -} - -/* 애니메이션 */ -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.table-row { - animation: fadeIn 0.3s ease-out; -} - -.control-section, -.materials-table-container { - animation: fadeIn 0.4s ease-out; -} - -/* 스크롤바 스타일 */ -.table-body::-webkit-scrollbar { - width: 8px; -} - -.table-body::-webkit-scrollbar-track { - background: #f1f5f9; -} - -.table-body::-webkit-scrollbar-thumb { - background: #cbd5e1; - border-radius: 4px; -} - -.table-body::-webkit-scrollbar-thumb:hover { - background: #94a3b8; -} diff --git a/frontend/src/pages/revision/FittingRevisionPage.jsx b/frontend/src/pages/revision/FittingRevisionPage.jsx deleted file mode 100644 index 1397e29..0000000 --- a/frontend/src/pages/revision/FittingRevisionPage.jsx +++ /dev/null @@ -1,141 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useRevisionLogic } from '../../hooks/useRevisionLogic'; -import { useRevisionStatus } from '../../hooks/useRevisionStatus'; -import { LoadingSpinner, ErrorMessage } from '../../components/common'; -import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator'; -import { FittingMaterialsView } from '../../components/bom'; -import './CategoryRevisionPage.css'; - -const FittingRevisionPage = ({ - jobNo, - fileId, - previousFileId, - onNavigate, - user -}) => { - const [selectedMaterials, setSelectedMaterials] = useState(new Set()); - const [userRequirements, setUserRequirements] = useState({}); - const [purchasedMaterials, setPurchasedMaterials] = useState(new Set()); - - // 리비전 로직 훅 - const { - materials, - loading: materialsLoading, - error: materialsError, - processingInfo, - handleMaterialSelection, - handleBulkAction, - refreshMaterials - } = useRevisionLogic(fileId, 'FITTING'); - - // 리비전 상태 훅 - const { - revisionStatus, - loading: statusLoading, - error: statusError, - uploadNewRevision, - navigateToRevision - } = useRevisionStatus(jobNo, fileId); - - // 자재 업데이트 함수 - const updateMaterial = (materialId, updates) => { - // 리비전 페이지에서는 자재 업데이트 로직을 별도로 처리 - console.log('Material update in revision page:', materialId, updates); - }; - - if (materialsLoading || statusLoading) { - return ; - } - - const error = materialsError || statusError; - if (error) { - return window.location.reload()} />; - } - - // FITTING 카테고리만 필터링 - const fittingMaterials = materials.filter(material => - material.classified_category === 'FITTING' || - material.category === 'FITTING' - ); - - // 기존 BOM 관리 페이지와 동일한 props 구성 - const commonProps = { - materials: fittingMaterials, - selectedMaterials, - setSelectedMaterials, - userRequirements, - setUserRequirements, - purchasedMaterials, - onPurchasedMaterialsUpdate: (materialIds) => { - setPurchasedMaterials(prev => { - const newSet = new Set(prev); - materialIds.forEach(id => newSet.add(id)); - return newSet; - }); - }, - updateMaterial, - fileId, - jobNo, - user, - onNavigate - }; - - return ( -
- {/* 헤더 */} -
-
- -
-

🔧 FITTING 리비전 관리

- - 구매 상태를 고려한 FITTING 자재 리비전 처리 - -
-
-
- -
-
- - {/* 리비전 처리 상태 요약 */} - {processingInfo && ( -
-

📊 리비전 처리 현황

-
-
- 전체 자재 - {processingInfo.total_materials || 0}개 -
-
- 리비전 자재 - {processingInfo.by_status?.REVISION_MATERIAL || 0}개 -
-
- 재고 자재 - {processingInfo.by_status?.INVENTORY_MATERIAL || 0}개 -
-
- 삭제 자재 - {processingInfo.by_status?.DELETED_MATERIAL || 0}개 -
-
-
- )} - - {/* 기존 FITTING 자재 뷰 컴포넌트 사용 */} - -
- ); -}; - -export default FittingRevisionPage; diff --git a/frontend/src/pages/revision/FlangeRevisionPage.jsx b/frontend/src/pages/revision/FlangeRevisionPage.jsx deleted file mode 100644 index 60f7cf6..0000000 --- a/frontend/src/pages/revision/FlangeRevisionPage.jsx +++ /dev/null @@ -1,141 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useRevisionLogic } from '../../hooks/useRevisionLogic'; -import { useRevisionStatus } from '../../hooks/useRevisionStatus'; -import { LoadingSpinner, ErrorMessage } from '../../components/common'; -import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator'; -import { FlangeMaterialsView } from '../../components/bom'; -import './CategoryRevisionPage.css'; - -const FlangeRevisionPage = ({ - jobNo, - fileId, - previousFileId, - onNavigate, - user -}) => { - const [selectedMaterials, setSelectedMaterials] = useState(new Set()); - const [userRequirements, setUserRequirements] = useState({}); - const [purchasedMaterials, setPurchasedMaterials] = useState(new Set()); - - // 리비전 로직 훅 - const { - materials, - loading: materialsLoading, - error: materialsError, - processingInfo, - handleMaterialSelection, - handleBulkAction, - refreshMaterials - } = useRevisionLogic(fileId, 'FLANGE'); - - // 리비전 상태 훅 - const { - revisionStatus, - loading: statusLoading, - error: statusError, - uploadNewRevision, - navigateToRevision - } = useRevisionStatus(jobNo, fileId); - - // 자재 업데이트 함수 - const updateMaterial = (materialId, updates) => { - // 리비전 페이지에서는 자재 업데이트 로직을 별도로 처리 - console.log('Material update in revision page:', materialId, updates); - }; - - if (materialsLoading || statusLoading) { - return ; - } - - const error = materialsError || statusError; - if (error) { - return window.location.reload()} />; - } - - // FLANGE 카테고리만 필터링 - const flangeMaterials = materials.filter(material => - material.classified_category === 'FLANGE' || - material.category === 'FLANGE' - ); - - // 기존 BOM 관리 페이지와 동일한 props 구성 - const commonProps = { - materials: flangeMaterials, - selectedMaterials, - setSelectedMaterials, - userRequirements, - setUserRequirements, - purchasedMaterials, - onPurchasedMaterialsUpdate: (materialIds) => { - setPurchasedMaterials(prev => { - const newSet = new Set(prev); - materialIds.forEach(id => newSet.add(id)); - return newSet; - }); - }, - updateMaterial, - fileId, - jobNo, - user, - onNavigate - }; - - return ( -
- {/* 헤더 */} -
-
- -
-

🔩 FLANGE 리비전 관리

- - 구매 상태를 고려한 FLANGE 자재 리비전 처리 - -
-
-
- -
-
- - {/* 리비전 처리 상태 요약 */} - {processingInfo && ( -
-

📊 리비전 처리 현황

-
-
- 전체 자재 - {processingInfo.total_materials || 0}개 -
-
- 리비전 자재 - {processingInfo.by_status?.REVISION_MATERIAL || 0}개 -
-
- 재고 자재 - {processingInfo.by_status?.INVENTORY_MATERIAL || 0}개 -
-
- 삭제 자재 - {processingInfo.by_status?.DELETED_MATERIAL || 0}개 -
-
-
- )} - - {/* 기존 FLANGE 자재 뷰 컴포넌트 사용 */} - -
- ); -}; - -export default FlangeRevisionPage; \ No newline at end of file diff --git a/frontend/src/pages/revision/GasketRevisionPage.jsx b/frontend/src/pages/revision/GasketRevisionPage.jsx deleted file mode 100644 index d763579..0000000 --- a/frontend/src/pages/revision/GasketRevisionPage.jsx +++ /dev/null @@ -1,459 +0,0 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { useRevisionLogic } from '../../hooks/useRevisionLogic'; -import { useRevisionComparison } from '../../hooks/useRevisionComparison'; -import { useRevisionStatus } from '../../hooks/useRevisionStatus'; -import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common'; -import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator'; -import './CategoryRevisionPage.css'; - -const GasketRevisionPage = ({ - jobNo, - fileId, - previousFileId, - onNavigate, - user -}) => { - const [selectedMaterials, setSelectedMaterials] = useState(new Set()); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const [actionType, setActionType] = useState(''); - const [searchTerm, setSearchTerm] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); - const [sortBy, setSortBy] = useState('description'); - const [sortOrder, setSortOrder] = useState('asc'); - const [gasketTypeFilter, setGasketTypeFilter] = useState('all'); - const [materialTypeFilter, setMaterialTypeFilter] = useState('all'); - const [pressureRatingFilter, setPressureRatingFilter] = useState('all'); - - // 리비전 로직 훅 - const { - materials, - loading: materialsLoading, - error: materialsError, - processingInfo, - handleMaterialSelection, - handleBulkAction, - refreshMaterials - } = useRevisionLogic(fileId, 'GASKET'); - - // 리비전 비교 훅 - const { - comparisonResult, - loading: comparisonLoading, - error: comparisonError, - performComparison, - getFilteredComparison - } = useRevisionComparison(fileId, previousFileId); - - // 리비전 상태 훅 - const { - revisionStatus, - loading: statusLoading, - error: statusError, - uploadNewRevision, - navigateToRevision - } = useRevisionStatus(jobNo, fileId); - - // 필터링 및 정렬된 자재 목록 - const filteredAndSortedMaterials = useMemo(() => { - if (!materials) return []; - - let filtered = materials.filter(material => { - const matchesSearch = !searchTerm || - material.description?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.gasket_type?.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesStatus = statusFilter === 'all' || - material.processing_info?.display_status === statusFilter; - - const matchesGasketType = gasketTypeFilter === 'all' || - material.gasket_type === gasketTypeFilter; - - const matchesMaterialType = materialTypeFilter === 'all' || - material.material_type === materialTypeFilter; - - const matchesPressureRating = pressureRatingFilter === 'all' || - material.pressure_rating === pressureRatingFilter; - - return matchesSearch && matchesStatus && matchesGasketType && matchesMaterialType && matchesPressureRating; - }); - - // 정렬 - filtered.sort((a, b) => { - let aValue = a[sortBy] || ''; - let bValue = b[sortBy] || ''; - - if (typeof aValue === 'string') { - aValue = aValue.toLowerCase(); - bValue = bValue.toLowerCase(); - } - - if (sortOrder === 'asc') { - return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; - } else { - return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; - } - }); - - return filtered; - }, [materials, searchTerm, statusFilter, gasketTypeFilter, materialTypeFilter, pressureRatingFilter, sortBy, sortOrder]); - - // 고유 값들 추출 (필터 옵션용) - const uniqueValues = useMemo(() => { - if (!materials) return { gasketTypes: [], materialTypes: [], pressureRatings: [] }; - - const gasketTypes = [...new Set(materials.map(m => m.gasket_type).filter(Boolean))]; - const materialTypes = [...new Set(materials.map(m => m.material_type).filter(Boolean))]; - const pressureRatings = [...new Set(materials.map(m => m.pressure_rating).filter(Boolean))]; - - return { gasketTypes, materialTypes, pressureRatings }; - }, [materials]); - - // 초기 비교 수행 - useEffect(() => { - if (fileId && previousFileId && !comparisonResult) { - performComparison('GASKET'); - } - }, [fileId, previousFileId, comparisonResult, performComparison]); - - // 자재 선택 처리 - const handleMaterialSelect = (materialId, isSelected) => { - const newSelected = new Set(selectedMaterials); - if (isSelected) { - newSelected.add(materialId); - } else { - newSelected.delete(materialId); - } - setSelectedMaterials(newSelected); - handleMaterialSelection(materialId, isSelected); - }; - - // 전체 선택/해제 - const handleSelectAll = (isSelected) => { - if (isSelected) { - const allIds = new Set(filteredAndSortedMaterials.map(m => m.id)); - setSelectedMaterials(allIds); - filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true)); - } else { - setSelectedMaterials(new Set()); - filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false)); - } - }; - - // 액션 실행 - const executeAction = async (action) => { - setActionType(action); - setShowConfirmDialog(true); - }; - - const confirmAction = async () => { - try { - await handleBulkAction(actionType, Array.from(selectedMaterials)); - setSelectedMaterials(new Set()); - setShowConfirmDialog(false); - await refreshMaterials(); - } catch (error) { - console.error('Action failed:', error); - } - }; - - // 상태별 색상 클래스 - const getStatusClass = (status) => { - switch (status) { - case 'REVISION_MATERIAL': return 'status-revision'; - case 'INVENTORY_MATERIAL': return 'status-inventory'; - case 'DELETED_MATERIAL': return 'status-deleted'; - case 'NEW_MATERIAL': return 'status-new'; - default: return 'status-normal'; - } - }; - - // 수량 표시 (정수로 변환) - const formatQuantity = (quantity) => { - if (quantity === null || quantity === undefined) return '-'; - return Math.round(parseFloat(quantity) || 0).toString(); - }; - - // GASKET 설명 생성 (가스켓 타입과 재질 포함) - const generateGasketDescription = (material) => { - const parts = []; - - if (material.gasket_type) parts.push(material.gasket_type); - if (material.nominal_size) parts.push(material.nominal_size); - if (material.material_type) parts.push(material.material_type); - if (material.pressure_rating) parts.push(`${material.pressure_rating}#`); - - const baseDesc = material.description || material.item_name || 'GASKET'; - - return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc; - }; - - // 가스켓 두께 표시 - const formatThickness = (material) => { - if (material.thickness) return `${material.thickness}mm`; - if (material.gasket_thickness) return `${material.gasket_thickness}mm`; - return '-'; - }; - - if (materialsLoading || comparisonLoading || statusLoading) { - return ; - } - - const error = materialsError || comparisonError || statusError; - if (error) { - return window.location.reload()} />; - } - - return ( -
- {/* 헤더 */} -
-
- -
-

⭕ GASKET 리비전 관리

- - 가스켓 타입과 재질을 고려한 GASKET 자재 리비전 처리 - -
-
-
- -
-
- - {/* 컨트롤 섹션 */} -
-
-

📊 자재 현황 및 필터

- {processingInfo && ( -
- 전체: {processingInfo.total_materials}개 | - 리비전: {processingInfo.by_status.REVISION_MATERIAL || 0}개 | - 재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0}개 | - 삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}개 -
- )} -
- -
-
- - setSearchTerm(e.target.value)} - /> -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - - -
-
- - {/* 선택된 자재 액션 */} - {selectedMaterials.size > 0 && ( -
- - {selectedMaterials.size}개 선택됨 - -
- - - -
-
- )} -
- - {/* 자재 테이블 */} -
-
-
- 0} - onChange={(e) => handleSelectAll(e.target.checked)} - /> -
-
상태
-
자재명
-
가스켓 타입
-
크기
-
재질
-
두께
-
압력등급
-
수량
-
액션
-
- -
- {filteredAndSortedMaterials.map((material) => ( -
-
- handleMaterialSelect(material.id, e.target.checked)} - /> -
-
- - {material.processing_info?.display_status || 'NORMAL'} - -
-
-
-
{generateGasketDescription(material)}
- {material.processing_info?.notes && ( -
{material.processing_info.notes}
- )} -
-
-
{material.gasket_type || '-'}
-
{material.nominal_size || '-'}
-
{material.material_type || '-'}
-
{formatThickness(material)}
-
{material.pressure_rating ? `${material.pressure_rating}#` : '-'}
-
- - {formatQuantity(material.quantity)} - - {material.processing_info?.quantity_change && ( - - ({material.processing_info.quantity_change > 0 ? '+' : ''} - {formatQuantity(material.processing_info.quantity_change)}) - - )} -
-
-
- - - -
-
-
- ))} -
- - {filteredAndSortedMaterials.length === 0 && ( -
-

조건에 맞는 GASKET 자재가 없습니다.

-
- )} -
- - {/* 확인 다이얼로그 */} - {showConfirmDialog && ( - setShowConfirmDialog(false)} - /> - )} -
- ); -}; - -export default GasketRevisionPage; diff --git a/frontend/src/pages/revision/PipeCuttingPlanPage.css b/frontend/src/pages/revision/PipeCuttingPlanPage.css deleted file mode 100644 index f25d107..0000000 --- a/frontend/src/pages/revision/PipeCuttingPlanPage.css +++ /dev/null @@ -1,666 +0,0 @@ -/* PIPE Cutting Plan 페이지 전용 스타일 */ - -/* PIPE 리비전 상태 표시 */ -.revision-status-section { - margin: 20px 0; - padding: 0 20px; -} - -.revision-alert { - display: flex; - align-items: flex-start; - gap: 15px; - padding: 20px; - border-radius: 12px; - border-left: 5px solid; - background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); -} - -.revision-alert.pre-cutting { - border-left-color: #3b82f6; - background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); -} - -.revision-alert.post-cutting { - border-left-color: #f59e0b; - background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); -} - -.alert-icon { - font-size: 24px; - margin-top: 2px; -} - -.alert-content h4 { - margin: 0 0 8px 0; - font-size: 18px; - font-weight: 600; - color: #1f2937; -} - -.alert-content p { - margin: 0 0 12px 0; - color: #4b5563; - line-height: 1.5; -} - -.revision-summary { - display: flex; - flex-wrap: wrap; - gap: 15px; - margin-top: 12px; -} - -.revision-summary span { - display: inline-flex; - align-items: center; - padding: 4px 12px; - background: rgba(255, 255, 255, 0.8); - border-radius: 20px; - font-size: 13px; - font-weight: 500; - color: #374151; - border: 1px solid rgba(0, 0, 0, 0.1); -} - -/* Cutting Plan 관리 섹션 */ -.cutting-plan-management-section { - margin: 30px 0; - padding: 25px; - background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); - border-radius: 16px; - border: 1px solid #e2e8f0; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); -} - -.cutting-plan-management-section .section-header h3 { - margin: 0 0 20px 0; - font-size: 20px; - font-weight: 600; - color: #1f2937; - display: flex; - align-items: center; - gap: 8px; -} - -.cutting-plan-actions { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 15px; - margin-bottom: 20px; -} - -.cutting-plan-actions button { - padding: 12px 20px; - border: none; - border-radius: 10px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 48px; -} - -.btn-export-temp { - background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); - color: white; - box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3); -} - -.btn-export-temp:hover:not(:disabled) { - background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(59, 130, 246, 0.4); -} - -.btn-finalize-cutting-plan { - background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); - color: white; - box-shadow: 0 2px 4px rgba(220, 38, 38, 0.3); -} - -.btn-finalize-cutting-plan:hover:not(:disabled) { - background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%); - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(220, 38, 38, 0.4); -} - -.btn-export-finalized { - background: linear-gradient(135deg, #059669 0%, #047857 100%); - color: white; - box-shadow: 0 2px 4px rgba(5, 150, 105, 0.3); -} - -.btn-export-finalized:hover:not(:disabled) { - background: linear-gradient(135deg, #047857 0%, #065f46 100%); - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(5, 150, 105, 0.4); -} - -.btn-issue-management { - background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%); - color: white; - box-shadow: 0 2px 4px rgba(124, 58, 237, 0.3); -} - -.btn-issue-management:hover:not(:disabled) { - background: linear-gradient(135deg, #6d28d9 0%, #5b21b6 100%); - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(124, 58, 237, 0.4); -} - -.cutting-plan-actions button:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none !important; - box-shadow: none !important; -} - -.action-descriptions { - display: flex; - flex-direction: column; - gap: 8px; - padding: 15px; - background: rgba(249, 250, 251, 0.8); - border-radius: 8px; - border: 1px solid #e5e7eb; -} - -.action-desc { - font-size: 13px; - color: #4b5563; - line-height: 1.4; -} - -.action-desc strong { - color: #1f2937; - font-weight: 600; -} - -/* 반응형 */ -@media (max-width: 768px) { - .cutting-plan-actions { - grid-template-columns: 1fr; - } - - .cutting-plan-actions button { - font-size: 13px; - padding: 10px 16px; - } -} - -.pipe-cutting-plan-page { - background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); - min-height: 100vh; - padding: 20px; -} - -/* 리비전 경고 섹션 */ -.revision-warning { - background: linear-gradient(135deg, #fef3c7 0%, #fbbf24 100%); - border: 2px solid #f59e0b; - border-radius: 12px; - padding: 20px; - margin-bottom: 24px; - box-shadow: 0 4px 12px rgba(245, 158, 11, 0.25); -} - -.warning-content h3 { - margin: 0 0 12px 0; - color: #92400e; - font-size: 18px; - font-weight: 700; -} - -.warning-content p { - margin: 0 0 16px 0; - color: #92400e; - font-size: 14px; - line-height: 1.5; -} - -.highlight { - background: rgba(239, 68, 68, 0.2); - padding: 2px 6px; - border-radius: 4px; - font-weight: 600; - color: #dc2626; -} - -.btn-force-upload { - background: #dc2626; - color: white; - border: none; - border-radius: 8px; - padding: 10px 20px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; -} - -.btn-force-upload:hover { - background: #b91c1c; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3); -} - -/* 분류 섹션 */ -.classification-section { - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - margin-bottom: 24px; - border: 1px solid #e2e8f0; - overflow: hidden; -} - -.classification-controls { - display: grid; - grid-template-columns: 200px 1fr 250px; - gap: 20px; - padding: 20px 24px; - align-items: end; -} - -.control-group { - display: flex; - flex-direction: column; - gap: 6px; -} - -.control-group label { - font-size: 14px; - font-weight: 600; - color: #374151; -} - -.control-group select, -.control-group input { - padding: 10px 12px; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 14px; - background: white; - transition: all 0.2s ease; -} - -.control-group select:focus, -.control-group input:focus { - outline: none; - border-color: #6366f1; - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); -} - -.control-group select:disabled, -.control-group input:disabled { - background: #f9fafb; - color: #9ca3af; - cursor: not-allowed; -} - -.btn-start-cutting-plan { - padding: 12px 20px; - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - color: white; - border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - height: fit-content; -} - -.btn-start-cutting-plan:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); -} - -.btn-start-cutting-plan:disabled { - background: #9ca3af; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -/* 자재 현황 요약 */ -.materials-summary { - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - padding: 20px 24px; - margin-bottom: 24px; - border: 1px solid #e2e8f0; -} - -.summary-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 20px; -} - -.stat-item { - display: flex; - flex-direction: column; - align-items: center; - padding: 16px; - background: #f8fafc; - border-radius: 8px; - border: 1px solid #f1f5f9; -} - -.stat-label { - font-size: 12px; - font-weight: 500; - color: #64748b; - margin-bottom: 4px; -} - -.stat-value { - font-size: 24px; - font-weight: 700; - color: #1e293b; -} - -/* Cutting Plan 콘텐츠 */ -.cutting-plan-content { - display: flex; - flex-direction: column; - gap: 24px; -} - -/* 구역 섹션 */ -.area-section { - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - border: 1px solid #e2e8f0; - overflow: hidden; -} - -.area-section.unassigned { - border-color: #fbbf24; - background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); -} - -.area-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 24px; - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; -} - -.area-section.unassigned .area-header { - background: #fbbf24; - color: white; -} - -.area-header h4 { - margin: 0; - font-size: 16px; - font-weight: 600; - color: #1e293b; -} - -.area-section.unassigned .area-header h4 { - color: white; -} - -.area-count { - font-size: 14px; - color: #64748b; - font-weight: 500; -} - -.area-section.unassigned .area-count { - color: rgba(255, 255, 255, 0.9); -} - -/* PIPE 테이블 */ -.pipe-table { - width: 100%; -} - -.pipe-table .table-header { - display: grid; - grid-template-columns: 100px 150px 120px 200px 100px 120px 100px; - gap: 12px; - padding: 16px 24px; - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; - font-weight: 600; - font-size: 14px; - color: #374151; -} - -.area-section.unassigned .pipe-table .table-header { - background: rgba(251, 191, 36, 0.1); -} - -.pipe-table .table-body { - max-height: 400px; - overflow-y: auto; -} - -.pipe-table .table-row { - display: grid; - grid-template-columns: 100px 150px 120px 200px 100px 120px 100px; - gap: 12px; - padding: 16px 24px; - border-bottom: 1px solid #f1f5f9; - font-size: 14px; - color: #374151; - transition: all 0.2s ease; - align-items: center; -} - -.pipe-table .table-row:hover { - background: #f8fafc; -} - -.pipe-table .table-row:last-child { - border-bottom: none; -} - -.pipe-table .header-cell, -.pipe-table .table-cell { - display: flex; - align-items: center; - justify-content: flex-start; - min-height: 40px; -} - -.pipe-table .table-cell select { - width: 100%; - padding: 6px 8px; - border: 1px solid #d1d5db; - border-radius: 6px; - font-size: 13px; - background: white; -} - -.pipe-table .table-cell select:focus { - outline: none; - border-color: #6366f1; - box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1); -} - -/* 액션 버튼 */ -.action-buttons-small { - display: flex; - gap: 4px; -} - -.btn-small { - padding: 4px 8px; - border: 1px solid #d1d5db; - border-radius: 4px; - background: white; - font-size: 12px; - cursor: pointer; - transition: all 0.2s ease; - min-width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; -} - -.btn-small:hover { - background: #f9fafb; - border-color: #9ca3af; -} - -.btn-small.btn-edit { - color: #f59e0b; -} - -.btn-small.btn-delete { - color: #ef4444; -} - -.btn-small.btn-delete:hover { - background: #fef2f2; - border-color: #fca5a5; -} - -/* 빈 상태 */ -.empty-state { - padding: 60px 20px; - text-align: center; - color: #6b7280; - background: white; - border-radius: 12px; - border: 1px solid #e2e8f0; -} - -.empty-state p { - margin: 0; - font-size: 16px; -} - -/* 반응형 디자인 */ -@media (max-width: 1400px) { - .pipe-table .table-header, - .pipe-table .table-row { - grid-template-columns: 80px 130px 100px 180px 80px 100px 80px; - } -} - -@media (max-width: 1200px) { - .classification-controls { - grid-template-columns: 1fr; - gap: 16px; - } - - .pipe-table .table-header, - .pipe-table .table-row { - grid-template-columns: 80px 120px 160px 80px 100px 80px; - } - - .pipe-table .table-header .header-cell:nth-child(3), - .pipe-table .table-row .table-cell:nth-child(3) { - display: none; - } -} - -@media (max-width: 768px) { - .pipe-cutting-plan-page { - padding: 16px; - } - - .classification-controls { - padding: 16px; - } - - .summary-stats { - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 12px; - } - - .pipe-table .table-header { - display: none; - } - - .pipe-table .table-row { - grid-template-columns: 1fr; - gap: 8px; - flex-direction: column; - align-items: stretch; - padding: 16px; - border: 1px solid #e5e7eb; - border-radius: 8px; - margin-bottom: 8px; - } - - .pipe-table .table-cell { - justify-content: space-between; - padding: 4px 0; - border-bottom: 1px solid #f3f4f6; - } - - .pipe-table .table-cell:last-child { - border-bottom: none; - justify-content: center; - } - - .pipe-table .table-cell::before { - content: attr(data-label); - font-weight: 600; - color: #6b7280; - min-width: 80px; - } - - .area-header { - flex-direction: column; - gap: 8px; - align-items: stretch; - text-align: center; - } -} - -/* 애니메이션 */ -@keyframes slideIn { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.area-section { - animation: slideIn 0.3s ease-out; -} - -.pipe-table .table-row { - animation: slideIn 0.2s ease-out; -} - -/* 스크롤바 스타일 */ -.pipe-table .table-body::-webkit-scrollbar { - width: 8px; -} - -.pipe-table .table-body::-webkit-scrollbar-track { - background: #f1f5f9; -} - -.pipe-table .table-body::-webkit-scrollbar-thumb { - background: #cbd5e1; - border-radius: 4px; -} - -.pipe-table .table-body::-webkit-scrollbar-thumb:hover { - background: #94a3b8; -} diff --git a/frontend/src/pages/revision/PipeCuttingPlanPage.jsx b/frontend/src/pages/revision/PipeCuttingPlanPage.jsx deleted file mode 100644 index d09d292..0000000 --- a/frontend/src/pages/revision/PipeCuttingPlanPage.jsx +++ /dev/null @@ -1,681 +0,0 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { useRevisionLogic } from '../../hooks/useRevisionLogic'; -import { useRevisionComparison } from '../../hooks/useRevisionComparison'; -import { useRevisionStatus } from '../../hooks/useRevisionStatus'; -import { usePipeRevision } from '../../hooks/usePipeRevision'; -import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common'; -import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator'; -import './CategoryRevisionPage.css'; -import './PipeCuttingPlanPage.css'; - -const PipeCuttingPlanPage = ({ - jobNo, - fileId, - previousFileId, - onNavigate, - user -}) => { - const [selectedMaterials, setSelectedMaterials] = useState(new Set()); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const [actionType, setActionType] = useState(''); - const [searchTerm, setSearchTerm] = useState(''); - const [selectedArea, setSelectedArea] = useState(''); - const [searchDrawing, setSearchDrawing] = useState(''); - const [cuttingPlanStarted, setCuttingPlanStarted] = useState(false); - const [areaAssignments, setAreaAssignments] = useState({}); - const [endPreparations, setEndPreparations] = useState({}); - - // 리비전 로직 훅 - const { - materials, - loading: materialsLoading, - error: materialsError, - processingInfo, - handleMaterialSelection, - handleBulkAction, - refreshMaterials - } = useRevisionLogic(fileId, 'PIPE'); - - // 리비전 비교 훅 - const { - comparisonResult, - loading: comparisonLoading, - error: comparisonError, - performComparison, - getFilteredComparison - } = useRevisionComparison(fileId, previousFileId); - - // PIPE 전용 리비전 훅 - const { - revisionStatus: pipeRevisionStatus, - comparisonResult: pipeComparisonResult, - loading: pipeRevisionLoading, - error: pipeRevisionError, - checkRevisionStatus, - handlePreCuttingPlanRevision, - handlePostCuttingPlanRevision, - processRevisionAutomatically, - finalizeCuttingPlan, - getSnapshotStatus, - exportFinalizedExcel, - checkFinalizationStatus, - isPreCuttingPlan, - isPostCuttingPlan, - requiresAction - } = usePipeRevision(jobNo, fileId); - - // 리비전 상태 훅 - const { - revisionStatus, - loading: statusLoading, - error: statusError, - uploadNewRevision, - navigateToRevision - } = useRevisionStatus(jobNo, fileId); - - // 구역 옵션 - const areaOptions = ['#01', '#02', '#03', '#04', '#05', '#06', '#07', '#08', '#09', '#10']; - - // 컴포넌트 마운트 시 데이터 로드 및 리비전 처리 - useEffect(() => { - refreshMaterials(); - - // PIPE 리비전 자동 처리 - if (jobNo && fileId && requiresAction) { - handlePipeRevisionAutomatically(); - } - }, [refreshMaterials, jobNo, fileId, requiresAction]); - - // PIPE 리비전 자동 처리 함수 - const handlePipeRevisionAutomatically = async () => { - try { - const result = await processRevisionAutomatically(); - - if (result.success) { - if (result.type === 'pre_cutting_plan') { - // Cutting Plan 작성 전 리비전 - 기존 데이터 삭제됨 - alert(`${result.message}\n새로운 Cutting Plan을 작성해주세요.`); - setCuttingPlanStarted(false); - setAreaAssignments({}); - } else if (result.type === 'post_cutting_plan') { - // Cutting Plan 작성 후 리비전 - 비교 결과 표시 - alert(`${result.message}\n변경사항을 검토해주세요.`); - setCuttingPlanStarted(true); - } - } else { - console.error('PIPE 리비전 자동 처리 실패:', result.message); - } - } catch (error) { - console.error('PIPE 리비전 자동 처리 중 오류:', error); - } - }; - - // 끝단 처리 옵션 - const endPrepOptions = [ - { value: 'plain', label: '무개선' }, - { value: 'single_bevel', label: '한개선' }, - { value: 'double_bevel', label: '양개선' } - ]; - - // 필터링된 자재 목록 (도면 검색 적용) - const filteredMaterials = useMemo(() => { - if (!materials) return []; - - return materials.filter(material => { - const matchesDrawing = !searchDrawing || - material.drawing_name?.toLowerCase().includes(searchDrawing.toLowerCase()); - - return matchesDrawing; - }); - }, [materials, searchDrawing]); - - // 구역별로 그룹화된 자재 - const groupedMaterials = useMemo(() => { - const grouped = { - assigned: {}, - unassigned: [] - }; - - filteredMaterials.forEach(material => { - const assignedArea = areaAssignments[material.id]; - if (assignedArea) { - if (!grouped.assigned[assignedArea]) { - grouped.assigned[assignedArea] = []; - } - grouped.assigned[assignedArea].push(material); - } else { - grouped.unassigned.push(material); - } - }); - - return grouped; - }, [filteredMaterials, areaAssignments]); - - // 초기 비교 수행 - useEffect(() => { - if (fileId && previousFileId && !comparisonResult) { - performComparison('PIPE'); - } - }, [fileId, previousFileId, comparisonResult, performComparison]); - - // Cutting Plan 시작 - const handleStartCuttingPlan = () => { - if (!selectedArea) { - alert('구역을 선택해주세요.'); - return; - } - - // 선택된 구역과 검색된 도면에 맞는 자재들을 자동 할당 - const newAssignments = { ...areaAssignments }; - filteredMaterials.forEach(material => { - if (!newAssignments[material.id]) { - newAssignments[material.id] = selectedArea; - } - }); - - setAreaAssignments(newAssignments); - setCuttingPlanStarted(true); - }; - - // 구역 할당 변경 - const handleAreaAssignment = (materialId, area) => { - setAreaAssignments(prev => ({ - ...prev, - [materialId]: area - })); - }; - - // 끝단 처리 변경 - const handleEndPrepChange = (materialId, endPrep) => { - setEndPreparations(prev => ({ - ...prev, - [materialId]: endPrep - })); - }; - - // 자재 삭제 - const handleRemoveMaterial = (materialId) => { - setSelectedMaterials(prev => { - const newSet = new Set(prev); - newSet.add(materialId); - return newSet; - }); - setActionType('delete_pipe_segment'); - setShowConfirmDialog(true); - }; - - // 액션 실행 - const confirmAction = async () => { - try { - if (actionType === 'delete_pipe_segment') { - // PIPE 세그먼트 삭제 로직 - console.log('Deleting pipe segments:', Array.from(selectedMaterials)); - } else if (actionType === 'force_revision_upload') { - // 강제 리비전 업로드 로직 - uploadNewRevision(); - } - - setSelectedMaterials(new Set()); - setShowConfirmDialog(false); - await refreshMaterials(); - } catch (error) { - console.error('Action failed:', error); - } - }; - - // 파이프 정보 포맷팅 - const formatPipeInfo = (material) => { - const parts = []; - if (material.material_grade) parts.push(material.material_grade); - if (material.schedule) parts.push(material.schedule); - if (material.nominal_size) parts.push(material.nominal_size); - - return parts.join(' ') || '-'; - }; - - // 길이 포맷팅 - const formatLength = (length) => { - if (!length) return '-'; - return `${parseFloat(length).toFixed(1)}mm`; - }; - - // 임시 Excel 내보내기 (현재 작업 중인 데이터) - const handleExportTempExcel = async () => { - try { - alert('임시 Excel 내보내기 기능은 구현 예정입니다.\n현재 작업 중인 데이터를 기준으로 생성됩니다.'); - } catch (error) { - console.error('임시 Excel 내보내기 실패:', error); - alert('Excel 내보내기에 실패했습니다.'); - } - }; - - // Cutting Plan 확정 - const handleFinalizeCuttingPlan = async () => { - try { - const confirmed = window.confirm( - '⚠️ Cutting Plan을 확정하시겠습니까?\n\n' + - '확정 후에는:\n' + - '• 데이터가 고정되어 리비전 영향을 받지 않습니다\n' + - '• 이슈 관리를 시작할 수 있습니다\n' + - '• Excel 내보내기가 고정된 데이터로 제공됩니다' - ); - - if (!confirmed) return; - - const result = await finalizeCuttingPlan(); - if (result && result.success) { - alert(`✅ ${result.message}\n\n스냅샷 ID: ${result.snapshot_id}\n총 단관: ${result.total_segments}개`); - // 페이지 새로고침 또는 상태 업데이트 - window.location.reload(); - } else { - alert(`❌ Cutting Plan 확정 실패\n${result?.message || '알 수 없는 오류'}`); - } - } catch (error) { - console.error('Cutting Plan 확정 실패:', error); - alert('Cutting Plan 확정에 실패했습니다.'); - } - }; - - // 확정된 Excel 내보내기 (고정된 데이터) - const handleExportFinalizedExcel = async () => { - try { - const result = await exportFinalizedExcel(); - if (result && result.success) { - alert('✅ 확정된 Excel 파일이 다운로드되었습니다.\n이 파일은 리비전과 무관하게 고정된 데이터입니다.'); - } else { - alert(`❌ Excel 내보내기 실패\n${result?.message || '알 수 없는 오류'}`); - } - } catch (error) { - console.error('확정된 Excel 내보내기 실패:', error); - alert('Excel 내보내기에 실패했습니다.'); - } - }; - - // 이슈 관리 페이지로 이동 - const handleGoToIssueManagement = async () => { - try { - const snapshotStatus = await getSnapshotStatus(); - if (snapshotStatus && snapshotStatus.has_snapshot && snapshotStatus.is_locked) { - // 이슈 관리 페이지로 이동 - onNavigate('pipe-issue-management'); - } else { - alert('❌ 이슈 관리를 시작하려면 먼저 Cutting Plan을 확정해주세요.'); - } - } catch (error) { - console.error('이슈 관리 페이지 이동 실패:', error); - alert('이슈 관리 페이지 접근에 실패했습니다.'); - } - }; - - if (materialsLoading || comparisonLoading || statusLoading || pipeRevisionLoading) { - return ; - } - - const error = materialsError || comparisonError || statusError || pipeRevisionError; - if (error) { - return window.location.reload()} />; - } - - return ( -
- {/* PIPE 리비전 상태 표시 */} - {pipeRevisionStatus && requiresAction && ( -
-
-
- {isPreCuttingPlan ? '🔄' : '⚠️'} -
-
-

- {isPreCuttingPlan ? 'Cutting Plan 작성 전 리비전' : 'Cutting Plan 작성 후 리비전'} -

-

{pipeRevisionStatus.message}

- {isPostCuttingPlan && pipeComparisonResult && ( -
- 변경된 도면: {pipeComparisonResult.summary?.changed_drawings_count || 0}개 - 추가된 단관: {pipeComparisonResult.summary?.added_segments || 0}개 - 삭제된 단관: {pipeComparisonResult.summary?.removed_segments || 0}개 - 수정된 단관: {pipeComparisonResult.summary?.modified_segments || 0}개 -
- )} -
-
-
- )} - - {/* 헤더 */} -
-
- -
-

🔧 PIPE Cutting Plan 관리

- - 도면-라인번호-길이 기반 파이프 절단 계획 관리 - -
-
-
- -
-
- - {/* 리비전 경고 (Cutting Plan 시작 전) */} - {!cuttingPlanStarted && ( -
-
-

⚠️ PIPE 리비전 처리 안내

-

- Cutting Plan 작성 전에 리비전이 발생하면 - 기존 단관정보가 전부 삭제되고 - 새 BOM 파일 업로드가 필요합니다. -

- {revisionStatus?.has_revision && ( - - )} -
-
- )} - - {/* 분류 섹션 */} -
-
-

📂 구역 및 도면 분류

-
- -
-
- - -
- -
- - setSearchDrawing(e.target.value)} - /> -
- -
- -
-
-
- - {/* 자재 현황 */} -
-
-
- 전체 단관 - {filteredMaterials.length} -
-
- 할당된 단관 - {Object.keys(areaAssignments).length} -
-
- 미할당 단관 - {groupedMaterials.unassigned.length} -
-
-
- - {/* 구역별 자재 테이블 */} -
- {/* 할당된 구역들 */} - {Object.keys(groupedMaterials.assigned).sort().map(area => ( -
-
-

📍 구역 {area}

- {groupedMaterials.assigned[area].length}개 단관 -
- -
-
-
구역
-
도면
-
라인번호
-
파이프정보(재질)
-
길이
-
끝단정보
-
액션
-
- -
- {groupedMaterials.assigned[area].map(material => ( -
-
- -
-
{material.drawing_name || '-'}
-
{material.line_no || '-'}
-
{formatPipeInfo(material)}
-
{formatLength(material.length || material.total_length)}
-
- -
-
-
- - -
-
-
- ))} -
-
-
- ))} - - {/* 미할당 단관들 */} - {groupedMaterials.unassigned.length > 0 && ( -
-
-

❓ 미할당 단관

- {groupedMaterials.unassigned.length}개 단관 -
- -
-
-
구역
-
도면
-
라인번호
-
파이프정보(재질)
-
길이
-
끝단정보
-
액션
-
- -
- {groupedMaterials.unassigned.map(material => ( -
-
- -
-
{material.drawing_name || '-'}
-
{material.line_no || '-'}
-
{formatPipeInfo(material)}
-
{formatLength(material.length || material.total_length)}
-
- -
-
-
- - -
-
-
- ))} -
-
-
- )} - - {/* 빈 상태 */} - {filteredMaterials.length === 0 && ( -
-

조건에 맞는 PIPE 자재가 없습니다.

-
- )} -
- - {/* Cutting Plan 관리 액션 */} -
-
-

🔧 Cutting Plan 관리

-
- -
- - - - - - - -
- -
-
- 📊 임시 Excel: 현재 작업 중인 데이터 (리비전 시 변경됨) -
-
- 🔒 확정: 데이터 고정 및 이슈 관리 시작 (리비전 보호) -
-
- 📋 확정된 Excel: 고정된 데이터 (리비전과 무관) -
-
-
- - {/* 확인 다이얼로그 */} - {showConfirmDialog && ( - setShowConfirmDialog(false)} - /> - )} -
- ); -}; - -export default PipeCuttingPlanPage; diff --git a/frontend/src/pages/revision/SpecialRevisionPage.jsx b/frontend/src/pages/revision/SpecialRevisionPage.jsx deleted file mode 100644 index d3bee4d..0000000 --- a/frontend/src/pages/revision/SpecialRevisionPage.jsx +++ /dev/null @@ -1,460 +0,0 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { useRevisionLogic } from '../../hooks/useRevisionLogic'; -import { useRevisionComparison } from '../../hooks/useRevisionComparison'; -import { useRevisionStatus } from '../../hooks/useRevisionStatus'; -import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common'; -import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator'; -import './CategoryRevisionPage.css'; - -const SpecialRevisionPage = ({ - jobNo, - fileId, - previousFileId, - onNavigate, - user -}) => { - const [selectedMaterials, setSelectedMaterials] = useState(new Set()); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const [actionType, setActionType] = useState(''); - const [searchTerm, setSearchTerm] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); - const [sortBy, setSortBy] = useState('description'); - const [sortOrder, setSortOrder] = useState('asc'); - const [subcategoryFilter, setSubcategoryFilter] = useState('all'); - const [priorityFilter, setPriorityFilter] = useState('all'); - - // 리비전 로직 훅 - const { - materials, - loading: materialsLoading, - error: materialsError, - processingInfo, - handleMaterialSelection, - handleBulkAction, - refreshMaterials - } = useRevisionLogic(fileId, 'SPECIAL'); - - // 리비전 비교 훅 - const { - comparisonResult, - loading: comparisonLoading, - error: comparisonError, - performComparison, - getFilteredComparison - } = useRevisionComparison(fileId, previousFileId); - - // 리비전 상태 훅 - const { - revisionStatus, - loading: statusLoading, - error: statusError, - uploadNewRevision, - navigateToRevision - } = useRevisionStatus(jobNo, fileId); - - // 필터링 및 정렬된 자재 목록 - const filteredAndSortedMaterials = useMemo(() => { - if (!materials) return []; - - let filtered = materials.filter(material => { - const matchesSearch = !searchTerm || - material.description?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.brand?.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesStatus = statusFilter === 'all' || - material.processing_info?.display_status === statusFilter; - - const matchesSubcategory = subcategoryFilter === 'all' || - material.subcategory === subcategoryFilter; - - const matchesPriority = priorityFilter === 'all' || - material.processing_info?.priority === priorityFilter; - - return matchesSearch && matchesStatus && matchesSubcategory && matchesPriority; - }); - - // 정렬 - filtered.sort((a, b) => { - let aValue = a[sortBy] || ''; - let bValue = b[sortBy] || ''; - - if (typeof aValue === 'string') { - aValue = aValue.toLowerCase(); - bValue = bValue.toLowerCase(); - } - - if (sortOrder === 'asc') { - return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; - } else { - return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; - } - }); - - return filtered; - }, [materials, searchTerm, statusFilter, subcategoryFilter, priorityFilter, sortBy, sortOrder]); - - // 고유 값들 추출 (필터 옵션용) - const uniqueValues = useMemo(() => { - if (!materials) return { subcategories: [], priorities: [] }; - - const subcategories = [...new Set(materials.map(m => m.subcategory).filter(Boolean))]; - const priorities = [...new Set(materials.map(m => m.processing_info?.priority).filter(Boolean))]; - - return { subcategories, priorities }; - }, [materials]); - - // 초기 비교 수행 - useEffect(() => { - if (fileId && previousFileId && !comparisonResult) { - performComparison('SPECIAL'); - } - }, [fileId, previousFileId, comparisonResult, performComparison]); - - // 자재 선택 처리 - const handleMaterialSelect = (materialId, isSelected) => { - const newSelected = new Set(selectedMaterials); - if (isSelected) { - newSelected.add(materialId); - } else { - newSelected.delete(materialId); - } - setSelectedMaterials(newSelected); - handleMaterialSelection(materialId, isSelected); - }; - - // 전체 선택/해제 - const handleSelectAll = (isSelected) => { - if (isSelected) { - const allIds = new Set(filteredAndSortedMaterials.map(m => m.id)); - setSelectedMaterials(allIds); - filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true)); - } else { - setSelectedMaterials(new Set()); - filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false)); - } - }; - - // 액션 실행 - const executeAction = async (action) => { - setActionType(action); - setShowConfirmDialog(true); - }; - - const confirmAction = async () => { - try { - await handleBulkAction(actionType, Array.from(selectedMaterials)); - setSelectedMaterials(new Set()); - setShowConfirmDialog(false); - await refreshMaterials(); - } catch (error) { - console.error('Action failed:', error); - } - }; - - // 상태별 색상 클래스 - const getStatusClass = (status) => { - switch (status) { - case 'REVISION_MATERIAL': return 'status-revision'; - case 'INVENTORY_MATERIAL': return 'status-inventory'; - case 'DELETED_MATERIAL': return 'status-deleted'; - case 'NEW_MATERIAL': return 'status-new'; - default: return 'status-normal'; - } - }; - - // 우선순위별 색상 클래스 - const getPriorityClass = (priority) => { - switch (priority) { - case 'high': return 'priority-high'; - case 'medium': return 'priority-medium'; - case 'low': return 'priority-low'; - default: return 'priority-normal'; - } - }; - - // 수량 표시 (정수로 변환) - const formatQuantity = (quantity) => { - if (quantity === null || quantity === undefined) return '-'; - return Math.round(parseFloat(quantity) || 0).toString(); - }; - - // SPECIAL 자재 설명 생성 (브랜드, 모델 포함) - const generateSpecialDescription = (material) => { - const parts = []; - - if (material.brand) parts.push(`[${material.brand}]`); - if (material.description || material.item_name) { - parts.push(material.description || material.item_name); - } - if (material.model_number) parts.push(`(${material.model_number})`); - - return parts.length > 0 ? parts.join(' ') : 'SPECIAL 자재'; - }; - - if (materialsLoading || comparisonLoading || statusLoading) { - return ; - } - - const error = materialsError || comparisonError || statusError; - if (error) { - return window.location.reload()} />; - } - - return ( -
- {/* 헤더 */} -
-
- -
-

⭐ SPECIAL 리비전 관리

- - 특수 자재 및 브랜드별 SPECIAL 자재 리비전 처리 - -
-
-
- -
-
- - {/* 컨트롤 섹션 */} -
-
-

📊 자재 현황 및 필터

- {processingInfo && ( -
- 전체: {processingInfo.total_materials}개 | - 리비전: {processingInfo.by_status.REVISION_MATERIAL || 0}개 | - 재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0}개 | - 삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}개 | - 높은 우선순위: {processingInfo.by_priority?.high || 0}개 -
- )} -
- -
-
- - setSearchTerm(e.target.value)} - /> -
- -
- - -
- -
- - -
- -
- - -
- -
- - - -
-
- - {/* 선택된 자재 액션 */} - {selectedMaterials.size > 0 && ( -
- - {selectedMaterials.size}개 선택됨 - -
- - - - -
-
- )} -
- - {/* 자재 테이블 */} -
-
-
- 0} - onChange={(e) => handleSelectAll(e.target.checked)} - /> -
-
상태
-
우선순위
-
자재명
-
브랜드
-
도면명
-
수량
-
단위
-
액션
-
- -
- {filteredAndSortedMaterials.map((material) => ( -
-
- handleMaterialSelect(material.id, e.target.checked)} - /> -
-
- - {material.processing_info?.display_status || 'NORMAL'} - -
-
- - {material.processing_info?.priority === 'high' ? '🔴' : - material.processing_info?.priority === 'medium' ? '🟡' : - material.processing_info?.priority === 'low' ? '🟢' : '⚪'} - -
-
-
-
{generateSpecialDescription(material)}
- {material.processing_info?.notes && ( -
{material.processing_info.notes}
- )} - {material.subcategory && ( -
📂 {material.subcategory}
- )} -
-
-
{material.brand || '-'}
-
{material.drawing_name || '-'}
-
- - {formatQuantity(material.quantity)} - - {material.processing_info?.quantity_change && ( - - ({material.processing_info.quantity_change > 0 ? '+' : ''} - {formatQuantity(material.processing_info.quantity_change)}) - - )} -
-
{material.unit || 'EA'}
-
-
- - - -
-
-
- ))} -
- - {filteredAndSortedMaterials.length === 0 && ( -
-

조건에 맞는 SPECIAL 자재가 없습니다.

-
- )} -
- - {/* 확인 다이얼로그 */} - {showConfirmDialog && ( - setShowConfirmDialog(false)} - /> - )} -
- ); -}; - -export default SpecialRevisionPage; diff --git a/frontend/src/pages/revision/SupportRevisionPage.jsx b/frontend/src/pages/revision/SupportRevisionPage.jsx deleted file mode 100644 index 98cac4b..0000000 --- a/frontend/src/pages/revision/SupportRevisionPage.jsx +++ /dev/null @@ -1,450 +0,0 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { useRevisionLogic } from '../../hooks/useRevisionLogic'; -import { useRevisionComparison } from '../../hooks/useRevisionComparison'; -import { useRevisionStatus } from '../../hooks/useRevisionStatus'; -import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common'; -import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator'; -import './CategoryRevisionPage.css'; - -const SupportRevisionPage = ({ - jobNo, - fileId, - previousFileId, - onNavigate, - user -}) => { - const [selectedMaterials, setSelectedMaterials] = useState(new Set()); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const [actionType, setActionType] = useState(''); - const [searchTerm, setSearchTerm] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); - const [sortBy, setSortBy] = useState('description'); - const [sortOrder, setSortOrder] = useState('asc'); - const [supportTypeFilter, setSupportTypeFilter] = useState('all'); - const [loadRatingFilter, setLoadRatingFilter] = useState('all'); - - // 리비전 로직 훅 - const { - materials, - loading: materialsLoading, - error: materialsError, - processingInfo, - handleMaterialSelection, - handleBulkAction, - refreshMaterials - } = useRevisionLogic(fileId, 'SUPPORT'); - - // 리비전 비교 훅 - const { - comparisonResult, - loading: comparisonLoading, - error: comparisonError, - performComparison, - getFilteredComparison - } = useRevisionComparison(fileId, previousFileId); - - // 리비전 상태 훅 - const { - revisionStatus, - loading: statusLoading, - error: statusError, - uploadNewRevision, - navigateToRevision - } = useRevisionStatus(jobNo, fileId); - - // 필터링 및 정렬된 자재 목록 - const filteredAndSortedMaterials = useMemo(() => { - if (!materials) return []; - - let filtered = materials.filter(material => { - const matchesSearch = !searchTerm || - material.description?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.support_type?.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesStatus = statusFilter === 'all' || - material.processing_info?.display_status === statusFilter; - - const matchesSupportType = supportTypeFilter === 'all' || - material.support_type === supportTypeFilter; - - const matchesLoadRating = loadRatingFilter === 'all' || - material.load_rating === loadRatingFilter; - - return matchesSearch && matchesStatus && matchesSupportType && matchesLoadRating; - }); - - // 정렬 - filtered.sort((a, b) => { - let aValue = a[sortBy] || ''; - let bValue = b[sortBy] || ''; - - if (typeof aValue === 'string') { - aValue = aValue.toLowerCase(); - bValue = bValue.toLowerCase(); - } - - if (sortOrder === 'asc') { - return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; - } else { - return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; - } - }); - - return filtered; - }, [materials, searchTerm, statusFilter, supportTypeFilter, loadRatingFilter, sortBy, sortOrder]); - - // 고유 값들 추출 (필터 옵션용) - const uniqueValues = useMemo(() => { - if (!materials) return { supportTypes: [], loadRatings: [] }; - - const supportTypes = [...new Set(materials.map(m => m.support_type).filter(Boolean))]; - const loadRatings = [...new Set(materials.map(m => m.load_rating).filter(Boolean))]; - - return { supportTypes, loadRatings }; - }, [materials]); - - // 초기 비교 수행 - useEffect(() => { - if (fileId && previousFileId && !comparisonResult) { - performComparison('SUPPORT'); - } - }, [fileId, previousFileId, comparisonResult, performComparison]); - - // 자재 선택 처리 - const handleMaterialSelect = (materialId, isSelected) => { - const newSelected = new Set(selectedMaterials); - if (isSelected) { - newSelected.add(materialId); - } else { - newSelected.delete(materialId); - } - setSelectedMaterials(newSelected); - handleMaterialSelection(materialId, isSelected); - }; - - // 전체 선택/해제 - const handleSelectAll = (isSelected) => { - if (isSelected) { - const allIds = new Set(filteredAndSortedMaterials.map(m => m.id)); - setSelectedMaterials(allIds); - filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true)); - } else { - setSelectedMaterials(new Set()); - filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false)); - } - }; - - // 액션 실행 - const executeAction = async (action) => { - setActionType(action); - setShowConfirmDialog(true); - }; - - const confirmAction = async () => { - try { - await handleBulkAction(actionType, Array.from(selectedMaterials)); - setSelectedMaterials(new Set()); - setShowConfirmDialog(false); - await refreshMaterials(); - } catch (error) { - console.error('Action failed:', error); - } - }; - - // 상태별 색상 클래스 - const getStatusClass = (status) => { - switch (status) { - case 'REVISION_MATERIAL': return 'status-revision'; - case 'INVENTORY_MATERIAL': return 'status-inventory'; - case 'DELETED_MATERIAL': return 'status-deleted'; - case 'NEW_MATERIAL': return 'status-new'; - default: return 'status-normal'; - } - }; - - // 수량 표시 (정수로 변환) - const formatQuantity = (quantity) => { - if (quantity === null || quantity === undefined) return '-'; - return Math.round(parseFloat(quantity) || 0).toString(); - }; - - // SUPPORT 자재 설명 생성 (지지대 타입과 하중 정보 포함) - const generateSupportDescription = (material) => { - const parts = []; - - if (material.support_type) parts.push(material.support_type); - if (material.pipe_size) parts.push(`${material.pipe_size}"`); - if (material.load_rating) parts.push(`${material.load_rating} 등급`); - if (material.material_grade) parts.push(material.material_grade); - - const baseDesc = material.description || material.item_name || 'SUPPORT'; - - return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc; - }; - - // 치수 정보 표시 - const formatDimensions = (material) => { - const dims = []; - if (material.length_mm) dims.push(`L${material.length_mm}`); - if (material.width_mm) dims.push(`W${material.width_mm}`); - if (material.height_mm) dims.push(`H${material.height_mm}`); - - return dims.length > 0 ? dims.join('×') : '-'; - }; - - if (materialsLoading || comparisonLoading || statusLoading) { - return ; - } - - const error = materialsError || comparisonError || statusError; - if (error) { - return window.location.reload()} />; - } - - return ( -
- {/* 헤더 */} -
-
- -
-

🏗️ SUPPORT 리비전 관리

- - 지지대 타입과 하중등급을 고려한 SUPPORT 자재 리비전 처리 - -
-
-
- -
-
- - {/* 컨트롤 섹션 */} -
-
-

📊 자재 현황 및 필터

- {processingInfo && ( -
- 전체: {processingInfo.total_materials}개 | - 리비전: {processingInfo.by_status.REVISION_MATERIAL || 0}개 | - 재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0}개 | - 삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}개 -
- )} -
- -
-
- - setSearchTerm(e.target.value)} - /> -
- -
- - -
- -
- - -
- -
- - -
- -
- - - -
-
- - {/* 선택된 자재 액션 */} - {selectedMaterials.size > 0 && ( -
- - {selectedMaterials.size}개 선택됨 - -
- - - -
-
- )} -
- - {/* 자재 테이블 */} -
-
-
- 0} - onChange={(e) => handleSelectAll(e.target.checked)} - /> -
-
상태
-
자재명
-
지지대 타입
-
파이프 크기
-
하중등급
-
치수
-
수량
-
단위
-
액션
-
- -
- {filteredAndSortedMaterials.map((material) => ( -
-
- handleMaterialSelect(material.id, e.target.checked)} - /> -
-
- - {material.processing_info?.display_status || 'NORMAL'} - -
-
-
-
{generateSupportDescription(material)}
- {material.processing_info?.notes && ( -
{material.processing_info.notes}
- )} - {material.load_capacity && ( -
💪 하중용량: {material.load_capacity}
- )} -
-
-
{material.support_type || '-'}
-
{material.pipe_size ? `${material.pipe_size}"` : '-'}
-
{material.load_rating || '-'}
-
{formatDimensions(material)}
-
- - {formatQuantity(material.quantity)} - - {material.processing_info?.quantity_change && ( - - ({material.processing_info.quantity_change > 0 ? '+' : ''} - {formatQuantity(material.processing_info.quantity_change)}) - - )} -
-
{material.unit || 'EA'}
-
-
- - - -
-
-
- ))} -
- - {filteredAndSortedMaterials.length === 0 && ( -
-

조건에 맞는 SUPPORT 자재가 없습니다.

-
- )} -
- - {/* 확인 다이얼로그 */} - {showConfirmDialog && ( - setShowConfirmDialog(false)} - /> - )} -
- ); -}; - -export default SupportRevisionPage; diff --git a/frontend/src/pages/revision/UnclassifiedRevisionPage.jsx b/frontend/src/pages/revision/UnclassifiedRevisionPage.jsx deleted file mode 100644 index 7e7611a..0000000 --- a/frontend/src/pages/revision/UnclassifiedRevisionPage.jsx +++ /dev/null @@ -1,483 +0,0 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { useRevisionLogic } from '../../hooks/useRevisionLogic'; -import { useRevisionComparison } from '../../hooks/useRevisionComparison'; -import { useRevisionStatus } from '../../hooks/useRevisionStatus'; -import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common'; -import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator'; -import './CategoryRevisionPage.css'; - -const UnclassifiedRevisionPage = ({ - jobNo, - fileId, - previousFileId, - onNavigate, - user -}) => { - const [selectedMaterials, setSelectedMaterials] = useState(new Set()); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const [actionType, setActionType] = useState(''); - const [searchTerm, setSearchTerm] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); - const [sortBy, setSortBy] = useState('description'); - const [sortOrder, setSortOrder] = useState('asc'); - const [classificationFilter, setClassificationFilter] = useState('all'); - const [showClassificationTools, setShowClassificationTools] = useState(false); - - // 리비전 로직 훅 - const { - materials, - loading: materialsLoading, - error: materialsError, - processingInfo, - handleMaterialSelection, - handleBulkAction, - refreshMaterials - } = useRevisionLogic(fileId, 'UNCLASSIFIED'); - - // 리비전 비교 훅 - const { - comparisonResult, - loading: comparisonLoading, - error: comparisonError, - performComparison, - getFilteredComparison - } = useRevisionComparison(fileId, previousFileId); - - // 리비전 상태 훅 - const { - revisionStatus, - loading: statusLoading, - error: statusError, - uploadNewRevision, - navigateToRevision - } = useRevisionStatus(jobNo, fileId); - - // 필터링 및 정렬된 자재 목록 - const filteredAndSortedMaterials = useMemo(() => { - if (!materials) return []; - - let filtered = materials.filter(material => { - const matchesSearch = !searchTerm || - material.description?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesStatus = statusFilter === 'all' || - material.processing_info?.display_status === statusFilter; - - const matchesClassification = classificationFilter === 'all' || - (classificationFilter === 'needs_classification' && material.classification_confidence < 0.5) || - (classificationFilter === 'low_confidence' && material.classification_confidence >= 0.5 && material.classification_confidence < 0.8) || - (classificationFilter === 'high_confidence' && material.classification_confidence >= 0.8); - - return matchesSearch && matchesStatus && matchesClassification; - }); - - // 정렬 - filtered.sort((a, b) => { - let aValue = a[sortBy] || ''; - let bValue = b[sortBy] || ''; - - if (typeof aValue === 'string') { - aValue = aValue.toLowerCase(); - bValue = bValue.toLowerCase(); - } - - if (sortOrder === 'asc') { - return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; - } else { - return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; - } - }); - - return filtered; - }, [materials, searchTerm, statusFilter, classificationFilter, sortBy, sortOrder]); - - // 분류 신뢰도별 통계 - const classificationStats = useMemo(() => { - if (!materials) return { needsClassification: 0, lowConfidence: 0, highConfidence: 0 }; - - return materials.reduce((stats, material) => { - const confidence = material.classification_confidence || 0; - if (confidence < 0.5) stats.needsClassification++; - else if (confidence < 0.8) stats.lowConfidence++; - else stats.highConfidence++; - return stats; - }, { needsClassification: 0, lowConfidence: 0, highConfidence: 0 }); - }, [materials]); - - // 초기 비교 수행 - useEffect(() => { - if (fileId && previousFileId && !comparisonResult) { - performComparison('UNCLASSIFIED'); - } - }, [fileId, previousFileId, comparisonResult, performComparison]); - - // 자재 선택 처리 - const handleMaterialSelect = (materialId, isSelected) => { - const newSelected = new Set(selectedMaterials); - if (isSelected) { - newSelected.add(materialId); - } else { - newSelected.delete(materialId); - } - setSelectedMaterials(newSelected); - handleMaterialSelection(materialId, isSelected); - }; - - // 전체 선택/해제 - const handleSelectAll = (isSelected) => { - if (isSelected) { - const allIds = new Set(filteredAndSortedMaterials.map(m => m.id)); - setSelectedMaterials(allIds); - filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true)); - } else { - setSelectedMaterials(new Set()); - filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false)); - } - }; - - // 액션 실행 - const executeAction = async (action) => { - setActionType(action); - setShowConfirmDialog(true); - }; - - const confirmAction = async () => { - try { - await handleBulkAction(actionType, Array.from(selectedMaterials)); - setSelectedMaterials(new Set()); - setShowConfirmDialog(false); - await refreshMaterials(); - } catch (error) { - console.error('Action failed:', error); - } - }; - - // 상태별 색상 클래스 - const getStatusClass = (status) => { - switch (status) { - case 'REVISION_MATERIAL': return 'status-revision'; - case 'INVENTORY_MATERIAL': return 'status-inventory'; - case 'DELETED_MATERIAL': return 'status-deleted'; - case 'NEW_MATERIAL': return 'status-new'; - default: return 'status-normal'; - } - }; - - // 분류 신뢰도별 색상 클래스 - const getConfidenceClass = (confidence) => { - if (confidence < 0.5) return 'confidence-low'; - if (confidence < 0.8) return 'confidence-medium'; - return 'confidence-high'; - }; - - // 수량 표시 (정수로 변환) - const formatQuantity = (quantity) => { - if (quantity === null || quantity === undefined) return '-'; - return Math.round(parseFloat(quantity) || 0).toString(); - }; - - // 분류 신뢰도 표시 - const formatConfidence = (confidence) => { - if (confidence === null || confidence === undefined) return '0%'; - return `${Math.round(confidence * 100)}%`; - }; - - // 분류 제안 카테고리 표시 - const getSuggestedCategory = (material) => { - // 간단한 키워드 기반 분류 제안 - const desc = (material.description || material.item_name || '').toLowerCase(); - - if (desc.includes('pipe') || desc.includes('파이프')) return 'PIPE'; - if (desc.includes('flange') || desc.includes('플랜지')) return 'FLANGE'; - if (desc.includes('fitting') || desc.includes('피팅')) return 'FITTING'; - if (desc.includes('support') || desc.includes('지지대')) return 'SUPPORT'; - if (desc.includes('valve') || desc.includes('밸브')) return 'SPECIAL'; - - return '수동 분류 필요'; - }; - - if (materialsLoading || comparisonLoading || statusLoading) { - return ; - } - - const error = materialsError || comparisonError || statusError; - if (error) { - return window.location.reload()} />; - } - - return ( -
- {/* 헤더 */} -
-
- -
-

❓ UNCLASSIFIED 리비전 관리

- - 미분류 자재의 리비전 처리 및 분류 작업 - -
-
-
- -
-
- - {/* 분류 통계 카드 */} -
-
-

🔍 분류 현황

- -
-
-
-
{classificationStats.needsClassification}
-
분류 필요
-
-
-
{classificationStats.lowConfidence}
-
낮은 신뢰도
-
-
-
{classificationStats.highConfidence}
-
높은 신뢰도
-
-
-
- - {/* 컨트롤 섹션 */} -
-
-

📊 자재 현황 및 필터

- {processingInfo && ( -
- 전체: {processingInfo.total_materials}개 | - 리비전: {processingInfo.by_status.REVISION_MATERIAL || 0}개 | - 재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0}개 | - 삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}개 -
- )} -
- -
-
- - setSearchTerm(e.target.value)} - /> -
- -
- - -
- -
- - -
- -
- - - -
-
- - {/* 선택된 자재 액션 */} - {selectedMaterials.size > 0 && ( -
- - {selectedMaterials.size}개 선택됨 - -
- - - - -
-
- )} -
- - {/* 자재 테이블 */} -
-
-
- 0} - onChange={(e) => handleSelectAll(e.target.checked)} - /> -
-
상태
-
자재명
-
분류 신뢰도
-
제안 카테고리
-
도면명
-
수량
-
단위
-
액션
-
- -
- {filteredAndSortedMaterials.map((material) => ( -
-
- handleMaterialSelect(material.id, e.target.checked)} - /> -
-
- - {material.processing_info?.display_status || 'NORMAL'} - -
-
-
-
{material.description || material.item_name || '자재명 없음'}
- {material.processing_info?.notes && ( -
{material.processing_info.notes}
- )} -
-
-
- - {formatConfidence(material.classification_confidence)} - -
-
- - {getSuggestedCategory(material)} - -
-
{material.drawing_name || '-'}
-
- - {formatQuantity(material.quantity)} - - {material.processing_info?.quantity_change && ( - - ({material.processing_info.quantity_change > 0 ? '+' : ''} - {formatQuantity(material.processing_info.quantity_change)}) - - )} -
-
{material.unit || 'EA'}
-
-
- - - -
-
-
- ))} -
- - {filteredAndSortedMaterials.length === 0 && ( -
-

조건에 맞는 UNCLASSIFIED 자재가 없습니다.

- {materials && materials.length === 0 && ( -

🎉 모든 자재가 분류되었습니다!

- )} -
- )} -
- - {/* 확인 다이얼로그 */} - {showConfirmDialog && ( - setShowConfirmDialog(false)} - /> - )} -
- ); -}; - -export default UnclassifiedRevisionPage; diff --git a/frontend/src/pages/revision/ValveRevisionPage.jsx b/frontend/src/pages/revision/ValveRevisionPage.jsx deleted file mode 100644 index 6e7b819..0000000 --- a/frontend/src/pages/revision/ValveRevisionPage.jsx +++ /dev/null @@ -1,453 +0,0 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { useRevisionLogic } from '../../hooks/useRevisionLogic'; -import { useRevisionComparison } from '../../hooks/useRevisionComparison'; -import { useRevisionStatus } from '../../hooks/useRevisionStatus'; -import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common'; -import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator'; -import './CategoryRevisionPage.css'; - -const ValveRevisionPage = ({ - jobNo, - fileId, - previousFileId, - onNavigate, - user -}) => { - const [selectedMaterials, setSelectedMaterials] = useState(new Set()); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const [actionType, setActionType] = useState(''); - const [searchTerm, setSearchTerm] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); - const [sortBy, setSortBy] = useState('description'); - const [sortOrder, setSortOrder] = useState('asc'); - const [valveTypeFilter, setValveTypeFilter] = useState('all'); - const [pressureRatingFilter, setPressureRatingFilter] = useState('all'); - const [connectionFilter, setConnectionFilter] = useState('all'); - - // 리비전 로직 훅 - const { - materials, - loading: materialsLoading, - error: materialsError, - processingInfo, - handleMaterialSelection, - handleBulkAction, - refreshMaterials - } = useRevisionLogic(fileId, 'VALVE'); - - // 리비전 비교 훅 - const { - comparisonResult, - loading: comparisonLoading, - error: comparisonError, - performComparison, - getFilteredComparison - } = useRevisionComparison(fileId, previousFileId); - - // 리비전 상태 훅 - const { - revisionStatus, - loading: statusLoading, - error: statusError, - uploadNewRevision, - navigateToRevision - } = useRevisionStatus(jobNo, fileId); - - // 필터링 및 정렬된 자재 목록 - const filteredAndSortedMaterials = useMemo(() => { - if (!materials) return []; - - let filtered = materials.filter(material => { - const matchesSearch = !searchTerm || - material.description?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.valve_type?.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesStatus = statusFilter === 'all' || - material.processing_info?.display_status === statusFilter; - - const matchesValveType = valveTypeFilter === 'all' || - material.valve_type === valveTypeFilter; - - const matchesPressureRating = pressureRatingFilter === 'all' || - material.pressure_rating === pressureRatingFilter; - - const matchesConnection = connectionFilter === 'all' || - material.connection_method === connectionFilter; - - return matchesSearch && matchesStatus && matchesValveType && matchesPressureRating && matchesConnection; - }); - - // 정렬 - filtered.sort((a, b) => { - let aValue = a[sortBy] || ''; - let bValue = b[sortBy] || ''; - - if (typeof aValue === 'string') { - aValue = aValue.toLowerCase(); - bValue = bValue.toLowerCase(); - } - - if (sortOrder === 'asc') { - return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; - } else { - return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; - } - }); - - return filtered; - }, [materials, searchTerm, statusFilter, valveTypeFilter, pressureRatingFilter, connectionFilter, sortBy, sortOrder]); - - // 고유 값들 추출 (필터 옵션용) - const uniqueValues = useMemo(() => { - if (!materials) return { valveTypes: [], pressureRatings: [], connections: [] }; - - const valveTypes = [...new Set(materials.map(m => m.valve_type).filter(Boolean))]; - const pressureRatings = [...new Set(materials.map(m => m.pressure_rating).filter(Boolean))]; - const connections = [...new Set(materials.map(m => m.connection_method).filter(Boolean))]; - - return { valveTypes, pressureRatings, connections }; - }, [materials]); - - // 초기 비교 수행 - useEffect(() => { - if (fileId && previousFileId && !comparisonResult) { - performComparison('VALVE'); - } - }, [fileId, previousFileId, comparisonResult, performComparison]); - - // 자재 선택 처리 - const handleMaterialSelect = (materialId, isSelected) => { - const newSelected = new Set(selectedMaterials); - if (isSelected) { - newSelected.add(materialId); - } else { - newSelected.delete(materialId); - } - setSelectedMaterials(newSelected); - handleMaterialSelection(materialId, isSelected); - }; - - // 전체 선택/해제 - const handleSelectAll = (isSelected) => { - if (isSelected) { - const allIds = new Set(filteredAndSortedMaterials.map(m => m.id)); - setSelectedMaterials(allIds); - filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true)); - } else { - setSelectedMaterials(new Set()); - filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false)); - } - }; - - // 액션 실행 - const executeAction = async (action) => { - setActionType(action); - setShowConfirmDialog(true); - }; - - const confirmAction = async () => { - try { - await handleBulkAction(actionType, Array.from(selectedMaterials)); - setSelectedMaterials(new Set()); - setShowConfirmDialog(false); - await refreshMaterials(); - } catch (error) { - console.error('Action failed:', error); - } - }; - - // 상태별 색상 클래스 - const getStatusClass = (status) => { - switch (status) { - case 'REVISION_MATERIAL': return 'status-revision'; - case 'INVENTORY_MATERIAL': return 'status-inventory'; - case 'DELETED_MATERIAL': return 'status-deleted'; - case 'NEW_MATERIAL': return 'status-new'; - default: return 'status-normal'; - } - }; - - // 수량 표시 (정수로 변환) - const formatQuantity = (quantity) => { - if (quantity === null || quantity === undefined) return '-'; - return Math.round(parseFloat(quantity) || 0).toString(); - }; - - // VALVE 설명 생성 (밸브 타입과 연결 방식 포함) - const generateValveDescription = (material) => { - const parts = []; - - if (material.valve_type) parts.push(material.valve_type); - if (material.nominal_size) parts.push(material.nominal_size); - if (material.pressure_rating) parts.push(`${material.pressure_rating}#`); - if (material.connection_method) parts.push(material.connection_method); - if (material.material_grade) parts.push(material.material_grade); - - const baseDesc = material.description || material.item_name || 'VALVE'; - - return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc; - }; - - if (materialsLoading || comparisonLoading || statusLoading) { - return ; - } - - const error = materialsError || comparisonError || statusError; - if (error) { - return window.location.reload()} />; - } - - return ( -
- {/* 헤더 */} -
-
- -
-

🚰 VALVE 리비전 관리

- - 밸브 타입과 연결 방식을 고려한 VALVE 자재 리비전 처리 - -
-
-
- -
-
- - {/* 컨트롤 섹션 */} -
-
-

📊 자재 현황 및 필터

- {processingInfo && ( -
- 전체: {processingInfo.total_materials}개 | - 리비전: {processingInfo.by_status.REVISION_MATERIAL || 0}개 | - 재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0}개 | - 삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}개 -
- )} -
- -
-
- - setSearchTerm(e.target.value)} - /> -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - - -
-
- - {/* 선택된 자재 액션 */} - {selectedMaterials.size > 0 && ( -
- - {selectedMaterials.size}개 선택됨 - -
- - - -
-
- )} -
- - {/* 자재 테이블 */} -
-
-
- 0} - onChange={(e) => handleSelectAll(e.target.checked)} - /> -
-
상태
-
자재명
-
밸브 타입
-
크기
-
압력등급
-
연결방식
-
수량
-
단위
-
액션
-
- -
- {filteredAndSortedMaterials.map((material) => ( -
-
- handleMaterialSelect(material.id, e.target.checked)} - /> -
-
- - {material.processing_info?.display_status || 'NORMAL'} - -
-
-
-
{generateValveDescription(material)}
- {material.processing_info?.notes && ( -
{material.processing_info.notes}
- )} -
-
-
{material.valve_type || '-'}
-
{material.nominal_size || '-'}
-
{material.pressure_rating ? `${material.pressure_rating}#` : '-'}
-
{material.connection_method || '-'}
-
- - {formatQuantity(material.quantity)} - - {material.processing_info?.quantity_change && ( - - ({material.processing_info.quantity_change > 0 ? '+' : ''} - {formatQuantity(material.processing_info.quantity_change)}) - - )} -
-
{material.unit || 'EA'}
-
-
- - - -
-
-
- ))} -
- - {filteredAndSortedMaterials.length === 0 && ( -
-

조건에 맞는 VALVE 자재가 없습니다.

-
- )} -
- - {/* 확인 다이얼로그 */} - {showConfirmDialog && ( - setShowConfirmDialog(false)} - /> - )} -
- ); -}; - -export default ValveRevisionPage;