diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8104c9b..a99bf71 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -34,7 +34,25 @@ function App() { const [newProjectName, setNewProjectName] = useState(''); const [newClientName, setNewClientName] = useState(''); const [pendingSignupCount, setPendingSignupCount] = useState(0); - const [inactiveProjects, setInactiveProjects] = useState(new Set()); + const [inactiveProjects, setInactiveProjects] = useState(() => { + // localStorage에서 비활성화된 프로젝트 목록 로드 + try { + const saved = localStorage.getItem('inactiveProjects'); + return saved ? new Set(JSON.parse(saved)) : new Set(); + } catch (error) { + console.error('비활성화 프로젝트 목록 로드 실패:', error); + return new Set(); + } + }); + + // 비활성화 프로젝트 목록이 변경될 때마다 localStorage에 저장 + useEffect(() => { + try { + localStorage.setItem('inactiveProjects', JSON.stringify(Array.from(inactiveProjects))); + } catch (error) { + console.error('비활성화 프로젝트 목록 저장 실패:', error); + } + }, [inactiveProjects]); // 승인 대기 중인 회원가입 수 조회 const loadPendingSignups = async () => { diff --git a/frontend/src/components/bom/materials/BoltMaterialsView.jsx b/frontend/src/components/bom/materials/BoltMaterialsView.jsx index c3b6a7b..f1006fb 100644 --- a/frontend/src/components/bom/materials/BoltMaterialsView.jsx +++ b/frontend/src/components/bom/materials/BoltMaterialsView.jsx @@ -149,17 +149,34 @@ const BoltMaterialsView = ({ }); }); - if (sortConfig.key) { + if (sortConfig && sortConfig.key) { filtered.sort((a, b) => { const aInfo = parseBoltInfo(a); const bInfo = parseBoltInfo(b); - const aValue = aInfo[sortConfig.key] || ''; - const bValue = bInfo[sortConfig.key] || ''; + + if (!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 aValue > bValue ? 1 : -1; + return aStr.localeCompare(bStr); } else { - return aValue < bValue ? 1 : -1; + return bStr.localeCompare(aStr); } }); } diff --git a/frontend/src/components/bom/materials/GasketMaterialsView.jsx b/frontend/src/components/bom/materials/GasketMaterialsView.jsx index 69d115c..6a7d583 100644 --- a/frontend/src/components/bom/materials/GasketMaterialsView.jsx +++ b/frontend/src/components/bom/materials/GasketMaterialsView.jsx @@ -10,7 +10,9 @@ const GasketMaterialsView = ({ userRequirements, setUserRequirements, purchasedMaterials, + onPurchasedMaterialsUpdate, fileId, + jobNo, user }) => { const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); @@ -21,27 +23,57 @@ const GasketMaterialsView = ({ const qty = Math.round(material.quantity || 0); const purchaseQty = Math.ceil(qty * 1.05 / 5) * 5; // 5% 여유율 + 5의 배수 - // original_description에서 재질 정보 파싱 (기존 NewMaterialsPage와 동일) const description = material.original_description || ''; - let materialStructure = '-'; // H/F/I/O 부분 - let materialDetail = '-'; // SS304/GRAPHITE/CS/CS 부분 - // H/F/I/O와 재질 상세 정보 추출 - const materialMatch = description.match(/H\/F\/I\/O\s+(.+?)(?:,|$)/); - if (materialMatch) { - materialStructure = 'H/F/I/O'; - materialDetail = materialMatch[1].trim(); - // 두께 정보 제거 (별도 추출) - materialDetail = materialDetail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim(); + // 가스켓 타입 풀네임 매핑 + const gasketTypeMap = { + 'SWG': 'SPIRAL WOUND GASKET', + 'RTJ': 'RING TYPE JOINT', + 'FF': 'FULL FACE GASKET', + 'RF': 'RAISED FACE GASKET', + 'SHEET': 'SHEET GASKET', + 'O-RING': 'O-RING GASKET' + }; + + // 타입 추출 및 풀네임 변환 + let gasketType = '-'; + const typeMatch = description.match(/\b(SWG|RTJ|FF|RF|SHEET|O-RING)\b/i); + if (typeMatch) { + const shortType = typeMatch[1].toUpperCase(); + gasketType = gasketTypeMap[shortType] || shortType; } - // 압력 정보 추출 + // 크기 정보 추출 (예: 1 1/2") + let size = material.size_spec || material.size_inch || '-'; + if (size === '-') { + const sizeMatch = description.match(/(\d+(?:\s+\d+\/\d+)?(?:\.\d+)?)\s*"/); + if (sizeMatch) { + size = sizeMatch[1] + '"'; + } + } + + // 압력등급 추출 let pressure = '-'; - const pressureMatch = description.match(/(\d+LB)/); + const pressureMatch = description.match(/(\d+LB)/i); if (pressureMatch) { pressure = pressureMatch[1]; } + // 구조 정보 추출 (H/F/I/O) + let structure = '-'; + if (description.includes('H/F/I/O')) { + structure = 'H/F/I/O'; + } + + // 재질 상세 정보 추출 (SS304/GRAPHITE/SS304/SS304) + let material_detail = '-'; + const materialMatch = description.match(/H\/F\/I\/O\s+([^,]+)/); + if (materialMatch) { + material_detail = materialMatch[1].trim(); + // 두께 정보 제거 + material_detail = material_detail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim(); + } + // 두께 정보 추출 let thickness = '-'; const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i); @@ -50,17 +82,14 @@ const GasketMaterialsView = ({ } return { - type: 'GASKET', - subtype: 'SWG', // 항상 SWG로 표시 - size: material.size_spec || '-', + type: gasketType, // 풀네임으로 표시 (SPIRAL WOUND GASKET) + size: size, pressure: pressure, - schedule: thickness, // 두께를 schedule 열에 표시 - materialStructure: materialStructure, - materialDetail: materialDetail, + structure: structure, // H/F/I/O + material: material_detail, // SS304/GRAPHITE/SS304/SS304 thickness: thickness, - grade: materialDetail, // 재질 상세를 grade로 표시 - quantity: purchaseQty, - unit: '개', + userRequirements: material.user_requirements?.join(', ') || '-', + purchaseQuantity: purchaseQty, isGasket: true }; }; @@ -85,17 +114,34 @@ const GasketMaterialsView = ({ }); }); - if (sortConfig.key) { + if (sortConfig && sortConfig.key) { filtered.sort((a, b) => { const aInfo = parseGasketInfo(a); const bInfo = parseGasketInfo(b); - const aValue = aInfo[sortConfig.key] || ''; - const bValue = bInfo[sortConfig.key] || ''; + + if (!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 aValue > bValue ? 1 : -1; + return aStr.localeCompare(bStr); } else { - return aValue < bValue ? 1 : -1; + return bStr.localeCompare(aStr); } }); } @@ -147,28 +193,79 @@ const GasketMaterialsView = ({ })); try { - await api.post('/files/save-excel', { + console.log('🔄 가스켓 엑셀 내보내기 시작 - 새로운 방식'); + + // 1. 먼저 클라이언트에서 엑셀 파일 생성 + console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료'); + const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, { + category: 'GASKET', + 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: 'GASKET', - materials: dataWithRequirements, - filename: excelFileName, - user_id: user?.id + 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] || '' + })) }); - exportMaterialsToExcel(dataWithRequirements, excelFileName, { - category: 'GASKET', - filename: excelFileName, - uploadDate: new Date().toLocaleDateString() - }); + 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', 'GASKET'); + + const uploadResponse = await api.post('/purchase-request/upload-excel', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + console.log('✅ 엑셀 업로드 완료:', uploadResponse.data); - alert('엑셀 파일이 생성되고 서버에 저장되었습니다.'); + 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); + console.error('엑셀 저장 또는 구매신청 실패:', error); + // 실패 시에도 클라이언트 다운로드는 진행 exportMaterialsToExcel(dataWithRequirements, excelFileName, { category: 'GASKET', filename: excelFileName, uploadDate: new Date().toLocaleDateString() }); + alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.'); } }; @@ -236,20 +333,23 @@ const GasketMaterialsView = ({