Files
TK-BOM-Project/frontend/src/pages/BOMManagementPage.jsx
hyungi c7297c6fb7
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
feat: 피팅류 엑셀 내보내기 개선 및 프로젝트 비활성화 버그 수정
 피팅류 엑셀 내보내기 개선:
- 품목명에 상세 피팅 타입 표시 (SOCK-O-LET, ELBOW 90° LR 등)
- G열부터 압력등급/스케줄/재질/사용자요구/추가요청사항 체계적 배치
- 분류기 추출 요구사항(J열)과 사용자 입력 요구사항(K열) 분리
- P열 납기일 고정 규칙 유지, 관리항목 자동 채움

🐛 프로젝트 비활성화 버그 수정:
- 백엔드: job_no 필드 추가로 프론트엔드 호환성 확보
- 프론트엔드: 안전한 프로젝트 식별자 처리 로직 구현
- 개별 프로젝트 비활성화 시 전체 프로젝트 영향 문제 해결
- 디버깅 로그 추가로 상태 변경 추적 가능

🔧 기타 개선사항:
- BOM 페이지 이모지 제거
- 구매신청 후 자재 비활성화 기능 구현
- 모든 카테고리 뷰에 onPurchasedMaterialsUpdate 콜백 추가
2025-10-16 14:00:44 +09:00

