Files
TK-BOM-Project/frontend/src/pages/_deprecated/BOMUploadPage.jsx
hyungi ab607dfa9a
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
feat: 통합 BOM 관리 시스템 구현
🎯 주요 변경사항:
- 통합 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 작업 처리
2025-10-17 14:44:17 +09:00

601 lines
19 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, useRef, useCallback } from 'react';
import api from '../api';
const BOMUploadPage = ({
onNavigate,
selectedProject,
user
}) => {
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [selectedFiles, setSelectedFiles] = useState([]);
const [bomName, setBomName] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const fileInputRef = useRef(null);
// 파일 검증
const validateFile = (file) => {
const allowedTypes = [
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv'
];
const maxSize = 50 * 1024 * 1024; // 50MB
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx|xls|csv)$/i)) {
return '지원되지 않는 파일 형식입니다. Excel 또는 CSV 파일만 업로드 가능합니다.';
}
if (file.size > maxSize) {
return '파일 크기가 너무 큽니다. 50MB 이하의 파일만 업로드 가능합니다.';
}
return null;
};
// 파일 선택 처리
const handleFileSelect = useCallback((files) => {
const fileList = Array.from(files);
const validFiles = [];
const errors = [];
fileList.forEach(file => {
const error = validateFile(file);
if (error) {
errors.push(`${file.name}: ${error}`);
} else {
validFiles.push(file);
}
});
if (errors.length > 0) {
setError(errors.join('\n'));
return;
}
setSelectedFiles(validFiles);
setError('');
// 첫 번째 파일명을 기본 BOM 이름으로 설정 (확장자 제거)
if (validFiles.length > 0 && !bomName) {
const fileName = validFiles[0].name;
const nameWithoutExt = fileName.replace(/\.[^/.]+$/, '');
setBomName(nameWithoutExt);
}
}, [bomName]);
// 드래그 앤 드롭 처리
const handleDragOver = useCallback((e) => {
e.preventDefault();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
setDragOver(false);
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
setDragOver(false);
handleFileSelect(e.dataTransfer.files);
}, [handleFileSelect]);
// 파일 선택 버튼 클릭
const handleFileButtonClick = () => {
fileInputRef.current?.click();
};
// 파일 업로드
const handleUpload = async () => {
if (selectedFiles.length === 0) {
setError('업로드할 파일을 선택해주세요.');
return;
}
if (!bomName.trim()) {
setError('BOM 이름을 입력해주세요.');
return;
}
if (!selectedProject) {
setError('프로젝트를 선택해주세요.');
return;
}
try {
setUploading(true);
setUploadProgress(0);
setError('');
setSuccess('');
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
const formData = new FormData();
formData.append('file', file);
formData.append('job_no', selectedProject.official_project_code || selectedProject.job_no);
formData.append('bom_name', bomName.trim());
formData.append('revision', 'Rev.0'); // 새 업로드는 항상 Rev.0
const response = await api.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
const progress = Math.round(
((i * 100) + (progressEvent.loaded * 100) / progressEvent.total) / selectedFiles.length
);
setUploadProgress(progress);
}
});
if (!response.data?.success) {
throw new Error(response.data?.message || '업로드 실패');
}
}
setSuccess(`${selectedFiles.length}개 파일이 성공적으로 업로드되었습니다!`);
// 3초 후 BOM 관리 페이지로 이동
setTimeout(() => {
if (onNavigate) {
onNavigate('bom-management', {
file_id: response.data.file_id,
jobNo: selectedProject.official_project_code || selectedProject.job_no,
bomName: bomName.trim(),
revision: 'Rev.0'
});
}
}, 3000);
} catch (err) {
console.error('업로드 실패:', err);
setError(`업로드 실패: ${err.response?.data?.detail || err.message}`);
} finally {
setUploading(false);
setUploadProgress(0);
}
};
// 파일 제거
const removeFile = (index) => {
const newFiles = selectedFiles.filter((_, i) => i !== index);
setSelectedFiles(newFiles);
if (newFiles.length === 0) {
setBomName('');
}
};
// 파일 크기 포맷팅
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
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: '24px' }}>
<div>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0',
letterSpacing: '-0.025em'
}}>
BOM File Upload
</h1>
<p style={{
fontSize: '16px',
color: '#64748b',
margin: 0,
fontWeight: '400'
}}>
Project: {selectedProject?.job_name || 'No Project Selected'}
</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'
}}
>
Back to Dashboard
</button>
</div>
{/* 프로젝트 정보 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px'
}}>
<div style={{
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#1d4ed8', marginBottom: '4px' }}>
{selectedProject?.official_project_code || selectedProject?.job_no || 'N/A'}
</div>
<div style={{ fontSize: '14px', color: '#1d4ed8', fontWeight: '500' }}>
Project Code
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#059669', marginBottom: '4px' }}>
{user?.username || 'Unknown'}
</div>
<div style={{ fontSize: '14px', color: '#059669', fontWeight: '500' }}>
Uploaded by
</div>
</div>
</div>
</div>
{/* 업로드 영역 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '40px',
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'
}}>
{/* BOM 이름 입력 */}
<div style={{ marginBottom: '32px' }}>
<label style={{
display: 'block',
fontSize: '16px',
fontWeight: '600',
color: '#374151',
marginBottom: '8px'
}}>
BOM Name
</label>
<input
type="text"
value={bomName}
onChange={(e) => setBomName(e.target.value)}
placeholder="Enter BOM name..."
style={{
width: '100%',
padding: '12px 16px',
border: '2px solid #e5e7eb',
borderRadius: '12px',
fontSize: '16px',
transition: 'border-color 0.2s ease',
outline: 'none'
}}
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
onBlur={(e) => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
{/* 파일 드롭 영역 */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: `3px dashed ${dragOver ? '#3b82f6' : '#d1d5db'}`,
borderRadius: '16px',
padding: '60px 40px',
textAlign: 'center',
background: dragOver ? '#eff6ff' : '#f9fafb',
transition: 'all 0.3s ease',
cursor: 'pointer',
marginBottom: '24px'
}}
onClick={handleFileButtonClick}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
{dragOver ? '📁' : '📄'}
</div>
<h3 style={{
fontSize: '20px',
fontWeight: '600',
color: '#374151',
margin: '0 0 8px 0'
}}>
{dragOver ? 'Drop files here' : 'Upload BOM Files'}
</h3>
<p style={{
fontSize: '16px',
color: '#6b7280',
margin: '0 0 16px 0'
}}>
Drag and drop your Excel or CSV files here, or click to browse
</p>
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
background: 'rgba(59, 130, 246, 0.1)',
borderRadius: '8px',
fontSize: '14px',
color: '#3b82f6'
}}>
<span>📋</span>
Supported: .xlsx, .xls, .csv (Max 50MB)
</div>
</div>
{/* 숨겨진 파일 입력 */}
<input
ref={fileInputRef}
type="file"
multiple
accept=".xlsx,.xls,.csv"
onChange={(e) => handleFileSelect(e.target.files)}
style={{ display: 'none' }}
/>
{/* 선택된 파일 목록 */}
{selectedFiles.length > 0 && (
<div style={{ marginBottom: '24px' }}>
<h4 style={{
fontSize: '18px',
fontWeight: '600',
color: '#374151',
marginBottom: '16px'
}}>
Selected Files ({selectedFiles.length})
</h4>
<div style={{
background: '#f8fafc',
borderRadius: '12px',
padding: '16px'
}}>
{selectedFiles.map((file, index) => (
<div key={index} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
background: 'white',
borderRadius: '8px',
marginBottom: index < selectedFiles.length - 1 ? '8px' : '0',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '20px' }}>📄</span>
<div>
<div style={{ fontWeight: '500', color: '#374151' }}>
{file.name}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{formatFileSize(file.size)}
</div>
</div>
</div>
<button
onClick={() => removeFile(index)}
style={{
background: '#fee2e2',
color: '#dc2626',
border: 'none',
borderRadius: '6px',
padding: '6px 12px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '500'
}}
>
Remove
</button>
</div>
))}
</div>
</div>
)}
{/* 업로드 진행률 */}
{uploading && (
<div style={{ marginBottom: '24px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
}}>
<span style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
Uploading...
</span>
<span style={{ fontSize: '14px', fontWeight: '500', color: '#3b82f6' }}>
{uploadProgress}%
</span>
</div>
<div style={{
width: '100%',
height: '8px',
background: '#e5e7eb',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
width: `${uploadProgress}%`,
height: '100%',
background: 'linear-gradient(90deg, #3b82f6 0%, #1d4ed8 100%)',
transition: 'width 0.3s ease'
}} />
</div>
</div>
)}
{/* 에러 메시지 */}
{error && (
<div style={{
background: '#fee2e2',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '24px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '16px' }}></span>
<div style={{ fontSize: '14px', color: '#dc2626', whiteSpace: 'pre-line' }}>
{error}
</div>
</div>
</div>
)}
{/* 성공 메시지 */}
{success && (
<div style={{
background: '#dcfce7',
border: '1px solid #bbf7d0',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '24px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '16px' }}></span>
<div style={{ fontSize: '14px', color: '#059669' }}>
{success}
</div>
</div>
</div>
)}
{/* 업로드 버튼 */}
<div style={{ display: 'flex', gap: '16px', justifyContent: 'flex-end' }}>
<button
onClick={() => onNavigate('dashboard')}
disabled={uploading}
style={{
padding: '12px 24px',
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '12px',
cursor: uploading ? 'not-allowed' : 'pointer',
fontSize: '16px',
fontWeight: '600',
opacity: uploading ? 0.5 : 1
}}
>
Cancel
</button>
<button
onClick={handleUpload}
disabled={uploading || selectedFiles.length === 0 || !bomName.trim()}
style={{
padding: '12px 32px',
background: (uploading || selectedFiles.length === 0 || !bomName.trim())
? '#d1d5db'
: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
color: 'white',
border: 'none',
borderRadius: '12px',
cursor: (uploading || selectedFiles.length === 0 || !bomName.trim())
? 'not-allowed'
: 'pointer',
fontSize: '16px',
fontWeight: '600',
transition: 'all 0.2s ease'
}}
>
{uploading ? 'Uploading...' : 'Upload BOM'}
</button>
</div>
</div>
{/* 가이드 정보 */}
<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)'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '600',
color: '#374151',
marginBottom: '16px'
}}>
📋 Upload Guidelines
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '24px'
}}>
<div>
<h4 style={{ fontSize: '16px', fontWeight: '600', color: '#059669', marginBottom: '8px' }}>
Supported Formats
</h4>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
<li>Excel files (.xlsx, .xls)</li>
<li>CSV files (.csv)</li>
<li>Maximum file size: 50MB</li>
</ul>
</div>
<div>
<h4 style={{ fontSize: '16px', fontWeight: '600', color: '#3b82f6', marginBottom: '8px' }}>
📊 Required Columns
</h4>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
<li>Description (자재명/품명)</li>
<li>Quantity (수량)</li>
<li>Size information (사이즈)</li>
</ul>
</div>
<div>
<h4 style={{ fontSize: '16px', fontWeight: '600', color: '#f59e0b', marginBottom: '8px' }}>
Auto Processing
</h4>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
<li>Automatic material classification</li>
<li>WELD GAP items excluded</li>
<li>Ready for BOM management</li>
</ul>
</div>
</div>
</div>
</div>
);
};
export default BOMUploadPage;