diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index da203c6..17a3af8 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -526,6 +526,7 @@ async def get_projects( projects.append({ "id": row.id, "official_project_code": row.official_project_code, + "job_no": row.official_project_code, # job_no 필드 추가 (프론트엔드 호환성) "project_name": row.project_name, "job_name": row.project_name, # 호환성을 위해 추가 "client_name": row.client_name, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c70cb52..8104c9b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -148,9 +148,13 @@ function App() { // 프로젝트 활성화 const handleActivateProject = (project) => { + const projectId = project.job_no || project.official_project_code || project.id; + console.log('🔄 프로젝트 활성화:', { project, projectId }); + setInactiveProjects(prev => { const newSet = new Set(prev); - newSet.delete(project.job_no); + newSet.delete(projectId); + console.log('📦 활성화 프로젝트 업데이트:', { prev: Array.from(prev), new: Array.from(newSet) }); return newSet; }); }; diff --git a/frontend/src/components/bom/materials/FittingMaterialsView.jsx b/frontend/src/components/bom/materials/FittingMaterialsView.jsx index cc0cc83..89bc56f 100644 --- a/frontend/src/components/bom/materials/FittingMaterialsView.jsx +++ b/frontend/src/components/bom/materials/FittingMaterialsView.jsx @@ -10,7 +10,9 @@ const FittingMaterialsView = ({ userRequirements, setUserRequirements, purchasedMaterials, + onPurchasedMaterialsUpdate, fileId, + jobNo, user, onNavigate }) => { @@ -245,16 +247,73 @@ const FittingMaterialsView = ({ } } - // 스케줄 표시 (분리 스케줄 지원) + // 스케줄 표시 (분리 스케줄 지원) - 개선된 로직 + // 레듀싱 자재인지 확인 + 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 (mainSchedule) { + } else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN') { + // 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시 + schedule = `${mainSchedule}×${mainSchedule}`; + } else if (mainSchedule && mainSchedule !== 'UNKNOWN') { schedule = mainSchedule; } else { - // Description에서 스케줄 추출 - const scheduleMatch = description.match(/SCH\s*(\d+[A-Z]*)/i); - if (scheduleMatch) { - schedule = `SCH ${scheduleMatch[1]}`; + // 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; + } + } + } } } @@ -353,6 +412,36 @@ const FittingMaterialsView = ({ })); try { + // 1. 구매신청 생성 + 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}`); + + // 2. 구매신청된 자재 ID를 purchasedMaterials에 추가 + if (onPurchasedMaterialsUpdate) { + onPurchasedMaterialsUpdate(allMaterialIds); + } + } + + // 3. 서버에 엑셀 파일 저장 요청 await api.post('/files/save-excel', { file_id: fileId, category: 'FITTING', @@ -361,21 +450,27 @@ const FittingMaterialsView = ({ user_id: user?.id }); + // 4. 클라이언트에서 다운로드 exportMaterialsToExcel(dataWithRequirements, excelFileName, { category: 'FITTING', filename: excelFileName, uploadDate: new Date().toLocaleDateString() }); - alert('엑셀 파일이 생성되고 서버에 저장되었습니다.'); + alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`); } catch (error) { - console.error('엑셀 저장 실패:', error); + console.error('엑셀 저장 또는 구매신청 실패:', error); + // 실패해도 다운로드는 진행 exportMaterialsToExcel(dataWithRequirements, excelFileName, { category: 'FITTING', filename: excelFileName, uploadDate: new Date().toLocaleDateString() }); + alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.'); } + + // 선택 해제 + setSelectedMaterials(new Set()); }; const filteredMaterials = getFilteredAndSortedMaterials(); @@ -506,21 +601,24 @@ const FittingMaterialsView = ({
- {/* 헤더 */} -
+
+ {/* 헤더 */} +
Pressure
Schedule
Material Grade
-
Quantity
-
Unit
-
User Requirement
+
User Requirements
+
Purchase Quantity
+
Additional Request
- {/* 데이터 행들 */} -
+ {/* 데이터 행들 */} {filteredMaterials.map((material, index) => { const info = parseFittingInfo(material); const isSelected = selectedMaterials.has(material.id); @@ -554,12 +651,13 @@ const FittingMaterialsView = ({ key={material.id} style={{ display: 'grid', - gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px', + gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 200px', gap: '16px', padding: '16px', borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none', background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'), - transition: 'background 0.15s ease' + transition: 'background 0.15s ease', + textAlign: 'center' }} onMouseEnter={(e) => { if (!isSelected && !isPurchased) { @@ -612,11 +710,17 @@ const FittingMaterialsView = ({
{info.grade}
-
- {info.quantity} +
+ {material.user_requirements?.join(', ') || '-'}
-
- {info.unit} +
+ {info.quantity} {info.unit}
{ @@ -199,6 +201,34 @@ const FlangeMaterialsView = ({ })); try { + // 1. 구매신청 생성 + const allMaterialIds = selectedMaterialsData.map(m => m.id); + const response = await api.post('/purchase-request/create', { + file_id: fileId, + job_no: jobNo, + category: 'FLANGE', + 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}`); + if (onPurchasedMaterialsUpdate) { + onPurchasedMaterialsUpdate(allMaterialIds); + } + } + + // 2. 서버에 엑셀 파일 저장 await api.post('/files/save-excel', { file_id: fileId, category: 'FLANGE', @@ -207,21 +237,25 @@ const FlangeMaterialsView = ({ user_id: user?.id }); + // 3. 클라이언트 다운로드 exportMaterialsToExcel(dataWithRequirements, excelFileName, { category: 'FLANGE', filename: excelFileName, uploadDate: new Date().toLocaleDateString() }); - alert('엑셀 파일이 생성되고 서버에 저장되었습니다.'); + alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`); } catch (error) { - console.error('엑셀 저장 실패:', error); + console.error('엑셀 저장 또는 구매신청 실패:', error); exportMaterialsToExcel(dataWithRequirements, excelFileName, { category: 'FLANGE', filename: excelFileName, uploadDate: new Date().toLocaleDateString() }); + alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.'); } + + setSelectedMaterials(new Set()); }; const filteredMaterials = getFilteredAndSortedMaterials(); diff --git a/frontend/src/components/bom/materials/PipeMaterialsView.jsx b/frontend/src/components/bom/materials/PipeMaterialsView.jsx index 6400a43..c55d743 100644 --- a/frontend/src/components/bom/materials/PipeMaterialsView.jsx +++ b/frontend/src/components/bom/materials/PipeMaterialsView.jsx @@ -10,7 +10,9 @@ const PipeMaterialsView = ({ userRequirements, setUserRequirements, purchasedMaterials, + onPurchasedMaterialsUpdate, fileId, + jobNo, user, onNavigate }) => { @@ -177,7 +179,36 @@ const PipeMaterialsView = ({ })); try { - // 서버에 엑셀 파일 저장 요청 + // 1. 구매신청 생성 + const allMaterialIds = selectedMaterialsData.map(m => m.id); + const response = await api.post('/purchase-request/create', { + file_id: fileId, + job_no: jobNo, + category: 'PIPE', + 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}`); + + // 2. 구매신청된 자재 ID를 purchasedMaterials에 추가 + if (onPurchasedMaterialsUpdate) { + onPurchasedMaterialsUpdate(allMaterialIds); + } + } + + // 3. 서버에 엑셀 파일 저장 요청 await api.post('/files/save-excel', { file_id: fileId, category: 'PIPE', @@ -186,23 +217,27 @@ const PipeMaterialsView = ({ user_id: user?.id }); - // 클라이언트에서 다운로드 + // 4. 클라이언트에서 다운로드 exportMaterialsToExcel(dataWithRequirements, excelFileName, { category: 'PIPE', filename: excelFileName, uploadDate: new Date().toLocaleDateString() }); - alert('엑셀 파일이 생성되고 서버에 저장되었습니다.'); + alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`); } catch (error) { - console.error('엑셀 저장 실패:', error); + console.error('엑셀 저장 또는 구매신청 실패:', error); // 실패해도 다운로드는 진행 exportMaterialsToExcel(dataWithRequirements, excelFileName, { category: 'PIPE', filename: excelFileName, uploadDate: new Date().toLocaleDateString() }); + alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.'); } + + // 선택 해제 + setSelectedMaterials(new Set()); }; const filteredMaterials = getFilteredAndSortedMaterials(); diff --git a/frontend/src/components/bom/materials/ValveMaterialsView.jsx b/frontend/src/components/bom/materials/ValveMaterialsView.jsx index 4d38b31..3d45109 100644 --- a/frontend/src/components/bom/materials/ValveMaterialsView.jsx +++ b/frontend/src/components/bom/materials/ValveMaterialsView.jsx @@ -10,7 +10,9 @@ const ValveMaterialsView = ({ userRequirements, setUserRequirements, purchasedMaterials, + onPurchasedMaterialsUpdate, fileId, + jobNo, user }) => { const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); diff --git a/frontend/src/pages/BOMManagementPage.jsx b/frontend/src/pages/BOMManagementPage.jsx index 012ba93..3c0d274 100644 --- a/frontend/src/pages/BOMManagementPage.jsx +++ b/frontend/src/pages/BOMManagementPage.jsx @@ -167,7 +167,16 @@ const BOMManagementPage = ({ userRequirements, setUserRequirements, purchasedMaterials, + onPurchasedMaterialsUpdate: (materialIds) => { + setPurchasedMaterials(prev => { + const newSet = new Set(prev); + materialIds.forEach(id => newSet.add(id)); + console.log(`📦 구매신청 자재 추가: 기존 ${prev.size}개 → 신규 ${newSet.size}개`); + return newSet; + }); + }, fileId, + jobNo, user, onNavigate }; diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 58ff764..019c78e 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -43,9 +43,20 @@ const DashboardPage = ({ // 프로젝트 비활성화 const handleDeactivateProject = (project) => { - if (window.confirm(`"${project.job_name || project.job_no}" 프로젝트를 비활성화하시겠습니까?`)) { - setInactiveProjects(prev => new Set([...prev, project.job_no])); - if (selectedProject?.job_no === project.job_no) { + const projectId = project.job_no || project.official_project_code || project.id; + const projectName = project.job_name || project.project_name || projectId; + + console.log('🔍 비활성화 요청:', { project, projectId, projectName }); + + if (window.confirm(`"${projectName}" 프로젝트를 비활성화하시겠습니까?`)) { + setInactiveProjects(prev => { + const newSet = new Set([...prev, projectId]); + console.log('📦 비활성화 프로젝트 업데이트:', { prev: Array.from(prev), new: Array.from(newSet) }); + return newSet; + }); + + const selectedProjectId = selectedProject?.job_no || selectedProject?.official_project_code || selectedProject?.id; + if (selectedProjectId === projectId) { setSelectedProject(null); } setShowProjectDropdown(false); @@ -323,7 +334,10 @@ const DashboardPage = ({
) : ( projects - .filter(project => !inactiveProjects.has(project.job_no)) + .filter(project => { + const projectId = project.job_no || project.official_project_code || project.id; + return !inactiveProjects.has(projectId); + }) .map((project) => (
{ } } } else if (category === 'FITTING') { - // 피팅 상세 타입 표시 (OLET 등 풀네임) + // 피팅 상세 타입 표시 - 프론트엔드 표시와 동일한 로직 사용 const fittingDetails = material.fitting_details || {}; - const fittingType = fittingDetails.fitting_type || ''; - const fittingSubtype = fittingDetails.fitting_subtype || ''; + const classificationDetails = material.classification_details || {}; + const fittingTypeInfo = classificationDetails.fitting_type || {}; + + const fittingType = fittingTypeInfo.type || fittingDetails.fitting_type || ''; + const fittingSubtype = fittingTypeInfo.subtype || fittingDetails.fitting_subtype || ''; + + // 프론트엔드와 동일한 displayType 로직 사용 + let displayType = ''; if (fittingType === 'OLET') { // OLET 풀네임 표시 switch (fittingSubtype) { case 'SOCKOLET': - itemName = 'SOCK-O-LET'; + displayType = 'SOCK-O-LET'; break; case 'WELDOLET': - itemName = 'WELD-O-LET'; + displayType = 'WELD-O-LET'; break; case 'ELLOLET': - itemName = 'ELL-O-LET'; + displayType = 'ELL-O-LET'; break; case 'THREADOLET': - itemName = 'THREAD-O-LET'; + displayType = 'THREAD-O-LET'; break; case 'ELBOLET': - itemName = 'ELB-O-LET'; + displayType = 'ELB-O-LET'; break; case 'NIPOLET': - itemName = 'NIP-O-LET'; + displayType = 'NIP-O-LET'; break; case 'COUPOLET': - itemName = 'COUP-O-LET'; + displayType = 'COUP-O-LET'; break; default: - itemName = 'OLET'; + // Description에서 직접 추출 + const descUpper = cleanDescription.toUpperCase(); + if (descUpper.includes('SOCK-O-LET') || descUpper.includes('SOCKOLET')) { + displayType = 'SOCK-O-LET'; + } else if (descUpper.includes('WELD-O-LET') || descUpper.includes('WELDOLET')) { + displayType = 'WELD-O-LET'; + } else if (descUpper.includes('ELL-O-LET') || descUpper.includes('ELLOLET')) { + displayType = 'ELL-O-LET'; + } else if (descUpper.includes('THREAD-O-LET') || descUpper.includes('THREADOLET')) { + displayType = 'THREAD-O-LET'; + } else if (descUpper.includes('ELB-O-LET') || descUpper.includes('ELBOLET')) { + displayType = 'ELB-O-LET'; + } else if (descUpper.includes('NIP-O-LET') || descUpper.includes('NIPOLET')) { + displayType = 'NIP-O-LET'; + } else if (descUpper.includes('COUP-O-LET') || descUpper.includes('COUPOLET')) { + displayType = 'COUP-O-LET'; + } else { + displayType = 'OLET'; + } } + } else 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 (cleanDescription.toUpperCase().includes('TEE RED')) { + displayType = 'TEE REDUCING'; + } else if (cleanDescription.toUpperCase().includes('RED CONC')) { + displayType = 'REDUCER CONC'; + } else if (cleanDescription.toUpperCase().includes('RED ECC')) { + displayType = 'REDUCER ECC'; + } else if (cleanDescription.toUpperCase().includes('CAP')) { + if (cleanDescription.includes('NPT(F)')) { + displayType = 'CAP NPT(F)'; + } else if (cleanDescription.includes('SW')) { + displayType = 'CAP SW'; + } else if (cleanDescription.includes('BW')) { + displayType = 'CAP BW'; + } else { + displayType = 'CAP'; + } + } else if (cleanDescription.toUpperCase().includes('PLUG')) { + if (cleanDescription.toUpperCase().includes('HEX')) { + if (cleanDescription.includes('NPT(M)')) { + displayType = 'HEX PLUG NPT(M)'; + } else { + displayType = 'HEX PLUG'; + } + } else if (cleanDescription.includes('NPT(M)')) { + displayType = 'PLUG NPT(M)'; + } else if (cleanDescription.includes('NPT')) { + displayType = 'PLUG NPT'; + } else { + displayType = 'PLUG'; + } + } else if (fittingType === 'NIPPLE') { + const length = fittingDetails.length_mm || fittingDetails.avg_length_mm; + let nippleType = 'NIPPLE'; + if (length) nippleType += ` ${length}mm`; + displayType = nippleType; } else if (fittingType === 'ELBOW') { - // 엘보 상세 정보 표시 (각도, 반경, 연결방식) + // 엘보 상세 정보 표시 let elbowDetails = []; // 각도 정보 if (fittingSubtype.includes('90DEG') || cleanDescription.includes('90')) { - elbowDetails.push('90도'); + elbowDetails.push('90°'); } else if (fittingSubtype.includes('45DEG') || cleanDescription.includes('45')) { - elbowDetails.push('45도'); - } else { - elbowDetails.push('90도'); // 기본값 + elbowDetails.push('45°'); } // 반경 정보 @@ -232,25 +295,12 @@ const formatMaterialForExcel = (material, includeComparison = false) => { elbowDetails.push('SR'); } - // 연결 방식 - if (cleanDescription.includes('SW')) { - elbowDetails.push('SW'); - } else if (cleanDescription.includes('BW')) { - elbowDetails.push('BW'); - } - - itemName = `엘보 ${elbowDetails.join(' ')}`.trim(); - } else if (fittingType === 'TEE') { - // 티 타입 표시 - const teeType = fittingSubtype === 'EQUAL' ? '등경' : fittingSubtype === 'REDUCING' ? '축소' : ''; - itemName = `티 ${teeType}`.trim(); - } else if (fittingType === 'REDUCER') { - // 리듀서 타입 표시 - const reducerType = fittingSubtype === 'CONCENTRIC' ? '동심' : fittingSubtype === 'ECCENTRIC' ? '편심' : ''; - itemName = `리듀서 ${reducerType}`.trim(); + displayType = elbowDetails.length > 0 ? `ELBOW ${elbowDetails.join(' ')}` : 'ELBOW'; } else { - itemName = fittingType || 'FITTING'; + displayType = fittingType || 'FITTING'; } + + itemName = displayType; } else if (category === 'FLANGE') { // 플랜지 상세 타입 표시 const flangeDetails = material.flange_details || {}; @@ -674,17 +724,17 @@ const formatMaterialForExcel = (material, includeComparison = false) => { base['관리항목3'] = ''; // N열 base['관리항목4'] = ''; // O열 } else if (category === 'FITTING') { - // 피팅 전용 컬럼 (F~O) - 타입 제거, 품목명에 포함됨 + // 피팅 전용 컬럼 (F~O) - 새로운 구조 base['크기'] = material.size_spec || '-'; // F열 base['압력등급'] = pressure; // G열 - base['재질'] = grade; // H열 - base['상세내역'] = detailInfo || '-'; // I열 - base['사용자요구'] = material.user_requirement || ''; // J열 - base['관리항목1'] = ''; // K열 - base['관리항목2'] = ''; // L열 - base['관리항목3'] = ''; // M열 - base['관리항목4'] = ''; // N열 - base['관리항목5'] = ''; // O열 + base['스케줄'] = schedule; // H열 + base['재질'] = grade; // I열 + base['사용자요구'] = material.user_requirements?.join(', ') || ''; // J열 (분류기에서 추출) + base['추가요청사항'] = material.user_requirement || ''; // K열 (사용자 입력) + base['관리항목1'] = ''; // L열 + base['관리항목2'] = ''; // M열 + base['관리항목3'] = ''; // N열 + base['관리항목4'] = ''; // O열 } else if (category === 'FLANGE') { // 플랜지 타입 풀네임 매핑 (영어) const flangeTypeMap = {