feat: BOM 관리 시스템 대폭 개선 및 Docker 배포 가이드 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- 🎨 UI/UX 개선: 데본씽크 스타일 모던 디자인 적용
- 📁 컴포넌트 구조 개선: 폴더별 체계적 관리 (common/, bom/, materials/)
- 🔧 BOM 관리 페이지 리팩토링: NewMaterialsPage → BOMManagementPage + 카테고리별 컴포넌트 분리
- 💾 구매신청 기능 개선: 선택된 자재 비활성화, 제목 편집, 엑셀 다운로드
- 📊 자재 표시 개선: 타입/서브타입 컬럼 정리, 상세 정보 복원
- 🐛 CSS 빌드 오류 수정: NewMaterialsPage.css 문법 오류 해결
- 📚 문서화: PAGES_GUIDE.md 추가, README에 Docker 캐시 문제 해결 가이드 추가
- 🔄 API 개선: 구매신청 자재 조회, 제목 수정 엔드포인트 추가
This commit is contained in:
hyungi
2025-10-16 12:45:23 +09:00
parent 5aef867110
commit 64fd9ad3d2
31 changed files with 7450 additions and 1604 deletions

175
RULES.md
View File

@@ -1255,6 +1255,8 @@ const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5;
- 자동 에러 핸들링 (검증, DB, 일반 예외)
### 📊 구현된 페이지들
#### **📋 기존 페이지들**
- MainPage: 메인 대시보드
- JobSelectionPage: 프로젝트 선택
- JobRegistrationPage: 프로젝트 등록
@@ -1264,6 +1266,179 @@ const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5;
- PurchaseConfirmationPage: 구매 확인
- RevisionPurchasePage: 리비전별 구매
#### **🎨 신규 모던 UI 페이지들 (2025.10.16 추가)**
##### **DashboardPage.jsx** - 프로젝트 중심 대시보드
```jsx
// 위치: frontend/src/pages/DashboardPage.jsx
// 특징: 데본씽크 스타일의 모던한 디자인
// 기능:
// - 프로젝트 선택 및 관리 (카드 형태)
// - 권한별 기능 카드 (BOM 관리, 자재 관리, 구매 관리)
// - 관리자 전용 기능 (사용자 관리, 시스템 설정)
// - 시스템 현황 대시보드
// - 프로젝트 생성 모달
// 디자인 특징:
// - 글래스모피즘 효과 (backdrop-filter: blur(10px))
// - 그라데이션 배경 및 버튼
// - 카드 호버 애니메이션
// - 타이포그래피 중심 디자인 (이모지 제거)
// - 반응형 그리드 레이아웃
// 주요 기능:
// 1. 프로젝트 선택 시스템
// - 프로젝트 목록을 카드 형태로 표시
// - 선택된 프로젝트 하이라이트
// - 프로젝트 정보 (코드, 이름, 고객사) 표시
// 2. 권한 기반 기능 접근
// - 프로젝트 선택 후에만 BOM/자재 관리 접근 가능
// - 관리자 전용 메뉴 분리 표시
// 3. 프로젝트 생성 기능
// - 모달 형태의 프로젝트 생성 폼
// - 프로젝트 코드, 이름, 고객사 입력
```
##### **UserMenu.jsx** - 사용자 메뉴 컴포넌트
```jsx
// 위치: frontend/src/components/UserMenu.jsx
// 특징: 드롭다운 형태의 사용자 메뉴
// 기능:
// - 사용자 프로필 표시 (아바타, 이름, 역할)
// - 계정 설정 링크
// - 관리자 전용 메뉴 (권한별 표시)
// - 로그아웃 기능
// 디자인 특징:
// - 원형 아바타 (그라데이션 배경)
// - 드롭다운 애니메이션
// - 호버 효과
// - 역할별 색상 구분
// 주요 기능:
// 1. 사용자 정보 표시
// - 이름 첫 글자로 아바타 생성
// - 역할 표시 (시스템 관리자, 관리자, 사용자)
// 2. 권한별 메뉴
// - 관리자: 사용자 관리, 시스템 설정, 시스템 로그
// - 일반 사용자: 계정 설정만
// 3. 네비게이션 연동
// - onNavigate 콜백을 통한 페이지 이동
// - onLogout 콜백을 통한 로그아웃 처리
```
#### **🎨 UI/UX 디자인 시스템**
##### **색상 팔레트**
```css
/* 주요 색상 */
--primary-gradient: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
--background-gradient: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
--glass-background: rgba(255, 255, 255, 0.9);
--text-primary: #0f172a;
--text-secondary: #64748b;
--border-color: rgba(255, 255, 255, 0.2);
/* 그림자 */
--shadow-card: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--shadow-button: 0 4px 14px 0 rgba(59, 130, 246, 0.39);
--shadow-hover: 0 8px 25px 0 rgba(59, 130, 246, 0.5);
```
##### **타이포그래피**
```css
/* 폰트 시스템 */
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
/* 제목 */
--heading-1: 36px, weight: 800, letter-spacing: -0.025em;
--heading-2: 24px, weight: 700, letter-spacing: -0.025em;
--heading-3: 18px, weight: 600;
/* 본문 */
--body-large: 18px, weight: 400;
--body-medium: 16px, weight: 400;
--body-small: 14px, weight: 400;
--caption: 12px, weight: 400;
```
##### **컴포넌트 스타일**
```css
/* 카드 */
.modern-card {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 32px;
box-shadow: var(--shadow-card);
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.modern-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-hover);
}
/* 버튼 */
.modern-button {
background: var(--primary-gradient);
color: white;
border: none;
border-radius: 12px;
padding: 12px 20px;
font-weight: 600;
box-shadow: var(--shadow-button);
transition: all 0.2s ease;
letter-spacing: 0.025em;
}
.modern-button:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-hover);
}
```
#### **🔧 컴포넌트 사용 가이드**
##### **DashboardPage 사용법**
```jsx
import DashboardPage from './pages/DashboardPage';
// App.jsx에서 사용
case 'dashboard':
return (
<DashboardPage
user={user}
onNavigate={navigateToPage}
pendingSignupCount={pendingSignupCount}
/>
);
```
##### **UserMenu 사용법**
```jsx
import UserMenu from './components/UserMenu';
// 헤더에서 사용
<UserMenu
user={user}
onNavigate={navigateToPage}
onLogout={handleLogout}
/>
```
#### **📱 반응형 디자인**
- **데스크톱**: 1200px 이상 - 3-4열 그리드
- **태블릿**: 768px-1199px - 2열 그리드
- **모바일**: 767px 이하 - 1열 스택
#### **♿ 접근성 고려사항**
- 키보드 네비게이션 지원
- 충분한 색상 대비 (WCAG 2.1 AA 준수)
- 스크린 리더 호환성
- 포커스 표시 명확화
---
## 🌐 시놀로지 NAS 배포 가이드 ⭐

View File

