feat: 통합 BOM 관리 시스템 구현
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
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:
429
frontend/src/components/bom/tabs/BOMFilesTab.jsx
Normal file
429
frontend/src/components/bom/tabs/BOMFilesTab.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user