import React, { useState } from 'react'; import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport'; import api from '../../../api'; import { FilterableHeader } from '../shared'; const BoltMaterialsView = ({ materials, selectedMaterials, setSelectedMaterials, userRequirements, setUserRequirements, purchasedMaterials, onPurchasedMaterialsUpdate, updateMaterial, // 자재 업데이트 함수 jobNo, fileId, user }) => { 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 extractBoltAdditionalRequirements = (description) => { const additionalReqs = []; // 표면처리 패턴 확인 const surfacePatterns = { 'ELEC.GALV': 'ELEC.GALV', 'ELEC GALV': 'ELEC.GALV', 'GALVANIZED': 'GALVANIZED', 'GALV': 'GALV', 'HOT DIP GALV': 'HDG', 'HDG': 'HDG', 'ZINC PLATED': 'ZINC PLATED', 'ZINC': 'ZINC', 'PLAIN': 'PLAIN' }; for (const [pattern, treatment] of Object.entries(surfacePatterns)) { if (description.includes(pattern)) { additionalReqs.push(treatment); break; // 첫 번째 매치만 사용 } } return additionalReqs.join(', ') || '-'; }; const parseBoltInfo = (material) => { const qty = Math.round(material.quantity || 0); // 플랜지당 볼트 세트 수 추출 (예: (8), (4)) const boltDetails = material.bolt_details || {}; let boltsPerFlange = boltDetails.dimensions?.bolts_per_flange || 1; // 백엔드에서 정보가 없으면 원본 설명에서 직접 추출 if (boltsPerFlange === 1) { const description = material.original_description || ''; const flangePattern = description.match(/\((\d+)\)/); if (flangePattern) { boltsPerFlange = parseInt(flangePattern[1]); } } // 실제 필요한 볼트 수 = 플랜지 수 × 플랜지당 볼트 세트 수 const totalBoltsNeeded = qty * boltsPerFlange; const safetyQty = Math.ceil(totalBoltsNeeded * 1.05); // 5% 여유율 const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수 // 길이 정보 (bolt_details 우선, 없으면 원본 설명에서 추출) let boltLength = '-'; if (boltDetails.length && boltDetails.length !== '-') { boltLength = boltDetails.length; } else { // 원본 설명에서 길이 추출 const description = material.original_description || ''; const lengthPatterns = [ /(\d+(?:\.\d+)?)\s*LG/i, // 75 LG, 90.0000 LG /(\d+(?:\.\d+)?)\s*mm/i, // 50mm /(\d+(?:\.\d+)?)\s*MM/i, // 50MM /LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태 ]; for (const pattern of lengthPatterns) { const match = description.match(pattern); if (match) { let lengthValue = match[1]; // 소수점 제거 (145.0000 → 145) if (lengthValue.includes('.') && lengthValue.endsWith('.0000')) { lengthValue = lengthValue.split('.')[0]; } else if (lengthValue.includes('.') && /\.0+$/.test(lengthValue)) { lengthValue = lengthValue.split('.')[0]; } boltLength = `${lengthValue}mm`; break; } } } // 재질 정보 (bolt_details 우선, 없으면 기본 필드 사용) let boltGrade = '-'; if (boltDetails.material_standard && boltDetails.material_grade) { // bolt_details에서 완전한 재질 정보 구성 if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== boltDetails.material_standard) { boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`; } else { boltGrade = boltDetails.material_standard; } } else if (material.full_material_grade && material.full_material_grade !== '-') { boltGrade = material.full_material_grade; } else if (material.material_grade && material.material_grade !== '-') { boltGrade = material.material_grade; } // 볼트 타입 (PSV_BOLT, LT_BOLT 등) let boltSubtype = 'BOLT_GENERAL'; if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN') { boltSubtype = boltDetails.bolt_type; } else { // 원본 설명에서 특수 볼트 타입 추출 const description = material.original_description || ''; const upperDesc = description.toUpperCase(); if (upperDesc.includes('PSV')) { boltSubtype = 'PSV_BOLT'; } else if (upperDesc.includes('LT')) { boltSubtype = 'LT_BOLT'; } else if (upperDesc.includes('CK')) { boltSubtype = 'CK_BOLT'; } } // 압력 등급 추출 (150LB 등) let boltPressure = '-'; const description = material.original_description || ''; const pressureMatch = description.match(/(\d+)\s*LB/i); if (pressureMatch) { boltPressure = `${pressureMatch[1]}LB`; } // User Requirements 추출 (ELEC.GALV 등) const userRequirements = extractBoltAdditionalRequirements(material.original_description || ''); // Purchase Quantity (Quantity + Unit 통합) - 플랜지당 볼트 세트 수 정보 포함 const purchaseQuantity = boltsPerFlange > 1 ? `${purchaseQty} SETS (${boltsPerFlange}/flange)` : `${purchaseQty} SETS`; return { type: 'BOLT', subtype: boltSubtype, size: material.size_spec || material.main_nom || '-', pressure: boltPressure, // 압력 등급 (150LB 등) schedule: boltLength, // 길이 정보 grade: boltGrade, userRequirements: userRequirements, // User Requirements (ELEC.GALV 등) additionalReq: '-', // 추가요구사항 (사용자 입력) purchaseQuantity: purchaseQuantity // 구매수량 (통합) }; }; // 정렬 처리 const handleSort = (key) => { let direction = 'asc'; if (sortConfig.key === key && sortConfig.direction === 'asc') { direction = 'desc'; } setSortConfig({ key, direction }); }; // 필터링된 및 정렬된 자재 목록 const getFilteredAndSortedMaterials = () => { let filtered = materials.filter(material => { return Object.entries(columnFilters).every(([key, filterValue]) => { if (!filterValue) return true; const info = parseBoltInfo(material); const value = info[key]?.toString().toLowerCase() || ''; return value.includes(filterValue.toLowerCase()); }); }); if (sortConfig && sortConfig.key) { filtered.sort((a, b) => { const aInfo = parseBoltInfo(a); const bInfo = parseBoltInfo(b); if (!aInfo || !bInfo) return 0; const aValue = aInfo[sortConfig.key]; const bValue = bInfo[sortConfig.key]; // 값이 없는 경우 처리 if (aValue === undefined && bValue === undefined) return 0; if (aValue === undefined) return 1; if (bValue === undefined) return -1; // 숫자인 경우 숫자로 비교 if (typeof aValue === 'number' && typeof bValue === 'number') { return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue; } // 문자열로 비교 const aStr = String(aValue).toLowerCase(); const bStr = String(bValue).toLowerCase(); if (sortConfig.direction === 'asc') { return aStr.localeCompare(bStr); } else { return bStr.localeCompare(aStr); } }); } return filtered; }; // 전체 선택/해제 (구매신청된 자재 제외) const handleSelectAll = () => { const filteredMaterials = getFilteredAndSortedMaterials(); const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); if (selectedMaterials.size === selectableMaterials.length) { setSelectedMaterials(new Set()); } else { setSelectedMaterials(new Set(selectableMaterials.map(m => m.id))); } }; // 개별 선택 (구매신청된 자재는 선택 불가) const handleMaterialSelect = (materialId) => { if (purchasedMaterials.has(materialId)) { return; // 구매신청된 자재는 선택 불가 } const newSelected = new Set(selectedMaterials); if (newSelected.has(materialId)) { newSelected.delete(materialId); } else { newSelected.add(materialId); } setSelectedMaterials(newSelected); }; // 엑셀 내보내기 const handleExportToExcel = async () => { const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id)); if (selectedMaterialsData.length === 0) { alert('내보낼 자재를 선택해주세요.'); return; } const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); const excelFileName = `BOLT_Materials_${timestamp}.xlsx`; const dataWithRequirements = selectedMaterialsData.map(material => ({ ...material, user_requirement: savedRequests[material.id] || userRequirements[material.id] || '' })); try { console.log('🔄 볼트 엑셀 내보내기 시작 - 새로운 방식'); // 1. 먼저 클라이언트에서 엑셀 파일 생성 console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료'); const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, { category: 'BOLT', 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: 'BOLT', 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. 엑셀 파일을 서버에 업로드 const formData = new FormData(); formData.append('excel_file', excelBlob, excelFileName); formData.append('request_id', response.data.request_id); formData.append('category', 'BOLT'); console.log('📤 엑셀 파일 서버 업로드 중...'); await api.post('/purchase-request/upload-excel', formData, { headers: { 'Content-Type': 'multipart/form-data' } }); console.log('✅ 엑셀 파일 서버 업로드 완료'); // 4. 구매된 자재 목록 업데이트 (비활성화) onPurchasedMaterialsUpdate(allMaterialIds); console.log('✅ 구매된 자재 목록 업데이트 완료'); // 5. 클라이언트에 파일 다운로드 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 || ''}이 생성되고 엑셀 파일이 저장되었습니다.`); } else { throw new Error(response.data?.message || '구매신청 생성 실패'); } } catch (error) { console.error('엑셀 저장 또는 구매신청 실패:', error); // 실패 시에도 클라이언트 다운로드는 진행 exportMaterialsToExcel(dataWithRequirements, excelFileName, { category: 'BOLT', filename: excelFileName, uploadDate: new Date().toLocaleDateString() }); alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.'); } // 선택 해제 setSelectedMaterials(new Set()); }; const filteredMaterials = getFilteredAndSortedMaterials(); return (
{/* 헤더 */}

Bolt 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 Length Material Grade
User Requirements
Additional Request
Purchase Quantity
{/* 데이터 행들 */} {filteredMaterials.map((material, index) => { const info = parseBoltInfo(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}
{info.userRequirements}
{!editingRequest[material.id] && savedRequests[material.id] ? ( // 저장된 상태 - 요구사항 표시 + 수정 버튼 <>
{savedRequests[material.id]}
) : ( // 편집 상태 - 입력 필드 + 저장 버튼 <> 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' }} /> )}
{info.purchaseQuantity}
); })}
{filteredMaterials.length === 0 && (
No Bolt Materials Found
{Object.keys(columnFilters).some(key => columnFilters[key]) ? 'Try adjusting your filters' : 'No bolt materials available in this BOM'}
)}
); }; export default BoltMaterialsView;