feat: 통합 BOM 관리 시스템 구현
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

🎯 주요 변경사항:
- 통합 BOM 페이지 (UnifiedBOMPage) 신규 개발
- 탭 구조로 업로드 → 파일 관리 → 자재 관리 워크플로우 개선
- 컴포넌트 분리로 스파게티 코드 방지

📤 업로드 탭 (BOMUploadTab):
- 드래그 앤 드롭 파일 업로드
- 파일 검증 및 진행률 표시
- 업로드 완료 후 자동 Files 탭 이동

📊 파일 관리 탭 (BOMFilesTab):
- 프로젝트별 BOM 파일 목록 조회
- 리비전 히스토리 표시
- BOM 선택 후 자동 Materials 탭 이동

📋 자재 관리 탭 (BOMMaterialsTab):
- 기존 BOMManagementPage 래핑
- 선택된 BOM의 자재 분류 및 관리

🔧 백엔드 API 개선:
- /files/project/{project_code} 엔드포인트 추가
- 한글 프로젝트 코드 URL 인코딩 지원
- 프로젝트별 파일 조회 기능 구현

🎨 대시보드 개선:
- 3개 BOM 카드를 1개 통합 카드로 변경
- 기능 미리보기 태그 추가
- 더 직관적인 네비게이션

📁 코드 구조 개선:
- 기존 페이지들을 _deprecated 폴더로 이동
- 탭별 컴포넌트 분리 (components/bom/tabs/)
- PAGES_GUIDE.md 업데이트

 사용자 경험 개선:
- 자연스러운 워크플로우 (업로드 → 선택 → 관리)
- 탭 간 상태 공유 및 자동 네비게이션
- 통합된 인터페이스에서 모든 BOM 작업 처리
This commit is contained in:
hyungi
2025-10-17 14:44:17 +09:00
parent e0ad21bfad
commit ab607dfa9a
12 changed files with 2405 additions and 23 deletions

View File

