리비전 페이지 제거 및 트랜잭션 오류 임시 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- frontend/src/pages/revision/ 폴더 완전 삭제
- EnhancedRevisionPage.css 제거
- support_details 저장 시 트랜잭션 오류로 인해 임시로 상세 정보 저장 비활성화
- 리비전 기능 재설계 예정
This commit is contained in:
Hyungi Ahn
2025-10-21 12:11:57 +09:00
parent 8f42a1054e
commit 1dc735f362
29 changed files with 1728 additions and 6987 deletions

View File

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

View File

@@ -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 등)
# 필요시 간단한 구조로 다시 추가 예정

View File

@@ -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. 파일 레코드 업데이트 (파싱된 자재 수)

View File

@@ -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)}")

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)}")

View File

@@ -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 환경 마이그레이션 시작")

View File

@@ -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"

View File

@@ -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 (
<div style={{
@@ -17,7 +113,7 @@ const RevisionUploadDialog = ({
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -25,53 +121,203 @@ const RevisionUploadDialog = ({
}}>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
borderRadius: '16px',
padding: '32px',
width: '90%',
maxWidth: '500px',
width: '90%'
maxHeight: '90vh',
overflow: 'auto',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)'
}}>
<h3 style={{ margin: '0 0 16px 0' }}>
리비전 업로드: {revisionDialog.bomName}
</h3>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => setRevisionFile(e.target.files[0])}
style={{
width: '100%',
marginBottom: '16px',
padding: '8px'
{/* 헤더 */}
<div style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{
fontSize: '24px',
fontWeight: '700',
color: '#1f2937',
margin: 0
}}>
📝 New Revision Upload
</h2>
<button
onClick={handleClose}
style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: '#6b7280',
padding: '4px'
}}
>
</button>
</div>
{/* 부모 파일 정보 */}
<div style={{
marginTop: '16px',
padding: '16px',
background: '#f8fafc',
borderRadius: '8px',
border: '1px solid #e2e8f0'
}}>
<div style={{ fontSize: '14px', color: '#64748b', marginBottom: '4px' }}>
Base BOM File:
</div>
<div style={{ fontSize: '16px', fontWeight: '600', color: '#1e293b' }}>
{parentFile?.bom_name || parentFile?.original_filename}
</div>
<div style={{ fontSize: '12px', color: '#64748b', marginTop: '4px' }}>
Current Revision: {parentFile?.revision || 'Rev.0'}
</div>
</div>
</div>
{/* 파일 선택 */}
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
marginBottom: '8px'
}}>
Select New BOM File
</label>
<div style={{
border: '2px dashed #d1d5db',
borderRadius: '8px',
padding: '24px',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
backgroundColor: selectedFile ? '#f0fdf4' : '#fafafa'
}}
/>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{selectedFile ? (
<div>
<div style={{ fontSize: '24px', marginBottom: '8px' }}>📄</div>
<div style={{ fontSize: '16px', fontWeight: '600', color: '#059669' }}>
{selectedFile.name}
</div>
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '4px' }}>
{Math.round(selectedFile.size / 1024)} KB
</div>
</div>
) : (
<div>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📁</div>
<div style={{ fontSize: '16px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
Click to select file
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
Excel (.xlsx, .xls) or CSV files only
</div>
</div>
)}
</div>
</div>
{/* 업로드 진행률 */}
{uploading && (
<div style={{ marginBottom: '24px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
}}>
<span style={{ fontSize: '14px', fontWeight: '600', color: '#374151' }}>
Uploading...
</span>
<span style={{ fontSize: '14px', color: '#6b7280' }}>
{uploadProgress}%
</span>
</div>
<div style={{
width: '100%',
height: '8px',
backgroundColor: '#e5e7eb',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
width: `${uploadProgress}%`,
height: '100%',
backgroundColor: '#3b82f6',
transition: 'width 0.3s ease'
}} />
</div>
</div>
)}
{/* 에러 메시지 */}
{error && (
<div style={{
marginBottom: '24px',
padding: '12px',
background: '#fee2e2',
border: '1px solid #fecaca',
borderRadius: '8px',
color: '#dc2626',
fontSize: '14px'
}}>
{error}
</div>
)}
{/* 버튼들 */}
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'flex-end'
}}>
<button
onClick={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}
onClick={handleClose}
disabled={uploading}
style={{
padding: '8px 16px',
background: '#e2e8f0',
color: '#4a5568',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
padding: '12px 24px',
background: 'white',
color: '#374151',
border: '1px solid #d1d5db',
borderRadius: '8px',
cursor: uploading ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: '600',
opacity: uploading ? 0.5 : 1
}}
>
취소
Cancel
</button>
<button
onClick={handleRevisionUpload}
disabled={!revisionFile || uploading}
onClick={handleUpload}
disabled={!selectedFile || uploading}
style={{
padding: '8px 16px',
background: (!revisionFile || uploading) ? '#e2e8f0' : '#4299e1',
color: (!revisionFile || uploading) ? '#a0aec0' : 'white',
padding: '12px 24px',
background: (!selectedFile || uploading) ? '#9ca3af' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: (!revisionFile || uploading) ? 'not-allowed' : 'pointer'
borderRadius: '8px',
cursor: (!selectedFile || uploading) ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease'
}}
>
{uploading ? '업로드 중...' : '업로드'}
{uploading ? 'Uploading...' : 'Upload Revision'}
</button>
</div>
</div>
@@ -79,26 +325,4 @@ const RevisionUploadDialog = ({
);
};
export default RevisionUploadDialog;
export default RevisionUploadDialog;

View File

@@ -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 = ({
</div>
</div>
</div>
{/* 리비전 업로드 다이얼로그 */}
<RevisionUploadDialog
isOpen={revisionDialog.open}
onClose={() => setRevisionDialog({ open: false, parentFile: null })}
parentFile={revisionDialog.parentFile}
selectedProject={selectedProject}
onUploadSuccess={handleRevisionUploadSuccess}
/>
</div>
);
};

View File

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

View File

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

View File

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

View File

