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 작업 처리
601 lines
19 KiB
JavaScript
601 lines
19 KiB
JavaScript
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;
|