@@ -176,6 +176,39 @@ async def get_signup_requests(
)
@router.get("/pending-signups/count")
async def get_pending_signups_count(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
승인 대기 중인 회원가입 수 조회 (관리자 전용)
Returns:
dict: 승인 대기 중인 사용자 수
"""
try:
# 관리자 권한 확인
if current_user.get('role') not in ['admin', 'system']:
return {"count": 0} # 관리자가 아니면 0 반환
# 승인 대기 중인 사용자 수 조회
query = text("""
SELECT COUNT(*) as count
FROM users
WHERE status = 'pending'
""")
result = db.execute(query).fetchone()
count = result.count if result else 0
return {"count": count}
except Exception as e:
logger.error(f"승인 대기 회원가입 수 조회 실패: {str(e)}")
return {"count": 0} # 오류 시 0 반환
@router.post("/approve-signup/{user_id}")
async def approve_signup(
user_id: int,

View File

@@ -32,6 +32,13 @@ from app.services.revision_comparator import get_revision_comparison
router = APIRouter()
class ExcelSaveRequest(BaseModel):
file_id: int
category: str
materials: List[Dict]
filename: str
user_id: Optional[int] = None
def extract_enhanced_material_grade(description: str, original_grade: str, category: str) -> str:
"""
원본 설명에서 개선된 재질 정보를 추출
@@ -3039,9 +3046,9 @@ async def get_valve_details(
except Exception as e:
raise HTTPException(status_code=500, detail=f"VALVE 상세 정보 조회 실패: {str(e)}")
@router.get("/user-requirements")
@router.get("/{file_id}/user-requirements")
async def get_user_requirements(
file_id: Optional[int] = None,
file_id: int,
job_no: Optional[str] = None,
status: Optional[str] = None,
db: Session = Depends(get_db)
@@ -3730,3 +3737,173 @@ async def process_missing_drawings(
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"도면 처리 실패: {str(e)}")
@router.post("/save-excel")
async def save_excel_file(
request: ExcelSaveRequest,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
엑셀 파일을 서버에 저장하고 메타데이터를 기록
"""
try:
# 엑셀 저장 디렉토리 생성
excel_dir = Path("uploads/excel_exports")
excel_dir.mkdir(parents=True, exist_ok=True)
# 파일 경로 생성
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_filename = f"{request.category}_{timestamp}_{request.filename}"
file_path = excel_dir / safe_filename
# 엑셀 파일 생성 (openpyxl 사용)
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
wb = openpyxl.Workbook()
ws = wb.active
ws.title = request.category
# 헤더 설정
headers = ['TAGNO', '품목명', '수량', '통화구분', '단가', '크기', '압력등급', '스케줄',
'재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3',
'관리항목4', '납기일(YYYY-MM-DD)']
# 헤더 스타일
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
# 헤더 작성
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
# 데이터 작성
for row_idx, material in enumerate(request.materials, 2):
# 기본 데이터
data = [
'', # TAGNO
request.category, # 품목명
material.get('quantity', 0), # 수량
'KRW', # 통화구분
1, # 단가
material.get('size_spec', '-'), # 크기
'-', # 압력등급
material.get('schedule', '-'), # 스케줄
material.get('full_material_grade', material.get('material_grade', '-')), # 재질
'-', # 상세내역
material.get('user_requirement', ''), # 사용자요구
'', '', '', '', # 관리항목들
datetime.now().strftime('%Y-%m-%d') # 납기일
]
# 데이터 입력
for col, value in enumerate(data, 1):
ws.cell(row=row_idx, column=col, value=value)
# 엑셀 파일 저장
wb.save(file_path)
# 데이터베이스에 메타데이터 저장 (테이블이 없으면 무시)
try:
save_query = text("""
INSERT INTO excel_exports (
file_id, category, filename, file_path,
material_count, created_by, created_at
) VALUES (
:file_id, :category, :filename, :file_path,
:material_count, :user_id, :created_at
)
""")
db.execute(save_query, {
"file_id": request.file_id,
"category": request.category,
"filename": safe_filename,
"file_path": str(file_path),
"material_count": len(request.materials),
"user_id": current_user.get('id'),
"created_at": datetime.now()
})
db.commit()
except Exception as db_error:
logger.warning(f"엑셀 메타데이터 저장 실패 (파일은 저장됨): {str(db_error)}")
# 메타데이터 저장 실패해도 파일은 저장되었으므로 계속 진행
logger.info(f"엑셀 파일 저장 완료: {safe_filename}")
return {
"success": True,
"message": "엑셀 파일이 성공적으로 저장되었습니다.",
"filename": safe_filename,
"file_path": str(file_path),
"material_count": len(request.materials)
}
except Exception as e:
logger.error(f"엑셀 파일 저장 실패: {str(e)}")
raise HTTPException(status_code=500, detail=f"엑셀 파일 저장 실패: {str(e)}")
@router.get("/excel-exports")
async def get_excel_exports(
file_id: Optional[int] = None,
category: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
저장된 엑셀 파일 목록 조회
"""
try:
query = text("""
SELECT
id, file_id, category, filename, file_path,
material_count, created_by, created_at
FROM excel_exports
WHERE 1=1
""")
params = {}
if file_id:
query = text(str(query) + " AND file_id = :file_id")
params["file_id"] = file_id
if category:
query = text(str(query) + " AND category = :category")
params["category"] = category
query = text(str(query) + " ORDER BY created_at DESC")
result = db.execute(query, params).fetchall()
exports = []
for row in result:
exports.append({
"id": row.id,
"file_id": row.file_id,
"category": row.category,
"filename": row.filename,
"file_path": row.file_path,
"material_count": row.material_count,
"created_by": row.created_by,
"created_at": row.created_at.isoformat() if row.created_at else None
})
return {
"success": True,
"exports": exports
}
except Exception as e:
logger.error(f"엑셀 내보내기 목록 조회 실패: {str(e)}")
return {
"success": False,
"exports": [],
"message": "엑셀 내보내기 목록을 조회할 수 없습니다."
}

View File

@@ -1,7 +1,7 @@
"""
구매신청 관리 API
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Body
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import text
@@ -439,6 +439,101 @@ async def get_request_materials(
)
@router.get("/requested-materials")
async def get_requested_material_ids(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
구매신청된 자재 ID 목록 조회 (BOM 페이지에서 비활성화용)
"""
try:
query = text("""
SELECT DISTINCT pri.material_id
FROM purchase_request_items pri
JOIN purchase_requests pr ON pri.request_id = pr.request_id
WHERE 1=1
AND (:file_id IS NULL OR pr.file_id = :file_id)
AND (:job_no IS NULL OR pr.job_no = :job_no)
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no
}).fetchall()
material_ids = [row.material_id for row in results]
return {
"success": True,
"requested_material_ids": material_ids,
"count": len(material_ids)
}
except Exception as e:
logger.error(f"Failed to get requested material IDs: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"구매신청 자재 ID 조회 실패: {str(e)}"
)
@router.patch("/{request_id}/title")
async def update_request_title(
request_id: int,
title: str = Body(..., embed=True),
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청 제목(request_no) 업데이트
"""
try:
# 구매신청 존재 확인
check_query = text("""
SELECT request_no FROM purchase_requests
WHERE request_id = :request_id
""")
existing = db.execute(check_query, {"request_id": request_id}).fetchone()
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="구매신청을 찾을 수 없습니다"
)
# 제목 업데이트
update_query = text("""
UPDATE purchase_requests
SET request_no = :title
WHERE request_id = :request_id
""")
db.execute(update_query, {
"request_id": request_id,
"title": title
})
db.commit()
return {
"success": True,
"message": "구매신청 제목이 업데이트되었습니다",
"old_title": existing.request_no,
"new_title": title
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to update request title: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"구매신청 제목 업데이트 실패: {str(e)}"
)
@router.get("/{request_id}/download-excel")
async def download_request_excel(
request_id: int,

273
frontend/PAGES_GUIDE.md Normal file
View File

@@ -0,0 +1,273 @@
# 프론트엔드 페이지 가이드
이 문서는 TK-MP 프로젝트의 프론트엔드 페이지들의 역할과 기능을 정리한 가이드입니다.
## 📋 목차
- [인증 관련 페이지](#인증-관련-페이지)
- [대시보드 및 메인 페이지](#대시보드-및-메인-페이지)
- [프로젝트 관리 페이지](#프로젝트-관리-페이지)
- [BOM 관리 페이지](#bom-관리-페이지)
- [구매 관리 페이지](#구매-관리-페이지)
- [시스템 관리 페이지](#시스템-관리-페이지)
- [컴포넌트 구조](#컴포넌트-구조)
---
## 인증 관련 페이지
### `LoginPage.jsx`
- **역할**: 사용자 로그인 페이지
- **기능**:
- 사용자 인증 (이메일/비밀번호)
- 로그인 상태 관리
- 인증 실패 시 에러 메시지 표시
- **라우팅**: `/login`
- **접근 권한**: 모든 사용자 (비인증)
---
## 대시보드 및 메인 페이지
### `DashboardPage.jsx`
- **역할**: 메인 대시보드 페이지
- **기능**:
- 프로젝트 선택 드롭다운
- 프로젝트별 기능 카드 (BOM 관리, 구매신청 관리)
- 관리자 전용 기능 (사용자 관리, 로그 관리)
- 프로젝트 생성/편집/삭제/비활성화
- **라우팅**: `/dashboard`
- **접근 권한**: 인증된 사용자
- **디자인**: 데본씽크 스타일, 글래스모피즘 효과
### `MainPage.jsx`
- **역할**: 초기 랜딩 페이지
- **기능**: 기본 페이지 구조 및 네비게이션
- **라우팅**: `/`
- **접근 권한**: 인증된 사용자
---
## 프로젝트 관리 페이지
### `ProjectsPage.jsx`
- **역할**: 프로젝트 목록 및 관리
- **기능**:
- 프로젝트 목록 조회
- 프로젝트 생성/수정/삭제
- 프로젝트 상태 관리
- **라우팅**: `/projects`
- **접근 권한**: 관리자
### `InactiveProjectsPage.jsx`
- **역할**: 비활성화된 프로젝트 관리
- **기능**:
- 비활성 프로젝트 목록 조회
- 프로젝트 활성화/삭제
- 전체 선택/해제 기능
- **라우팅**: `/inactive-projects`
- **접근 권한**: 관리자
### `JobRegistrationPage.jsx`
- **역할**: 새로운 작업(Job) 등록
- **기능**:
- 작업 정보 입력 및 등록
- 프로젝트 연결
- **라우팅**: `/job-registration`
- **접근 권한**: 관리자
### `JobSelectionPage.jsx`
- **역할**: 작업 선택 페이지
- **기능**:
- 등록된 작업 목록 조회
- 작업 선택 및 이동
- **라우팅**: `/job-selection`
- **접근 권한**: 인증된 사용자
---
## BOM 관리 페이지
### `BOMManagementPage.jsx`
- **역할**: BOM(Bill of Materials) 통합 관리 페이지
- **기능**:
- 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT)
- 자재 선택 및 구매신청 (엑셀 내보내기)
- 구매신청된 자재 비활성화 표시
- 사용자 요구사항 입력
- 리비전 관리
- **라우팅**: `/bom-management`
- **접근 권한**: 인증된 사용자
- **특징**: 카테고리별 컴포넌트로 분리된 구조
### `NewMaterialsPage.jsx` (레거시)
- **역할**: 기존 자재 관리 페이지 (현재 백업용)
- **상태**: 사용 중단, `BOMManagementPage`로 대체됨
- **기능**: 자재 분류, 편집, 내보내기 (기존 로직 보존)
### `BOMStatusPage.jsx`
- **역할**: BOM 상태 조회 페이지
- **기능**:
- BOM 파일 상태 확인
- 처리 진행률 표시
- **라우팅**: `/bom-status`
- **접근 권한**: 인증된 사용자
### `BOMWorkspacePage.jsx`
- **역할**: BOM 작업 공간
- **기능**:
- BOM 파일 업로드 및 처리
- 자재 분류 작업
- **라우팅**: `/bom-workspace`
- **접근 권한**: 인증된 사용자
---
## 구매 관리 페이지
### `PurchaseRequestPage.jsx`
- **역할**: 구매신청 관리 페이지
- **기능**:
- 구매신청 목록 조회
- 구매신청 제목 편집 (인라인 편집)
- 원본 파일 정보 표시
- 엑셀 파일 다운로드
- 구매신청 자재 상세 조회
- **라우팅**: `/purchase-requests`
- **접근 권한**: 인증된 사용자
- **특징**: BOM 페이지와 연동된 구매 워크플로우
### `PurchaseBatchPage.jsx`
- **역할**: 구매 배치 처리 페이지
- **기능**:
- 대량 구매 처리
- 배치 작업 관리
- **라우팅**: `/purchase-batch`
- **접근 권한**: 관리자
---
## 시스템 관리 페이지
### `UserManagementPage.jsx`
- **역할**: 사용자 관리 페이지
- **기능**:
- 사용자 목록 조회
- 사용자 생성/수정/삭제
- 권한 관리
- 사용자 상태 관리
- **라우팅**: `/user-management`
- **접근 권한**: 관리자
### `SystemSettingsPage.jsx`
- **역할**: 시스템 설정 페이지
- **기능**:
- 시스템 전반 설정 관리
- 환경 변수 설정
- **라우팅**: `/system-settings`
- **접근 권한**: 관리자
### `SystemSetupPage.jsx`
- **역할**: 시스템 초기 설정 페이지
- **기능**:
- 시스템 초기 구성
- 기본 데이터 설정
- **라우팅**: `/system-setup`
- **접근 권한**: 관리자
### `SystemLogsPage.jsx`
- **역할**: 시스템 로그 조회 페이지
- **기능**:
- 시스템 로그 조회
- 로그 필터링 및 검색
- **라우팅**: `/system-logs`
- **접근 권한**: 관리자
### `LogMonitoringPage.jsx`
- **역할**: 로그 모니터링 페이지
- **기능**:
- 실시간 로그 모니터링
- 로그 분석 및 알림
- **라우팅**: `/log-monitoring`
- **접근 권한**: 관리자
### `AccountSettingsPage.jsx`
- **역할**: 개인 계정 설정 페이지
- **기능**:
- 개인 정보 수정
- 비밀번호 변경
- 계정 설정 관리
- **라우팅**: `/account-settings`
- **접근 권한**: 인증된 사용자
---
## 워크스페이스 페이지
### `ProjectWorkspacePage.jsx`
- **역할**: 프로젝트 작업 공간
- **기능**:
- 프로젝트별 작업 환경
- 파일 관리 및 협업 도구
- **라우팅**: `/project-workspace`
- **접근 권한**: 인증된 사용자
---
## 컴포넌트 구조
### `components/common/`
- **ErrorBoundary.jsx**: 에러 경계 컴포넌트
- **UserMenu.jsx**: 사용자 드롭다운 메뉴
### `components/bom/`
- **shared/**: 공통 BOM 컴포넌트
- `FilterableHeader.jsx`: 필터링 가능한 테이블 헤더
- `MaterialTable.jsx`: 자재 테이블 공통 컴포넌트
- **materials/**: 카테고리별 자재 뷰 컴포넌트
- `PipeMaterialsView.jsx`: 파이프 자재 관리
- `FittingMaterialsView.jsx`: 피팅 자재 관리
- `FlangeMaterialsView.jsx`: 플랜지 자재 관리
- `ValveMaterialsView.jsx`: 밸브 자재 관리
- `GasketMaterialsView.jsx`: 가스켓 자재 관리
- `BoltMaterialsView.jsx`: 볼트 자재 관리
- `SupportMaterialsView.jsx`: 서포트 자재 관리
### 기타 컴포넌트
- **NavigationMenu.jsx**: 사이드바 네비게이션
- **NavigationBar.jsx**: 상단 네비게이션 바
- **FileUpload.jsx**: 파일 업로드 컴포넌트
- **ProtectedRoute.jsx**: 권한 기반 라우트 보호
---
## 페이지 추가 시 규칙
1. **새 페이지 생성 시 이 문서 업데이트 필수**
2. **페이지 역할과 기능을 명확히 문서화**
3. **라우팅 경로와 접근 권한 명시**
4. **관련 컴포넌트와의 연관성 설명**
5. **디자인 시스템 준수 (데본씽크 스타일, 글래스모피즘)**
---
## 디자인 시스템
### 색상 팔레트
- **Primary**: 블루 그라데이션 (#3b82f6#1d4ed8)
- **Background**: 글래스 효과 (backdrop-filter: blur)
- **Cards**: 20px 둥근 모서리, 그림자 효과
### 반응형 디자인
- **Desktop**: 3-4열 그리드
- **Tablet**: 2열 그리드
- **Mobile**: 1열 그리드
### 타이포그래피
- **Font Family**: Apple 시스템 폰트
- **Weight**: 다양한 weight 활용 (400, 500, 600, 700)
---
*마지막 업데이트: 2024-10-16*
*다음 페이지 추가 시 반드시 이 문서를 업데이트하세요.*

View File

@@ -50,40 +50,95 @@ uvicorn app.main:app --reload
frontend/
├── src/
│ ├── components/
│ │ ├── Dashboard.jsx # 대시보드
│ │ ├── FileUpload.jsx # 파일 업로드
│ │ ├── MaterialList.jsx # 자재 목록
│ │ └── ProjectManager.jsx # 프로젝트 관리
├── App.jsx # 메인 앱
│ ├── main.jsx # 엔트리 포인
└── index.css # 전역 스타일
│ │ ├── common/ # 공통 컴포넌트
│ │ ├── bom/ # BOM 관련 컴포넌트
│ │ ├── materials/ # 카테고리별 자재
│ │ │ └── shared/ # BOM 공통 컴포넌트
│ └── ... # 기타 컴포넌트
│ ├── pages/ # 페이지 컴포넌
│ ├── DashboardPage.jsx # 메인 대시보드
│ │ ├── BOMManagementPage.jsx # BOM 관리
│ │ ├── PurchaseRequestPage.jsx # 구매신청 관리
│ │ └── ... # 기타 페이지들
│ ├── App.jsx # 메인 앱
│ ├── main.jsx # 엔트리 포인트
│ └── index.css # 전역 스타일
├── PAGES_GUIDE.md # 📋 페이지 역할 가이드
├── package.json
└── vite.config.js
```
## 🎯 주요 컴포넌트
## 📋 페이지 가이드
### Dashboard
- 프로젝트 통계 및 현황 표시
- 최근 활동 목록
- 실시간 데이터 업데이트
**모든 페이지의 역할과 기능은 [`PAGES_GUIDE.md`](./PAGES_GUIDE.md)에 상세히 문서화되어 있습니다.**
### FileUpload
- 드래그&드롭 인터페이스
- Excel 파일 검증
- 업로드 진행률 표시
- 배치 파일 처리
### 🔄 페이지 개발 규칙
### MaterialList
- 페이지네이션이 있는 데이터 그리드
- 실시간 검색 및 필터링
- CSV 내보내기
- 정렬 및 컬럼 관리
1. **새 페이지 생성 시 `PAGES_GUIDE.md` 업데이트 필수**
2. **페이지 역할, 기능, 라우팅, 접근 권한을 명확히 문서화**
3. **관련 컴포넌트와의 연관성 설명**
4. **디자인 시스템 준수 (데본씽크 스타일, 글래스모피즘)**
### ProjectManager
- 프로젝트 CRUD 작업
- 카드 형태의 프로젝트 표시
- 모달 기반 편집
### ⚠️ **Docker 배포 시 주의사항**
**프론트엔드 변경사항이 반영되지 않을 때:**
```bash
# 1. 프론트엔드 컨테이너 완전 재빌드 (캐시 문제 해결)
docker-compose stop frontend
docker-compose rm -f frontend
docker-compose build --no-cache frontend
docker-compose up -d frontend
# 2. 배포 후 index 파일 버전 확인
docker exec tk-mp-frontend find /usr/share/nginx/html -name "index-*.js"
# 3. 로컬 빌드 버전과 비교
ls -la frontend/dist/assets/index-*.js
```
**주의:** Docker 컨테이너는 이전 빌드를 캐시할 수 있어 최신 변경사항이 반영되지 않을 수 있습니다.
변경사항이 보이지 않으면 반드시 `--no-cache` 옵션으로 재빌드하세요.
### 🚀 **빠른 배포 명령어**
```bash
# 프론트엔드 빠른 재배포 (한 줄 명령어)
docker-compose stop frontend && docker-compose rm -f frontend && docker-compose build --no-cache frontend && docker-compose up -d frontend
# 전체 시스템 재시작
docker-compose restart
# 특정 서비스만 재시작
docker-compose restart backend
docker-compose restart frontend
```
## 🎯 주요 페이지
### DashboardPage
- 프로젝트 선택 드롭다운
- 프로젝트별 기능 카드 (BOM 관리, 구매신청 관리)
- 관리자 전용 기능 (사용자 관리, 로그 관리)
- 데본씽크 스타일 디자인
### BOMManagementPage
- 카테고리별 자재 관리 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT)
- 구매신청된 자재 비활성화 표시
- 엑셀 내보내기 및 서버 저장
- 사용자 요구사항 입력
### PurchaseRequestPage
- 구매신청 목록 조회 및 관리
- 구매신청 제목 인라인 편집
- 원본 파일 정보 표시
- 엑셀 파일 다운로드
### 카테고리별 자재 뷰 컴포넌트
- 각 자재 카테고리별 전용 뷰 컴포넌트
- 통일된 테이블 형태 UI
- 정렬, 필터링, 전체 선택 기능
- 구매신청된 자재 비활성화 처리
## 📱 반응형 디자인

View File

@@ -34,20 +34,6 @@
"vite": "^4.5.0"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -63,9 +49,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
"integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz",
"integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -73,22 +59,22 @@
}
},
"node_modules/@babel/core": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.0",
"@babel/generator": "^7.28.3",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-module-transforms": "^7.27.3",
"@babel/helpers": "^7.27.6",
"@babel/parser": "^7.28.0",
"@babel/helper-module-transforms": "^7.28.3",
"@babel/helpers": "^7.28.4",
"@babel/parser": "^7.28.4",
"@babel/template": "^7.27.2",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.0",
"@babel/traverse": "^7.28.4",
"@babel/types": "^7.28.4",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -111,13 +97,13 @@
"license": "MIT"
},
"node_modules/@babel/generator": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.0",
"@babel/types": "^7.28.0",
"@babel/parser": "^7.28.3",
"@babel/types": "^7.28.2",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -166,15 +152,15 @@
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
"integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/traverse": "^7.27.3"
"@babel/traverse": "^7.28.3"
},
"engines": {
"node": ">=6.9.0"
@@ -222,26 +208,26 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz",
"integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
"@babel/types": "^7.27.6"
"@babel/types": "^7.28.4"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.0"
"@babel/types": "^7.28.4"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -283,9 +269,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -306,17 +292,17 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
"integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.0",
"@babel/generator": "^7.28.3",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/parser": "^7.28.4",
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.0",
"@babel/types": "^7.28.4",
"debug": "^4.3.1"
},
"engines": {
@@ -324,9 +310,9 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -375,9 +361,9 @@
"license": "MIT"
},
"node_modules/@emotion/is-prop-valid": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz",
"integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
"license": "MIT",
"dependencies": {
"@emotion/memoize": "^0.9.0"
@@ -857,9 +843,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
"integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -958,15 +944,26 @@
"license": "BSD-3-Clause"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.12",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -977,15 +974,15 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.29",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1079,7 +1076,7 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/private-theming": {
"node_modules/@mui/private-theming": {
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz",
"integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==",
@@ -1106,7 +1103,7 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/styled-engine": {
"node_modules/@mui/styled-engine": {
"version": "5.18.0",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz",
"integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==",
@@ -1139,7 +1136,7 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/system": {
"node_modules/@mui/system": {
"version": "5.18.0",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz",
"integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==",
@@ -1179,7 +1176,7 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/types": {
"node_modules/@mui/types": {
"version": "7.2.24",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz",
"integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==",
@@ -1193,7 +1190,7 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/utils": {
"node_modules/@mui/utils": {
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz",
"integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==",
@@ -1281,9 +1278,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz",
"integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==",
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
"dev": true,
"license": "MIT"
},
@@ -1323,13 +1320,13 @@
}
},
"node_modules/@types/babel__traverse": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
"integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.20.7"
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/parse-json": {
@@ -1345,9 +1342,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.23",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
"version": "18.3.26",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -1381,16 +1378,16 @@
"license": "ISC"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz",
"integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==",
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.27.4",
"@babel/core": "^7.28.0",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.19",
"@rolldown/pluginutils": "1.0.0-beta.27",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0"
},
@@ -1398,7 +1395,7 @@
"node": "^14.18.0 || >=16.0.0"
},
"peerDependencies": {
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/acorn": {
@@ -1663,13 +1660,13 @@
}
},
"node_modules/axios": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -1695,6 +1692,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz",
"integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -1707,9 +1714,9 @@
}
},
"node_modules/browserslist": {
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"dev": true,
"funding": [
{
@@ -1727,9 +1734,10 @@
],
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
"node-releases": "^2.0.19",
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
},
"bin": {
@@ -1798,9 +1806,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001727",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
"version": "1.0.30001750",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz",
"integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==",
"dev": true,
"funding": [
{
@@ -1849,9 +1857,9 @@
}
},
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
@@ -1939,15 +1947,6 @@
"node": ">=10"
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -2036,9 +2035,9 @@
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -2142,16 +2141,16 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.182",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.182.tgz",
"integrity": "sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==",
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"dev": true,
"license": "ISC"
},
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
@@ -2494,9 +2493,9 @@
}
},
"node_modules/eslint-plugin-react-refresh": {
"version": "0.4.20",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz",
"integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==",
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz",
"integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -2736,9 +2735,9 @@
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
@@ -2772,9 +2771,9 @@
}
},
"node_modules/form-data": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -2858,6 +2857,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -3353,14 +3362,15 @@
}
},
"node_modules/is-generator-function": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
"integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.3",
"get-proto": "^1.0.0",
"call-bound": "^1.0.4",
"generator-function": "^2.0.0",
"get-proto": "^1.0.1",
"has-tostringtag": "^1.0.2",
"safe-regex-test": "^1.1.0"
},
@@ -3852,9 +3862,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"version": "2.0.23",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
"integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==",
"dev": true,
"license": "MIT"
},
@@ -4280,9 +4290,9 @@
}
},
"node_modules/react-is": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
"integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==",
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
"license": "MIT"
},
"node_modules/react-refresh": {
@@ -5308,6 +5318,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

File diff suppressed because it is too large Load Diff

View File

@@ -1,268 +0,0 @@
import React from 'react';
import errorLogger from '../utils/errorLogger';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null
};
}
static getDerivedStateFromError(error) {
// 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트합니다.
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 오류 정보를 상태에 저장
this.setState({
error: error,
errorInfo: errorInfo
});
// 오류 로깅
errorLogger.logError({
type: 'react_error_boundary',
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
url: window.location.href,
props: this.props.errorContext || {}
});
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
handleReload = () => {
window.location.reload();
};
handleGoHome = () => {
window.location.href = '/';
};
handleReportError = () => {
const errorReport = {
error: this.state.error?.message,
stack: this.state.error?.stack,
componentStack: this.state.errorInfo?.componentStack,
url: window.location.href,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent
};
// 오류 보고서를 클립보드에 복사
navigator.clipboard.writeText(JSON.stringify(errorReport, null, 2))
.then(() => {
alert('오류 정보가 클립보드에 복사되었습니다.');
})
.catch(() => {
// 클립보드 복사 실패 시 텍스트 영역에 표시
const textarea = document.createElement('textarea');
textarea.value = JSON.stringify(errorReport, null, 2);
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert('오류 정보가 클립보드에 복사되었습니다.');
});
};
render() {
if (this.state.hasError) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f8f9fa',
padding: '20px'
}}>
<div style={{
maxWidth: '600px',
width: '100%',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
padding: '40px',
textAlign: 'center'
}}>
<div style={{
fontSize: '48px',
marginBottom: '20px'
}}>
😵
</div>
<h1 style={{
fontSize: '24px',
fontWeight: '600',
color: '#dc3545',
marginBottom: '16px'
}}>
! 문제가 발생했습니다
</h1>
<p style={{
fontSize: '16px',
color: '#6c757d',
marginBottom: '30px',
lineHeight: '1.5'
}}>
예상치 못한 오류가 발생했습니다. <br />
문제는 자동으로 개발팀에 보고되었습니다.
</p>
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'center',
flexWrap: 'wrap',
marginBottom: '30px'
}}>
<button
onClick={this.handleReload}
style={{
padding: '12px 24px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s'
}}
onMouseOver={(e) => e.target.style.backgroundColor = '#0056b3'}
onMouseOut={(e) => e.target.style.backgroundColor = '#007bff'}
>
🔄 페이지 새로고침
</button>
<button
onClick={this.handleGoHome}
style={{
padding: '12px 24px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s'
}}
onMouseOver={(e) => e.target.style.backgroundColor = '#1e7e34'}
onMouseOut={(e) => e.target.style.backgroundColor = '#28a745'}
>
🏠 홈으로 이동
</button>
<button
onClick={this.handleReportError}
style={{
padding: '12px 24px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s'
}}
onMouseOver={(e) => e.target.style.backgroundColor = '#545b62'}
onMouseOut={(e) => e.target.style.backgroundColor = '#6c757d'}
>
📋 오류 정보 복사
</button>
</div>
{/* 개발 환경에서만 상세 오류 정보 표시 */}
{process.env.NODE_ENV === 'development' && this.state.error && (
<details style={{
textAlign: 'left',
backgroundColor: '#f8f9fa',
padding: '16px',
borderRadius: '4px',
marginTop: '20px',
fontSize: '12px',
fontFamily: 'monospace'
}}>
<summary style={{
cursor: 'pointer',
fontWeight: '600',
marginBottom: '8px',
color: '#495057'
}}>
개발자 정보 (클릭하여 펼치기)
</summary>
<div style={{ marginBottom: '12px' }}>
<strong>오류 메시지:</strong>
<pre style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: '4px 0',
color: '#dc3545'
}}>
{this.state.error.message}
</pre>
</div>
<div style={{ marginBottom: '12px' }}>
<strong>스택 트레이스:</strong>
<pre style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: '4px 0',
fontSize: '11px',
color: '#6c757d'
}}>
{this.state.error.stack}
</pre>
</div>
{this.state.errorInfo?.componentStack && (
<div>
<strong>컴포넌트 스택:</strong>
<pre style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: '4px 0',
fontSize: '11px',
color: '#6c757d'
}}>
{this.state.errorInfo.componentStack}
</pre>
</div>
)}
</details>
)}
<div style={{
marginTop: '30px',
padding: '16px',
backgroundColor: '#e3f2fd',
borderRadius: '4px',
fontSize: '14px',
color: '#1565c0'
}}>
💡 <strong>도움말:</strong> 문제가 계속 발생하면 페이지를 새로고침하거나
브라우저 캐시를 삭제해보세요.
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,3 @@
// BOM Components
export * from './materials';
export * from './shared';

View File

@@ -0,0 +1,460 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';
const BoltMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
fileId,
user
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
// 볼트 추가요구사항 추출 함수
const extractBoltAdditionalRequirements = (description) => {
const additionalReqs = [];
// 표면처리 패턴 확인
const surfacePatterns = {
'ELEC.GALV': 'ELEC.GALV',
'ELEC GALV': 'ELEC.GALV',
'GALVANIZED': 'GALVANIZED',
'GALV': 'GALV',
'HOT DIP GALV': 'HDG',
'HDG': 'HDG',
'ZINC PLATED': 'ZINC PLATED',
'ZINC': 'ZINC',
'PLAIN': 'PLAIN'
};
for (const [pattern, treatment] of Object.entries(surfacePatterns)) {
if (description.includes(pattern)) {
additionalReqs.push(treatment);
break; // 첫 번째 매치만 사용
}
}
return additionalReqs.join(', ') || '-';
};
const parseBoltInfo = (material) => {
const qty = Math.round(material.quantity || 0);
const safetyQty = Math.ceil(qty * 1.05); // 5% 여유율
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
// 볼트 상세 정보 우선 사용
const boltDetails = material.bolt_details || {};
// 길이 정보 (bolt_details 우선, 없으면 원본 설명에서 추출)
let boltLength = '-';
if (boltDetails.length && boltDetails.length !== '-') {
boltLength = boltDetails.length;
} else {
// 원본 설명에서 길이 추출
const description = material.original_description || '';
const lengthPatterns = [
/(\d+(?:\.\d+)?)\s*LG/i, // 75 LG, 90.0000 LG
/(\d+(?:\.\d+)?)\s*mm/i, // 50mm
/(\d+(?:\.\d+)?)\s*MM/i, // 50MM
/LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태
];
for (const pattern of lengthPatterns) {
const match = description.match(pattern);
if (match) {
let lengthValue = match[1];
// 소수점 제거 (145.0000 → 145)
if (lengthValue.includes('.') && lengthValue.endsWith('.0000')) {
lengthValue = lengthValue.split('.')[0];
} else if (lengthValue.includes('.') && /\.0+$/.test(lengthValue)) {
lengthValue = lengthValue.split('.')[0];
}
boltLength = `${lengthValue}mm`;
break;
}
}
}
// 재질 정보 (bolt_details 우선, 없으면 기본 필드 사용)
let boltGrade = '-';
if (boltDetails.material_standard && boltDetails.material_grade) {
// bolt_details에서 완전한 재질 정보 구성
if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== boltDetails.material_standard) {
boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`;
} else {
boltGrade = boltDetails.material_standard;
}
} else if (material.full_material_grade && material.full_material_grade !== '-') {
boltGrade = material.full_material_grade;
} else if (material.material_grade && material.material_grade !== '-') {
boltGrade = material.material_grade;
}
// 볼트 타입 (PSV_BOLT, LT_BOLT 등)
let boltSubtype = 'BOLT_GENERAL';
if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN') {
boltSubtype = boltDetails.bolt_type;
} else {
// 원본 설명에서 특수 볼트 타입 추출
const description = material.original_description || '';
const upperDesc = description.toUpperCase();
if (upperDesc.includes('PSV')) {
boltSubtype = 'PSV_BOLT';
} else if (upperDesc.includes('LT')) {
boltSubtype = 'LT_BOLT';
} else if (upperDesc.includes('CK')) {
boltSubtype = 'CK_BOLT';
}
}
// 추가요구사항 추출 (ELEC.GALV 등)
const additionalReq = extractBoltAdditionalRequirements(material.original_description || '');
return {
type: 'BOLT',
subtype: boltSubtype,
size: material.size_spec || material.main_nom || '-',
pressure: '-', // 볼트는 압력 등급 없음
schedule: boltLength, // 길이 정보
grade: boltGrade,
additionalReq: additionalReq, // 추가요구사항
quantity: purchaseQty,
unit: 'SETS'
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parseBoltInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseBoltInfo(a);
const bInfo = parseBoltInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
}
return filtered;
};
// 전체 선택/해제 (구매신청된 자재 제외)
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
if (selectedMaterials.size === selectableMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
}
};
// 개별 선택 (구매신청된 자재는 선택 불가)
const handleMaterialSelect = (materialId) => {
if (purchasedMaterials.has(materialId)) {
return; // 구매신청된 자재는 선택 불가
}
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `BOLT_Materials_${timestamp}.xlsx`;
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: userRequirements[material.id] || ''
}));
try {
await api.post('/files/save-excel', {
file_id: fileId,
category: 'BOLT',
materials: dataWithRequirements,
filename: excelFileName,
user_id: user?.id
});
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'BOLT',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
} catch (error) {
console.error('엑셀 저장 실패:', error);
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'BOLT',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
}
};
const filteredMaterials = getFilteredAndSortedMaterials();
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Bolt Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
<div>
<input
type="checkbox"
checked={(() => {
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
})()}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
<FilterableHeader sortKey="schedule" filterKey="schedule">Length</FilterableHeader>
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader>
<div>Unit</div>
<div>User Requirement</div>
</div>
{/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
{filteredMaterials.map((material, index) => {
const info = parseBoltInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.subtype}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.pressure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
</div>
<div>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
/>
</div>
</div>
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔩</div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Bolt Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No bolt materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default BoltMaterialsView;

View File

@@ -0,0 +1,666 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader, MaterialTable } from '../shared';
const FittingMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
fileId,
user,
onNavigate
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
// 니플 끝단 정보 추출 (기존 로직 복원)
const extractNippleEndInfo = (description) => {
const descUpper = description.toUpperCase();
// 니플 끝단 패턴들 (기존 NewMaterialsPage와 동일)
const endPatterns = {
'PBE': 'PBE', // Plain Both End
'BBE': 'BBE', // Bevel Both End
'POE': 'POE', // Plain One End
'BOE': 'BOE', // Bevel One End
'TOE': 'TOE', // Thread One End
'SW X NPT': 'SW×NPT', // Socket Weld x NPT
'SW X SW': 'SW×SW', // Socket Weld x Socket Weld
'NPT X NPT': 'NPT×NPT', // NPT x NPT
'BOTH END THREADED': 'B.E.T',
'B.E.T': 'B.E.T',
'ONE END THREADED': 'O.E.T',
'O.E.T': 'O.E.T',
'THREADED': 'THD'
};
for (const [pattern, display] of Object.entries(endPatterns)) {
if (descUpper.includes(pattern)) {
return display;
}
}
return '';
};
// 피팅 정보 파싱 (기존 상세 로직 복원)
const parseFittingInfo = (material) => {
const fittingDetails = material.fitting_details || {};
const classificationDetails = material.classification_details || {};
// 개선된 분류기 결과 우선 사용
const fittingTypeInfo = classificationDetails.fitting_type || {};
const scheduleInfo = classificationDetails.schedule_info || {};
// 기존 필드와 새 필드 통합
const fittingType = fittingTypeInfo.type || fittingDetails.fitting_type || '';
const fittingSubtype = fittingTypeInfo.subtype || fittingDetails.fitting_subtype || '';
const mainSchedule = scheduleInfo.main_schedule || fittingDetails.schedule || '';
const redSchedule = scheduleInfo.red_schedule || '';
const hasDifferentSchedules = scheduleInfo.has_different_schedules || false;
const description = material.original_description || '';
// 피팅 타입별 상세 표시
let displayType = '';
// 개선된 분류기 결과 우선 표시
if (fittingType === 'TEE' && fittingSubtype === 'REDUCING') {
displayType = 'TEE REDUCING';
} else if (fittingType === 'REDUCER' && fittingSubtype === 'CONCENTRIC') {
displayType = 'REDUCER CONC';
} else if (fittingType === 'REDUCER' && fittingSubtype === 'ECCENTRIC') {
displayType = 'REDUCER ECC';
} else if (description.toUpperCase().includes('TEE RED')) {
displayType = 'TEE REDUCING';
} else if (description.toUpperCase().includes('RED CONC')) {
displayType = 'REDUCER CONC';
} else if (description.toUpperCase().includes('RED ECC')) {
displayType = 'REDUCER ECC';
} else if (description.toUpperCase().includes('CAP')) {
if (description.includes('NPT(F)')) {
displayType = 'CAP NPT(F)';
} else if (description.includes('SW')) {
displayType = 'CAP SW';
} else if (description.includes('BW')) {
displayType = 'CAP BW';
} else {
displayType = 'CAP';
}
} else if (description.toUpperCase().includes('PLUG')) {
if (description.toUpperCase().includes('HEX')) {
if (description.includes('NPT(M)')) {
displayType = 'HEX PLUG NPT(M)';
} else {
displayType = 'HEX PLUG';
}
} else if (description.includes('NPT(M)')) {
displayType = 'PLUG NPT(M)';
} else if (description.includes('NPT')) {
displayType = 'PLUG NPT';
} else {
displayType = 'PLUG';
}
} else if (fittingType === 'NIPPLE') {
const length = fittingDetails.length_mm || fittingDetails.avg_length_mm;
const endInfo = extractNippleEndInfo(description);
let nippleType = 'NIPPLE';
if (length) nippleType += ` ${length}mm`;
if (endInfo) nippleType += ` ${endInfo}`;
displayType = nippleType;
} else if (fittingType === 'ELBOW') {
let elbowDetails = [];
// 각도 정보 추출
if (fittingSubtype.includes('90DEG') || description.includes('90') || description.includes('90°')) {
elbowDetails.push('90°');
} else if (fittingSubtype.includes('45DEG') || description.includes('45') || description.includes('45°')) {
elbowDetails.push('45°');
}
// 반경 정보 추출 (Long Radius / Short Radius)
if (fittingSubtype.includes('LONG_RADIUS') || description.toUpperCase().includes('LR') || description.toUpperCase().includes('LONG RADIUS')) {
elbowDetails.push('LR');
} else if (fittingSubtype.includes('SHORT_RADIUS') || description.toUpperCase().includes('SR') || description.toUpperCase().includes('SHORT RADIUS')) {
elbowDetails.push('SR');
}
// 연결 방식
if (description.includes('SW')) {
elbowDetails.push('SW');
} else if (description.includes('BW')) {
elbowDetails.push('BW');
}
// 기본값 설정 (각도가 없으면 90도로 가정)
if (!elbowDetails.some(detail => detail.includes('°'))) {
elbowDetails.unshift('90°');
}
displayType = `ELBOW ${elbowDetails.join(' ')}`.trim();
} else if (fittingType === 'TEE') {
// TEE 타입과 연결 방식 상세 표시
let teeDetails = [];
// 등경/축소 타입
if (fittingSubtype === 'EQUAL' || description.toUpperCase().includes('TEE EQ')) {
teeDetails.push('EQ');
} else if (fittingSubtype === 'REDUCING' || description.toUpperCase().includes('TEE RED')) {
teeDetails.push('RED');
}
// 연결 방식
if (description.includes('SW')) {
teeDetails.push('SW');
} else if (description.includes('BW')) {
teeDetails.push('BW');
}
displayType = `TEE ${teeDetails.join(' ')}`.trim();
} else if (fittingType === 'REDUCER') {
const reducerType = fittingSubtype === 'CONCENTRIC' ? 'CONC' : fittingSubtype === 'ECCENTRIC' ? 'ECC' : '';
const sizes = fittingDetails.reduced_size ? `${material.size_spec}×${fittingDetails.reduced_size}` : material.size_spec;
displayType = `RED ${reducerType} ${sizes}`.trim();
} else if (fittingType === 'SWAGE') {
const swageType = fittingSubtype || '';
displayType = `SWAGE ${swageType}`.trim();
} else if (fittingType === 'OLET') {
const oletSubtype = fittingSubtype || '';
let oletDisplayName = '';
// 백엔드 분류기 결과 우선 사용
switch (oletSubtype) {
case 'SOCKOLET':
oletDisplayName = 'SOCK-O-LET';
break;
case 'WELDOLET':
oletDisplayName = 'WELD-O-LET';
break;
case 'ELLOLET':
oletDisplayName = 'ELL-O-LET';
break;
case 'THREADOLET':
oletDisplayName = 'THREAD-O-LET';
break;
case 'ELBOLET':
oletDisplayName = 'ELB-O-LET';
break;
case 'NIPOLET':
oletDisplayName = 'NIP-O-LET';
break;
case 'COUPOLET':
oletDisplayName = 'COUP-O-LET';
break;
default:
// 백엔드 분류가 없으면 description에서 직접 추출
const upperDesc = description.toUpperCase();
if (upperDesc.includes('SOCK-O-LET') || upperDesc.includes('SOCKOLET')) {
oletDisplayName = 'SOCK-O-LET';
} else if (upperDesc.includes('WELD-O-LET') || upperDesc.includes('WELDOLET')) {
oletDisplayName = 'WELD-O-LET';
} else if (upperDesc.includes('ELL-O-LET') || upperDesc.includes('ELLOLET')) {
oletDisplayName = 'ELL-O-LET';
} else if (upperDesc.includes('THREAD-O-LET') || upperDesc.includes('THREADOLET')) {
oletDisplayName = 'THREAD-O-LET';
} else if (upperDesc.includes('ELB-O-LET') || upperDesc.includes('ELBOLET')) {
oletDisplayName = 'ELB-O-LET';
} else if (upperDesc.includes('NIP-O-LET') || upperDesc.includes('NIPOLET')) {
oletDisplayName = 'NIP-O-LET';
} else if (upperDesc.includes('COUP-O-LET') || upperDesc.includes('COUPOLET')) {
oletDisplayName = 'COUP-O-LET';
} else {
oletDisplayName = 'OLET';
}
}
displayType = oletDisplayName;
} else if (!displayType) {
displayType = fittingType || 'FITTING';
}
// 압력 등급과 스케줄 추출 (기존 NewMaterialsPage 로직)
let pressure = '-';
let schedule = '-';
// 압력 등급 찾기 (3000LB, 6000LB 등) - 소켓웰드 피팅에 특히 중요
const pressureMatch = description.match(/(\d+)LB/i);
if (pressureMatch) {
pressure = `${pressureMatch[1]}LB`;
}
// 소켓웰드 피팅의 경우 압력 등급이 더 중요함
if (description.includes('SW') && !pressureMatch) {
// SW 피팅인데 압력이 명시되지 않은 경우 기본값 설정
if (description.includes('3000') || description.includes('3K')) {
pressure = '3000LB';
} else if (description.includes('6000') || description.includes('6K')) {
pressure = '6000LB';
}
}
// 스케줄 표시 (분리 스케줄 지원)
if (hasDifferentSchedules && mainSchedule && redSchedule) {
schedule = `${mainSchedule}×${redSchedule}`;
} else if (mainSchedule) {
schedule = mainSchedule;
} else {
// Description에서 스케줄 추출
const scheduleMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
if (scheduleMatch) {
schedule = `SCH ${scheduleMatch[1]}`;
}
}
return {
type: 'FITTING',
subtype: displayType,
size: material.size_spec || '-',
pressure: pressure,
schedule: schedule,
grade: material.full_material_grade || material.material_grade || '-',
quantity: Math.round(material.quantity || 0),
unit: '개',
isFitting: true
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parseFittingInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseFittingInfo(a);
const bInfo = parseFittingInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
}
return filtered;
};
// 전체 선택/해제 (구매신청된 자재 제외)
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
if (selectedMaterials.size === selectableMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
}
};
// 개별 선택 (구매신청된 자재는 선택 불가)
const handleMaterialSelect = (materialId) => {
if (purchasedMaterials.has(materialId)) {
return; // 구매신청된 자재는 선택 불가
}
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `FITTING_Materials_${timestamp}.xlsx`;
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: userRequirements[material.id] || ''
}));
try {
await api.post('/files/save-excel', {
file_id: fileId,
category: 'FITTING',
materials: dataWithRequirements,
filename: excelFileName,
user_id: user?.id
});
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FITTING',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
} catch (error) {
console.error('엑셀 저장 실패:', error);
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FITTING',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
}
};
const filteredMaterials = getFilteredAndSortedMaterials();
// 필터 헤더 컴포넌트
const FilterableHeader = ({ sortKey, filterKey, children }) => (
<div className="filterable-header" style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
onClick={() => handleSort(sortKey)}
style={{ cursor: 'pointer', flex: 1 }}
>
{children}
{sortConfig.key === sortKey && (
<span style={{ marginLeft: '4px' }}>
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
<button
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px',
fontSize: '12px',
color: '#6b7280'
}}
>
🔍
</button>
</div>
{showFilterDropdown === filterKey && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
minWidth: '150px'
}}>
<input
type="text"
placeholder={`Filter ${children}...`}
value={columnFilters[filterKey] || ''}
onChange={(e) => setColumnFilters({
...columnFilters,
[filterKey]: e.target.value
})}
style={{
width: '100%',
padding: '4px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
autoFocus
/>
</div>
)}
</div>
);
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Fitting Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
<div>
<input
type="checkbox"
checked={(() => {
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
})()}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<div>Type</div>
<div>Size</div>
<div>Pressure</div>
<div>Schedule</div>
<div>Material Grade</div>
<div>Quantity</div>
<div>Unit</div>
<div>User Requirement</div>
</div>
{/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
{filteredMaterials.map((material, index) => {
const info = parseFittingInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.subtype}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.pressure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
</div>
<div>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
/>
</div>
</div>
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Fitting Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No fitting materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default FittingMaterialsView;

View File

@@ -0,0 +1,512 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader, MaterialTable } from '../shared';
const FlangeMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
fileId,
user,
onNavigate
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
// 플랜지 정보 파싱
const parseFlangeInfo = (material) => {
const description = material.original_description || '';
const flangeDetails = material.flange_details || {};
const flangeTypeMap = {
'WN': 'WELD NECK FLANGE',
'WELD_NECK': 'WELD NECK FLANGE',
'SO': 'SLIP ON FLANGE',
'SLIP_ON': 'SLIP ON FLANGE',
'SW': 'SOCKET WELD FLANGE',
'SOCKET_WELD': 'SOCKET WELD FLANGE',
'BLIND': 'BLIND FLANGE',
'REDUCING': 'REDUCING FLANGE',
'ORIFICE': 'ORIFICE FLANGE',
'SPECTACLE': 'SPECTACLE BLIND',
'PADDLE': 'PADDLE BLIND',
'SPACER': 'SPACER'
};
const facingTypeMap = {
'RF': 'RAISED FACE',
'RAISED_FACE': 'RAISED FACE',
'FF': 'FLAT FACE',
'FLAT_FACE': 'FLAT FACE',
'RTJ': 'RING TYPE JOINT',
'RING_TYPE_JOINT': 'RING TYPE JOINT'
};
const rawFlangeType = flangeDetails.flange_type || '';
const rawFacingType = flangeDetails.facing_type || '';
let displayType = flangeTypeMap[rawFlangeType] || rawFlangeType || '-';
let facingType = facingTypeMap[rawFacingType] || rawFacingType || '-';
// Description에서 추출
if (displayType === '-') {
const desc = description.toUpperCase();
if (desc.includes('ORIFICE')) {
displayType = 'ORIFICE FLANGE';
} else if (desc.includes('SPECTACLE')) {
displayType = 'SPECTACLE BLIND';
} else if (desc.includes('PADDLE')) {
displayType = 'PADDLE BLIND';
} else if (desc.includes('SPACER')) {
displayType = 'SPACER';
} else if (desc.includes('REDUCING') || desc.includes('RED')) {
displayType = 'REDUCING FLANGE';
} else if (desc.includes('BLIND')) {
displayType = 'BLIND FLANGE';
} else if (desc.includes('WN')) {
displayType = 'WELD NECK FLANGE';
} else if (desc.includes('SO')) {
displayType = 'SLIP ON FLANGE';
} else if (desc.includes('SW')) {
displayType = 'SOCKET WELD FLANGE';
} else {
displayType = 'FLANGE';
}
}
if (facingType === '-') {
const desc = description.toUpperCase();
if (desc.includes('RF')) {
facingType = 'RAISED FACE';
} else if (desc.includes('FF')) {
facingType = 'FLAT FACE';
} else if (desc.includes('RTJ')) {
facingType = 'RING TYPE JOINT';
}
}
// 원본 설명에서 스케줄 추출
let schedule = '-';
const upperDesc = description.toUpperCase();
// SCH 40, SCH 80 등의 패턴 찾기
if (upperDesc.includes('SCH')) {
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
if (schMatch && schMatch[1]) {
schedule = `SCH ${schMatch[1]}`;
}
}
// 압력 등급 추출
let pressure = '-';
const pressureMatch = description.match(/(\d+)LB/i);
if (pressureMatch) {
pressure = `${pressureMatch[1]}LB`;
}
return {
type: 'FLANGE',
subtype: displayType, // 풀네임 플랜지 타입
facing: facingType, // 새로 추가: 끝단처리 정보
size: material.size_spec || '-',
pressure: flangeDetails.pressure_rating || pressure,
schedule: schedule,
grade: material.full_material_grade || material.material_grade || '-',
quantity: Math.round(material.quantity || 0),
unit: '개',
isFlange: true // 플랜지 구분용 플래그
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parseFlangeInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseFlangeInfo(a);
const bInfo = parseFlangeInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
}
return filtered;
};
// 전체 선택/해제
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
if (selectedMaterials.size === filteredMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(filteredMaterials.map(m => m.id)));
}
};
// 개별 선택
const handleMaterialSelect = (materialId) => {
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `FLANGE_Materials_${timestamp}.xlsx`;
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: userRequirements[material.id] || ''
}));
try {
await api.post('/files/save-excel', {
file_id: fileId,
category: 'FLANGE',
materials: dataWithRequirements,
filename: excelFileName,
user_id: user?.id
});
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FLANGE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
} catch (error) {
console.error('엑셀 저장 실패:', error);
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FLANGE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
}
};
const filteredMaterials = getFilteredAndSortedMaterials();
// 필터 헤더 컴포넌트
const FilterableHeader = ({ sortKey, filterKey, children }) => (
<div className="filterable-header" style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
onClick={() => handleSort(sortKey)}
style={{ cursor: 'pointer', flex: 1 }}
>
{children}
{sortConfig.key === sortKey && (
<span style={{ marginLeft: '4px' }}>
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
<button
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px',
fontSize: '12px',
color: '#6b7280'
}}
>
🔍
</button>
</div>
{showFilterDropdown === filterKey && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
minWidth: '150px'
}}>
<input
type="text"
placeholder={`Filter ${children}...`}
value={columnFilters[filterKey] || ''}
onChange={(e) => setColumnFilters({
...columnFilters,
[filterKey]: e.target.value
})}
style={{
width: '100%',
padding: '4px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
autoFocus
/>
</div>
)}
</div>
);
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Flange Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 1fr 200px 150px 120px 100px 120px 150px 80px 80px 200px',
gap: '12px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
<div>
<input
type="checkbox"
checked={selectedMaterials.size === filteredMaterials.length && filteredMaterials.length > 0}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
<FilterableHeader sortKey="facing" filterKey="facing">Facing</FilterableHeader>
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
<FilterableHeader sortKey="schedule" filterKey="schedule">Schedule</FilterableHeader>
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader>
<div>Unit</div>
<div>User Requirement</div>
</div>
{/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
{filteredMaterials.map((material, index) => {
const info = parseFlangeInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 1fr 200px 150px 120px 100px 120px 150px 80px 80px 200px',
gap: '12px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
style={{ cursor: 'pointer' }}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
FLANGE
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.subtype}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.facing}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.pressure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
</div>
<div>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
/>
</div>
</div>
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔩</div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Flange Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No flange materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default FlangeMaterialsView;

View File

@@ -0,0 +1,396 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';
const GasketMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
fileId,
user
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
const parseGasketInfo = (material) => {
const qty = Math.round(material.quantity || 0);
const purchaseQty = Math.ceil(qty * 1.05 / 5) * 5; // 5% 여유율 + 5의 배수
// original_description에서 재질 정보 파싱 (기존 NewMaterialsPage와 동일)
const description = material.original_description || '';
let materialStructure = '-'; // H/F/I/O 부분
let materialDetail = '-'; // SS304/GRAPHITE/CS/CS 부분
// H/F/I/O와 재질 상세 정보 추출
const materialMatch = description.match(/H\/F\/I\/O\s+(.+?)(?:,|$)/);
if (materialMatch) {
materialStructure = 'H/F/I/O';
materialDetail = materialMatch[1].trim();
// 두께 정보 제거 (별도 추출)
materialDetail = materialDetail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim();
}
// 압력 정보 추출
let pressure = '-';
const pressureMatch = description.match(/(\d+LB)/);
if (pressureMatch) {
pressure = pressureMatch[1];
}
// 두께 정보 추출
let thickness = '-';
const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i);
if (thicknessMatch) {
thickness = thicknessMatch[1] + 'mm';
}
return {
type: 'GASKET',
subtype: 'SWG', // 항상 SWG로 표시
size: material.size_spec || '-',
pressure: pressure,
schedule: thickness, // 두께를 schedule 열에 표시
materialStructure: materialStructure,
materialDetail: materialDetail,
thickness: thickness,
grade: materialDetail, // 재질 상세를 grade로 표시
quantity: purchaseQty,
unit: '개',
isGasket: true
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parseGasketInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseGasketInfo(a);
const bInfo = parseGasketInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
}
return filtered;
};
// 전체 선택/해제 (구매신청된 자재 제외)
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
if (selectedMaterials.size === selectableMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
}
};
// 개별 선택 (구매신청된 자재는 선택 불가)
const handleMaterialSelect = (materialId) => {
if (purchasedMaterials.has(materialId)) {
return; // 구매신청된 자재는 선택 불가
}
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `GASKET_Materials_${timestamp}.xlsx`;
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: userRequirements[material.id] || ''
}));
try {
await api.post('/files/save-excel', {
file_id: fileId,
category: 'GASKET',
materials: dataWithRequirements,
filename: excelFileName,
user_id: user?.id
});
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'GASKET',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
} catch (error) {
console.error('엑셀 저장 실패:', error);
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'GASKET',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
}
};
const filteredMaterials = getFilteredAndSortedMaterials();
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Gasket Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
<div>
<input
type="checkbox"
checked={(() => {
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
})()}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
<FilterableHeader sortKey="schedule" filterKey="schedule">Thickness</FilterableHeader>
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader>
<div>Unit</div>
<div>User Requirement</div>
</div>
{/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
{filteredMaterials.map((material, index) => {
const info = parseGasketInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.subtype}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.pressure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
</div>
<div>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
/>
</div>
</div>
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Gasket Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No gasket materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default GasketMaterialsView;

View File

@@ -0,0 +1,525 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader, MaterialTable } from '../shared';
const PipeMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
fileId,
user,
onNavigate
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
// 파이프 구매 수량 계산 (기존 로직 복원)
const calculatePipePurchase = (material) => {
const pipeDetails = material.pipe_details || {};
const totalLength = pipeDetails.length || material.length || 0;
const standardLength = 6; // 표준 6M
const purchaseCount = Math.ceil(totalLength / standardLength);
const totalPurchaseLength = purchaseCount * standardLength;
const wasteLength = totalPurchaseLength - totalLength;
const wastePercentage = totalLength > 0 ? (wasteLength / totalLength * 100) : 0;
return {
totalLength,
standardLength,
purchaseCount,
totalPurchaseLength,
wasteLength,
wastePercentage
};
};
// 파이프 정보 파싱 (기존 상세 로직 복원)
const parsePipeInfo = (material) => {
const calc = calculatePipePurchase(material);
const pipeDetails = material.pipe_details || {};
return {
type: 'PIPE',
subtype: pipeDetails.manufacturing_method || 'SMLS',
size: material.size_spec || '-',
schedule: pipeDetails.schedule || material.schedule || '-',
grade: material.full_material_grade || material.material_grade || '-',
length: calc.totalLength,
quantity: calc.purchaseCount,
unit: '본',
details: calc,
isPipe: true
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parsePipeInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parsePipeInfo(a);
const bInfo = parsePipeInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
}
return filtered;
};
// 전체 선택/해제
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
if (selectedMaterials.size === filteredMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(filteredMaterials.map(m => m.id)));
}
};
// 개별 선택
const handleMaterialSelect = (materialId) => {
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `PIPE_Materials_${timestamp}.xlsx`;
// 사용자 요구사항 포함
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: userRequirements[material.id] || ''
}));
try {
// 서버에 엑셀 파일 저장 요청
await api.post('/files/save-excel', {
file_id: fileId,
category: 'PIPE',
materials: dataWithRequirements,
filename: excelFileName,
user_id: user?.id
});
// 클라이언트에서 다운로드
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'PIPE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
} catch (error) {
console.error('엑셀 저장 실패:', error);
// 실패해도 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'PIPE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
}
};
const filteredMaterials = getFilteredAndSortedMaterials();
// 필터 헤더 컴포넌트
const FilterableHeader = ({ sortKey, filterKey, children }) => (
<div className="filterable-header" style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
onClick={() => handleSort(sortKey)}
style={{ cursor: 'pointer', flex: 1 }}
>
{children}
{sortConfig.key === sortKey && (
<span style={{ marginLeft: '4px' }}>
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
<button
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px',
fontSize: '12px',
color: '#6b7280'
}}
>
🔍
</button>
</div>
{showFilterDropdown === filterKey && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
minWidth: '150px'
}}>
<input
type="text"
placeholder={`Filter ${children}...`}
value={columnFilters[filterKey] || ''}
onChange={(e) => setColumnFilters({
...columnFilters,
[filterKey]: e.target.value
})}
style={{
width: '100%',
padding: '4px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
autoFocus
/>
</div>
)}
</div>
);
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Pipe Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 1fr 120px 120px 120px 150px 100px 80px 80px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
<div>
<input
type="checkbox"
checked={selectedMaterials.size === filteredMaterials.length && filteredMaterials.length > 0}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader
sortKey="type"
filterKey="type"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Type
</FilterableHeader>
<FilterableHeader
sortKey="subtype"
filterKey="subtype"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Subtype
</FilterableHeader>
<FilterableHeader
sortKey="size"
filterKey="size"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Size
</FilterableHeader>
<FilterableHeader
sortKey="schedule"
filterKey="schedule"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Schedule
</FilterableHeader>
<FilterableHeader
sortKey="grade"
filterKey="grade"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Material Grade
</FilterableHeader>
<FilterableHeader
sortKey="length"
filterKey="length"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Length (M)
</FilterableHeader>
<FilterableHeader
sortKey="quantity"
filterKey="quantity"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Quantity
</FilterableHeader>
<div>Unit</div>
<div>User Requirement</div>
</div>
{/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
{filteredMaterials.map((material, index) => {
const info = parsePipeInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 1fr 120px 120px 120px 150px 100px 80px 80px 200px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
style={{ cursor: 'pointer' }}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
PIPE
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.subtype}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'right' }}>
{info.length.toFixed(2)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
</div>
<div>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
/>
</div>
</div>
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔧</div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Pipe Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No pipe materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default PipeMaterialsView;

View File

@@ -0,0 +1,377 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';
const SupportMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
fileId,
user
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
const parseSupportInfo = (material) => {
const desc = material.original_description || '';
const isUrethaneBlock = desc.includes('URETHANE') || desc.includes('BLOCK SHOE') || desc.includes('우레탄');
const isClamp = desc.includes('CLAMP') || desc.includes('클램프');
let subtypeText = '';
if (isUrethaneBlock) {
subtypeText = '우레탄블럭슈';
} else if (isClamp) {
subtypeText = '클램프';
} else {
subtypeText = '유볼트';
}
return {
type: 'SUPPORT',
subtype: subtypeText,
size: material.main_nom || material.size_inch || material.size_spec || '-',
pressure: '-', // 서포트는 압력 등급 없음
schedule: '-', // 서포트는 스케줄 없음
description: material.original_description || '-',
grade: material.full_material_grade || material.material_grade || '-',
additionalReq: '-',
quantity: Math.round(material.quantity || 0),
unit: '개',
isSupport: true
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parseSupportInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseSupportInfo(a);
const bInfo = parseSupportInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
}
return filtered;
};
// 전체 선택/해제 (구매신청된 자재 제외)
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
if (selectedMaterials.size === selectableMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
}
};
// 개별 선택 (구매신청된 자재는 선택 불가)
const handleMaterialSelect = (materialId) => {
if (purchasedMaterials.has(materialId)) {
return; // 구매신청된 자재는 선택 불가
}
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `SUPPORT_Materials_${timestamp}.xlsx`;
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: userRequirements[material.id] || ''
}));
try {
await api.post('/files/save-excel', {
file_id: fileId,
category: 'SUPPORT',
materials: dataWithRequirements,
filename: excelFileName,
user_id: user?.id
});
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'SUPPORT',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
} catch (error) {
console.error('엑셀 저장 실패:', error);
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'SUPPORT',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
}
};
const filteredMaterials = getFilteredAndSortedMaterials();
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Support Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
<div>
<input
type="checkbox"
checked={(() => {
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
})()}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
<FilterableHeader sortKey="schedule" filterKey="schedule">Schedule</FilterableHeader>
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader>
<div>Unit</div>
<div>User Requirement</div>
</div>
{/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
{filteredMaterials.map((material, index) => {
const info = parseSupportInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.subtype}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.pressure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
</div>
<div>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
/>
</div>
</div>
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🏗</div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Support Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No support materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default SupportMaterialsView;

View File

@@ -0,0 +1,403 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';
const ValveMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
fileId,
user
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
const parseValveInfo = (material) => {
const valveDetails = material.valve_details || {};
const description = material.original_description || '';
// 밸브 타입 파싱 (GATE, BALL, CHECK, GLOBE 등) - 기존 NewMaterialsPage와 동일
let valveType = valveDetails.valve_type || '';
if (!valveType && description) {
if (description.includes('GATE')) valveType = 'GATE';
else if (description.includes('BALL')) valveType = 'BALL';
else if (description.includes('CHECK')) valveType = 'CHECK';
else if (description.includes('GLOBE')) valveType = 'GLOBE';
else if (description.includes('BUTTERFLY')) valveType = 'BUTTERFLY';
else if (description.includes('NEEDLE')) valveType = 'NEEDLE';
else if (description.includes('RELIEF')) valveType = 'RELIEF';
}
// 연결 방식 파싱 (FLG, SW, THRD 등) - 기존 NewMaterialsPage와 동일
let connectionType = '';
if (description.includes('FLG')) {
connectionType = 'FLG';
} else if (description.includes('SW X THRD')) {
connectionType = 'SW×THRD';
} else if (description.includes('SW')) {
connectionType = 'SW';
} else if (description.includes('THRD')) {
connectionType = 'THRD';
} else if (description.includes('BW')) {
connectionType = 'BW';
}
// 압력 등급 파싱
let pressure = '-';
const pressureMatch = description.match(/(\d+)LB/i);
if (pressureMatch) {
pressure = `${pressureMatch[1]}LB`;
}
// 스케줄은 밸브에는 일반적으로 없음 (기본값)
let schedule = '-';
return {
type: 'VALVE',
subtype: `${valveType} ${connectionType}`.trim() || 'VALVE', // 타입과 연결방식 결합
valveType: valveType,
connectionType: connectionType,
size: material.size_spec || '-',
pressure: pressure,
schedule: schedule,
grade: material.material_grade || '-',
quantity: Math.round(material.quantity || 0),
unit: '개',
isValve: true
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parseValveInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseValveInfo(a);
const bInfo = parseValveInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
}
return filtered;
};
// 전체 선택/해제 (구매신청된 자재 제외)
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
if (selectedMaterials.size === selectableMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
}
};
// 개별 선택 (구매신청된 자재는 선택 불가)
const handleMaterialSelect = (materialId) => {
if (purchasedMaterials.has(materialId)) {
return; // 구매신청된 자재는 선택 불가
}
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `VALVE_Materials_${timestamp}.xlsx`;
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: userRequirements[material.id] || ''
}));
try {
await api.post('/files/save-excel', {
file_id: fileId,
category: 'VALVE',
materials: dataWithRequirements,
filename: excelFileName,
user_id: user?.id
});
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'VALVE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
} catch (error) {
console.error('엑셀 저장 실패:', error);
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'VALVE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
}
};
const filteredMaterials = getFilteredAndSortedMaterials();
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Valve Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
<div>
<input
type="checkbox"
checked={(() => {
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
})()}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
<FilterableHeader sortKey="schedule" filterKey="schedule">Schedule</FilterableHeader>
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader>
<div>Unit</div>
<div>User Requirement</div>
</div>
{/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
{filteredMaterials.map((material, index) => {
const info = parseValveInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.subtype}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.pressure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
</div>
<div>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
/>
</div>
</div>
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🚰</div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Valve Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No valve materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default ValveMaterialsView;

View File

@@ -0,0 +1,8 @@
// BOM Materials Components
export { default as PipeMaterialsView } from './PipeMaterialsView';
export { default as FittingMaterialsView } from './FittingMaterialsView';
export { default as FlangeMaterialsView } from './FlangeMaterialsView';
export { default as ValveMaterialsView } from './ValveMaterialsView';
export { default as GasketMaterialsView } from './GasketMaterialsView';
export { default as BoltMaterialsView } from './BoltMaterialsView';
export { default as SupportMaterialsView } from './SupportMaterialsView';

View File

@@ -0,0 +1,78 @@
import React from 'react';
const FilterableHeader = ({
sortKey,
filterKey,
children,
sortConfig,
onSort,
columnFilters,
onFilterChange,
showFilterDropdown,
setShowFilterDropdown
}) => {
return (
<div className="filterable-header" style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
onClick={() => onSort(sortKey)}
style={{ cursor: 'pointer', flex: 1 }}
>
{children}
{sortConfig.key === sortKey && (
<span style={{ marginLeft: '4px' }}>
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
<button
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px',
fontSize: '12px',
color: '#6b7280'
}}
>
🔍
</button>
</div>
{showFilterDropdown === filterKey && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
minWidth: '150px'
}}>
<input
type="text"
placeholder={`Filter ${children}...`}
value={columnFilters[filterKey] || ''}
onChange={(e) => onFilterChange({
...columnFilters,
[filterKey]: e.target.value
})}
style={{
width: '100%',
padding: '4px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
autoFocus
/>
</div>
)}
</div>
);
};
export default FilterableHeader;

View File

@@ -0,0 +1,161 @@
import React from 'react';
const MaterialTable = ({
children,
className = '',
style = {}
}) => {
return (
<div
className={`material-table ${className}`}
style={{
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
...style
}}
>
{children}
</div>
);
};
const MaterialTableHeader = ({
children,
gridColumns,
className = ''
}) => {
return (
<div
className={`material-table-header ${className}`}
style={{
display: 'grid',
gridTemplateColumns: gridColumns,
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}
>
{children}
</div>
);
};
const MaterialTableBody = ({
children,
maxHeight = '600px',
className = ''
}) => {
return (
<div
className={`material-table-body ${className}`}
style={{
maxHeight,
overflowY: 'auto'
}}
>
{children}
</div>
);
};
const MaterialTableRow = ({
children,
gridColumns,
isSelected = false,
isPurchased = false,
isLast = false,
onClick,
className = ''
}) => {
return (
<div
className={`material-table-row ${className}`}
onClick={onClick}
style={{
display: 'grid',
gridTemplateColumns: gridColumns,
gap: '16px',
padding: '16px',
borderBottom: !isLast ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease',
cursor: onClick ? 'pointer' : 'default'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased && !onClick) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased && !onClick) {
e.target.style.background = 'white';
}
}}
>
{children}
</div>
);
};
const MaterialTableCell = ({
children,
align = 'left',
fontWeight = 'normal',
color = '#1f2937',
className = ''
}) => {
return (
<div
className={`material-table-cell ${className}`}
style={{
fontSize: '14px',
color,
fontWeight,
textAlign: align
}}
>
{children}
</div>
);
};
const MaterialTableEmpty = ({
icon = '📦',
title = 'No Materials Found',
message = 'No materials available',
className = ''
}) => {
return (
<div
className={`material-table-empty ${className}`}
style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{icon}</div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
{title}
</div>
<div style={{ fontSize: '14px' }}>
{message}
</div>
</div>
);
};
// 복합 컴포넌트로 export
MaterialTable.Header = MaterialTableHeader;
MaterialTable.Body = MaterialTableBody;
MaterialTable.Row = MaterialTableRow;
MaterialTable.Cell = MaterialTableCell;
MaterialTable.Empty = MaterialTableEmpty;
export default MaterialTable;

View File

@@ -0,0 +1,3 @@
// BOM Shared Components
export { default as FilterableHeader } from './FilterableHeader';
export { default as MaterialTable } from './MaterialTable';

View File

@@ -0,0 +1,163 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
this.setState({
error: error,
errorInfo: errorInfo
});
// 에러 로깅
console.error('ErrorBoundary caught an error:', error, errorInfo);
// 에러 컨텍스트 정보 로깅
if (this.props.errorContext) {
console.error('Error context:', this.props.errorContext);
}
}
render() {
if (this.state.hasError) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
padding: '40px'
}}>
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '40px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.2)',
textAlign: 'center',
maxWidth: '600px'
}}>
<div style={{ fontSize: '64px', marginBottom: '24px' }}></div>
<h2 style={{
fontSize: '28px',
fontWeight: '700',
color: '#dc2626',
margin: '0 0 16px 0',
letterSpacing: '-0.025em'
}}>
Something went wrong
</h2>
<p style={{
fontSize: '16px',
color: '#64748b',
marginBottom: '32px',
lineHeight: '1.6'
}}>
An unexpected error occurred. Please try refreshing the page or contact support if the problem persists.
</p>
<div style={{ display: 'flex', gap: '16px', justifyContent: 'center' }}>
<button
onClick={() => window.location.reload()}
style={{
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
color: 'white',
border: 'none',
borderRadius: '12px',
padding: '12px 24px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.target.style.transform = 'translateY(-2px)';
e.target.style.boxShadow = '0 8px 25px 0 rgba(59, 130, 246, 0.5)';
}}
onMouseLeave={(e) => {
e.target.style.transform = 'translateY(0)';
e.target.style.boxShadow = 'none';
}}
>
Refresh Page
</button>
<button
onClick={() => this.setState({ hasError: false, error: null, errorInfo: null })}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '12px',
padding: '12px 24px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.target.style.background = '#f9fafb';
e.target.style.borderColor = '#9ca3af';
}}
onMouseLeave={(e) => {
e.target.style.background = 'white';
e.target.style.borderColor = '#d1d5db';
}}
>
Try Again
</button>
</div>
{/* 개발 환경에서만 에러 상세 정보 표시 */}
{process.env.NODE_ENV === 'development' && this.state.error && (
<details style={{
marginTop: '32px',
textAlign: 'left',
background: '#f8fafc',
padding: '16px',
borderRadius: '8px',
border: '1px solid #e2e8f0'
}}>
<summary style={{
cursor: 'pointer',
fontWeight: '600',
color: '#374151',
marginBottom: '8px'
}}>
Error Details (Development)
</summary>
<pre style={{
fontSize: '12px',
color: '#dc2626',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}>
{this.state.error && this.state.error.toString()}
<br />
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,219 @@
import React, { useState } from 'react';
const UserMenu = ({ user, onNavigate, onLogout }) => {
const [showUserMenu, setShowUserMenu] = useState(false);
return (
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
background: '#f8f9fa',
border: '1px solid #e9ecef',
borderRadius: '8px',
padding: '8px 12px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
color: '#495057',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.target.style.background = '#e9ecef';
e.target.style.borderColor = '#dee2e6';
}}
onMouseLeave={(e) => {
e.target.style.background = '#f8f9fa';
e.target.style.borderColor = '#e9ecef';
}}
>
<div style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '14px',
fontWeight: '600'
}}>
{(user?.name || user?.username || 'U').charAt(0).toUpperCase()}
</div>
<div style={{ textAlign: 'left' }}>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
{user?.name || user?.username}
</div>
<div style={{ fontSize: '12px', color: '#6c757d' }}>
{user?.role === 'system' ? '시스템 관리자' :
user?.role === 'admin' ? '관리자' : '사용자'}
</div>
</div>
<div style={{
fontSize: '12px',
color: '#6c757d',
transform: showUserMenu ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease'
}}>
</div>
</button>
{/* 드롭다운 메뉴 */}
{showUserMenu && (
<div style={{
position: 'absolute',
top: '100%',
right: 0,
marginTop: '8px',
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
minWidth: '200px'
}}>
<div style={{ padding: '8px 0' }}>
<div style={{ padding: '8px 16px', borderBottom: '1px solid #f1f3f4' }}>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
{user?.name || user?.username}
</div>
<div style={{ fontSize: '12px', color: '#6c757d' }}>
{user?.email || '이메일 없음'}
</div>
</div>
<button
onClick={() => {
onNavigate('account-settings');
setShowUserMenu(false);
}}
style={{
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'none',
textAlign: 'left',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px',
':hover': { background: '#f8f9fa' }
}}
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
onMouseLeave={(e) => e.target.style.background = 'none'}
>
계정 설정
</button>
{user?.role === 'admin' && (
<>
<button
onClick={() => {
onNavigate('user-management');
setShowUserMenu(false);
}}
style={{
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'none',
textAlign: 'left',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
onMouseLeave={(e) => e.target.style.background = 'none'}
>
👥 사용자 관리
</button>
<button
onClick={() => {
onNavigate('system-settings');
setShowUserMenu(false);
}}
style={{
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'none',
textAlign: 'left',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
onMouseLeave={(e) => e.target.style.background = 'none'}
>
🔧 시스템 설정
</button>
<button
onClick={() => {
onNavigate('system-logs');
setShowUserMenu(false);
}}
style={{
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'none',
textAlign: 'left',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
onMouseLeave={(e) => e.target.style.background = 'none'}
>
📊 시스템 로그
</button>
</>
)}
<div style={{ borderTop: '1px solid #f1f3f4', marginTop: '4px' }}>
<button
onClick={() => {
onLogout();
setShowUserMenu(false);
}}
style={{
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'none',
textAlign: 'left',
cursor: 'pointer',
fontSize: '14px',
color: '#dc3545',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
onMouseLeave={(e) => e.target.style.background = 'none'}
>
🚪 로그아웃
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default UserMenu;

View File

@@ -0,0 +1,3 @@
// Common Components
export { default as UserMenu } from './UserMenu';
export { default as ErrorBoundary } from './ErrorBoundary';

View File

@@ -0,0 +1,184 @@
/* BOM Management Page Styles */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.bom-management-page {
padding: 40px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
min-height: 100vh;
}
.bom-header-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 32px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(255, 255, 255, 0.2);
margin-bottom: 40px;
}
.bom-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.bom-stat-card {
padding: 20px;
border-radius: 12px;
text-align: center;
transition: transform 0.2s ease;
}
.bom-stat-card:hover {
transform: translateY(-2px);
}
.bom-stat-number {
font-size: 32px;
font-weight: 700;
margin-bottom: 4px;
}
.bom-stat-label {
font-size: 14px;
font-weight: 500;
}
.bom-category-tabs {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 24px 32px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(255, 255, 255, 0.2);
margin-bottom: 40px;
}
.bom-category-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 16px;
}
.bom-category-button {
border-radius: 12px;
padding: 16px 12px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s ease;
text-align: center;
border: none;
outline: none;
}
.bom-category-button:hover {
transform: translateY(-1px);
}
.bom-category-icon {
font-size: 20px;
margin-bottom: 8px;
}
.bom-category-count {
font-size: 12px;
opacity: 0.8;
font-weight: 500;
}
.bom-content-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(255, 255, 255, 0.2);
overflow: hidden;
}
.bom-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
.bom-loading-spinner {
width: 60px;
height: 60px;
border: 4px solid #e2e8f0;
border-top: 4px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
.bom-loading-text {
font-size: 18px;
color: #64748b;
font-weight: 600;
}
.bom-error {
padding: 60px;
text-align: center;
color: #dc2626;
}
.bom-error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.bom-error-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.bom-error-message {
font-size: 14px;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.bom-management-page {
padding: 20px;
}
.bom-header-card {
padding: 24px;
}
.bom-stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.bom-category-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.bom-category-button {
padding: 12px 8px;
font-size: 12px;
}
}
@media (max-width: 480px) {
.bom-stats-grid {
grid-template-columns: 1fr;
}
.bom-category-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,451 @@
import React, { useState, useEffect } from 'react';
import { fetchMaterials } from '../api';
import api from '../api';
import {
PipeMaterialsView,
FittingMaterialsView,
FlangeMaterialsView,
ValveMaterialsView,
GasketMaterialsView,
BoltMaterialsView,
SupportMaterialsView
} from '../components/bom';
import './BOMManagementPage.css';
const BOMManagementPage = ({
onNavigate,
selectedProject,
fileId,
jobNo,
bomName,
revision,
filename,
user
}) => {
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState('PIPE');
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [exportHistory, setExportHistory] = useState([]);
const [availableRevisions, setAvailableRevisions] = useState([]);
const [currentRevision, setCurrentRevision] = useState(revision || 'Rev.0');
const [userRequirements, setUserRequirements] = useState({});
const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
const [error, setError] = useState(null);
// 카테고리 정의
const categories = [
{ key: 'PIPE', label: 'Pipes', icon: '🔧', color: '#3b82f6' },
{ key: 'FITTING', label: 'Fittings', icon: '⚙️', color: '#10b981' },
{ key: 'FLANGE', label: 'Flanges', icon: '🔩', color: '#f59e0b' },
{ key: 'VALVE', label: 'Valves', icon: '🚰', color: '#ef4444' },
{ key: 'GASKET', label: 'Gaskets', icon: '⭕', color: '#8b5cf6' },
{ key: 'BOLT', label: 'Bolts', icon: '🔩', color: '#6b7280' },
{ key: 'SUPPORT', label: 'Supports', icon: '🏗️', color: '#f97316' }
];
// 자료 로드 함수들
const loadMaterials = async (id) => {
try {
setLoading(true);
console.log('🔍 자재 데이터 로딩 중...', {
file_id: id,
selectedProject: selectedProject?.job_no || selectedProject?.official_project_code,
jobNo
});
// 구매신청된 자재 먼저 확인
const projectJobNo = selectedProject?.job_no || selectedProject?.official_project_code || jobNo;
await loadPurchasedMaterials(projectJobNo);
const response = await fetchMaterials({
file_id: parseInt(id),
limit: 10000,
exclude_requested: false,
job_no: projectJobNo
});
if (response.data?.materials) {
const materialsData = response.data.materials;
console.log(`${materialsData.length}개 원본 자재 로드 완료`);
setMaterials(materialsData);
setError(null);
} else {
console.warn('⚠️ 자재 데이터가 없습니다:', response.data);
setMaterials([]);
}
} catch (error) {
console.error('자재 로드 실패:', error);
setError('자재 로드에 실패했습니다.');
} finally {
setLoading(false);
}
};
const loadAvailableRevisions = async () => {
try {
const response = await api.get('/files/', {
params: { job_no: jobNo }
});
const allFiles = Array.isArray(response.data) ? response.data : response.data?.files || [];
const sameBomFiles = allFiles.filter(file =>
(file.bom_name || file.original_filename) === bomName
);
sameBomFiles.sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA;
});
setAvailableRevisions(sameBomFiles);
} catch (error) {
console.error('리비전 목록 조회 실패:', error);
}
};
const loadPurchasedMaterials = async (jobNo) => {
try {
// 새로운 API로 구매신청된 자재 ID 목록 조회
const response = await api.get('/purchase-request/requested-materials', {
params: {
job_no: jobNo,
file_id: fileId
}
});
if (response.data?.requested_material_ids) {
const purchasedIds = new Set(response.data.requested_material_ids);
setPurchasedMaterials(purchasedIds);
console.log(`${purchasedIds.size}개 구매신청된 자재 ID 로드 완료`);
}
} catch (error) {
console.error('구매신청 자재 조회 실패:', error);
}
};
const loadUserRequirements = async (fileId) => {
try {
const response = await api.get(`/files/${fileId}/user-requirements`);
if (response.data?.requirements) {
const reqMap = {};
response.data.requirements.forEach(req => {
reqMap[req.material_id] = req.requirement;
});
setUserRequirements(reqMap);
}
} catch (error) {
console.error('사용자 요구사항 로드 실패:', error);
}
};
// 초기 로드
useEffect(() => {
if (fileId) {
loadMaterials(fileId);
loadAvailableRevisions();
loadUserRequirements(fileId);
}
}, [fileId]);
// 카테고리별 자재 필터링
const getCategoryMaterials = (category) => {
return materials.filter(material =>
material.classified_category === category ||
material.category === category
);
};
// 카테고리별 컴포넌트 렌더링
const renderCategoryView = () => {
const categoryMaterials = getCategoryMaterials(selectedCategory);
const commonProps = {
materials: categoryMaterials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
fileId,
user,
onNavigate
};
switch (selectedCategory) {
case 'PIPE':
return <PipeMaterialsView {...commonProps} />;
case 'FITTING':
return <FittingMaterialsView {...commonProps} />;
case 'FLANGE':
return <FlangeMaterialsView {...commonProps} />;
case 'VALVE':
return <ValveMaterialsView {...commonProps} />;
case 'GASKET':
return <GasketMaterialsView {...commonProps} />;
case 'BOLT':
return <BoltMaterialsView {...commonProps} />;
case 'SUPPORT':
return <SupportMaterialsView {...commonProps} />;
default:
return <div>카테고리를 선택해주세요.</div>;
}
};
if (loading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: '60px',
height: '60px',
border: '4px solid #e2e8f0',
borderTop: '4px solid #3b82f6',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 20px'
}}></div>
<div style={{ fontSize: '18px', color: '#64748b', fontWeight: '600' }}>
Loading Materials...
</div>
</div>
</div>
);
}
return (
<div style={{
padding: '40px',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
minHeight: '100vh'
}}>
{/* 헤더 섹션 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '32px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.2)',
marginBottom: '40px'
}}>
<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>
<p style={{
fontSize: '16px',
color: '#64748b',
margin: 0,
fontWeight: '400'
}}>
{bomName} - {currentRevision} | Project: {selectedProject?.job_name || jobNo}
</p>
</div>
<button
onClick={() => onNavigate('dashboard')}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '12px',
padding: '12px 20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease',
letterSpacing: '0.025em'
}}
onMouseEnter={(e) => {
e.target.style.background = '#f9fafb';
e.target.style.borderColor = '#9ca3af';
}}
onMouseLeave={(e) => {
e.target.style.background = 'white';
e.target.style.borderColor = '#d1d5db';
}}
>
Back to Dashboard
</button>
</div>
{/* 통계 정보 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
<div style={{
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#1d4ed8', marginBottom: '4px' }}>
{materials.length}
</div>
<div style={{ fontSize: '14px', color: '#1d4ed8', fontWeight: '500' }}>
Total Materials
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#059669', marginBottom: '4px' }}>
{getCategoryMaterials(selectedCategory).length}
</div>
<div style={{ fontSize: '14px', color: '#059669', fontWeight: '500' }}>
{selectedCategory} Items
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#92400e', marginBottom: '4px' }}>
{selectedMaterials.size}
</div>
<div style={{ fontSize: '14px', color: '#92400e', fontWeight: '500' }}>
Selected
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #fee2e2 0%, #fecaca 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#dc2626', marginBottom: '4px' }}>
{purchasedMaterials.size}
</div>
<div style={{ fontSize: '14px', color: '#dc2626', fontWeight: '500' }}>
Purchased
</div>
</div>
</div>
</div>
{/* 카테고리 탭 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '24px 32px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.2)',
marginBottom: '40px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: '16px'
}}>
{categories.map((category) => {
const isActive = selectedCategory === category.key;
const count = getCategoryMaterials(category.key).length;
return (
<button
key={category.key}
onClick={() => setSelectedCategory(category.key)}
style={{
background: isActive
? `linear-gradient(135deg, ${category.color} 0%, ${category.color}dd 100%)`
: 'white',
color: isActive ? 'white' : '#64748b',
border: isActive ? 'none' : '1px solid #e2e8f0',
borderRadius: '12px',
padding: '16px 12px',
cursor: 'pointer',
fontSize: '14px',
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)'
}}
onMouseEnter={(e) => {
if (!isActive) {
e.target.style.background = '#f8fafc';
e.target.style.borderColor = '#cbd5e1';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.target.style.background = 'white';
e.target.style.borderColor = '#e2e8f0';
}
}}
>
<div style={{ fontSize: '20px', marginBottom: '8px' }}>
{category.icon}
</div>
<div style={{ marginBottom: '4px' }}>
{category.label}
</div>
<div style={{
fontSize: '12px',
opacity: 0.8,
fontWeight: '500'
}}>
{count} items
</div>
</button>
);
})}
</div>
</div>
{/* 카테고리별 컨텐츠 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.2)',
overflow: 'hidden'
}}>
{error ? (
<div style={{
padding: '60px',
textAlign: 'center',
color: '#dc2626'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
Error Loading Materials
</div>
<div style={{ fontSize: '14px' }}>
{error}
</div>
</div>
) : (
renderCategoryView()
)}
</div>
</div>
);
};
export default BOMManagementPage;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,398 @@
import React, { useState } from 'react';
const InactiveProjectsPage = ({
onNavigate,
user,
projects,
inactiveProjects,
onActivateProject,
onDeleteProject
}) => {
const [selectedProjects, setSelectedProjects] = useState(new Set());
// 비활성 프로젝트 목록 필터링
const inactiveProjectList = projects.filter(project =>
inactiveProjects.has(project.job_no)
);
// 프로젝트 선택/해제
const handleProjectSelect = (projectNo) => {
setSelectedProjects(prev => {
const newSet = new Set(prev);
if (newSet.has(projectNo)) {
newSet.delete(projectNo);
} else {
newSet.add(projectNo);
}
return newSet;
});
};
// 전체 선택/해제
const handleSelectAll = () => {
if (selectedProjects.size === inactiveProjectList.length) {
setSelectedProjects(new Set());
} else {
setSelectedProjects(new Set(inactiveProjectList.map(p => p.job_no)));
}
};
// 선택된 프로젝트들 활성화
const handleBulkActivate = () => {
if (selectedProjects.size === 0) {
alert('활성화할 프로젝트를 선택해주세요.');
return;
}
if (window.confirm(`선택된 ${selectedProjects.size}개 프로젝트를 활성화하시겠습니까?`)) {
selectedProjects.forEach(projectNo => {
const project = projects.find(p => p.job_no === projectNo);
if (project) {
onActivateProject(project);
}
});
setSelectedProjects(new Set());
}
};
// 선택된 프로젝트들 삭제
const handleBulkDelete = () => {
if (selectedProjects.size === 0) {
alert('삭제할 프로젝트를 선택해주세요.');
return;
}
if (window.confirm(`선택된 ${selectedProjects.size}개 프로젝트를 완전히 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) {
selectedProjects.forEach(projectNo => {
onDeleteProject(projectNo);
});
setSelectedProjects(new Set());
}
};
return (
<div style={{
padding: '40px',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
minHeight: '100vh'
}}>
{/* 헤더 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '32px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.2)',
marginBottom: '40px'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
<div>
<h2 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 4px 0',
letterSpacing: '-0.025em'
}}>
Inactive Projects Management
</h2>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0,
fontWeight: '400'
}}>
Manage deactivated projects - activate or permanently delete
</p>
</div>
<button
onClick={() => onNavigate('dashboard')}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '12px',
padding: '12px 20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease',
letterSpacing: '0.025em'
}}
onMouseEnter={(e) => {
e.target.style.background = '#f9fafb';
e.target.style.borderColor = '#9ca3af';
}}
onMouseLeave={(e) => {
e.target.style.background = 'white';
e.target.style.borderColor = '#d1d5db';
}}
>
Back to Dashboard
</button>
</div>
{/* 통계 정보 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
<div style={{
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '28px', fontWeight: '700', color: '#92400e', marginBottom: '4px' }}>
{inactiveProjectList.length}
</div>
<div style={{ fontSize: '14px', color: '#92400e', fontWeight: '500' }}>
Inactive Projects
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '28px', fontWeight: '700', color: '#1d4ed8', marginBottom: '4px' }}>
{selectedProjects.size}
</div>
<div style={{ fontSize: '14px', color: '#1d4ed8', fontWeight: '500' }}>
Selected
</div>
</div>
</div>
{/* 일괄 작업 버튼들 */}
{inactiveProjectList.length > 0 && (
<div style={{ display: 'flex', gap: '12px', marginBottom: '24px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.target.style.background = '#f9fafb';
e.target.style.borderColor = '#9ca3af';
}}
onMouseLeave={(e) => {
e.target.style.background = 'white';
e.target.style.borderColor = '#d1d5db';
}}
>
{selectedProjects.size === inactiveProjectList.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleBulkActivate}
disabled={selectedProjects.size === 0}
style={{
background: selectedProjects.size > 0 ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : '#e5e7eb',
color: selectedProjects.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedProjects.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s ease'
}}
>
Activate Selected ({selectedProjects.size})
</button>
<button
onClick={handleBulkDelete}
disabled={selectedProjects.size === 0}
style={{
background: selectedProjects.size > 0 ? 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' : '#e5e7eb',
color: selectedProjects.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedProjects.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s ease'
}}
>
Delete Selected ({selectedProjects.size})
</button>
</div>
)}
</div>
{/* 비활성 프로젝트 목록 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '32px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.2)'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 24px 0',
letterSpacing: '-0.025em'
}}>
Inactive Projects List
</h3>
{inactiveProjectList.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📂</div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Inactive Projects
</div>
<div style={{ fontSize: '14px' }}>
All projects are currently active
</div>
</div>
) : (
<div style={{
display: 'grid',
gap: '16px'
}}>
{inactiveProjectList.map((project) => (
<div
key={project.job_no}
style={{
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '12px',
padding: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'all 0.2s ease',
boxShadow: '0 2px 8px rgba(0,0,0,0.05)'
}}
onMouseEnter={(e) => {
e.target.style.borderColor = '#cbd5e1';
e.target.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
}}
onMouseLeave={(e) => {
e.target.style.borderColor = '#e2e8f0';
e.target.style.boxShadow = '0 2px 8px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', flex: 1 }}>
<input
type="checkbox"
checked={selectedProjects.has(project.job_no)}
onChange={() => handleProjectSelect(project.job_no)}
style={{
width: '18px',
height: '18px',
cursor: 'pointer'
}}
/>
<div style={{ flex: 1 }}>
<div style={{
fontSize: '18px',
fontWeight: '600',
color: '#1a202c',
marginBottom: '4px'
}}>
{project.job_name || project.job_no}
</div>
<div style={{
fontSize: '14px',
color: '#64748b'
}}>
Code: {project.job_no} | Client: {project.client_name || 'N/A'}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => {
if (window.confirm(`"${project.job_name || project.job_no}" 프로젝트를 활성화하시겠습니까?`)) {
onActivateProject(project);
}
}}
style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
padding: '8px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.target.style.transform = 'translateY(-1px)';
e.target.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)';
}}
onMouseLeave={(e) => {
e.target.style.transform = 'translateY(0)';
e.target.style.boxShadow = 'none';
}}
>
Activate
</button>
<button
onClick={() => {
if (window.confirm(`"${project.job_name || project.job_no}" 프로젝트를 완전히 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) {
onDeleteProject(project.job_no);
}
}}
style={{
background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
padding: '8px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.target.style.transform = 'translateY(-1px)';
e.target.style.boxShadow = '0 4px 12px rgba(239, 68, 68, 0.4)';
}}
onMouseLeave={(e) => {
e.target.style.transform = 'translateY(0)';
e.target.style.boxShadow = 'none';
}}
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default InactiveProjectsPage;

View File

@@ -58,6 +58,8 @@
border-color: #4299e1;
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.2);
}
.materials-header {
display: flex;
align-items: center;
justify-content: space-between;

View File

@@ -13,7 +13,8 @@ const NewMaterialsPage = ({
jobNo,
bomName,
revision,
filename
filename,
user
}) => {
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(true);
@@ -127,15 +128,21 @@ const NewMaterialsPage = ({
const loadMaterials = async (id) => {
try {
setLoading(true);
console.log('🔍 자재 데이터 로딩 중...', { file_id: id });
console.log('🔍 자재 데이터 로딩 중...', {
file_id: id,
selectedProject: selectedProject?.job_no || selectedProject?.official_project_code,
jobNo
});
// 구매신청된 자재 먼저 확인
await loadPurchasedMaterials(jobNo);
const projectJobNo = selectedProject?.job_no || selectedProject?.official_project_code || jobNo;
await loadPurchasedMaterials(projectJobNo);
const response = await fetchMaterials({
file_id: parseInt(id),
limit: 10000,
exclude_requested: false // 구매신청된 자재도 포함하여 표시
exclude_requested: false, // 구매신청된 자재도 포함하여 표시
job_no: projectJobNo // 프로젝트별 필터링 추가
});
if (response.data?.materials) {

View File

@@ -8,6 +8,8 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) =>
const [selectedRequest, setSelectedRequest] = useState(null);
const [requestMaterials, setRequestMaterials] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [editingTitle, setEditingTitle] = useState(null);
const [newTitle, setNewTitle] = useState('');
useEffect(() => {
loadRequests();
@@ -81,6 +83,45 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) =>
}
};
const handleEditTitle = (request) => {
setEditingTitle(request.request_id);
setNewTitle(request.request_no);
};
const handleSaveTitle = async (requestId) => {
try {
const response = await api.patch(`/purchase-request/${requestId}/title`, {
title: newTitle
});
if (response.data.success) {
// 목록에서 해당 요청의 제목 업데이트
setRequests(prev => prev.map(req =>
req.request_id === requestId
? { ...req, request_no: newTitle }
: req
));
// 선택된 요청도 업데이트
if (selectedRequest?.request_id === requestId) {
setSelectedRequest(prev => ({ ...prev, request_no: newTitle }));
}
setEditingTitle(null);
setNewTitle('');
console.log('✅ 구매신청 제목 업데이트 완료');
}
} catch (error) {
console.error('❌ 제목 업데이트 실패:', error);
alert('제목 업데이트 실패: ' + error.message);
}
};
const handleCancelEdit = () => {
setEditingTitle(null);
setNewTitle('');
};
return (
<div className="purchase-request-page">
<div className="page-header">
@@ -111,7 +152,82 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) =>
onClick={() => handleRequestSelect(request)}
>
<div className="request-header">
<span className="request-no">{request.request_no}</span>
{editingTitle === request.request_id ? (
<div className="title-edit" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSaveTitle(request.request_id);
} else if (e.key === 'Escape') {
handleCancelEdit();
}
}}
style={{
width: '200px',
padding: '4px 8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '14px'
}}
autoFocus
/>
<button
onClick={() => handleSaveTitle(request.request_id)}
style={{
marginLeft: '8px',
padding: '4px 8px',
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
저장
</button>
<button
onClick={handleCancelEdit}
style={{
marginLeft: '4px',
padding: '4px 8px',
background: '#6b7280',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
취소
</button>
</div>
) : (
<div className="title-display">
<span className="request-no">{request.request_no}</span>
<button
onClick={(e) => {
e.stopPropagation();
handleEditTitle(request);
}}
style={{
marginLeft: '8px',
padding: '2px 6px',
background: 'transparent',
border: '1px solid #d1d5db',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
color: '#6b7280'
}}
>
</button>
</div>
)}
<span className="request-date">
{new Date(request.requested_at).toLocaleDateString()}
</span>
@@ -154,6 +270,57 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) =>
</button>
</div>
{/* 원본 파일 정보 */}
<div className="original-file-info" style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: '8px',
padding: '16px',
marginBottom: '20px'
}}>
<h3 style={{
fontSize: '16px',
fontWeight: '600',
color: '#374151',
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
📄 원본 파일 정보
</h3>
<div className="file-details" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '12px'
}}>
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>파일명:</span>
<span className="value" style={{ color: '#1f2937' }}>{selectedRequest.original_filename || 'N/A'}</span>
</div>
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>프로젝트:</span>
<span className="value" style={{ color: '#1f2937' }}>{selectedRequest.job_no} - {selectedRequest.job_name}</span>
</div>
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>신청일:</span>
<span className="value" style={{ color: '#1f2937' }}>{new Date(selectedRequest.requested_at).toLocaleString()}</span>
</div>
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>신청자:</span>
<span className="value" style={{ color: '#1f2937' }}>{selectedRequest.requested_by}</span>
</div>
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>자재 수량:</span>
<span className="value" style={{ color: '#1f2937', fontWeight: '600' }}>{selectedRequest.material_count}</span>
</div>
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>카테고리:</span>
<span className="value" style={{ color: '#1f2937' }}>{selectedRequest.category || '전체'}</span>
</div>
</div>
</div>
<div className="materials-table">
{/* 업로드 당시 분류된 정보를 그대로 표시 */}
{requestMaterials.length === 0 ? (