feat: SPECIAL/UNCLASSIFIED 카테고리 추가 및 WELD GAP 자동 제외
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:
hyungi
2025-10-17 13:48:48 +09:00
parent f336b5a4a8
commit e0ad21bfad
14 changed files with 1335 additions and 41 deletions

View File

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

View File

@@ -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": "절단 시 손실 고려용 계산 항목",

View File

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

View File

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

View File

@@ -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 {
// 원본 설명에서 특수 볼트 타입 추출

View File

@@ -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에서 스케줄 추출 - 더 강력한 패턴 매칭

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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