feat: SPECIAL/UNCLASSIFIED 카테고리 추가 및 WELD GAP 자동 제외
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
주요 변경사항: - SPECIAL 카테고리 추가: 특수 제작 품목 관리 (Type, Drawing, Detail1-4) - UNCLASSIFIED 카테고리 추가: 미분류 자재 원본 그대로 표시 - UNKNOWN → UNCLASSIFIED 통합: 기존 UNKNOWN 카테고리 제거 - WELD GAP 자동 제외: BOM 업로드 시 WELD GAP 항목 자동 필터링 백엔드: - integrated_classifier.py: UNKNOWN → UNCLASSIFIED 변경, SPECIAL 우선순위 분류 - files.py: parse_dataframe에서 WELD GAP 필터링, UNKNOWN 참조 제거 - exclude_classifier.py: WELD GAP 제외 로직 유지 프론트엔드: - SpecialMaterialsView.jsx: 특수 제작 품목 관리 컴포넌트 - UnclassifiedMaterialsView.jsx: 미분류 자재 관리 컴포넌트 - BOMManagementPage.jsx: 새 카테고리 추가 및 라우팅 - excelExport.js: SPECIAL/UNCLASSIFIED 엑셀 내보내기 지원 - 모든 UNKNOWN 참조를 UNCLASSIFIED로 변경 기능 개선: - 저장 기능: 모든 카테고리에 추가요청사항 저장/편집 기능 - P열 납기일 규칙: 모든 카테고리 엑셀 내보내기 통일 - UI 개선: Detail1-4 컬럼명으로 혼동 방지 - 데이터 정리: 모든 프로젝트 및 BOM 데이터 초기화
This commit is contained in:
@@ -231,6 +231,14 @@ def parse_dataframe(df):
|
||||
materials = []
|
||||
for index, row in df.iterrows():
|
||||
description = str(row.get(mapped_columns.get('description', ''), ''))
|
||||
|
||||
# WELD GAP 항목은 업로드 단계에서 제외 (불필요한 계산용 항목)
|
||||
description_upper = description.upper()
|
||||
if ('WELD GAP' in description_upper or 'WELDING GAP' in description_upper or
|
||||
'웰드갭' in description_upper or '용접갭' in description_upper):
|
||||
print(f"⚠️ WELD GAP 항목 제외: {description}")
|
||||
continue
|
||||
|
||||
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
|
||||
|
||||
try:
|
||||
@@ -547,7 +555,7 @@ async def upload_file(
|
||||
materials_to_insert.append({
|
||||
"file_id": file_id,
|
||||
"original_description": material_data["original_description"],
|
||||
"classified_category": previous_item.get("category", "UNKNOWN"),
|
||||
"classified_category": previous_item.get("category", "UNCLASSIFIED"),
|
||||
"confidence": 1.0, # 확정된 자료이므로 신뢰도 100%
|
||||
"quantity": material_data["quantity"],
|
||||
"unit": material_data.get("unit", "EA"),
|
||||
@@ -659,7 +667,7 @@ async def upload_file(
|
||||
# 1. 통합 분류기로 자재 타입 결정
|
||||
integrated_result = classify_material_integrated(description, main_nom or "", red_nom or "", length_value)
|
||||
print(f"[분류] {description}")
|
||||
print(f"통합 분류 결과: {integrated_result.get('category', 'UNKNOWN')} (신뢰도: {integrated_result.get('confidence', 0)}, Level: {integrated_result.get('classification_level', 'NONE')})")
|
||||
print(f"통합 분류 결과: {integrated_result.get('category', 'UNCLASSIFIED')} (신뢰도: {integrated_result.get('confidence', 0)}, Level: {integrated_result.get('classification_level', 'NONE')})")
|
||||
|
||||
# 2. 제외 대상 확인
|
||||
if should_exclude_material(description):
|
||||
@@ -670,7 +678,7 @@ async def upload_file(
|
||||
}
|
||||
else:
|
||||
# 3. 타입별 상세 분류기 실행
|
||||
material_type = integrated_result.get('category', 'UNKNOWN')
|
||||
material_type = integrated_result.get('category', 'UNCLASSIFIED')
|
||||
|
||||
if material_type == "PIPE":
|
||||
from ..services.pipe_classifier import classify_pipe_for_purchase
|
||||
@@ -714,9 +722,9 @@ async def upload_file(
|
||||
}
|
||||
}
|
||||
else:
|
||||
# UNKNOWN 처리
|
||||
# UNCLASSIFIED 처리
|
||||
classification_result = {
|
||||
"category": "UNKNOWN",
|
||||
"category": "UNCLASSIFIED",
|
||||
"overall_confidence": integrated_result.get('confidence', 0.0),
|
||||
"reason": f"분류 불가: {integrated_result.get('evidence', [])}"
|
||||
}
|
||||
@@ -728,7 +736,7 @@ async def upload_file(
|
||||
integrated_result.get('confidence', 0.0) + 0.2
|
||||
)
|
||||
|
||||
print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}")
|
||||
print(f"최종 분류 결과: {classification_result.get('category', 'UNCLASSIFIED')}")
|
||||
|
||||
# 전체 재질명 추출
|
||||
from ..services.material_grade_extractor import extract_full_material_grade
|
||||
@@ -758,7 +766,7 @@ async def upload_file(
|
||||
print(f"첫 번째 자재 저장:")
|
||||
print(f" size_spec: '{material_data['size_spec']}'")
|
||||
print(f" original_description: {material_data['original_description']}")
|
||||
print(f" category: {classification_result.get('category', 'UNKNOWN')}")
|
||||
print(f" category: {classification_result.get('category', 'UNCLASSIFIED')}")
|
||||
print(f" drawing_name: {material_data.get('dwg_name')}")
|
||||
print(f" line_no: {material_data.get('line_num')}")
|
||||
|
||||
@@ -774,7 +782,7 @@ async def upload_file(
|
||||
"full_material_grade": full_material_grade,
|
||||
"line_number": material_data["line_number"],
|
||||
"row_number": material_data["row_number"],
|
||||
"classified_category": classification_result.get("category", "UNKNOWN"),
|
||||
"classified_category": classification_result.get("category", "UNCLASSIFIED"),
|
||||
"classification_confidence": classification_result.get("overall_confidence", 0.0),
|
||||
"is_verified": False,
|
||||
"drawing_name": material_data.get("dwg_name"),
|
||||
@@ -889,7 +897,7 @@ async def upload_file(
|
||||
material_grade_from_classifier = material_info.get("grade", "")
|
||||
|
||||
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
||||
if material_grade_from_classifier and material_grade_from_classifier != "UNKNOWN":
|
||||
if material_grade_from_classifier and material_grade_from_classifier not in ["UNKNOWN", "UNCLASSIFIED"]:
|
||||
material_spec = material_grade_from_classifier
|
||||
|
||||
# materials 테이블의 material_grade도 업데이트
|
||||
@@ -958,7 +966,7 @@ async def upload_file(
|
||||
material_grade = material_info.get("grade", "")
|
||||
|
||||
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
||||
if material_grade and material_grade != "UNKNOWN":
|
||||
if material_grade and material_grade not in ["UNKNOWN", "UNCLASSIFIED"]:
|
||||
db.execute(text("""
|
||||
UPDATE materials
|
||||
SET material_grade = :new_material_grade
|
||||
@@ -1055,7 +1063,7 @@ async def upload_file(
|
||||
material_grade = material_info.get("grade", "")
|
||||
|
||||
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
||||
if material_grade and material_grade != "UNKNOWN":
|
||||
if material_grade and material_grade not in ["UNKNOWN", "UNCLASSIFIED"]:
|
||||
db.execute(text("""
|
||||
UPDATE materials
|
||||
SET material_grade = :new_material_grade
|
||||
@@ -1245,7 +1253,7 @@ async def upload_file(
|
||||
material_grade = material_info.get("grade", "")
|
||||
|
||||
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
||||
if material_grade and material_grade != "UNKNOWN":
|
||||
if material_grade and material_grade not in ["UNKNOWN", "UNCLASSIFIED"]:
|
||||
db.execute(text("""
|
||||
UPDATE materials
|
||||
SET material_grade = :new_material_grade
|
||||
|
||||
@@ -8,11 +8,6 @@ from typing import Dict, List, Optional
|
||||
|
||||
# ========== 제외 대상 타입 ==========
|
||||
EXCLUDE_TYPES = {
|
||||
"WELD_GAP": {
|
||||
"description_keywords": ["WELD GAP", "WELDING GAP", "GAP", "용접갭", "웰드갭"],
|
||||
"characteristics": "용접 시 수축 고려용 계산 항목",
|
||||
"reason": "실제 자재 아님 - 용접 갭 계산용"
|
||||
},
|
||||
"CUTTING_LOSS": {
|
||||
"description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"],
|
||||
"characteristics": "절단 시 손실 고려용 계산 항목",
|
||||
|
||||
@@ -110,6 +110,7 @@ def classify_material_integrated(description: str, main_nom: str = "",
|
||||
"reason": "스페셜 키워드 발견"
|
||||
}
|
||||
|
||||
|
||||
# VALVE 카테고리 우선 확인 (SIGHT GLASS, STRAINER)
|
||||
if ('SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or
|
||||
'사이트글라스' in desc_upper or '스트레이너' in desc_upper):
|
||||
@@ -295,7 +296,7 @@ def classify_material_integrated(description: str, main_nom: str = "",
|
||||
|
||||
# 분류 실패
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"category": "UNCLASSIFIED",
|
||||
"confidence": 0.0,
|
||||
"evidence": ["NO_CLASSIFICATION_POSSIBLE"],
|
||||
"classification_level": "NONE"
|
||||
|
||||
@@ -91,10 +91,10 @@
|
||||
### `BOMManagementPage.jsx`
|
||||
- **역할**: BOM(Bill of Materials) 통합 관리 페이지
|
||||
- **기능**:
|
||||
- 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT)
|
||||
- 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT, SPECIAL)
|
||||
- 자재 선택 및 구매신청 (엑셀 내보내기)
|
||||
- 구매신청된 자재 비활성화 표시
|
||||
- 사용자 요구사항 입력
|
||||
- 사용자 요구사항 입력 및 저장
|
||||
- 리비전 관리
|
||||
- **라우팅**: `/bom-management`
|
||||
- **접근 권한**: 인증된 사용자
|
||||
@@ -232,6 +232,25 @@
|
||||
- `GasketMaterialsView.jsx`: 가스켓 자재 관리
|
||||
- `BoltMaterialsView.jsx`: 볼트 자재 관리
|
||||
- `SupportMaterialsView.jsx`: 서포트 자재 관리
|
||||
- `SpecialMaterialsView.jsx`: 특수 제작 자재 관리
|
||||
|
||||
#### SPECIAL 카테고리 상세 기능
|
||||
`SpecialMaterialsView.jsx`는 특수 제작이 필요한 자재들을 관리하는 컴포넌트입니다:
|
||||
|
||||
**주요 기능:**
|
||||
- **자동 타입 분류**: FLANGE, OIL PUMP, COMPRESSOR, VALVE, FITTING, PIPE 등 큰 범주 자동 인식
|
||||
- **정보 파싱**: 자재 설명을 도면, 항목1-4로 체계적 분리
|
||||
- **테이블 구조**: `Type | Drawing | Item 1 | Item 2 | Item 3 | Item 4 | Additional Request | Purchase Quantity`
|
||||
- **엑셀 내보내기**: P열 납기일 규칙 준수, 관리항목 자동 채움
|
||||
- **저장 기능**: 추가요청사항 저장/편집 (다른 카테고리와 동일)
|
||||
|
||||
**처리 예시:**
|
||||
- `SAE SPECIAL FF, OIL PUMP, ASTM A105` → Type: OIL PUMP, Item1: SAE SPECIAL FF, Item2: OIL PUMP, Item3: ASTM A105
|
||||
- `FLG SPECIAL FF, COMPRESSOR(N11), ASTM A105` → Type: FLANGE, Item1: FLG SPECIAL FF, Item2: COMPRESSOR(N11), Item3: ASTM A105
|
||||
|
||||
**분류 조건:**
|
||||
- `SPECIAL` 키워드 포함 (단, `SPECIFICATION` 제외)
|
||||
- 한글 `스페셜` 또는 `SPL` 키워드 포함
|
||||
|
||||
### 기타 컴포넌트
|
||||
- **NavigationMenu.jsx**: 사이드바 네비게이션
|
||||
@@ -258,6 +277,16 @@
|
||||
- **Background**: 글래스 효과 (backdrop-filter: blur)
|
||||
- **Cards**: 20px 둥근 모서리, 그림자 효과
|
||||
|
||||
### BOM 카테고리 색상
|
||||
- **PIPE**: #3b82f6 (파란색)
|
||||
- **FITTING**: #10b981 (초록색)
|
||||
- **FLANGE**: #f59e0b (주황색)
|
||||
- **VALVE**: #ef4444 (빨간색)
|
||||
- **GASKET**: #8b5cf6 (보라색)
|
||||
- **BOLT**: #6b7280 (회색)
|
||||
- **SUPPORT**: #f97316 (주황색)
|
||||
- **SPECIAL**: #ec4899 (핑크색)
|
||||
|
||||
### 반응형 디자인
|
||||
- **Desktop**: 3-4열 그리드
|
||||
- **Tablet**: 2열 그리드
|
||||
@@ -269,5 +298,14 @@
|
||||
|
||||
---
|
||||
|
||||
*마지막 업데이트: 2024-10-16*
|
||||
*마지막 업데이트: 2024-10-17*
|
||||
*다음 페이지 추가 시 반드시 이 문서를 업데이트하세요.*
|
||||
|
||||
## 최근 업데이트 내역
|
||||
|
||||
### 2024-10-17: SPECIAL 카테고리 추가
|
||||
- `SpecialMaterialsView.jsx` 컴포넌트 추가
|
||||
- 특수 제작 자재 관리 기능 구현
|
||||
- 자동 타입 분류 및 정보 파싱 시스템
|
||||
- 엑셀 내보내기 규칙 적용 (P열 납기일, 관리항목 자동 채움)
|
||||
- BOM 카테고리 색상 팔레트에 SPECIAL (#ec4899) 추가
|
||||
|
||||
@@ -154,7 +154,7 @@ const BoltMaterialsView = ({
|
||||
let boltGrade = '-';
|
||||
if (boltDetails.material_standard && boltDetails.material_grade) {
|
||||
// bolt_details에서 완전한 재질 정보 구성
|
||||
if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== boltDetails.material_standard) {
|
||||
if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== 'UNCLASSIFIED' && boltDetails.material_grade !== boltDetails.material_standard) {
|
||||
boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`;
|
||||
} else {
|
||||
boltGrade = boltDetails.material_standard;
|
||||
@@ -167,7 +167,7 @@ const BoltMaterialsView = ({
|
||||
|
||||
// 볼트 타입 (PSV_BOLT, LT_BOLT 등)
|
||||
let boltSubtype = 'BOLT_GENERAL';
|
||||
if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN') {
|
||||
if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN' && boltDetails.bolt_type !== 'UNCLASSIFIED') {
|
||||
boltSubtype = boltDetails.bolt_type;
|
||||
} else {
|
||||
// 원본 설명에서 특수 볼트 타입 추출
|
||||
|
||||
@@ -308,10 +308,10 @@ const FittingMaterialsView = ({
|
||||
|
||||
if (hasDifferentSchedules && mainSchedule && redSchedule) {
|
||||
schedule = `${mainSchedule}×${redSchedule}`;
|
||||
} else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN') {
|
||||
} else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
|
||||
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
|
||||
schedule = `${mainSchedule}×${mainSchedule}`;
|
||||
} else if (mainSchedule && mainSchedule !== 'UNKNOWN') {
|
||||
} else if (mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
|
||||
schedule = mainSchedule;
|
||||
} else {
|
||||
// Description에서 스케줄 추출 - 더 강력한 패턴 매칭
|
||||
|
||||
628
frontend/src/components/bom/materials/SpecialMaterialsView.jsx
Normal file
628
frontend/src/components/bom/materials/SpecialMaterialsView.jsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const SpecialMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
jobNo,
|
||||
fileId,
|
||||
user,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// SPECIAL 자재 정보 파싱
|
||||
const parseSpecialInfo = (material) => {
|
||||
const description = material.original_description || '';
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
|
||||
// Type 추출 (큰 범주: 우선순위 기반 분류)
|
||||
let type = 'SPECIAL';
|
||||
const descUpper = description.toUpperCase();
|
||||
|
||||
// 우선순위 1: 주요 장비 타입 (OIL PUMP, COMPRESSOR 등이 FLG보다 우선)
|
||||
if (descUpper.includes('OIL PUMP') || (descUpper.includes('PUMP') && !descUpper.includes('FITTING'))) {
|
||||
type = 'OIL PUMP';
|
||||
} else if (descUpper.includes('COMPRESSOR')) {
|
||||
type = 'COMPRESSOR';
|
||||
} else if (descUpper.includes('VALVE') && !descUpper.includes('FLG')) {
|
||||
type = 'VALVE';
|
||||
}
|
||||
// 우선순위 2: 구조물/부품 타입
|
||||
else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) {
|
||||
// FLG가 있어도 주요 장비가 함께 있으면 장비 타입 우선
|
||||
if (descUpper.includes('OIL PUMP') || descUpper.includes('COMPRESSOR')) {
|
||||
if (descUpper.includes('OIL PUMP')) {
|
||||
type = 'OIL PUMP';
|
||||
} else if (descUpper.includes('COMPRESSOR')) {
|
||||
type = 'COMPRESSOR';
|
||||
}
|
||||
} else {
|
||||
type = 'FLANGE';
|
||||
}
|
||||
} else if (descUpper.includes('FITTING')) {
|
||||
type = 'FITTING';
|
||||
} else if (descUpper.includes('PIPE')) {
|
||||
type = 'PIPE';
|
||||
}
|
||||
|
||||
// 도면 정보 (drawing_name 또는 line_no에서 추출)
|
||||
const drawing = material.drawing_name || material.line_no || '-';
|
||||
|
||||
// 설명을 항목별로 분리 (콤마, 세미콜론, 파이프 등으로 구분)
|
||||
const parts = description
|
||||
.split(/[,;|\/]/)
|
||||
.map(part => part.trim())
|
||||
.filter(part => part.length > 0);
|
||||
|
||||
// 최대 4개 항목으로 제한
|
||||
const detail1 = parts[0] || '-';
|
||||
const detail2 = parts[1] || '-';
|
||||
const detail3 = parts[2] || '-';
|
||||
const detail4 = parts[3] || '-';
|
||||
|
||||
return {
|
||||
type,
|
||||
drawing,
|
||||
detail1,
|
||||
detail2,
|
||||
detail3,
|
||||
detail4,
|
||||
quantity: qty,
|
||||
originalQuantity: qty,
|
||||
purchaseQuantity: qty,
|
||||
isSpecial: 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 => {
|
||||
const info = parseSpecialInfo(material);
|
||||
|
||||
// 컬럼 필터 적용
|
||||
for (const [key, filterValue] of Object.entries(columnFilters)) {
|
||||
if (filterValue && filterValue.trim()) {
|
||||
const materialValue = String(info[key] || '').toLowerCase();
|
||||
const filter = filterValue.toLowerCase();
|
||||
if (!materialValue.includes(filter)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 정렬 적용
|
||||
if (sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseSpecialInfo(a);
|
||||
const bInfo = parseSpecialInfo(b);
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = () => {
|
||||
const selectableMaterials = filteredMaterials.filter(material =>
|
||||
!purchasedMaterials.has(material.id)
|
||||
);
|
||||
|
||||
const allSelected = selectableMaterials.every(material =>
|
||||
selectedMaterials.has(material.id)
|
||||
);
|
||||
|
||||
if (allSelected) {
|
||||
// 전체 해제
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
selectableMaterials.forEach(material => {
|
||||
newSelected.delete(material.id);
|
||||
});
|
||||
setSelectedMaterials(newSelected);
|
||||
} else {
|
||||
// 전체 선택
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
selectableMaterials.forEach(material => {
|
||||
newSelected.add(material.id);
|
||||
});
|
||||
setSelectedMaterials(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택/해제
|
||||
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 = `SPECIAL_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 스페셜 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'SPECIAL',
|
||||
filename: excelFileName,
|
||||
user: user?.username || 'unknown'
|
||||
});
|
||||
|
||||
// 2. 구매신청 생성
|
||||
console.log('📝 구매신청 생성 중...');
|
||||
const purchaseResponse = await api.post('/purchase-request/create', {
|
||||
materials_data: dataWithRequirements,
|
||||
file_id: fileId,
|
||||
job_no: jobNo
|
||||
});
|
||||
|
||||
console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
|
||||
|
||||
// 3. 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', purchaseResponse.data.request_id);
|
||||
formData.append('filename', excelFileName);
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
// 4. 구매신청된 자재들을 비활성화
|
||||
const purchasedIds = selectedMaterialsData.map(m => m.id);
|
||||
onPurchasedMaterialsUpdate(purchasedIds);
|
||||
|
||||
// 5. 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
|
||||
// 6. 클라이언트에서도 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ 스페셜 엑셀 내보내기 완료');
|
||||
alert(`스페셜 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
|
||||
|
||||
// 구매신청 관리 페이지로 이동
|
||||
if (onNavigate) {
|
||||
onNavigate('purchase-requests');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 스페셜 엑셀 내보내기 실패:', error);
|
||||
alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const allSelected = filteredMaterials.length > 0 &&
|
||||
filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
|
||||
.every(material => selectedMaterials.has(material.id));
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
|
||||
borderRadius: '12px',
|
||||
color: 'white'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
|
||||
Special Items
|
||||
</h2>
|
||||
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>
|
||||
특수 제작 품목 관리 ({filteredMaterials.length}개)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: selectedMaterials.size > 0 ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.2s ease',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
>
|
||||
구매신청 ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
border: '1px solid #e5e7eb',
|
||||
minWidth: '1400px'
|
||||
}}>
|
||||
<div style={{ minWidth: '1400px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 150px 200px 200px 200px 200px 200px 250px 150px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '2px solid #e2e8f0',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
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="drawing"
|
||||
filterKey="drawing"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Drawing
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="detail1"
|
||||
filterKey="detail1"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Detail 1
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="detail2"
|
||||
filterKey="detail2"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Detail 2
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="detail3"
|
||||
filterKey="detail3"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Detail 3
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="detail4"
|
||||
filterKey="detail4"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Detail 4
|
||||
</FilterableHeader>
|
||||
<div>Additional Request</div>
|
||||
<div>Purchase Quantity</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
<div>
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseSpecialInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 150px 200px 200px 200px 200px 200px 250px 150px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
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.type}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.drawing}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail1}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail2}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail3}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail4}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.purchaseQuantity}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#6b7280',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
marginTop: '20px'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📋</div>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#374151' }}>스페셜 자재가 없습니다</h3>
|
||||
<p style={{ margin: 0 }}>특수 제작이 필요한 자재가 발견되면 여기에 표시됩니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecialMaterialsView;
|
||||
@@ -0,0 +1,561 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const UnclassifiedMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
jobNo,
|
||||
fileId,
|
||||
user,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// 미분류 자재 정보 파싱 (원본 그대로 표시)
|
||||
const parseUnclassifiedInfo = (material) => {
|
||||
const description = material.original_description || material.description || '';
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
|
||||
return {
|
||||
description: description || '-',
|
||||
size: material.main_nom || material.size_spec || '-',
|
||||
drawing: material.drawing_name || material.line_no || '-',
|
||||
lineNo: material.line_no || '-',
|
||||
quantity: qty,
|
||||
originalQuantity: qty,
|
||||
purchaseQuantity: qty,
|
||||
isUnclassified: 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 => {
|
||||
const info = parseUnclassifiedInfo(material);
|
||||
|
||||
// 컬럼 필터 적용
|
||||
for (const [key, filterValue] of Object.entries(columnFilters)) {
|
||||
if (filterValue && filterValue.trim()) {
|
||||
const materialValue = String(info[key] || '').toLowerCase();
|
||||
const filter = filterValue.toLowerCase();
|
||||
if (!materialValue.includes(filter)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 정렬 적용
|
||||
if (sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseUnclassifiedInfo(a);
|
||||
const bInfo = parseUnclassifiedInfo(b);
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = () => {
|
||||
const selectableMaterials = filteredMaterials.filter(material =>
|
||||
!purchasedMaterials.has(material.id)
|
||||
);
|
||||
|
||||
const allSelected = selectableMaterials.every(material =>
|
||||
selectedMaterials.has(material.id)
|
||||
);
|
||||
|
||||
if (allSelected) {
|
||||
// 전체 해제
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
selectableMaterials.forEach(material => {
|
||||
newSelected.delete(material.id);
|
||||
});
|
||||
setSelectedMaterials(newSelected);
|
||||
} else {
|
||||
// 전체 선택
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
selectableMaterials.forEach(material => {
|
||||
newSelected.add(material.id);
|
||||
});
|
||||
setSelectedMaterials(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택/해제
|
||||
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 = `UNCLASSIFIED_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 미분류 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'UNCLASSIFIED',
|
||||
filename: excelFileName,
|
||||
user: user?.username || 'unknown'
|
||||
});
|
||||
|
||||
// 2. 구매신청 생성
|
||||
console.log('📝 구매신청 생성 중...');
|
||||
const purchaseResponse = await api.post('/purchase-request/create', {
|
||||
materials_data: dataWithRequirements,
|
||||
file_id: fileId,
|
||||
job_no: jobNo
|
||||
});
|
||||
|
||||
console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
|
||||
|
||||
// 3. 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', purchaseResponse.data.request_id);
|
||||
formData.append('filename', excelFileName);
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
// 4. 구매신청된 자재들을 비활성화
|
||||
const purchasedIds = selectedMaterialsData.map(m => m.id);
|
||||
onPurchasedMaterialsUpdate(purchasedIds);
|
||||
|
||||
// 5. 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
|
||||
// 6. 클라이언트에서도 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ 미분류 엑셀 내보내기 완료');
|
||||
alert(`미분류 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
|
||||
|
||||
// 구매신청 관리 페이지로 이동
|
||||
if (onNavigate) {
|
||||
onNavigate('purchase-requests');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 미분류 엑셀 내보내기 실패:', error);
|
||||
alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const allSelected = filteredMaterials.length > 0 &&
|
||||
filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
|
||||
.every(material => selectedMaterials.has(material.id));
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #64748b 0%, #475569 100%)',
|
||||
borderRadius: '12px',
|
||||
color: 'white'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
|
||||
Unclassified Materials
|
||||
</h2>
|
||||
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>
|
||||
분류되지 않은 자재 관리 ({filteredMaterials.length}개)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: selectedMaterials.size > 0 ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.2s ease',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
>
|
||||
구매신청 ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
border: '1px solid #e5e7eb',
|
||||
minWidth: '1200px'
|
||||
}}>
|
||||
<div style={{ minWidth: '1200px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 400px 150px 200px 150px 250px 150px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '2px solid #e2e8f0',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="description"
|
||||
filterKey="description"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Description
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="drawing"
|
||||
filterKey="drawing"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Drawing
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="lineNo"
|
||||
filterKey="lineNo"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Line No
|
||||
</FilterableHeader>
|
||||
<div>Additional Request</div>
|
||||
<div>Purchase Quantity</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
<div>
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseUnclassifiedInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 400px 150px 200px 150px 250px 150px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
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',
|
||||
textAlign: 'left',
|
||||
paddingLeft: '8px',
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{info.description}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.drawing}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.lineNo}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.purchaseQuantity}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#6b7280',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
marginTop: '20px'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>❓</div>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#374151' }}>미분류 자재가 없습니다</h3>
|
||||
<p style={{ margin: 0 }}>분류되지 않은 자재가 발견되면 여기에 표시됩니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnclassifiedMaterialsView;
|
||||
@@ -6,3 +6,5 @@ export { default as ValveMaterialsView } from './ValveMaterialsView';
|
||||
export { default as GasketMaterialsView } from './GasketMaterialsView';
|
||||
export { default as BoltMaterialsView } from './BoltMaterialsView';
|
||||
export { default as SupportMaterialsView } from './SupportMaterialsView';
|
||||
export { default as SpecialMaterialsView } from './SpecialMaterialsView';
|
||||
export { default as UnclassifiedMaterialsView } from './UnclassifiedMaterialsView';
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
ValveMaterialsView,
|
||||
GasketMaterialsView,
|
||||
BoltMaterialsView,
|
||||
SupportMaterialsView
|
||||
SupportMaterialsView,
|
||||
SpecialMaterialsView,
|
||||
UnclassifiedMaterialsView
|
||||
} from '../components/bom';
|
||||
import './BOMManagementPage.css';
|
||||
|
||||
@@ -52,7 +54,9 @@ const BOMManagementPage = ({
|
||||
{ key: 'VALVE', label: 'Valves', color: '#ef4444' },
|
||||
{ key: 'GASKET', label: 'Gaskets', color: '#8b5cf6' },
|
||||
{ key: 'BOLT', label: 'Bolts', color: '#6b7280' },
|
||||
{ key: 'SUPPORT', label: 'Supports', color: '#f97316' }
|
||||
{ key: 'SUPPORT', label: 'Supports', color: '#f97316' },
|
||||
{ key: 'SPECIAL', label: 'Special Items', color: '#ec4899' },
|
||||
{ key: 'UNCLASSIFIED', label: 'Unclassified', color: '#64748b' }
|
||||
];
|
||||
|
||||
// 자료 로드 함수들
|
||||
@@ -208,6 +212,10 @@ const BOMManagementPage = ({
|
||||
return <BoltMaterialsView {...commonProps} />;
|
||||
case 'SUPPORT':
|
||||
return <SupportMaterialsView {...commonProps} />;
|
||||
case 'SPECIAL':
|
||||
return <SpecialMaterialsView {...commonProps} />;
|
||||
case 'UNCLASSIFIED':
|
||||
return <UnclassifiedMaterialsView {...commonProps} />;
|
||||
default:
|
||||
return <div>카테고리를 선택해주세요.</div>;
|
||||
}
|
||||
|
||||
@@ -874,7 +874,7 @@
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
/* UNKNOWN 전용 헤더 - 5개 컬럼 */
|
||||
/* UNCLASSIFIED 전용 헤더 - 5개 컬럼 */
|
||||
.detailed-grid-header.unknown-header {
|
||||
grid-template-columns: 5% 10% 1fr 20% 10%;
|
||||
|
||||
@@ -891,7 +891,7 @@
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* UNKNOWN 전용 행 - 5개 컬럼 */
|
||||
/* UNCLASSIFIED 전용 행 - 5개 컬럼 */
|
||||
.detailed-material-row.unknown-row {
|
||||
grid-template-columns: 5% 10% 1fr 20% 10%;
|
||||
|
||||
@@ -906,7 +906,7 @@
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* UNKNOWN 설명 셀 스타일 */
|
||||
/* UNCLASSIFIED 설명 셀 스타일 */
|
||||
.description-cell {
|
||||
overflow: visible;
|
||||
text-overflow: initial;
|
||||
|
||||
@@ -433,7 +433,7 @@ const NewMaterialsPage = ({
|
||||
const getCategoryCounts = () => {
|
||||
const counts = {};
|
||||
materials.forEach(material => {
|
||||
const category = material.classified_category || 'UNKNOWN';
|
||||
const category = material.classified_category || 'UNCLASSIFIED';
|
||||
counts[category] = (counts[category] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
@@ -476,7 +476,7 @@ const NewMaterialsPage = ({
|
||||
'BOLT': 'BOLT',
|
||||
'GASKET': 'GASKET',
|
||||
'INSTRUMENT': 'INSTRUMENT',
|
||||
'UNKNOWN': 'UNKNOWN'
|
||||
'UNCLASSIFIED': 'UNCLASSIFIED'
|
||||
};
|
||||
return categoryMap[category] || category;
|
||||
};
|
||||
@@ -1090,17 +1090,17 @@ const NewMaterialsPage = ({
|
||||
unit: '개',
|
||||
isSpecial: true
|
||||
};
|
||||
} else if (category === 'UNKNOWN') {
|
||||
} else if (category === 'UNCLASSIFIED') {
|
||||
return {
|
||||
type: 'UNKNOWN',
|
||||
description: material.original_description || 'Unknown Item',
|
||||
type: 'UNCLASSIFIED',
|
||||
description: material.original_description || 'Unclassified Item',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개',
|
||||
isUnknown: true
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: category || 'UNKNOWN',
|
||||
type: category || 'UNCLASSIFIED',
|
||||
subtype: '-',
|
||||
size: material.size_spec || '-',
|
||||
schedule: '-',
|
||||
@@ -1939,7 +1939,7 @@ const NewMaterialsPage = ({
|
||||
<div>사용자요구</div>
|
||||
<FilterableHeader sortKey="quantity" filterKey="quantity">수량</FilterableHeader>
|
||||
</div>
|
||||
) : selectedCategory === 'UNKNOWN' ? (
|
||||
) : selectedCategory === 'UNCLASSIFIED' ? (
|
||||
<div className="detailed-grid-header unknown-header">
|
||||
<div>선택</div>
|
||||
<div>종류</div>
|
||||
@@ -2464,7 +2464,7 @@ const NewMaterialsPage = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (material.classified_category === 'UNKNOWN') {
|
||||
if (material.classified_category === 'UNCLASSIFIED') {
|
||||
// 구매신청 여부 확인
|
||||
const isPurchased = material.grouped_ids ?
|
||||
material.grouped_ids.some(id => purchasedMaterials.has(id)) :
|
||||
|
||||
@@ -339,7 +339,7 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) =>
|
||||
{(() => {
|
||||
// 카테고리별로 자재 그룹화
|
||||
const groupedByCategory = requestMaterials.reduce((acc, material) => {
|
||||
const category = material.category || material.classified_category || 'UNKNOWN';
|
||||
const category = material.category || material.classified_category || 'UNCLASSIFIED';
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(material);
|
||||
return acc;
|
||||
|
||||
@@ -522,7 +522,7 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
// BOM 페이지의 타입을 따라가도록 - 프론트엔드 parseBoltInfo와 동일한 로직
|
||||
let boltSubtype = 'BOLT_GENERAL';
|
||||
|
||||
if (boltType && boltType !== 'UNKNOWN') {
|
||||
if (boltType && boltType !== 'UNKNOWN' && boltType !== 'UNCLASSIFIED') {
|
||||
boltSubtype = boltType;
|
||||
} else {
|
||||
// 원본 설명에서 특수 볼트 타입 추출 (프론트엔드와 동일)
|
||||
@@ -565,8 +565,29 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
} else {
|
||||
itemName = 'SUPPORT';
|
||||
}
|
||||
} else if (category === 'SPECIAL') {
|
||||
// 스페셜 자재 타입 추출 (큰 범주)
|
||||
const desc = cleanDescription.toUpperCase();
|
||||
if (desc.includes('FLG') || desc.includes('FLANGE')) {
|
||||
itemName = 'SPECIAL FLANGE';
|
||||
} else if (desc.includes('OIL PUMP') || desc.includes('PUMP')) {
|
||||
itemName = 'SPECIAL OIL PUMP';
|
||||
} else if (desc.includes('COMPRESSOR')) {
|
||||
itemName = 'SPECIAL COMPRESSOR';
|
||||
} else if (desc.includes('VALVE')) {
|
||||
itemName = 'SPECIAL VALVE';
|
||||
} else if (desc.includes('FITTING')) {
|
||||
itemName = 'SPECIAL FITTING';
|
||||
} else if (desc.includes('PIPE')) {
|
||||
itemName = 'SPECIAL PIPE';
|
||||
} else {
|
||||
itemName = 'SPECIAL ITEM';
|
||||
}
|
||||
} else if (category === 'UNCLASSIFIED') {
|
||||
// 미분류 자재는 원본 설명을 품목명으로 사용
|
||||
itemName = cleanDescription || 'UNCLASSIFIED ITEM';
|
||||
} else {
|
||||
itemName = category || 'UNKNOWN';
|
||||
itemName = category || 'UNCLASSIFIED';
|
||||
}
|
||||
|
||||
// 압력 등급 추출 (카테고리별 처리)
|
||||
@@ -950,6 +971,36 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
base['관리항목4'] = ''; // M열
|
||||
base['관리항목5'] = ''; // N열
|
||||
base['관리항목6'] = ''; // O열
|
||||
} else if (category === 'SPECIAL') {
|
||||
// 스페셜 전용 컬럼 (F~O) - Type은 품목명에 포함, 나머지는 항목별로 분리
|
||||
const description = cleanDescription || '';
|
||||
const parts = description
|
||||
.split(/[,;|\/]/)
|
||||
.map(part => part.trim())
|
||||
.filter(part => part.length > 0);
|
||||
|
||||
base['도면'] = material.drawing_name || material.line_no || '-'; // F열
|
||||
base['상세1'] = parts[0] || '-'; // G열
|
||||
base['상세2'] = parts[1] || '-'; // H열
|
||||
base['상세3'] = parts[2] || '-'; // I열
|
||||
base['상세4'] = parts[3] || '-'; // J열
|
||||
base['추가요청사항'] = material.user_requirement || ''; // K열
|
||||
base['관리항목1'] = ''; // L열
|
||||
base['관리항목2'] = ''; // M열
|
||||
base['관리항목3'] = ''; // N열
|
||||
base['관리항목4'] = ''; // O열
|
||||
} else if (category === 'UNCLASSIFIED') {
|
||||
// 미분류 전용 컬럼 (F~O) - 원본 정보 그대로 표시
|
||||
base['크기'] = material.main_nom || material.size_spec || '-'; // F열
|
||||
base['도면'] = material.drawing_name || '-'; // G열
|
||||
base['라인번호'] = material.line_no || '-'; // H열
|
||||
base['추가요청사항'] = material.user_requirement || ''; // I열
|
||||
base['관리항목1'] = ''; // J열
|
||||
base['관리항목2'] = ''; // K열
|
||||
base['관리항목3'] = ''; // L열
|
||||
base['관리항목4'] = ''; // M열
|
||||
base['관리항목5'] = ''; // N열
|
||||
base['관리항목6'] = ''; // O열
|
||||
} else {
|
||||
// 기타 카테고리 기본 컬럼 (F~O) - 타입 제거, 품목명에 포함됨
|
||||
base['크기'] = material.size_spec || '-'; // F열
|
||||
@@ -1213,6 +1264,8 @@ export const createExcelBlob = async (materials, filename, options = {}) => {
|
||||
'GASKET': ['크기', '압력등급', '구조', '재질1', '재질2', '두께', '사용자요구', '추가요청사항', '관리항목1', '관리항목2'],
|
||||
'BOLT': ['크기', '압력등급', '길이', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
||||
'SUPPORT': ['크기', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '관리항목5', '관리항목6'],
|
||||
'SPECIAL': ['도면', '상세1', '상세2', '상세3', '상세4', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
||||
'UNCLASSIFIED': ['크기', '도면', '라인번호', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '관리항목5', '관리항목6'],
|
||||
};
|
||||
|
||||
const deliveryDateHeader = '납기일(YYYY-MM-DD)';
|
||||
|
||||
Reference in New Issue
Block a user