import React, { useState } from 'react'; import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport'; import api from '../../../api'; import { FilterableHeader, MaterialTable } from '../shared'; const FittingMaterialsView = ({ materials, selectedMaterials, setSelectedMaterials, userRequirements, setUserRequirements, purchasedMaterials, onPurchasedMaterialsUpdate, fileId, jobNo, user, onNavigate }) => { const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); const [columnFilters, setColumnFilters] = useState({}); const [showFilterDropdown, setShowFilterDropdown] = useState(null); // 니플 끝단 정보 추출 (기존 로직 복원) const extractNippleEndInfo = (description) => { const descUpper = description.toUpperCase(); // 니플 끝단 패턴들 (기존 NewMaterialsPage와 동일) const endPatterns = { 'PBE': 'PBE', // Plain Both End 'BBE': 'BBE', // Bevel Both End 'POE': 'POE', // Plain One End 'BOE': 'BOE', // Bevel One End 'TOE': 'TOE', // Thread One End 'SW X NPT': 'SW×NPT', // Socket Weld x NPT 'SW X SW': 'SW×SW', // Socket Weld x Socket Weld 'NPT X NPT': 'NPT×NPT', // NPT x NPT 'BOTH END THREADED': 'B.E.T', 'B.E.T': 'B.E.T', 'ONE END THREADED': 'O.E.T', 'O.E.T': 'O.E.T', 'THREADED': 'THD' }; for (const [pattern, display] of Object.entries(endPatterns)) { if (descUpper.includes(pattern)) { return display; } } return ''; }; // 피팅 정보 파싱 (기존 상세 로직 복원) const parseFittingInfo = (material) => { const fittingDetails = material.fitting_details || {}; const classificationDetails = material.classification_details || {}; // 개선된 분류기 결과 우선 사용 const fittingTypeInfo = classificationDetails.fitting_type || {}; const scheduleInfo = classificationDetails.schedule_info || {}; // 기존 필드와 새 필드 통합 const fittingType = fittingTypeInfo.type || fittingDetails.fitting_type || ''; const fittingSubtype = fittingTypeInfo.subtype || fittingDetails.fitting_subtype || ''; const mainSchedule = scheduleInfo.main_schedule || fittingDetails.schedule || ''; const redSchedule = scheduleInfo.red_schedule || ''; const hasDifferentSchedules = scheduleInfo.has_different_schedules || false; const description = material.original_description || ''; // 피팅 타입별 상세 표시 let displayType = ''; // 개선된 분류기 결과 우선 표시 if (fittingType === 'TEE' && fittingSubtype === 'REDUCING') { displayType = 'TEE REDUCING'; } else if (fittingType === 'REDUCER' && fittingSubtype === 'CONCENTRIC') { displayType = 'REDUCER CONC'; } else if (fittingType === 'REDUCER' && fittingSubtype === 'ECCENTRIC') { displayType = 'REDUCER ECC'; } else if (description.toUpperCase().includes('TEE RED')) { displayType = 'TEE REDUCING'; } else if (description.toUpperCase().includes('RED CONC')) { displayType = 'REDUCER CONC'; } else if (description.toUpperCase().includes('RED ECC')) { displayType = 'REDUCER ECC'; } else if (description.toUpperCase().includes('CAP')) { if (description.includes('NPT(F)')) { displayType = 'CAP NPT(F)'; } else if (description.includes('SW')) { displayType = 'CAP SW'; } else if (description.includes('BW')) { displayType = 'CAP BW'; } else { displayType = 'CAP'; } } else if (description.toUpperCase().includes('PLUG')) { if (description.toUpperCase().includes('HEX')) { if (description.includes('NPT(M)')) { displayType = 'HEX PLUG NPT(M)'; } else { displayType = 'HEX PLUG'; } } else if (description.includes('NPT(M)')) { displayType = 'PLUG NPT(M)'; } else if (description.includes('NPT')) { displayType = 'PLUG NPT'; } else { displayType = 'PLUG'; } } else if (fittingType === 'NIPPLE') { const length = fittingDetails.length_mm || fittingDetails.avg_length_mm; const endInfo = extractNippleEndInfo(description); let nippleType = 'NIPPLE'; if (length) nippleType += ` ${length}mm`; if (endInfo) nippleType += ` ${endInfo}`; displayType = nippleType; } else if (fittingType === 'ELBOW') { let elbowDetails = []; // 각도 정보 추출 if (fittingSubtype.includes('90DEG') || description.includes('90') || description.includes('90°')) { elbowDetails.push('90°'); } else if (fittingSubtype.includes('45DEG') || description.includes('45') || description.includes('45°')) { elbowDetails.push('45°'); } // 반경 정보 추출 (Long Radius / Short Radius) if (fittingSubtype.includes('LONG_RADIUS') || description.toUpperCase().includes('LR') || description.toUpperCase().includes('LONG RADIUS')) { elbowDetails.push('LR'); } else if (fittingSubtype.includes('SHORT_RADIUS') || description.toUpperCase().includes('SR') || description.toUpperCase().includes('SHORT RADIUS')) { elbowDetails.push('SR'); } // 연결 방식 if (description.includes('SW')) { elbowDetails.push('SW'); } else if (description.includes('BW')) { elbowDetails.push('BW'); } // 기본값 설정 (각도가 없으면 90도로 가정) if (!elbowDetails.some(detail => detail.includes('°'))) { elbowDetails.unshift('90°'); } displayType = `ELBOW ${elbowDetails.join(' ')}`.trim(); } else if (fittingType === 'TEE') { // TEE 타입과 연결 방식 상세 표시 let teeDetails = []; // 등경/축소 타입 if (fittingSubtype === 'EQUAL' || description.toUpperCase().includes('TEE EQ')) { teeDetails.push('EQ'); } else if (fittingSubtype === 'REDUCING' || description.toUpperCase().includes('TEE RED')) { teeDetails.push('RED'); } // 연결 방식 if (description.includes('SW')) { teeDetails.push('SW'); } else if (description.includes('BW')) { teeDetails.push('BW'); } displayType = `TEE ${teeDetails.join(' ')}`.trim(); } else if (fittingType === 'REDUCER') { const reducerType = fittingSubtype === 'CONCENTRIC' ? 'CONC' : fittingSubtype === 'ECCENTRIC' ? 'ECC' : ''; const sizes = fittingDetails.reduced_size ? `${material.size_spec}×${fittingDetails.reduced_size}` : material.size_spec; displayType = `RED ${reducerType} ${sizes}`.trim(); } else if (fittingType === 'SWAGE') { const swageType = fittingSubtype || ''; displayType = `SWAGE ${swageType}`.trim(); } else if (fittingType === 'OLET') { const oletSubtype = fittingSubtype || ''; let oletDisplayName = ''; // 백엔드 분류기 결과 우선 사용 switch (oletSubtype) { case 'SOCKOLET': oletDisplayName = 'SOCK-O-LET'; break; case 'WELDOLET': oletDisplayName = 'WELD-O-LET'; break; case 'ELLOLET': oletDisplayName = 'ELL-O-LET'; break; case 'THREADOLET': oletDisplayName = 'THREAD-O-LET'; break; case 'ELBOLET': oletDisplayName = 'ELB-O-LET'; break; case 'NIPOLET': oletDisplayName = 'NIP-O-LET'; break; case 'COUPOLET': oletDisplayName = 'COUP-O-LET'; break; default: // 백엔드 분류가 없으면 description에서 직접 추출 const upperDesc = description.toUpperCase(); if (upperDesc.includes('SOCK-O-LET') || upperDesc.includes('SOCKOLET')) { oletDisplayName = 'SOCK-O-LET'; } else if (upperDesc.includes('WELD-O-LET') || upperDesc.includes('WELDOLET')) { oletDisplayName = 'WELD-O-LET'; } else if (upperDesc.includes('ELL-O-LET') || upperDesc.includes('ELLOLET')) { oletDisplayName = 'ELL-O-LET'; } else if (upperDesc.includes('THREAD-O-LET') || upperDesc.includes('THREADOLET')) { oletDisplayName = 'THREAD-O-LET'; } else if (upperDesc.includes('ELB-O-LET') || upperDesc.includes('ELBOLET')) { oletDisplayName = 'ELB-O-LET'; } else if (upperDesc.includes('NIP-O-LET') || upperDesc.includes('NIPOLET')) { oletDisplayName = 'NIP-O-LET'; } else if (upperDesc.includes('COUP-O-LET') || upperDesc.includes('COUPOLET')) { oletDisplayName = 'COUP-O-LET'; } else { oletDisplayName = 'OLET'; } } displayType = oletDisplayName; } else if (!displayType) { displayType = fittingType || 'FITTING'; } // 압력 등급과 스케줄 추출 (기존 NewMaterialsPage 로직) let pressure = '-'; let schedule = '-'; // 압력 등급 찾기 (3000LB, 6000LB 등) - 소켓웰드 피팅에 특히 중요 const pressureMatch = description.match(/(\d+)LB/i); if (pressureMatch) { pressure = `${pressureMatch[1]}LB`; } // 소켓웰드 피팅의 경우 압력 등급이 더 중요함 if (description.includes('SW') && !pressureMatch) { // SW 피팅인데 압력이 명시되지 않은 경우 기본값 설정 if (description.includes('3000') || description.includes('3K')) { pressure = '3000LB'; } else if (description.includes('6000') || description.includes('6K')) { pressure = '6000LB'; } } // 스케줄 표시 (분리 스케줄 지원) - 개선된 로직 // 레듀싱 자재인지 확인 const isReducingFitting = displayType.includes('REDUCING') || displayType.includes('RED') || description.toUpperCase().includes('RED') || description.toUpperCase().includes('REDUCING'); if (hasDifferentSchedules && mainSchedule && redSchedule) { schedule = `${mainSchedule}×${redSchedule}`; } else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN') { // 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시 schedule = `${mainSchedule}×${mainSchedule}`; } else if (mainSchedule && mainSchedule !== 'UNKNOWN') { schedule = mainSchedule; } else { // Description에서 스케줄 추출 - 더 강력한 패턴 매칭 const schedulePatterns = [ /SCH\s*(\d+S?)/i, // SCH 40, SCH 80S /SCHEDULE\s*(\d+S?)/i, // SCHEDULE 40 /스케줄\s*(\d+S?)/i, // 스케줄 40 /(\d+S?)\s*SCH/i, // 40 SCH (역순) /SCH\.?\s*(\d+S?)/i, // SCH.40 /SCH\s*(\d+S?)\s*[xX×]\s*SCH\s*(\d+S?)/i // SCH 40 x SCH 80 ]; for (const pattern of schedulePatterns) { const match = description.match(pattern); if (match) { if (match.length > 2) { // 분리 스케줄 패턴 (SCH 40 x SCH 80) schedule = `SCH ${match[1]}×SCH ${match[2]}`; } else { const scheduleNum = match[1]; if (isReducingFitting) { // 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시 schedule = `SCH ${scheduleNum}×SCH ${scheduleNum}`; } else { schedule = `SCH ${scheduleNum}`; } } break; } } // 여전히 찾지 못했다면 더 넓은 패턴 시도 if (schedule === '-') { const broadPatterns = [ /\b(\d+)\s*LB/i, // 압력 등급에서 유추 /\b(40|80|120|160)\b/i, // 일반적인 스케줄 숫자 /\b(10|20|30|40|60|80|100|120|140|160)\b/i // 모든 표준 스케줄 ]; for (const pattern of broadPatterns) { const match = description.match(pattern); if (match) { const num = match[1]; // 압력 등급이 아닌 경우만 스케줄로 간주 if (!description.includes(`${num}LB`)) { if (isReducingFitting) { // 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시 schedule = `SCH ${num}×SCH ${num}`; } else { schedule = `SCH ${num}`; } break; } } } } } return { type: 'FITTING', subtype: displayType, size: material.size_spec || '-', pressure: pressure, schedule: schedule, grade: material.full_material_grade || material.material_grade || '-', quantity: Math.round(material.quantity || 0), unit: '개', isFitting: true }; }; // 정렬 처리 const handleSort = (key) => { let direction = 'asc'; if (sortConfig.key === key && sortConfig.direction === 'asc') { direction = 'desc'; } setSortConfig({ key, direction }); }; // 필터링된 및 정렬된 자재 목록 const getFilteredAndSortedMaterials = () => { let filtered = materials.filter(material => { return Object.entries(columnFilters).every(([key, filterValue]) => { if (!filterValue) return true; const info = parseFittingInfo(material); const value = info[key]?.toString().toLowerCase() || ''; return value.includes(filterValue.toLowerCase()); }); }); if (sortConfig.key) { filtered.sort((a, b) => { const aInfo = parseFittingInfo(a); const bInfo = parseFittingInfo(b); const aValue = aInfo[sortConfig.key] || ''; const bValue = bInfo[sortConfig.key] || ''; if (sortConfig.direction === 'asc') { return aValue > bValue ? 1 : -1; } else { return aValue < bValue ? 1 : -1; } }); } return filtered; }; // 전체 선택/해제 (구매신청된 자재 제외) const handleSelectAll = () => { const filteredMaterials = getFilteredAndSortedMaterials(); const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); if (selectedMaterials.size === selectableMaterials.length) { setSelectedMaterials(new Set()); } else { setSelectedMaterials(new Set(selectableMaterials.map(m => m.id))); } }; // 개별 선택 (구매신청된 자재는 선택 불가) const handleMaterialSelect = (materialId) => { if (purchasedMaterials.has(materialId)) { return; // 구매신청된 자재는 선택 불가 } const newSelected = new Set(selectedMaterials); if (newSelected.has(materialId)) { newSelected.delete(materialId); } else { newSelected.add(materialId); } setSelectedMaterials(newSelected); }; // 엑셀 내보내기 const handleExportToExcel = async () => { const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id)); if (selectedMaterialsData.length === 0) { alert('내보낼 자재를 선택해주세요.'); return; } const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); const excelFileName = `FITTING_Materials_${timestamp}.xlsx`; const dataWithRequirements = selectedMaterialsData.map(material => ({ ...material, user_requirement: userRequirements[material.id] || '' })); try { console.log('🔄 피팅 엑셀 내보내기 시작 - 새로운 방식'); // 1. 먼저 클라이언트에서 엑셀 파일 생성 console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료'); const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, { category: 'FITTING', filename: excelFileName, uploadDate: new Date().toLocaleDateString() }); console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes'); // 2. 구매신청 생성 const allMaterialIds = selectedMaterialsData.map(m => m.id); const response = await api.post('/purchase-request/create', { file_id: fileId, job_no: jobNo, category: 'FITTING', material_ids: allMaterialIds, materials_data: dataWithRequirements.map(m => ({ material_id: m.id, description: m.original_description, category: m.classified_category, size: m.size_inch || m.size_spec, schedule: m.schedule, material_grade: m.material_grade || m.full_material_grade, quantity: m.quantity, unit: m.unit, user_requirement: userRequirements[m.id] || '' })) }); if (response.data.success) { console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`); // 3. 생성된 엑셀 파일을 서버에 업로드 console.log('📤 서버에 엑셀 파일 업로드 중...'); const formData = new FormData(); formData.append('excel_file', excelBlob, excelFileName); formData.append('request_id', response.data.request_id); formData.append('category', 'FITTING'); const uploadResponse = await api.post('/purchase-request/upload-excel', formData, { headers: { 'Content-Type': 'multipart/form-data', }, }); console.log('✅ 엑셀 업로드 완료:', uploadResponse.data); if (onPurchasedMaterialsUpdate) { onPurchasedMaterialsUpdate(allMaterialIds); } } // 4. 클라이언트 다운로드 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); alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`); } catch (error) { console.error('엑셀 저장 또는 구매신청 실패:', error); // 실패 시에도 클라이언트 다운로드는 진행 exportMaterialsToExcel(dataWithRequirements, excelFileName, { category: 'FITTING', filename: excelFileName, uploadDate: new Date().toLocaleDateString() }); alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.'); } // 선택 해제 setSelectedMaterials(new Set()); }; const filteredMaterials = getFilteredAndSortedMaterials(); // 필터 헤더 컴포넌트 const FilterableHeader = ({ sortKey, filterKey, children }) => (
handleSort(sortKey)} style={{ cursor: 'pointer', flex: 1 }} > {children} {sortConfig.key === sortKey && ( {sortConfig.direction === 'asc' ? '↑' : '↓'} )}
{showFilterDropdown === filterKey && (
setColumnFilters({ ...columnFilters, [filterKey]: e.target.value })} style={{ width: '100%', padding: '4px 8px', border: '1px solid #d1d5db', borderRadius: '4px', fontSize: '12px' }} autoFocus />
)}
); return (
{/* 헤더 */}

Fitting Materials

{filteredMaterials.length} items • {selectedMaterials.size} selected

{/* 테이블 */}
{/* 헤더 */}
{ const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0; })()} onChange={handleSelectAll} style={{ cursor: 'pointer' }} />
Type
Size
Pressure
Schedule
Material Grade
User Requirements
Purchase Quantity
Additional Request
{/* 데이터 행들 */} {filteredMaterials.map((material, index) => { const info = parseFittingInfo(material); const isSelected = selectedMaterials.has(material.id); const isPurchased = purchasedMaterials.has(material.id); return (
{ if (!isSelected && !isPurchased) { e.target.style.background = '#f8fafc'; } }} onMouseLeave={(e) => { if (!isSelected && !isPurchased) { e.target.style.background = 'white'; } }} >
handleMaterialSelect(material.id)} disabled={isPurchased} style={{ cursor: isPurchased ? 'not-allowed' : 'pointer', opacity: isPurchased ? 0.5 : 1 }} />
{info.subtype} {isPurchased && ( PURCHASED )}
{info.size}
{info.pressure}
{info.schedule}
{info.grade}
{material.user_requirements?.join(', ') || '-'}
{info.quantity} {info.unit}
setUserRequirements({ ...userRequirements, [material.id]: e.target.value })} placeholder="Enter additional request..." style={{ width: '100%', padding: '6px 8px', border: '1px solid #d1d5db', borderRadius: '4px', fontSize: '12px' }} />
); })}
{filteredMaterials.length === 0 && (
No Fitting Materials Found
{Object.keys(columnFilters).some(key => columnFilters[key]) ? 'Try adjusting your filters' : 'No fitting materials available in this BOM'}
)}
); }; export default FittingMaterialsView;