리비전 페이지 제거 및 트랜잭션 오류 임시 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- frontend/src/pages/revision/ 폴더 완전 삭제 - EnhancedRevisionPage.css 제거 - support_details 저장 시 트랜잭션 오류로 인해 임시로 상세 정보 저장 비활성화 - 리비전 기능 재설계 예정
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 등)
|
||||
# 필요시 간단한 구조로 다시 추가 예정
|
||||
|
||||
@@ -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. 파일 레코드 업데이트 (파싱된 자재 수)
|
||||
|
||||
278
backend/app/routers/simple_revision.py
Normal file
278
backend/app/routers/simple_revision.py
Normal 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)}")
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
395
backend/app/services/simple_revision_service.py
Normal file
395
backend/app/services/simple_revision_service.py
Normal 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
|
||||
}
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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 환경 마이그레이션 시작")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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">분류 필요 (<50%)</option>
|
||||
<option value="low_confidence">낮은 신뢰도 (50-80%)</option>
|
||||
<option value="high_confidence">높은 신뢰도 (>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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user