@@ -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 = ({
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
<div>
<h2 style={{
fontSize: '28px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0',
letterSpacing: '-0.025em'
}}>
BOM Materials Management
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<h2 style={{
fontSize: '28px',
fontWeight: '700',
color: '#0f172a',
margin: 0,
letterSpacing: '-0.025em'
}}>
BOM Materials Management
</h2>
{isRevisionMode && (
<div style={{
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
color: 'white',
padding: '6px 12px',
borderRadius: '8px',
fontSize: '12px',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.05em',
boxShadow: '0 4px 6px -1px rgba(245, 158, 11, 0.3)'
}}>
📊 Revision Mode
</div>
)}
</div>
<p style={{
fontSize: '16px',
color: '#64748b',
@@ -298,6 +389,15 @@ const BOMManagementPage = ({
fontWeight: '400'
}}>
{bomName} - {currentRevision} | Project: {selectedProject?.job_name || jobNo}
{isRevisionMode && revisionData && (
<span style={{
marginLeft: '16px',
color: '#f59e0b',
fontWeight: '600'
}}>
{revisionData.total_changed_materials} materials changed
</span>
)}
</p>
</div>
<button
@@ -415,6 +515,7 @@ const BOMManagementPage = ({
.map((category) => {
const isActive = selectedCategory === category.key;
const count = getCategoryMaterials(category.key).length;
const hasChanges = isRevisionMode && changedMaterials[category.key];
return (
<button
@@ -423,9 +524,11 @@ const BOMManagementPage = ({
style={{
background: isActive
? `linear-gradient(135deg, ${category.color} 0%, ${category.color}dd 100%)`
: 'white',
color: isActive ? 'white' : '#64748b',
border: isActive ? 'none' : '1px solid #e2e8f0',
: hasChanges
? 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)'
: 'white',
color: isActive ? 'white' : hasChanges ? '#92400e' : '#64748b',
border: isActive ? 'none' : hasChanges ? '2px solid #f59e0b' : '1px solid #e2e8f0',
borderRadius: '12px',
padding: '16px 12px',
cursor: 'pointer',
@@ -433,7 +536,8 @@ const BOMManagementPage = ({
fontWeight: '600',
transition: 'all 0.2s ease',
textAlign: 'center',
boxShadow: isActive ? `0 4px 14px 0 ${category.color}39` : '0 2px 8px rgba(0,0,0,0.05)'
boxShadow: isActive ? `0 4px 14px 0 ${category.color}39` : hasChanges ? '0 4px 14px 0 rgba(245, 158, 11, 0.3)' : '0 2px 8px rgba(0,0,0,0.05)',
position: 'relative'
}}
onMouseEnter={(e) => {
if (!isActive) {
@@ -448,8 +552,16 @@ const BOMManagementPage = ({
}
}}
>
<div style={{ marginBottom: '4px' }}>
<div style={{ marginBottom: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px' }}>
{category.label}
{hasChanges && (
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: isActive ? 'rgba(255,255,255,0.8)' : '#f59e0b'
}}></div>
)}
</div>
<div style={{
fontSize: '12px',
@@ -457,6 +569,11 @@ const BOMManagementPage = ({
fontWeight: '500'
}}>
{count} items
{hasChanges && (
<span style={{ marginLeft: '4px', fontSize: '10px' }}>
({changedMaterials[category.key].changed_count} changed)
</span>
)}
</div>
</button>
);

View File

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

View File

@@ -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 (
<div className="comparison-summary">
<h3>📊 비교 요약</h3>
<div className="summary-grid">
<div className="summary-card purchased">
<h4>🛒 구매 완료 자재</h4>
<div className="summary-stats">
<span className="stat-item">유지: {summary.purchased_maintained}</span>
<span className="stat-item increase">추가구매: {summary.purchased_increased}</span>
<span className="stat-item decrease">잉여재고: {summary.purchased_decreased}</span>
</div>
</div>
<div className="summary-card unpurchased">
<h4>📋 구매 미완료 자재</h4>
<div className="summary-stats">
<span className="stat-item">유지: {summary.unpurchased_maintained}</span>
<span className="stat-item increase">수량증가: {summary.unpurchased_increased}</span>
<span className="stat-item decrease">수량감소: {summary.unpurchased_decreased}</span>
</div>
</div>
<div className="summary-card changes">
<h4>🔄 변경사항</h4>
<div className="summary-stats">
<span className="stat-item new">신규: {summary.new_materials}</span>
<span className="stat-item deleted">삭제: {summary.deleted_materials}</span>
</div>
</div>
</div>
</div>
);
};
const renderChangeDetails = (changes) => {
if (!changes) return null;
return (
<div className="change-details">
<h3>📋 상세 변경사항</h3>
{/* 구매 완료 자재 변경사항 */}
{changes.purchased_materials && (
<div className="change-section">
<h4>🛒 구매 완료 자재</h4>
{changes.purchased_materials.additional_purchase_needed?.length > 0 && (
<div className="change-category additional-purchase">
<h5>📈 추가 구매 필요</h5>
<div className="material-list">
{changes.purchased_materials.additional_purchase_needed.map((item, idx) => (
<div key={idx} className="material-item">
<span className="material-desc">{item.material.original_description}</span>
<span className="quantity-change">
{item.previous_quantity} {item.current_quantity}
(+{item.additional_needed})
</span>
</div>
))}
</div>
</div>
)}
{changes.purchased_materials.excess_inventory?.length > 0 && (
<div className="change-category excess-inventory">
<h5>📉 잉여 재고</h5>
<div className="material-list">
{changes.purchased_materials.excess_inventory.map((item, idx) => (
<div key={idx} className="material-item">
<span className="material-desc">{item.material.original_description}</span>
<span className="quantity-change">
{item.previous_quantity} {item.current_quantity}
(-{item.excess_quantity})
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* 구매 미완료 자재 변경사항 */}
{changes.unpurchased_materials && (
<div className="change-section">
<h4>📋 구매 미완료 자재</h4>
{changes.unpurchased_materials.quantity_updated?.length > 0 && (
<div className="change-category quantity-updated">
<h5>📊 수량 변경</h5>
<div className="material-list">
{changes.unpurchased_materials.quantity_updated.map((item, idx) => (
<div key={idx} className="material-item">
<span className="material-desc">{item.material.original_description}</span>
<span className="quantity-change">
{item.previous_quantity} {item.current_quantity}
</span>
</div>
))}
</div>
</div>
)}
{changes.unpurchased_materials.quantity_reduced?.length > 0 && (
<div className="change-category quantity-reduced">
<h5>📉 수량 감소</h5>
<div className="material-list">
{changes.unpurchased_materials.quantity_reduced.map((item, idx) => (
<div key={idx} className="material-item">
<span className="material-desc">{item.material.original_description}</span>
<span className="quantity-change">
{item.previous_quantity} {item.current_quantity}
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* 신규/삭제 자재 */}
{(changes.new_materials?.length > 0 || changes.deleted_materials?.length > 0) && (
<div className="change-section">
<h4>🔄 신규/삭제 자재</h4>
{changes.new_materials?.length > 0 && (
<div className="change-category new-materials">
<h5> 신규 자재</h5>
<div className="material-list">
{changes.new_materials.map((item, idx) => (
<div key={idx} className="material-item">
<span className="material-desc">{item.material.original_description}</span>
<span className="quantity-info">수량: {item.material.quantity}</span>
</div>
))}
</div>
</div>
)}
{changes.deleted_materials?.length > 0 && (
<div className="change-category deleted-materials">
<h5> 삭제된 자재</h5>
<div className="material-list">
{changes.deleted_materials.map((item, idx) => (
<div key={idx} className="material-item">
<span className="material-desc">{item.material.original_description}</span>
<span className="reason">{item.reason}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
};
const renderPipeLengthSummary = () => {
if (!pipeLengthSummary) return null;
return (
<div className="pipe-length-summary">
<div className="summary-header">
<h3>🔧 PIPE 자재 길이 요약</h3>
<button
className="btn-recalculate"
onClick={handleRecalculatePipeLengths}
disabled={loading}
>
🔄 길이 재계산
</button>
</div>
<div className="pipe-stats">
<span> 라인: {pipeLengthSummary.total_lines}</span>
<span> 길이: {pipeLengthSummary.total_length?.toFixed(2)}m</span>
</div>
<div className="pipe-lines">
{pipeLengthSummary.pipe_lines?.map((line, idx) => (
<div key={idx} className={`pipe-line ${line.purchase_status}`}>
<div className="line-info">
<span className="drawing-line">
{line.drawing_name} - {line.line_no}
</span>
<span className="material-spec">
{line.material_grade} {line.schedule} {line.nominal_size}
</span>
</div>
<div className="line-stats">
<span className="length">길이: {line.total_length?.toFixed(2)}m</span>
<span className="segments">구간: {line.segment_count}</span>
<span className={`status ${line.purchase_status}`}>
{line.purchase_status === 'purchased' ? '구매완료' :
line.purchase_status === 'pending' ? '구매대기' : '혼재'}
</span>
</div>
</div>
))}
</div>
</div>
);
};
// 카테고리별 페이지 렌더링
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 <FittingRevisionPage {...categoryProps} />;
case 'FLANGE':
return <FlangeRevisionPage {...categoryProps} />;
case 'SPECIAL':
return <SpecialRevisionPage {...categoryProps} />;
case 'SUPPORT':
return <SupportRevisionPage {...categoryProps} />;
case 'UNCLASSIFIED':
return <UnclassifiedRevisionPage {...categoryProps} />;
case 'VALVE':
return <ValveRevisionPage {...categoryProps} />;
case 'GASKET':
return <GasketRevisionPage {...categoryProps} />;
case 'BOLT':
return <BoltRevisionPage {...categoryProps} />;
case 'PIPE':
return <PipeCuttingPlanPage {...categoryProps} />;
default:
setSelectedCategory('');
break;
}
}
return (
<div className="materials-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate ? onNavigate('dashboard') : window.history.back()}
>
뒤로가기
</button>
<div className="header-center">
<h1 style={{ margin: 0, fontSize: '24px', fontWeight: '600', color: '#1f2937' }}>
🔄 강화된 리비전 관리
</h1>
<span style={{ color: '#6b7280', fontSize: '14px' }}>
구매 상태를 고려한 스마트 리비전 비교
</span>
</div>
</div>
</div>
{error && <ErrorMessage message={error} onClose={() => setError('')} />}
{/* 메인 콘텐츠 */}
<div className="materials-content">
<div className="control-section">
<div className="section-header">
<h3>📂 비교 설정</h3>
</div>
<div className="control-grid">
<div className="control-group">
<label>작업 선택:</label>
<select
value={selectedJob}
onChange={(e) => setSelectedJob(e.target.value)}
disabled={loading}
>
<option value="">작업을 선택하세요</option>
{jobs.map(job => (
<option key={job.job_no} value={job.job_no}>
{job.job_no} - {job.job_name}
</option>
))}
</select>
</div>
<div className="control-group">
<label>현재 파일:</label>
<select
value={currentFile}
onChange={(e) => setCurrentFile(e.target.value)}
disabled={loading || !selectedJob}
>
<option value="">현재 파일을 선택하세요</option>
{files.map(file => (
<option key={file.id} value={file.id}>
{file.original_filename} ({file.revision})
</option>
))}
</select>
</div>
<div className="control-group">
<label>이전 파일 (선택사항):</label>
<select
value={previousFile}
onChange={(e) => setPreviousFile(e.target.value)}
disabled={loading || !selectedJob}
>
<option value="">자동 탐지</option>
{files.filter(f => f.id !== parseInt(currentFile)).map(file => (
<option key={file.id} value={file.id}>
{file.original_filename} ({file.revision})
</option>
))}
</select>
</div>
<div className="control-group">
<button
className="btn-compare"
onClick={handleCompareRevisions}
disabled={loading || !selectedJob || !currentFile}
>
{loading ? <LoadingSpinner size="small" /> : '🔍 리비전 비교'}
</button>
</div>
</div>
</div>
</div>
<div className="revision-content">
<div className="content-left">
{/* 비교 결과 */}
{comparisonResult && (
<div className="comparison-result">
<div className="result-header">
<h3>📊 비교 결과</h3>
{comparisonResult.comparison_id && (
<button
className="btn-apply"
onClick={() => {
setSelectedComparison(comparisonResult.comparison_id);
setShowApplyDialog(true);
}}
disabled={loading}
>
변경사항 적용
</button>
)}
</div>
{renderComparisonSummary(comparisonResult.summary)}
{renderChangeDetails(comparisonResult.changes)}
</div>
)}
{/* PIPE 길이 요약 */}
{renderPipeLengthSummary()}
{/* 카테고리별 자재 관리 */}
{currentFile && previousFile && Object.keys(categoryMaterials).length > 0 && (
<div className="category-materials-section">
<h3>📂 카테고리별 리비전 관리</h3>
<div className="category-grid">
{[
{ 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 (
<div
key={category.key}
className={`category-card ${hasRevisionMaterials ? 'has-revisions' : ''}`}
onClick={() => stats.count > 0 && setSelectedCategory(category.key)}
style={{ cursor: stats.count > 0 ? 'pointer' : 'not-allowed' }}
>
<div className="category-header">
<span className="category-icon">{category.icon}</span>
<div className="category-info">
<h4>{category.name}</h4>
<span className="category-desc">{category.description}</span>
</div>
</div>
<div className="category-stats">
<div className="stat-item">
<span className="stat-label">전체</span>
<span className="stat-value">{stats.count}</span>
</div>
{hasRevisionMaterials && (
<div className="stat-item revision">
<span className="stat-label">리비전</span>
<span className="stat-value">{stats.processing_info.by_status.REVISION_MATERIAL}</span>
</div>
)}
{stats.processing_info?.by_status?.INVENTORY_MATERIAL > 0 && (
<div className="stat-item inventory">
<span className="stat-label">재고</span>
<span className="stat-value">{stats.processing_info.by_status.INVENTORY_MATERIAL}</span>
</div>
)}
</div>
{stats.count === 0 && (
<div className="empty-category">자료 없음</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
<div className="content-right">
{/* 비교 이력 */}
<div className="comparison-history">
<h3>📋 비교 이력</h3>
{comparisonHistory.length > 0 ? (
<div className="history-list">
{comparisonHistory.map(comp => (
<div key={comp.id} className={`history-item ${comp.is_applied ? 'applied' : 'pending'}`}>
<div className="history-header">
<span className="comparison-date">
{new Date(comp.comparison_date).toLocaleString()}
</span>
<span className={`status ${comp.is_applied ? 'applied' : 'pending'}`}>
{comp.is_applied ? '적용완료' : '대기중'}
</span>
</div>
<div className="history-summary">
{comp.summary_stats && (
<>
<span>구매완료 변경: {comp.summary_stats.purchased_increased + comp.summary_stats.purchased_decreased}</span>
<span>구매미완료 변경: {comp.summary_stats.unpurchased_increased + comp.summary_stats.unpurchased_decreased}</span>
<span>신규/삭제: {comp.summary_stats.new_materials + comp.summary_stats.deleted_materials}</span>
</>
)}
</div>
{!comp.is_applied && (
<button
className="btn-apply-small"
onClick={() => {
setSelectedComparison(comp.id);
setShowApplyDialog(true);
}}
disabled={loading}
>
적용
</button>
)}
</div>
))}
</div>
) : (
<p className="no-history">비교 이력이 없습니다.</p>
)}
</div>
</div>
</div>
{/* 적용 확인 다이얼로그 */}
<ConfirmDialog
isOpen={showApplyDialog}
title="변경사항 적용 확인"
message="리비전 변경사항을 실제 데이터베이스에 적용하시겠습니까? 이 작업은 되돌릴 수 없습니다."
onConfirm={() => handleApplyChanges(selectedComparison)}
onCancel={() => {
setShowApplyDialog(false);
setSelectedComparison(null);
}}
confirmText="적용"
cancelText="취소"
/>
</div>
);
};
export default EnhancedRevisionPage;

View File

@@ -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 <LoadingSpinner message="BOLT 리비전 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1>🔩 BOLT 리비전 관리</h1>
<span className="header-subtitle">
볼트 타입과 나사 규격을 고려한 BOLT 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 컨트롤 섹션 */}
<div className="control-section">
<div className="section-header">
<h3>📊 자재 현황 필터</h3>
{processingInfo && (
<div className="processing-summary">
전체: {processingInfo.total_materials} |
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0} |
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0} |
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}
</div>
)}
</div>
<div className="control-grid">
<div className="control-group">
<label>검색:</label>
<input
type="text"
placeholder="자재명, 볼트타입, 도면명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="control-group">
<label>상태 필터:</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">전체</option>
<option value="REVISION_MATERIAL">리비전 자재</option>
<option value="INVENTORY_MATERIAL">재고 자재</option>
<option value="DELETED_MATERIAL">삭제 자재</option>
<option value="NEW_MATERIAL">신규 자재</option>
</select>
</div>
<div className="control-group">
<label>볼트 타입:</label>
<select value={boltTypeFilter} onChange={(e) => setBoltTypeFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.boltTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="control-group">
<label>나사 타입:</label>
<select value={threadTypeFilter} onChange={(e) => setThreadTypeFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.threadTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="control-group">
<label>길이:</label>
<select value={lengthFilter} onChange={(e) => setLengthFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.lengths.map(length => (
<option key={length} value={length}>{length}mm</option>
))}
</select>
</div>
<div className="control-group">
<label>정렬:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="description">자재명</option>
<option value="bolt_type">볼트 타입</option>
<option value="thread_size">나사 크기</option>
<option value="bolt_length">길이</option>
<option value="quantity">수량</option>
</select>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select>
</div>
</div>
{/* 선택된 자재 액션 */}
{selectedMaterials.size > 0 && (
<div className="selected-actions">
<span className="selected-count">
{selectedMaterials.size} 선택됨
</span>
<div className="action-buttons">
<button
className="btn-action btn-purchase"
onClick={() => executeAction('request_purchase')}
>
구매 신청
</button>
<button
className="btn-action btn-inventory"
onClick={() => executeAction('mark_inventory')}
>
재고 처리
</button>
<button
className="btn-action btn-delete"
onClick={() => executeAction('mark_deleted')}
>
삭제 처리
</button>
</div>
</div>
)}
</div>
{/* 자재 테이블 */}
<div className="materials-table-container">
<div className="table-header">
<div className="header-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
<div className="header-cell">상태</div>
<div className="header-cell">자재명</div>
<div className="header-cell">볼트 타입</div>
<div className="header-cell">나사 크기</div>
<div className="header-cell">길이</div>
<div className="header-cell">세트 구성</div>
<div className="header-cell">수량</div>
<div className="header-cell">단위</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{filteredAndSortedMaterials.map((material) => (
<div
key={material.id}
className={`table-row ${getStatusClass(material.processing_info?.display_status)}`}
>
<div className="table-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
/>
</div>
<div className="table-cell">
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
{material.processing_info?.display_status || 'NORMAL'}
</span>
</div>
<div className="table-cell">
<div className="material-info">
<div className="material-name">{generateBoltDescription(material)}</div>
{material.processing_info?.notes && (
<div className="material-notes">{material.processing_info.notes}</div>
)}
</div>
</div>
<div className="table-cell">{material.bolt_type || '-'}</div>
<div className="table-cell">{material.thread_size || '-'}</div>
<div className="table-cell">{material.bolt_length ? `${material.bolt_length}mm` : '-'}</div>
<div className="table-cell">{formatBoltSet(material)}</div>
<div className="table-cell quantity-cell">
<span className="quantity-value">
{formatQuantity(material.quantity)}
</span>
{material.processing_info?.quantity_change && (
<span className="quantity-change">
({material.processing_info.quantity_change > 0 ? '+' : ''}
{formatQuantity(material.processing_info.quantity_change)})
</span>
)}
</div>
<div className="table-cell">{material.unit || 'SET'}</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-view"
onClick={() => {/* 상세 보기 로직 */}}
title="상세 정보"
>
👁
</button>
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-torque"
onClick={() => {/* 토크 계산 로직 */}}
title="토크 계산"
>
🔧
</button>
</div>
</div>
</div>
))}
</div>
{filteredAndSortedMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 BOLT 자재가 없습니다.</p>
</div>
)}
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title="작업 확인"
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default BoltRevisionPage;

View File

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

View File

@@ -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 <LoadingSpinner message="FITTING 리비전 데이터 로딩 중..." />;
}
const error = materialsError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => 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 (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1>🔧 FITTING 리비전 관리</h1>
<span className="header-subtitle">
구매 상태를 고려한 FITTING 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 리비전 처리 상태 요약 */}
{processingInfo && (
<div className="revision-summary">
<h3>📊 리비전 처리 현황</h3>
<div className="summary-stats">
<div className="stat-item">
<span className="stat-label">전체 자재</span>
<span className="stat-value">{processingInfo.total_materials || 0}</span>
</div>
<div className="stat-item revision">
<span className="stat-label">리비전 자재</span>
<span className="stat-value">{processingInfo.by_status?.REVISION_MATERIAL || 0}</span>
</div>
<div className="stat-item inventory">
<span className="stat-label">재고 자재</span>
<span className="stat-value">{processingInfo.by_status?.INVENTORY_MATERIAL || 0}</span>
</div>
<div className="stat-item deleted">
<span className="stat-label">삭제 자재</span>
<span className="stat-value">{processingInfo.by_status?.DELETED_MATERIAL || 0}</span>
</div>
</div>
</div>
)}
{/* 기존 FITTING 자재 뷰 컴포넌트 사용 */}
<FittingMaterialsView {...commonProps} />
</div>
);
};
export default FittingRevisionPage;

View File

@@ -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 <LoadingSpinner message="FLANGE 리비전 데이터 로딩 중..." />;
}
const error = materialsError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => 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 (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1>🔩 FLANGE 리비전 관리</h1>
<span className="header-subtitle">
구매 상태를 고려한 FLANGE 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 리비전 처리 상태 요약 */}
{processingInfo && (
<div className="revision-summary">
<h3>📊 리비전 처리 현황</h3>
<div className="summary-stats">
<div className="stat-item">
<span className="stat-label">전체 자재</span>
<span className="stat-value">{processingInfo.total_materials || 0}</span>
</div>
<div className="stat-item revision">
<span className="stat-label">리비전 자재</span>
<span className="stat-value">{processingInfo.by_status?.REVISION_MATERIAL || 0}</span>
</div>
<div className="stat-item inventory">
<span className="stat-label">재고 자재</span>
<span className="stat-value">{processingInfo.by_status?.INVENTORY_MATERIAL || 0}</span>
</div>
<div className="stat-item deleted">
<span className="stat-label">삭제 자재</span>
<span className="stat-value">{processingInfo.by_status?.DELETED_MATERIAL || 0}</span>
</div>
</div>
</div>
)}
{/* 기존 FLANGE 자재 뷰 컴포넌트 사용 */}
<FlangeMaterialsView {...commonProps} />
</div>
);
};
export default FlangeRevisionPage;

View File

@@ -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 <LoadingSpinner message="GASKET 리비전 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1> GASKET 리비전 관리</h1>
<span className="header-subtitle">
가스켓 타입과 재질을 고려한 GASKET 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 컨트롤 섹션 */}
<div className="control-section">
<div className="section-header">
<h3>📊 자재 현황 필터</h3>
{processingInfo && (
<div className="processing-summary">
전체: {processingInfo.total_materials} |
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0} |
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0} |
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}
</div>
)}
</div>
<div className="control-grid">
<div className="control-group">
<label>검색:</label>
<input
type="text"
placeholder="자재명, 가스켓타입, 도면명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="control-group">
<label>상태 필터:</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">전체</option>
<option value="REVISION_MATERIAL">리비전 자재</option>
<option value="INVENTORY_MATERIAL">재고 자재</option>
<option value="DELETED_MATERIAL">삭제 자재</option>
<option value="NEW_MATERIAL">신규 자재</option>
</select>
</div>
<div className="control-group">
<label>가스켓 타입:</label>
<select value={gasketTypeFilter} onChange={(e) => setGasketTypeFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.gasketTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="control-group">
<label>재질:</label>
<select value={materialTypeFilter} onChange={(e) => setMaterialTypeFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.materialTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="control-group">
<label>압력등급:</label>
<select value={pressureRatingFilter} onChange={(e) => setPressureRatingFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.pressureRatings.map(rating => (
<option key={rating} value={rating}>{rating}#</option>
))}
</select>
</div>
<div className="control-group">
<label>정렬:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="description">자재명</option>
<option value="gasket_type">가스켓 타입</option>
<option value="nominal_size">크기</option>
<option value="material_type">재질</option>
<option value="quantity">수량</option>
</select>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select>
</div>
</div>
{/* 선택된 자재 액션 */}
{selectedMaterials.size > 0 && (
<div className="selected-actions">
<span className="selected-count">
{selectedMaterials.size} 선택됨
</span>
<div className="action-buttons">
<button
className="btn-action btn-purchase"
onClick={() => executeAction('request_purchase')}
>
구매 신청
</button>
<button
className="btn-action btn-inventory"
onClick={() => executeAction('mark_inventory')}
>
재고 처리
</button>
<button
className="btn-action btn-delete"
onClick={() => executeAction('mark_deleted')}
>
삭제 처리
</button>
</div>
</div>
)}
</div>
{/* 자재 테이블 */}
<div className="materials-table-container">
<div className="table-header">
<div className="header-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
<div className="header-cell">상태</div>
<div className="header-cell">자재명</div>
<div className="header-cell">가스켓 타입</div>
<div className="header-cell">크기</div>
<div className="header-cell">재질</div>
<div className="header-cell">두께</div>
<div className="header-cell">압력등급</div>
<div className="header-cell">수량</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{filteredAndSortedMaterials.map((material) => (
<div
key={material.id}
className={`table-row ${getStatusClass(material.processing_info?.display_status)}`}
>
<div className="table-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
/>
</div>
<div className="table-cell">
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
{material.processing_info?.display_status || 'NORMAL'}
</span>
</div>
<div className="table-cell">
<div className="material-info">
<div className="material-name">{generateGasketDescription(material)}</div>
{material.processing_info?.notes && (
<div className="material-notes">{material.processing_info.notes}</div>
)}
</div>
</div>
<div className="table-cell">{material.gasket_type || '-'}</div>
<div className="table-cell">{material.nominal_size || '-'}</div>
<div className="table-cell">{material.material_type || '-'}</div>
<div className="table-cell">{formatThickness(material)}</div>
<div className="table-cell">{material.pressure_rating ? `${material.pressure_rating}#` : '-'}</div>
<div className="table-cell quantity-cell">
<span className="quantity-value">
{formatQuantity(material.quantity)}
</span>
{material.processing_info?.quantity_change && (
<span className="quantity-change">
({material.processing_info.quantity_change > 0 ? '+' : ''}
{formatQuantity(material.processing_info.quantity_change)})
</span>
)}
</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-view"
onClick={() => {/* 상세 보기 로직 */}}
title="상세 정보"
>
👁
</button>
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-spec"
onClick={() => {/* 규격 확인 로직 */}}
title="규격 확인"
>
📏
</button>
</div>
</div>
</div>
))}
</div>
{filteredAndSortedMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 GASKET 자재가 없습니다.</p>
</div>
)}
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title="작업 확인"
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default GasketRevisionPage;

View File

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

View File

@@ -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 <LoadingSpinner message="PIPE 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError || pipeRevisionError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="pipe-cutting-plan-page">
{/* PIPE 리비전 상태 표시 */}
{pipeRevisionStatus && requiresAction && (
<div className="revision-status-section">
<div className={`revision-alert ${isPreCuttingPlan ? 'pre-cutting' : 'post-cutting'}`}>
<div className="alert-icon">
{isPreCuttingPlan ? '🔄' : '⚠️'}
</div>
<div className="alert-content">
<h4>
{isPreCuttingPlan ? 'Cutting Plan 작성 전 리비전' : 'Cutting Plan 작성 후 리비전'}
</h4>
<p>{pipeRevisionStatus.message}</p>
{isPostCuttingPlan && pipeComparisonResult && (
<div className="revision-summary">
<span>변경된 도면: {pipeComparisonResult.summary?.changed_drawings_count || 0}</span>
<span>추가된 단관: {pipeComparisonResult.summary?.added_segments || 0}</span>
<span>삭제된 단관: {pipeComparisonResult.summary?.removed_segments || 0}</span>
<span>수정된 단관: {pipeComparisonResult.summary?.modified_segments || 0}</span>
</div>
)}
</div>
</div>
</div>
)}
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1>🔧 PIPE Cutting Plan 관리</h1>
<span className="header-subtitle">
도면-라인번호-길이 기반 파이프 절단 계획 관리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 리비전 경고 (Cutting Plan 시작 전) */}
{!cuttingPlanStarted && (
<div className="revision-warning">
<div className="warning-content">
<h3> PIPE 리비전 처리 안내</h3>
<p>
<strong>Cutting Plan 작성 </strong> 리비전이 발생하면
<span className="highlight">기존 단관정보가 전부 삭제</span>되고
<span className="highlight"> BOM 파일 업로드</span> 필요합니다.
</p>
{revisionStatus?.has_revision && (
<button
className="btn-force-upload"
onClick={() => {
setActionType('force_revision_upload');
setShowConfirmDialog(true);
}}
>
🔄 BOM 파일 업로드
</button>
)}
</div>
</div>
)}
{/* 분류 섹션 */}
<div className="classification-section">
<div className="section-header">
<h3>📂 구역 도면 분류</h3>
</div>
<div className="classification-controls">
<div className="control-group">
<label>구역 선택:</label>
<select
value={selectedArea}
onChange={(e) => setSelectedArea(e.target.value)}
disabled={cuttingPlanStarted}
>
<option value="">구역을 선택하세요</option>
{areaOptions.map(area => (
<option key={area} value={area}>{area}</option>
))}
</select>
</div>
<div className="control-group">
<label>도면 검색:</label>
<input
type="text"
placeholder="도면명으로 검색..."
value={searchDrawing}
onChange={(e) => setSearchDrawing(e.target.value)}
/>
</div>
<div className="control-group">
<button
className="btn-start-cutting-plan"
onClick={handleStartCuttingPlan}
disabled={cuttingPlanStarted || !selectedArea}
>
{cuttingPlanStarted ? '✅ Cutting Plan 작성 중' : '📝 Cutting Plan 작성 시작'}
</button>
</div>
</div>
</div>
{/* 자재 현황 */}
<div className="materials-summary">
<div className="summary-stats">
<div className="stat-item">
<span className="stat-label">전체 단관</span>
<span className="stat-value">{filteredMaterials.length}</span>
</div>
<div className="stat-item">
<span className="stat-label">할당된 단관</span>
<span className="stat-value">{Object.keys(areaAssignments).length}</span>
</div>
<div className="stat-item">
<span className="stat-label">미할당 단관</span>
<span className="stat-value">{groupedMaterials.unassigned.length}</span>
</div>
</div>
</div>
{/* 구역별 자재 테이블 */}
<div className="cutting-plan-content">
{/* 할당된 구역들 */}
{Object.keys(groupedMaterials.assigned).sort().map(area => (
<div key={area} className="area-section">
<div className="area-header">
<h4>📍 구역 {area}</h4>
<span className="area-count">{groupedMaterials.assigned[area].length} 단관</span>
</div>
<div className="pipe-table">
<div className="table-header">
<div className="header-cell">구역</div>
<div className="header-cell">도면</div>
<div className="header-cell">라인번호</div>
<div className="header-cell">파이프정보(재질)</div>
<div className="header-cell">길이</div>
<div className="header-cell">끝단정보</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{groupedMaterials.assigned[area].map(material => (
<div key={material.id} className="table-row">
<div className="table-cell">
<select
value={areaAssignments[material.id] || ''}
onChange={(e) => handleAreaAssignment(material.id, e.target.value)}
>
<option value="">미할당</option>
{areaOptions.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
<div className="table-cell">{material.drawing_name || '-'}</div>
<div className="table-cell">{material.line_no || '-'}</div>
<div className="table-cell">{formatPipeInfo(material)}</div>
<div className="table-cell">{formatLength(material.length || material.total_length)}</div>
<div className="table-cell">
<select
value={endPreparations[material.id] || 'plain'}
onChange={(e) => handleEndPrepChange(material.id, e.target.value)}
>
{endPrepOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-delete"
onClick={() => handleRemoveMaterial(material.id)}
title="삭제"
>
</button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
))}
{/* 미할당 단관들 */}
{groupedMaterials.unassigned.length > 0 && (
<div className="area-section unassigned">
<div className="area-header">
<h4> 미할당 단관</h4>
<span className="area-count">{groupedMaterials.unassigned.length} 단관</span>
</div>
<div className="pipe-table">
<div className="table-header">
<div className="header-cell">구역</div>
<div className="header-cell">도면</div>
<div className="header-cell">라인번호</div>
<div className="header-cell">파이프정보(재질)</div>
<div className="header-cell">길이</div>
<div className="header-cell">끝단정보</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{groupedMaterials.unassigned.map(material => (
<div key={material.id} className="table-row">
<div className="table-cell">
<select
value={areaAssignments[material.id] || ''}
onChange={(e) => handleAreaAssignment(material.id, e.target.value)}
>
<option value="">미할당</option>
{areaOptions.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
<div className="table-cell">{material.drawing_name || '-'}</div>
<div className="table-cell">{material.line_no || '-'}</div>
<div className="table-cell">{formatPipeInfo(material)}</div>
<div className="table-cell">{formatLength(material.length || material.total_length)}</div>
<div className="table-cell">
<select
value={endPreparations[material.id] || 'plain'}
onChange={(e) => handleEndPrepChange(material.id, e.target.value)}
>
{endPrepOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-delete"
onClick={() => handleRemoveMaterial(material.id)}
title="삭제"
>
</button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* 빈 상태 */}
{filteredMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 PIPE 자재가 없습니다.</p>
</div>
)}
</div>
{/* Cutting Plan 관리 액션 */}
<div className="cutting-plan-management-section">
<div className="section-header">
<h3>🔧 Cutting Plan 관리</h3>
</div>
<div className="cutting-plan-actions">
<button
className="btn-export-temp"
onClick={handleExportTempExcel}
disabled={pipeRevisionLoading}
>
📊 임시 Excel 내보내기
</button>
<button
className="btn-finalize-cutting-plan"
onClick={handleFinalizeCuttingPlan}
disabled={pipeRevisionLoading}
>
🔒 Cutting Plan 확정 (이슈 관리 시작)
</button>
<button
className="btn-export-finalized"
onClick={handleExportFinalizedExcel}
disabled={pipeRevisionLoading}
>
📋 확정된 Excel 내보내기 (고정)
</button>
<button
className="btn-issue-management"
onClick={handleGoToIssueManagement}
disabled={pipeRevisionLoading}
>
🛠 이슈 관리 페이지
</button>
</div>
<div className="action-descriptions">
<div className="action-desc">
<strong>📊 임시 Excel:</strong> 현재 작업 중인 데이터 (리비전 변경됨)
</div>
<div className="action-desc">
<strong>🔒 확정:</strong> 데이터 고정 이슈 관리 시작 (리비전 보호)
</div>
<div className="action-desc">
<strong>📋 확정된 Excel:</strong> 고정된 데이터 (리비전과 무관)
</div>
</div>
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title={actionType === 'force_revision_upload' ? '강제 리비전 업로드' : '작업 확인'}
message={
actionType === 'force_revision_upload'
? '기존 단관정보를 모두 삭제하고 새 BOM 파일을 업로드하시겠습니까?'
: `선택된 단관을 삭제하시겠습니까?`
}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default PipeCuttingPlanPage;

View File

@@ -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 <LoadingSpinner message="SPECIAL 리비전 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1> SPECIAL 리비전 관리</h1>
<span className="header-subtitle">
특수 자재 브랜드별 SPECIAL 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 컨트롤 섹션 */}
<div className="control-section">
<div className="section-header">
<h3>📊 자재 현황 필터</h3>
{processingInfo && (
<div className="processing-summary">
전체: {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}
</div>
)}
</div>
<div className="control-grid">
<div className="control-group">
<label>검색:</label>
<input
type="text"
placeholder="자재명, 브랜드, 도면명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="control-group">
<label>상태 필터:</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">전체</option>
<option value="REVISION_MATERIAL">리비전 자재</option>
<option value="INVENTORY_MATERIAL">재고 자재</option>
<option value="DELETED_MATERIAL">삭제 자재</option>
<option value="NEW_MATERIAL">신규 자재</option>
</select>
</div>
<div className="control-group">
<label>서브카테고리:</label>
<select value={subcategoryFilter} onChange={(e) => setSubcategoryFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.subcategories.map(sub => (
<option key={sub} value={sub}>{sub}</option>
))}
</select>
</div>
<div className="control-group">
<label>우선순위:</label>
<select value={priorityFilter} onChange={(e) => setPriorityFilter(e.target.value)}>
<option value="all">전체</option>
<option value="high">높음</option>
<option value="medium">보통</option>
<option value="low">낮음</option>
</select>
</div>
<div className="control-group">
<label>정렬:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="description">자재명</option>
<option value="brand">브랜드</option>
<option value="subcategory">서브카테고리</option>
<option value="quantity">수량</option>
</select>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select>
</div>
</div>
{/* 선택된 자재 액션 */}
{selectedMaterials.size > 0 && (
<div className="selected-actions">
<span className="selected-count">
{selectedMaterials.size} 선택됨
</span>
<div className="action-buttons">
<button
className="btn-action btn-purchase"
onClick={() => executeAction('request_purchase')}
>
구매 신청
</button>
<button
className="btn-action btn-inventory"
onClick={() => executeAction('mark_inventory')}
>
재고 처리
</button>
<button
className="btn-action btn-delete"
onClick={() => executeAction('mark_deleted')}
>
삭제 처리
</button>
<button
className="btn-action btn-priority"
onClick={() => executeAction('set_high_priority')}
style={{ background: '#dc2626' }}
>
높은 우선순위
</button>
</div>
</div>
)}
</div>
{/* 자재 테이블 */}
<div className="materials-table-container">
<div className="table-header">
<div className="header-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
<div className="header-cell">상태</div>
<div className="header-cell">우선순위</div>
<div className="header-cell">자재명</div>
<div className="header-cell">브랜드</div>
<div className="header-cell">도면명</div>
<div className="header-cell">수량</div>
<div className="header-cell">단위</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{filteredAndSortedMaterials.map((material) => (
<div
key={material.id}
className={`table-row ${getStatusClass(material.processing_info?.display_status)} ${getPriorityClass(material.processing_info?.priority)}`}
>
<div className="table-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
/>
</div>
<div className="table-cell">
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
{material.processing_info?.display_status || 'NORMAL'}
</span>
</div>
<div className="table-cell">
<span className={`priority-badge ${getPriorityClass(material.processing_info?.priority)}`}>
{material.processing_info?.priority === 'high' ? '🔴' :
material.processing_info?.priority === 'medium' ? '🟡' :
material.processing_info?.priority === 'low' ? '🟢' : '⚪'}
</span>
</div>
<div className="table-cell">
<div className="material-info">
<div className="material-name">{generateSpecialDescription(material)}</div>
{material.processing_info?.notes && (
<div className="material-notes">{material.processing_info.notes}</div>
)}
{material.subcategory && (
<div className="material-subcategory">📂 {material.subcategory}</div>
)}
</div>
</div>
<div className="table-cell">{material.brand || '-'}</div>
<div className="table-cell">{material.drawing_name || '-'}</div>
<div className="table-cell quantity-cell">
<span className="quantity-value">
{formatQuantity(material.quantity)}
</span>
{material.processing_info?.quantity_change && (
<span className="quantity-change">
({material.processing_info.quantity_change > 0 ? '+' : ''}
{formatQuantity(material.processing_info.quantity_change)})
</span>
)}
</div>
<div className="table-cell">{material.unit || 'EA'}</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-view"
onClick={() => {/* 상세 보기 로직 */}}
title="상세 정보"
>
👁
</button>
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-priority"
onClick={() => {/* 우선순위 변경 로직 */}}
title="우선순위 변경"
>
</button>
</div>
</div>
</div>
))}
</div>
{filteredAndSortedMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 SPECIAL 자재가 없습니다.</p>
</div>
)}
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title="작업 확인"
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default SpecialRevisionPage;

View File

@@ -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 <LoadingSpinner message="SUPPORT 리비전 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1>🏗 SUPPORT 리비전 관리</h1>
<span className="header-subtitle">
지지대 타입과 하중등급을 고려한 SUPPORT 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 컨트롤 섹션 */}
<div className="control-section">
<div className="section-header">
<h3>📊 자재 현황 필터</h3>
{processingInfo && (
<div className="processing-summary">
전체: {processingInfo.total_materials} |
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0} |
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0} |
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}
</div>
)}
</div>
<div className="control-grid">
<div className="control-group">
<label>검색:</label>
<input
type="text"
placeholder="자재명, 지지대 타입, 도면명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="control-group">
<label>상태 필터:</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">전체</option>
<option value="REVISION_MATERIAL">리비전 자재</option>
<option value="INVENTORY_MATERIAL">재고 자재</option>
<option value="DELETED_MATERIAL">삭제 자재</option>
<option value="NEW_MATERIAL">신규 자재</option>
</select>
</div>
<div className="control-group">
<label>지지대 타입:</label>
<select value={supportTypeFilter} onChange={(e) => setSupportTypeFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.supportTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="control-group">
<label>하중등급:</label>
<select value={loadRatingFilter} onChange={(e) => setLoadRatingFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.loadRatings.map(rating => (
<option key={rating} value={rating}>{rating}</option>
))}
</select>
</div>
<div className="control-group">
<label>정렬:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="description">자재명</option>
<option value="support_type">지지대 타입</option>
<option value="load_rating">하중등급</option>
<option value="pipe_size">파이프 크기</option>
<option value="quantity">수량</option>
</select>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select>
</div>
</div>
{/* 선택된 자재 액션 */}
{selectedMaterials.size > 0 && (
<div className="selected-actions">
<span className="selected-count">
{selectedMaterials.size} 선택됨
</span>
<div className="action-buttons">
<button
className="btn-action btn-purchase"
onClick={() => executeAction('request_purchase')}
>
구매 신청
</button>
<button
className="btn-action btn-inventory"
onClick={() => executeAction('mark_inventory')}
>
재고 처리
</button>
<button
className="btn-action btn-delete"
onClick={() => executeAction('mark_deleted')}
>
삭제 처리
</button>
</div>
</div>
)}
</div>
{/* 자재 테이블 */}
<div className="materials-table-container">
<div className="table-header">
<div className="header-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
<div className="header-cell">상태</div>
<div className="header-cell">자재명</div>
<div className="header-cell">지지대 타입</div>
<div className="header-cell">파이프 크기</div>
<div className="header-cell">하중등급</div>
<div className="header-cell">치수</div>
<div className="header-cell">수량</div>
<div className="header-cell">단위</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{filteredAndSortedMaterials.map((material) => (
<div
key={material.id}
className={`table-row ${getStatusClass(material.processing_info?.display_status)}`}
>
<div className="table-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
/>
</div>
<div className="table-cell">
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
{material.processing_info?.display_status || 'NORMAL'}
</span>
</div>
<div className="table-cell">
<div className="material-info">
<div className="material-name">{generateSupportDescription(material)}</div>
{material.processing_info?.notes && (
<div className="material-notes">{material.processing_info.notes}</div>
)}
{material.load_capacity && (
<div className="material-capacity">💪 하중용량: {material.load_capacity}</div>
)}
</div>
</div>
<div className="table-cell">{material.support_type || '-'}</div>
<div className="table-cell">{material.pipe_size ? `${material.pipe_size}"` : '-'}</div>
<div className="table-cell">{material.load_rating || '-'}</div>
<div className="table-cell">{formatDimensions(material)}</div>
<div className="table-cell quantity-cell">
<span className="quantity-value">
{formatQuantity(material.quantity)}
</span>
{material.processing_info?.quantity_change && (
<span className="quantity-change">
({material.processing_info.quantity_change > 0 ? '+' : ''}
{formatQuantity(material.processing_info.quantity_change)})
</span>
)}
</div>
<div className="table-cell">{material.unit || 'EA'}</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-view"
onClick={() => {/* 상세 보기 로직 */}}
title="상세 정보"
>
👁
</button>
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-calc"
onClick={() => {/* 하중 계산 로직 */}}
title="하중 계산"
>
🧮
</button>
</div>
</div>
</div>
))}
</div>
{filteredAndSortedMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 SUPPORT 자재가 없습니다.</p>
</div>
)}
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title="작업 확인"
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default SupportRevisionPage;

View File

@@ -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 <LoadingSpinner message="UNCLASSIFIED 리비전 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1> UNCLASSIFIED 리비전 관리</h1>
<span className="header-subtitle">
미분류 자재의 리비전 처리 분류 작업
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 분류 통계 카드 */}
<div className="classification-stats-card">
<div className="stats-header">
<h3>🔍 분류 현황</h3>
<button
className="btn-toggle-tools"
onClick={() => setShowClassificationTools(!showClassificationTools)}
>
{showClassificationTools ? '도구 숨기기' : '분류 도구 보기'}
</button>
</div>
<div className="stats-grid">
<div className="stat-item needs-classification">
<div className="stat-value">{classificationStats.needsClassification}</div>
<div className="stat-label">분류 필요</div>
</div>
<div className="stat-item low-confidence">
<div className="stat-value">{classificationStats.lowConfidence}</div>
<div className="stat-label">낮은 신뢰도</div>
</div>
<div className="stat-item high-confidence">
<div className="stat-value">{classificationStats.highConfidence}</div>
<div className="stat-label">높은 신뢰도</div>
</div>
</div>
</div>
{/* 컨트롤 섹션 */}
<div className="control-section">
<div className="section-header">
<h3>📊 자재 현황 필터</h3>
{processingInfo && (
<div className="processing-summary">
전체: {processingInfo.total_materials} |
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0} |
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0} |
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}
</div>
)}
</div>
<div className="control-grid">
<div className="control-group">
<label>검색:</label>
<input
type="text"
placeholder="자재명, 도면명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="control-group">
<label>상태 필터:</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">전체</option>
<option value="REVISION_MATERIAL">리비전 자재</option>
<option value="INVENTORY_MATERIAL">재고 자재</option>
<option value="DELETED_MATERIAL">삭제 자재</option>
<option value="NEW_MATERIAL">신규 자재</option>
</select>
</div>
<div className="control-group">
<label>분류 상태:</label>
<select value={classificationFilter} onChange={(e) => setClassificationFilter(e.target.value)}>
<option value="all">전체</option>
<option value="needs_classification">분류 필요 (&lt;50%)</option>
<option value="low_confidence">낮은 신뢰도 (50-80%)</option>
<option value="high_confidence">높은 신뢰도 (&gt;80%)</option>
</select>
</div>
<div className="control-group">
<label>정렬:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="description">자재명</option>
<option value="classification_confidence">분류 신뢰도</option>
<option value="quantity">수량</option>
<option value="drawing_name">도면명</option>
</select>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select>
</div>
</div>
{/* 선택된 자재 액션 */}
{selectedMaterials.size > 0 && (
<div className="selected-actions">
<span className="selected-count">
{selectedMaterials.size} 선택됨
</span>
<div className="action-buttons">
<button
className="btn-action btn-purchase"
onClick={() => executeAction('request_purchase')}
>
구매 신청
</button>
<button
className="btn-action btn-inventory"
onClick={() => executeAction('mark_inventory')}
>
재고 처리
</button>
<button
className="btn-action btn-delete"
onClick={() => executeAction('mark_deleted')}
>
삭제 처리
</button>
<button
className="btn-action btn-classify"
onClick={() => executeAction('auto_classify')}
style={{ background: '#8b5cf6' }}
>
자동 분류
</button>
</div>
</div>
)}
</div>
{/* 자재 테이블 */}
<div className="materials-table-container">
<div className="table-header">
<div className="header-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
<div className="header-cell">상태</div>
<div className="header-cell">자재명</div>
<div className="header-cell">분류 신뢰도</div>
<div className="header-cell">제안 카테고리</div>
<div className="header-cell">도면명</div>
<div className="header-cell">수량</div>
<div className="header-cell">단위</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{filteredAndSortedMaterials.map((material) => (
<div
key={material.id}
className={`table-row ${getStatusClass(material.processing_info?.display_status)} ${getConfidenceClass(material.classification_confidence)}`}
>
<div className="table-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
/>
</div>
<div className="table-cell">
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
{material.processing_info?.display_status || 'NORMAL'}
</span>
</div>
<div className="table-cell">
<div className="material-info">
<div className="material-name">{material.description || material.item_name || '자재명 없음'}</div>
{material.processing_info?.notes && (
<div className="material-notes">{material.processing_info.notes}</div>
)}
</div>
</div>
<div className="table-cell">
<span className={`confidence-badge ${getConfidenceClass(material.classification_confidence)}`}>
{formatConfidence(material.classification_confidence)}
</span>
</div>
<div className="table-cell">
<span className="suggested-category">
{getSuggestedCategory(material)}
</span>
</div>
<div className="table-cell">{material.drawing_name || '-'}</div>
<div className="table-cell quantity-cell">
<span className="quantity-value">
{formatQuantity(material.quantity)}
</span>
{material.processing_info?.quantity_change && (
<span className="quantity-change">
({material.processing_info.quantity_change > 0 ? '+' : ''}
{formatQuantity(material.processing_info.quantity_change)})
</span>
)}
</div>
<div className="table-cell">{material.unit || 'EA'}</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-view"
onClick={() => {/* 상세 보기 로직 */}}
title="상세 정보"
>
👁
</button>
<button
className="btn-small btn-classify"
onClick={() => {/* 수동 분류 로직 */}}
title="수동 분류"
>
🏷
</button>
<button
className="btn-small btn-auto-classify"
onClick={() => {/* 자동 분류 로직 */}}
title="자동 분류"
>
🤖
</button>
</div>
</div>
</div>
))}
</div>
{filteredAndSortedMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 UNCLASSIFIED 자재가 없습니다.</p>
{materials && materials.length === 0 && (
<p>🎉 모든 자재가 분류되었습니다!</p>
)}
</div>
)}
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title="작업 확인"
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default UnclassifiedRevisionPage;

View File

@@ -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 <LoadingSpinner message="VALVE 리비전 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1>🚰 VALVE 리비전 관리</h1>
<span className="header-subtitle">
밸브 타입과 연결 방식을 고려한 VALVE 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 컨트롤 섹션 */}
<div className="control-section">
<div className="section-header">
<h3>📊 자재 현황 필터</h3>
{processingInfo && (
<div className="processing-summary">
전체: {processingInfo.total_materials} |
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0} |
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0} |
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}
</div>
)}
</div>
<div className="control-grid">
<div className="control-group">
<label>검색:</label>
<input
type="text"
placeholder="자재명, 밸브타입, 도면명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="control-group">
<label>상태 필터:</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">전체</option>
<option value="REVISION_MATERIAL">리비전 자재</option>
<option value="INVENTORY_MATERIAL">재고 자재</option>
<option value="DELETED_MATERIAL">삭제 자재</option>
<option value="NEW_MATERIAL">신규 자재</option>
</select>
</div>
<div className="control-group">
<label>밸브 타입:</label>
<select value={valveTypeFilter} onChange={(e) => setValveTypeFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.valveTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="control-group">
<label>압력등급:</label>
<select value={pressureRatingFilter} onChange={(e) => setPressureRatingFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.pressureRatings.map(rating => (
<option key={rating} value={rating}>{rating}#</option>
))}
</select>
</div>
<div className="control-group">
<label>연결 방식:</label>
<select value={connectionFilter} onChange={(e) => setConnectionFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.connections.map(conn => (
<option key={conn} value={conn}>{conn}</option>
))}
</select>
</div>
<div className="control-group">
<label>정렬:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="description">자재명</option>
<option value="valve_type">밸브 타입</option>
<option value="nominal_size">크기</option>
<option value="pressure_rating">압력등급</option>
<option value="quantity">수량</option>
</select>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select>
</div>
</div>
{/* 선택된 자재 액션 */}
{selectedMaterials.size > 0 && (
<div className="selected-actions">
<span className="selected-count">
{selectedMaterials.size} 선택됨
</span>
<div className="action-buttons">
<button
className="btn-action btn-purchase"
onClick={() => executeAction('request_purchase')}
>
구매 신청
</button>
<button
className="btn-action btn-inventory"
onClick={() => executeAction('mark_inventory')}
>
재고 처리
</button>
<button
className="btn-action btn-delete"
onClick={() => executeAction('mark_deleted')}
>
삭제 처리
</button>
</div>
</div>
)}
</div>
{/* 자재 테이블 */}
<div className="materials-table-container">
<div className="table-header">
<div className="header-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
<div className="header-cell">상태</div>
<div className="header-cell">자재명</div>
<div className="header-cell">밸브 타입</div>
<div className="header-cell">크기</div>
<div className="header-cell">압력등급</div>
<div className="header-cell">연결방식</div>
<div className="header-cell">수량</div>
<div className="header-cell">단위</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{filteredAndSortedMaterials.map((material) => (
<div
key={material.id}
className={`table-row ${getStatusClass(material.processing_info?.display_status)}`}
>
<div className="table-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
/>
</div>
<div className="table-cell">
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
{material.processing_info?.display_status || 'NORMAL'}
</span>
</div>
<div className="table-cell">
<div className="material-info">
<div className="material-name">{generateValveDescription(material)}</div>
{material.processing_info?.notes && (
<div className="material-notes">{material.processing_info.notes}</div>
)}
</div>
</div>
<div className="table-cell">{material.valve_type || '-'}</div>
<div className="table-cell">{material.nominal_size || '-'}</div>
<div className="table-cell">{material.pressure_rating ? `${material.pressure_rating}#` : '-'}</div>
<div className="table-cell">{material.connection_method || '-'}</div>
<div className="table-cell quantity-cell">
<span className="quantity-value">
{formatQuantity(material.quantity)}
</span>
{material.processing_info?.quantity_change && (
<span className="quantity-change">
({material.processing_info.quantity_change > 0 ? '+' : ''}
{formatQuantity(material.processing_info.quantity_change)})
</span>
)}
</div>
<div className="table-cell">{material.unit || 'EA'}</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-view"
onClick={() => {/* 상세 보기 로직 */}}
title="상세 정보"
>
👁
</button>
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-test"
onClick={() => {/* 밸브 테스트 로직 */}}
title="밸브 테스트"
>
🧪
</button>
</div>
</div>
</div>
))}
</div>
{filteredAndSortedMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 VALVE 자재가 없습니다.</p>
</div>
)}
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title="작업 확인"
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default ValveRevisionPage;