458 lines
15 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { fetchMaterials } from '../api';
import api from '../api';
import {
PipeMaterialsView,
FittingMaterialsView,
FlangeMaterialsView,
ValveMaterialsView,
GasketMaterialsView,
BoltMaterialsView,
SupportMaterialsView
} from '../components/bom';
import './BOMManagementPage.css';
const BOMManagementPage = ({
onNavigate,
selectedProject,
fileId,
jobNo,
bomName,
revision,
filename,
user
}) => {
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState('PIPE');
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [exportHistory, setExportHistory] = useState([]);
const [availableRevisions, setAvailableRevisions] = useState([]);
const [currentRevision, setCurrentRevision] = useState(revision || 'Rev.0');
const [userRequirements, setUserRequirements] = useState({});
const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
const [error, setError] = useState(null);
// 카테고리 정의
const categories = [
{ key: 'PIPE', label: 'Pipes', color: '#3b82f6' },
{ key: 'FITTING', label: 'Fittings', color: '#10b981' },
{ key: 'FLANGE', label: 'Flanges', color: '#f59e0b' },
{ key: 'VALVE', label: 'Valves', color: '#ef4444' },
{ key: 'GASKET', label: 'Gaskets', color: '#8b5cf6' },
{ key: 'BOLT', label: 'Bolts', color: '#6b7280' },
{ key: 'SUPPORT', label: 'Supports', color: '#f97316' }
];
// 자료 로드 함수들
const loadMaterials = async (id) => {
try {
setLoading(true);
console.log('🔍 자재 데이터 로딩 중...', {
file_id: id,
selectedProject: selectedProject?.job_no || selectedProject?.official_project_code,
jobNo
});
// 구매신청된 자재 먼저 확인
const projectJobNo = selectedProject?.job_no || selectedProject?.official_project_code || jobNo;
await loadPurchasedMaterials(projectJobNo);
const response = await fetchMaterials({
file_id: parseInt(id),
limit: 10000,
exclude_requested: false,
job_no: projectJobNo
});
if (response.data?.materials) {
const materialsData = response.data.materials;
console.log(`${materialsData.length}개 원본 자재 로드 완료`);
setMaterials(materialsData);
setError(null);
} else {
console.warn('⚠️ 자재 데이터가 없습니다:', response.data);
setMaterials([]);
}
} catch (error) {
console.error('자재 로드 실패:', error);
setError('자재 로드에 실패했습니다.');
} finally {
setLoading(false);
}
};
const loadAvailableRevisions = async () => {
try {
const response = await api.get('/files/', {
params: { job_no: jobNo }
});
const allFiles = Array.isArray(response.data) ? response.data : response.data?.files || [];
const sameBomFiles = allFiles.filter(file =>
(file.bom_name || file.original_filename) === bomName
);
sameBomFiles.sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA;
});
setAvailableRevisions(sameBomFiles);
} catch (error) {
console.error('리비전 목록 조회 실패:', error);
}
};
const loadPurchasedMaterials = async (jobNo) => {
try {
// 새로운 API로 구매신청된 자재 ID 목록 조회
const response = await api.get('/purchase-request/requested-materials', {
params: {
job_no: jobNo,
file_id: fileId
}
});
if (response.data?.requested_material_ids) {
const purchasedIds = new Set(response.data.requested_material_ids);
setPurchasedMaterials(purchasedIds);
console.log(`${purchasedIds.size}개 구매신청된 자재 ID 로드 완료`);
}
} catch (error) {
console.error('구매신청 자재 조회 실패:', error);
}
};
const loadUserRequirements = async (fileId) => {
try {
const response = await api.get(`/files/${fileId}/user-requirements`);
if (response.data?.requirements) {
const reqMap = {};
response.data.requirements.forEach(req => {
reqMap[req.material_id] = req.requirement;
});
setUserRequirements(reqMap);
}
} catch (error) {
console.error('사용자 요구사항 로드 실패:', error);
}
};
// 초기 로드
useEffect(() => {
if (fileId) {
loadMaterials(fileId);
loadAvailableRevisions();
loadUserRequirements(fileId);
}
}, [fileId]);
// 카테고리별 자재 필터링
const getCategoryMaterials = (category) => {
return materials.filter(material =>
material.classified_category === category ||
material.category === category
);
};
// 카테고리별 컴포넌트 렌더링
const renderCategoryView = () => {
const categoryMaterials = getCategoryMaterials(selectedCategory);
const commonProps = {
materials: categoryMaterials,
selectedMaterials,
setSelectedMaterials,
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
};
switch (selectedCategory) {
case 'PIPE':
return <PipeMaterialsView {...commonProps} />;
case 'FITTING':
return <FittingMaterialsView {...commonProps} />;
case 'FLANGE':
return <FlangeMaterialsView {...commonProps} />;
case 'VALVE':
return <ValveMaterialsView {...commonProps} />;
case 'GASKET':
return <GasketMaterialsView {...commonProps} />;
case 'BOLT':
return <BoltMaterialsView {...commonProps} />;
case 'SUPPORT':
return <SupportMaterialsView {...commonProps} />;
default:
return <div>카테고리를 선택해주세요.</div>;
}
};
if (loading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: '60px',
height: '60px',
border: '4px solid #e2e8f0',
borderTop: '4px solid #3b82f6',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 20px'
}}></div>
<div style={{ fontSize: '18px', color: '#64748b', fontWeight: '600' }}>
Loading Materials...
</div>
</div>
</div>
);
}
return (
<div style={{
padding: '40px',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
minHeight: '100vh'
}}>
{/* 헤더 섹션 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '32px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.2)',
marginBottom: '40px'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
<div>
<h2 style={{
fontSize: '28px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0',
letterSpacing: '-0.025em'
}}>
BOM Materials Management
</h2>
<p style={{
fontSize: '16px',
color: '#64748b',
margin: 0,
fontWeight: '400'
}}>
{bomName} - {currentRevision} | Project: {selectedProject?.job_name || jobNo}
</p>
</div>
<button
onClick={() => onNavigate('dashboard')}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '12px',
padding: '12px 20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease',
letterSpacing: '0.025em'
}}
onMouseEnter={(e) => {
e.target.style.background = '#f9fafb';
e.target.style.borderColor = '#9ca3af';
}}
onMouseLeave={(e) => {
e.target.style.background = 'white';
e.target.style.borderColor = '#d1d5db';
}}
>
Back to Dashboard
</button>
</div>
{/* 통계 정보 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
<div style={{
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#1d4ed8', marginBottom: '4px' }}>
{materials.length}
</div>
<div style={{ fontSize: '14px', color: '#1d4ed8', fontWeight: '500' }}>
Total Materials
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#059669', marginBottom: '4px' }}>
{getCategoryMaterials(selectedCategory).length}
</div>
<div style={{ fontSize: '14px', color: '#059669', fontWeight: '500' }}>
{selectedCategory} Items
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#92400e', marginBottom: '4px' }}>
{selectedMaterials.size}
</div>
<div style={{ fontSize: '14px', color: '#92400e', fontWeight: '500' }}>
Selected
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #fee2e2 0%, #fecaca 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#dc2626', marginBottom: '4px' }}>
{purchasedMaterials.size}
</div>
<div style={{ fontSize: '14px', color: '#dc2626', fontWeight: '500' }}>
Purchased
</div>
</div>
</div>
</div>
{/* 카테고리 탭 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '24px 32px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.2)',
marginBottom: '40px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: '16px'
}}>
{categories.map((category) => {
const isActive = selectedCategory === category.key;
const count = getCategoryMaterials(category.key).length;
return (
<button
key={category.key}
onClick={() => setSelectedCategory(category.key)}
style={{
background: isActive
? `linear-gradient(135deg, ${category.color} 0%, ${category.color}dd 100%)`
: 'white',
color: isActive ? 'white' : '#64748b',
border: isActive ? 'none' : '1px solid #e2e8f0',
borderRadius: '12px',
padding: '16px 12px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease',
textAlign: 'center',
boxShadow: isActive ? `0 4px 14px 0 ${category.color}39` : '0 2px 8px rgba(0,0,0,0.05)'
}}
onMouseEnter={(e) => {
if (!isActive) {
e.target.style.background = '#f8fafc';
e.target.style.borderColor = '#cbd5e1';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.target.style.background = 'white';
e.target.style.borderColor = '#e2e8f0';
}
}}
>
<div style={{ marginBottom: '4px' }}>
{category.label}
</div>
<div style={{
fontSize: '12px',
opacity: 0.8,
fontWeight: '500'
}}>
{count} items
</div>
</button>
);
})}
</div>
</div>
{/* 카테고리별 컨텐츠 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.2)',
overflow: 'hidden'
}}>
{error ? (
<div style={{
padding: '60px',
textAlign: 'center',
color: '#dc2626'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
Error Loading Materials
</div>
<div style={{ fontSize: '14px' }}>
{error}
</div>
</div>
) : (
renderCategoryView()
)}
</div>
</div>
);
};
export default BOMManagementPage;