@@ -0,0 +1,429 @@
import React, { useState, useEffect } from 'react';
import api from '../../../api';
const BOMFilesTab = ({
selectedProject,
user,
bomFiles,
setBomFiles,
selectedBOM,
onBOMSelect,
refreshTrigger
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [groupedFiles, setGroupedFiles] = useState({});
// BOM 파일 목록 로드
useEffect(() => {
const loadBOMFiles = async () => {
if (!selectedProject) return;
try {
setLoading(true);
setError('');
const projectCode = selectedProject.official_project_code || selectedProject.job_no;
const encodedProjectCode = encodeURIComponent(projectCode);
const response = await api.get(`/files/project/${encodedProjectCode}`);
const files = response.data || [];
setBomFiles(files);
// BOM 이름별로 그룹화
const groups = groupFilesByBOM(files);
setGroupedFiles(groups);
} catch (err) {
console.error('BOM 파일 로드 실패:', err);
setError('BOM 파일을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
loadBOMFiles();
}, [selectedProject, refreshTrigger, setBomFiles]);
// 파일을 BOM 이름별로 그룹화
const groupFilesByBOM = (fileList) => {
const groups = {};
fileList.forEach(file => {
const bomName = file.bom_name || file.original_filename;
if (!groups[bomName]) {
groups[bomName] = [];
}
groups[bomName].push(file);
});
// 각 그룹 내에서 리비전 번호로 정렬
Object.keys(groups).forEach(bomName => {
groups[bomName].sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA; // 최신 리비전이 위로
});
});
return groups;
};
// BOM 선택 처리
const handleBOMClick = (bomFile) => {
if (onBOMSelect) {
onBOMSelect(bomFile);
}
};
// 파일 삭제
const handleDeleteFile = async (fileId, bomName) => {
if (!window.confirm(`이 파일을 삭제하시겠습니까?`)) {
return;
}
try {
await api.delete(`/files/delete/${fileId}`);
// 파일 목록 새로고침
const projectCode = selectedProject.official_project_code || selectedProject.job_no;
const encodedProjectCode = encodeURIComponent(projectCode);
const response = await api.get(`/files/project/${encodedProjectCode}`);
const files = response.data || [];
setBomFiles(files);
setGroupedFiles(groupFilesByBOM(files));
} catch (err) {
console.error('파일 삭제 실패:', err);
setError('파일 삭제에 실패했습니다.');
}
};
// 리비전 업로드 (향후 구현)
const handleRevisionUpload = (parentFile) => {
// TODO: 리비전 업로드 기능 구현
alert('리비전 업로드 기능은 향후 구현 예정입니다.');
};
// 날짜 포맷팅
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
try {
return new Date(dateString).toLocaleDateString('ko-KR');
} catch {
return dateString;
}
};
if (loading) {
return (
<div style={{
padding: '60px',
textAlign: 'center',
color: '#6b7280'
}}>
<div style={{ fontSize: '24px', marginBottom: '16px' }}></div>
<div>Loading BOM files...</div>
</div>
);
}
if (error) {
return (
<div style={{
padding: '40px',
textAlign: 'center'
}}>
<div style={{
background: '#fee2e2',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '16px',
color: '#dc2626'
}}>
<div style={{ fontSize: '20px', marginBottom: '8px' }}></div>
{error}
</div>
</div>
);
}
if (bomFiles.length === 0) {
return (
<div style={{
padding: '60px',
textAlign: 'center',
color: '#6b7280'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📄</div>
<h3 style={{ fontSize: '20px', fontWeight: '600', marginBottom: '8px' }}>
No BOM Files Found
</h3>
<p style={{ fontSize: '16px', margin: 0 }}>
Upload your first BOM file using the Upload tab
</p>
</div>
);
}
return (
<div style={{ padding: '40px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '32px'
}}>
<div>
<h2 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
BOM Files & Revisions
</h2>
<p style={{
fontSize: '16px',
color: '#64748b',
margin: 0
}}>
Select a BOM file to manage its materials
</p>
</div>
<div style={{
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
padding: '12px 20px',
borderRadius: '12px',
fontSize: '14px',
fontWeight: '600',
color: '#1d4ed8'
}}>
{Object.keys(groupedFiles).length} BOM Groups {bomFiles.length} Total Files
</div>
</div>
{/* BOM 파일 그룹 목록 */}
<div style={{ display: 'grid', gap: '24px' }}>
{Object.entries(groupedFiles).map(([bomName, files]) => {
const latestFile = files[0]; // 최신 리비전
const isSelected = selectedBOM?.id === latestFile.id;
return (
<div key={bomName} style={{
background: isSelected ? '#eff6ff' : 'white',
border: isSelected ? '2px solid #3b82f6' : '1px solid #e5e7eb',
borderRadius: '16px',
padding: '24px',
transition: 'all 0.2s ease',
cursor: 'pointer'
}}
onClick={() => handleBOMClick(latestFile)}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '16px'
}}>
<div style={{ flex: 1 }}>
<h3 style={{
fontSize: '20px',
fontWeight: '600',
color: isSelected ? '#1d4ed8' : '#374151',
margin: '0 0 8px 0',
display: 'flex',
alignItems: 'center',
gap: '12px'
}}>
<span style={{ fontSize: '24px' }}>📋</span>
{bomName}
{isSelected && (
<span style={{
background: '#3b82f6',
color: 'white',
fontSize: '12px',
padding: '4px 8px',
borderRadius: '6px',
fontWeight: '500'
}}>
SELECTED
</span>
)}
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px',
fontSize: '14px',
color: '#6b7280'
}}>
<div>
<span style={{ fontWeight: '500' }}>Latest:</span> {latestFile.revision || 'Rev.0'}
</div>
<div>
<span style={{ fontWeight: '500' }}>Revisions:</span> {files.length}
</div>
<div>
<span style={{ fontWeight: '500' }}>Updated:</span> {formatDate(latestFile.upload_date)}
</div>
<div>
<span style={{ fontWeight: '500' }}>Size:</span> {latestFile.file_size ? `${Math.round(latestFile.file_size / 1024)} KB` : 'N/A'}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginLeft: '16px' }}>
<button
onClick={(e) => {
e.stopPropagation();
handleRevisionUpload(latestFile);
}}
style={{
padding: '8px 12px',
background: 'white',
color: '#f59e0b',
border: '1px solid #f59e0b',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '500',
transition: 'all 0.2s ease'
}}
>
📝 New Revision
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteFile(latestFile.id, bomName);
}}
style={{
padding: '8px 12px',
background: '#fee2e2',
color: '#dc2626',
border: '1px solid #fecaca',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '500'
}}
>
🗑 Delete
</button>
</div>
</div>
{/* 리비전 히스토리 */}
{files.length > 1 && (
<div style={{
background: '#f8fafc',
borderRadius: '8px',
padding: '12px',
marginTop: '16px'
}}>
<h4 style={{
fontSize: '14px',
fontWeight: '600',
color: '#374151',
margin: '0 0 8px 0'
}}>
Revision History
</h4>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{files.map((file, index) => (
<div key={file.id} style={{
background: index === 0 ? '#dbeafe' : 'white',
color: index === 0 ? '#1d4ed8' : '#6b7280',
padding: '4px 8px',
borderRadius: '6px',
fontSize: '12px',
fontWeight: '500',
border: '1px solid #e5e7eb'
}}>
{file.revision || 'Rev.0'}
{index === 0 && ' (Latest)'}
</div>
))}
</div>
</div>
)}
{/* 선택 안내 */}
{!isSelected && (
<div style={{
marginTop: '16px',
padding: '12px',
background: 'rgba(59, 130, 246, 0.05)',
borderRadius: '8px',
textAlign: 'center',
fontSize: '14px',
color: '#3b82f6',
fontWeight: '500'
}}>
Click to select this BOM for material management
</div>
)}
</div>
);
})}
</div>
{/* 향후 기능 안내 */}
<div style={{
marginTop: '40px',
padding: '24px',
background: '#f8fafc',
borderRadius: '12px',
border: '1px solid #e2e8f0'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#374151',
marginBottom: '16px'
}}>
🚧 Coming Soon: Advanced Revision Features
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '8px' }}>📊</div>
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
Visual Timeline
</div>
<div style={{ fontSize: '12px', color: '#6b7280' }}>
Interactive revision history
</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '8px' }}>🔍</div>
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
Diff Comparison
</div>
<div style={{ fontSize: '12px', color: '#6b7280' }}>
Compare changes between revisions
</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '8px' }}></div>
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
Rollback System
</div>
<div style={{ fontSize: '12px', color: '#6b7280' }}>
Restore previous versions
</div>
</div>
</div>
</div>
</div>
);
};
export default BOMFilesTab;