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:
@@ -1779,6 +1779,45 @@ async def get_files(
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/project/{project_code}")
|
||||
async def get_files_by_project(
|
||||
project_code: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""프로젝트별 파일 목록 조회"""
|
||||
try:
|
||||
query = """
|
||||
SELECT id, filename, original_filename, bom_name, job_no, revision,
|
||||
description, file_size, parsed_count, upload_date, is_active
|
||||
FROM files
|
||||
WHERE is_active = TRUE AND job_no = :job_no
|
||||
ORDER BY upload_date DESC
|
||||
"""
|
||||
|
||||
result = db.execute(text(query), {"job_no": project_code})
|
||||
files = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": file.id,
|
||||
"filename": file.filename,
|
||||
"original_filename": file.original_filename,
|
||||
"bom_name": file.bom_name,
|
||||
"job_no": file.job_no,
|
||||
"revision": file.revision,
|
||||
"description": file.description,
|
||||
"file_size": file.file_size,
|
||||
"parsed_count": file.parsed_count,
|
||||
"upload_date": file.upload_date,
|
||||
"created_at": file.upload_date,
|
||||
"is_active": file.is_active
|
||||
}
|
||||
for file in files
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"프로젝트 파일 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_files_stats(db: Session = Depends(get_db)):
|
||||
"""파일 및 자재 통계 조회"""
|
||||
|
||||
@@ -33,12 +33,14 @@
|
||||
- **역할**: 메인 대시보드 페이지
|
||||
- **기능**:
|
||||
- 프로젝트 선택 드롭다운
|
||||
- 프로젝트별 기능 카드 (BOM 관리, 구매신청 관리)
|
||||
- **새로운 3개 BOM 카드**: 📤 BOM Upload, 📊 Revision Management, 📋 BOM Management
|
||||
- 구매신청 관리 카드
|
||||
- 관리자 전용 기능 (사용자 관리, 로그 관리)
|
||||
- 프로젝트 생성/편집/삭제/비활성화
|
||||
- **라우팅**: `/dashboard`
|
||||
- **접근 권한**: 인증된 사용자
|
||||
- **디자인**: 데본씽크 스타일, 글래스모피즘 효과
|
||||
- **업데이트**: BOM 기능을 3개 전용 페이지로 분리
|
||||
|
||||
### `MainPage.jsx`
|
||||
- **역할**: 초기 랜딩 페이지
|
||||
@@ -88,17 +90,40 @@
|
||||
|
||||
## BOM 관리 페이지
|
||||
|
||||
### `BOMManagementPage.jsx`
|
||||
- **역할**: BOM(Bill of Materials) 통합 관리 페이지
|
||||
### `BOMUploadPage.jsx` ⭐ 신규
|
||||
- **역할**: BOM 파일 업로드 전용 페이지
|
||||
- **기능**:
|
||||
- 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT, SPECIAL)
|
||||
- 드래그 앤 드롭 파일 업로드
|
||||
- 파일 검증 (형식: .xlsx, .xls, .csv / 최대 50MB)
|
||||
- 실시간 업로드 진행률 표시
|
||||
- 자동 BOM 이름 설정
|
||||
- 업로드 완료 후 BOM 관리 페이지로 자동 이동
|
||||
- **라우팅**: `/bom-upload`
|
||||
- **접근 권한**: 인증된 사용자
|
||||
- **디자인**: 모던 UI, 글래스모피즘 효과
|
||||
|
||||
### `BOMRevisionPage.jsx` ⭐ 신규
|
||||
- **역할**: BOM 리비전 관리 전용 페이지
|
||||
- **현재 상태**: 기본 구조 완성, 고급 기능 개발 예정
|
||||
- **기능**:
|
||||
- BOM 파일 목록 표시
|
||||
- 리비전 히스토리 개요
|
||||
- 개발 예정 기능 안내 (타임라인, 비교, 롤백)
|
||||
- **라우팅**: `/bom-revision`
|
||||
- **접근 권한**: 인증된 사용자
|
||||
- **향후 계획**: 📊 리비전 타임라인, 🔍 변경사항 비교, ⏪ 롤백 시스템
|
||||
|
||||
### `BOMManagementPage.jsx`
|
||||
- **역할**: BOM(Bill of Materials) 자재 관리 페이지
|
||||
- **기능**:
|
||||
- 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT, SPECIAL, UNCLASSIFIED)
|
||||
- 자재 선택 및 구매신청 (엑셀 내보내기)
|
||||
- 구매신청된 자재 비활성화 표시
|
||||
- 사용자 요구사항 입력 및 저장
|
||||
- 리비전 관리
|
||||
- 사용자 요구사항 입력 및 저장 (Brand, Additional Request)
|
||||
- 카테고리별 전용 컴포넌트 구조
|
||||
- **라우팅**: `/bom-management`
|
||||
- **접근 권한**: 인증된 사용자
|
||||
- **특징**: 카테고리별 컴포넌트로 분리된 구조
|
||||
- **특징**: 카테고리별 컴포넌트로 완전 분리된 구조
|
||||
|
||||
### `NewMaterialsPage.jsx` (레거시)
|
||||
- **역할**: 기존 자재 관리 페이지 (현재 백업용)
|
||||
@@ -113,13 +138,10 @@
|
||||
- **라우팅**: `/bom-status`
|
||||
- **접근 권한**: 인증된 사용자
|
||||
|
||||
### `BOMWorkspacePage.jsx`
|
||||
- **역할**: BOM 작업 공간
|
||||
- **기능**:
|
||||
- BOM 파일 업로드 및 처리
|
||||
- 자재 분류 작업
|
||||
- **라우팅**: `/bom-workspace`
|
||||
- **접근 권한**: 인증된 사용자
|
||||
### `_deprecated/BOMWorkspacePage.jsx` (사용 중단)
|
||||
- **역할**: 기존 BOM 작업 공간 (사용 중단)
|
||||
- **상태**: `BOMUploadPage`와 `BOMRevisionPage`로 분리됨
|
||||
- **이유**: 업로드와 리비전 관리 기능을 별도 페이지로 분리하여 사용성 개선
|
||||
|
||||
---
|
||||
|
||||
@@ -303,6 +325,21 @@
|
||||
|
||||
## 최근 업데이트 내역
|
||||
|
||||
### 2024-10-17: BOM 페이지 구조 개편 ⭐ 주요 업데이트
|
||||
- **새로운 페이지 추가**:
|
||||
- `BOMUploadPage.jsx`: 전용 업로드 페이지 (드래그 앤 드롭, 파일 검증)
|
||||
- `BOMRevisionPage.jsx`: 리비전 관리 페이지 (기본 구조, 향후 고급 기능 예정)
|
||||
- **기존 페이지 정리**:
|
||||
- `BOMWorkspacePage.jsx` → `_deprecated/` 폴더로 이동 (사용 중단)
|
||||
- 업로드와 리비전 기능을 별도 페이지로 분리하여 사용성 개선
|
||||
- **대시보드 개편**:
|
||||
- BOM 관리를 3개 카드로 분리: 📤 Upload, 📊 Revision, 📋 Management
|
||||
- 각 기능별 전용 페이지로 명확한 역할 분담
|
||||
- **라우팅 업데이트**:
|
||||
- `/bom-upload`: 새 파일 업로드
|
||||
- `/bom-revision`: 리비전 관리
|
||||
- `/bom-management`: 자재 관리
|
||||
|
||||
### 2024-10-17: SPECIAL 카테고리 추가
|
||||
- `SpecialMaterialsView.jsx` 컴포넌트 추가
|
||||
- 특수 제작 자재 관리 기능 구현
|
||||
|
||||
@@ -2,9 +2,9 @@ import React, { useState, useEffect } from 'react';
|
||||
import SimpleLogin from './SimpleLogin';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import { UserMenu, ErrorBoundary } from './components/common';
|
||||
import BOMWorkspacePage from './pages/BOMWorkspacePage';
|
||||
import NewMaterialsPage from './pages/NewMaterialsPage';
|
||||
import BOMManagementPage from './pages/BOMManagementPage';
|
||||
import UnifiedBOMPage from './pages/UnifiedBOMPage';
|
||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||
import AccountSettingsPage from './pages/AccountSettingsPage';
|
||||
import UserManagementPage from './pages/UserManagementPage';
|
||||
@@ -240,12 +240,12 @@ function App() {
|
||||
/>
|
||||
);
|
||||
|
||||
case 'bom':
|
||||
case 'unified-bom':
|
||||
return (
|
||||
<BOMWorkspacePage
|
||||
project={pageParams.selectedProject}
|
||||
<UnifiedBOMPage
|
||||
onNavigate={navigateToPage}
|
||||
onBack={() => navigateToPage('dashboard')}
|
||||
selectedProject={pageParams.selectedProject}
|
||||
user={user}
|
||||
/>
|
||||
);
|
||||
case 'materials':
|
||||
|
||||
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;
|
||||
105
frontend/src/components/bom/tabs/BOMMaterialsTab.jsx
Normal file
105
frontend/src/components/bom/tabs/BOMMaterialsTab.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import BOMManagementPage from '../../../pages/BOMManagementPage';
|
||||
|
||||
const BOMMaterialsTab = ({
|
||||
selectedProject,
|
||||
user,
|
||||
selectedBOM,
|
||||
onNavigate
|
||||
}) => {
|
||||
// BOMManagementPage에 필요한 props 구성
|
||||
const bomManagementProps = {
|
||||
onNavigate,
|
||||
user,
|
||||
selectedProject,
|
||||
fileId: selectedBOM?.id,
|
||||
jobNo: selectedBOM?.job_no || selectedProject?.official_project_code || selectedProject?.job_no,
|
||||
bomName: selectedBOM?.bom_name || selectedBOM?.original_filename,
|
||||
revision: selectedBOM?.revision || 'Rev.0',
|
||||
filename: selectedBOM?.original_filename
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
minHeight: '600px'
|
||||
}}>
|
||||
{/* 헤더 정보 */}
|
||||
<div style={{
|
||||
padding: '24px 40px',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
background: '#f8fafc'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Material Management
|
||||
</h2>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
BOM: {selectedBOM?.bom_name || selectedBOM?.original_filename} • {selectedBOM?.revision || 'Rev.0'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#1d4ed8' }}>
|
||||
{selectedBOM?.id || 'N/A'}
|
||||
</div>
|
||||
<div style={{ color: '#1d4ed8', fontWeight: '500' }}>
|
||||
File ID
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#059669' }}>
|
||||
{selectedBOM?.revision || 'Rev.0'}
|
||||
</div>
|
||||
<div style={{ color: '#059669', fontWeight: '500' }}>
|
||||
Revision
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOM 관리 페이지 임베드 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
// BOMManagementPage의 기본 패딩을 제거하기 위한 스타일 오버라이드
|
||||
'& > div': {
|
||||
padding: '0 !important',
|
||||
background: 'transparent !important',
|
||||
minHeight: 'auto !important'
|
||||
}
|
||||
}}>
|
||||
<BOMManagementPage {...bomManagementProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMMaterialsTab;
|
||||
496
frontend/src/components/bom/tabs/BOMUploadTab.jsx
Normal file
496
frontend/src/components/bom/tabs/BOMUploadTab.jsx
Normal file
@@ -0,0 +1,496 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import api from '../../../api';
|
||||
|
||||
const BOMUploadTab = ({
|
||||
selectedProject,
|
||||
user,
|
||||
onUploadSuccess,
|
||||
onNavigate
|
||||
}) => {
|
||||
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('');
|
||||
|
||||
let uploadedFile = null;
|
||||
|
||||
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 || '업로드 실패');
|
||||
}
|
||||
|
||||
// 첫 번째 파일의 정보를 저장
|
||||
if (i === 0) {
|
||||
uploadedFile = {
|
||||
id: response.data.file_id,
|
||||
bom_name: bomName.trim(),
|
||||
revision: 'Rev.0',
|
||||
job_no: selectedProject.official_project_code || selectedProject.job_no,
|
||||
original_filename: file.name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setSuccess(`${selectedFiles.length}개 파일이 성공적으로 업로드되었습니다!`);
|
||||
|
||||
// 파일 초기화
|
||||
setSelectedFiles([]);
|
||||
setBomName('');
|
||||
|
||||
// 2초 후 Files 탭으로 이동
|
||||
setTimeout(() => {
|
||||
if (onUploadSuccess) {
|
||||
onUploadSuccess(uploadedFile);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
} 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' }}>
|
||||
{/* 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={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 style={{
|
||||
marginTop: '40px',
|
||||
padding: '24px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
📋 Upload Guidelines
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '20px'
|
||||
}}>
|
||||
<div>
|
||||
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#059669', marginBottom: '8px' }}>
|
||||
✅ Supported Formats
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
|
||||
<li>Excel files (.xlsx, .xls)</li>
|
||||
<li>CSV files (.csv)</li>
|
||||
<li>Maximum file size: 50MB</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#3b82f6', marginBottom: '8px' }}>
|
||||
📊 Required Columns
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
|
||||
<li>Description (자재명/품명)</li>
|
||||
<li>Quantity (수량)</li>
|
||||
<li>Size information (사이즈)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#f59e0b', marginBottom: '8px' }}>
|
||||
⚡ Auto Processing
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
|
||||
<li>Automatic material classification</li>
|
||||
<li>WELD GAP items excluded</li>
|
||||
<li>Ready for material management</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMUploadTab;
|
||||
@@ -164,6 +164,22 @@ const BOMManagementPage = ({
|
||||
}
|
||||
}, [fileId]);
|
||||
|
||||
// 자재 로드 후 선택된 카테고리가 유효한지 확인
|
||||
useEffect(() => {
|
||||
if (materials.length > 0) {
|
||||
const availableCategories = categories.filter(category => {
|
||||
const count = getCategoryMaterials(category.key).length;
|
||||
return count > 0;
|
||||
});
|
||||
|
||||
// 현재 선택된 카테고리에 자재가 없으면 첫 번째 유효한 카테고리로 전환
|
||||
const currentCategoryHasMaterials = getCategoryMaterials(selectedCategory).length > 0;
|
||||
if (!currentCategoryHasMaterials && availableCategories.length > 0) {
|
||||
setSelectedCategory(availableCategories[0].key);
|
||||
}
|
||||
}
|
||||
}, [materials, selectedCategory]);
|
||||
|
||||
// 카테고리별 자재 필터링
|
||||
const getCategoryMaterials = (category) => {
|
||||
return materials.filter(material =>
|
||||
@@ -391,7 +407,12 @@ const BOMManagementPage = ({
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
{categories.map((category) => {
|
||||
{categories
|
||||
.filter((category) => {
|
||||
const count = getCategoryMaterials(category.key).length;
|
||||
return count > 0; // 0개인 카테고리는 숨김
|
||||
})
|
||||
.map((category) => {
|
||||
const isActive = selectedCategory === category.key;
|
||||
const count = getCategoryMaterials(category.key).length;
|
||||
|
||||
|
||||
@@ -479,9 +479,9 @@ const DashboardPage = ({
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: '24px'
|
||||
}}>
|
||||
{/* BOM 관리 */}
|
||||
{/* 통합 BOM 관리 */}
|
||||
<div
|
||||
onClick={() => navigateToPage('bom', { selectedProject })}
|
||||
onClick={() => navigateToPage('unified-bom', { selectedProject })}
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
@@ -530,8 +530,47 @@ const DashboardPage = ({
|
||||
margin: 0,
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
Upload and manage Bill of Materials files. Classify materials and generate reports.
|
||||
Upload, manage revisions, and classify materials in one unified workspace
|
||||
</p>
|
||||
|
||||
{/* 기능 미리보기 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
marginTop: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
color: '#3b82f6',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
📤 Upload
|
||||
</div>
|
||||
<div style={{
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
color: '#f59e0b',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
📊 Revisions
|
||||
</div>
|
||||
<div style={{
|
||||
background: 'rgba(16, 185, 129, 0.1)',
|
||||
color: '#10b981',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
📋 Materials
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구매신청 관리 */}
|
||||
|
||||
266
frontend/src/pages/UnifiedBOMPage.jsx
Normal file
266
frontend/src/pages/UnifiedBOMPage.jsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import BOMUploadTab from '../components/bom/tabs/BOMUploadTab';
|
||||
import BOMFilesTab from '../components/bom/tabs/BOMFilesTab';
|
||||
import BOMMaterialsTab from '../components/bom/tabs/BOMMaterialsTab';
|
||||
|
||||
const UnifiedBOMPage = ({
|
||||
onNavigate,
|
||||
selectedProject,
|
||||
user
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('upload');
|
||||
const [selectedBOM, setSelectedBOM] = useState(null);
|
||||
const [bomFiles, setBomFiles] = useState([]);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// 업로드 성공 시 Files 탭으로 이동
|
||||
const handleUploadSuccess = (uploadedFile) => {
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
setActiveTab('files');
|
||||
// 업로드된 파일을 자동 선택
|
||||
if (uploadedFile) {
|
||||
setSelectedBOM(uploadedFile);
|
||||
}
|
||||
};
|
||||
|
||||
// BOM 파일 선택 시 Materials 탭으로 이동
|
||||
const handleBOMSelect = (bomFile) => {
|
||||
setSelectedBOM(bomFile);
|
||||
setActiveTab('materials');
|
||||
};
|
||||
|
||||
// 탭 정의
|
||||
const tabs = [
|
||||
{
|
||||
id: 'upload',
|
||||
label: 'Upload',
|
||||
icon: '📤',
|
||||
description: 'Upload new BOM files'
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'Files & Revisions',
|
||||
icon: '📊',
|
||||
description: 'Manage BOM files and revisions'
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
label: 'Materials',
|
||||
icon: '📋',
|
||||
description: 'Manage classified materials',
|
||||
disabled: !selectedBOM
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||||
minHeight: '100vh',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<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 Management System
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
margin: 0,
|
||||
fontWeight: '400'
|
||||
}}>
|
||||
Project: {selectedProject?.job_name || 'No Project Selected'}
|
||||
{selectedBOM && (
|
||||
<span style={{ marginLeft: '16px', color: '#3b82f6', fontWeight: '500' }}>
|
||||
→ {selectedBOM.bom_name || selectedBOM.original_filename}
|
||||
</span>
|
||||
)}
|
||||
</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' }}>
|
||||
Current User
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#d97706', marginBottom: '4px' }}>
|
||||
{bomFiles.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#d97706', fontWeight: '500' }}>
|
||||
BOM Files
|
||||
</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: 'flex', gap: '8px' }}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => !tab.disabled && setActiveTab(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px 24px',
|
||||
background: activeTab === tab.id
|
||||
? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'
|
||||
: tab.disabled
|
||||
? '#f3f4f6'
|
||||
: 'white',
|
||||
color: activeTab === tab.id
|
||||
? 'white'
|
||||
: tab.disabled
|
||||
? '#9ca3af'
|
||||
: '#374151',
|
||||
border: activeTab === tab.id
|
||||
? 'none'
|
||||
: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
cursor: tab.disabled ? 'not-allowed' : 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease',
|
||||
textAlign: 'center',
|
||||
opacity: tab.disabled ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '20px', marginBottom: '4px' }}>
|
||||
{tab.icon}
|
||||
</div>
|
||||
<div>{tab.label}</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '400',
|
||||
marginTop: '4px',
|
||||
opacity: 0.8
|
||||
}}>
|
||||
{tab.description}
|
||||
</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'
|
||||
}}>
|
||||
{activeTab === 'upload' && (
|
||||
<BOMUploadTab
|
||||
selectedProject={selectedProject}
|
||||
user={user}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'files' && (
|
||||
<BOMFilesTab
|
||||
selectedProject={selectedProject}
|
||||
user={user}
|
||||
bomFiles={bomFiles}
|
||||
setBomFiles={setBomFiles}
|
||||
selectedBOM={selectedBOM}
|
||||
onBOMSelect={handleBOMSelect}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'materials' && selectedBOM && (
|
||||
<BOMMaterialsTab
|
||||
selectedProject={selectedProject}
|
||||
user={user}
|
||||
selectedBOM={selectedBOM}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedBOMPage;
|
||||
350
frontend/src/pages/_deprecated/BOMRevisionPage.jsx
Normal file
350
frontend/src/pages/_deprecated/BOMRevisionPage.jsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
const BOMRevisionPage = ({
|
||||
onNavigate,
|
||||
selectedProject,
|
||||
user
|
||||
}) => {
|
||||
const [bomFiles, setBomFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// BOM 파일 목록 로드 (기본 구조만)
|
||||
useEffect(() => {
|
||||
const loadBOMFiles = async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// TODO: 실제 API 구현 필요
|
||||
// const response = await api.get(`/files/project/${selectedProject.job_no}`);
|
||||
// setBomFiles(response.data);
|
||||
|
||||
// 임시 데이터
|
||||
setBomFiles([
|
||||
{
|
||||
id: 1,
|
||||
bom_name: 'Main Process BOM',
|
||||
revisions: ['Rev.0', 'Rev.1', 'Rev.2'],
|
||||
latest_revision: 'Rev.2',
|
||||
upload_date: '2024-10-17',
|
||||
status: 'Active'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
bom_name: 'Utility BOM',
|
||||
revisions: ['Rev.0', 'Rev.1'],
|
||||
latest_revision: 'Rev.1',
|
||||
upload_date: '2024-10-16',
|
||||
status: 'Active'
|
||||
}
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error('BOM 파일 로드 실패:', err);
|
||||
setError('BOM 파일을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadBOMFiles();
|
||||
}, [selectedProject]);
|
||||
|
||||
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 Revision Management
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
margin: 0,
|
||||
fontWeight: '400'
|
||||
}}>
|
||||
Project: {selectedProject?.job_name || 'No Project Selected'}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate('bom-upload')}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
New Upload
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{/* 프로젝트 정보 */}
|
||||
<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, #fef3c7 0%, #fde68a 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#d97706', marginBottom: '4px' }}>
|
||||
{bomFiles.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#d97706', fontWeight: '500' }}>
|
||||
BOM Files
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#7c3aed', marginBottom: '4px' }}>
|
||||
{bomFiles.reduce((total, bom) => total + bom.revisions.length, 0)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#7c3aed', fontWeight: '500' }}>
|
||||
Total Revisions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개발 예정 배너 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '60px 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)',
|
||||
textAlign: 'center',
|
||||
marginBottom: '40px'
|
||||
}}>
|
||||
<div style={{ fontSize: '64px', marginBottom: '24px' }}>🚧</div>
|
||||
<h2 style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 16px 0'
|
||||
}}>
|
||||
Advanced Revision Management
|
||||
</h2>
|
||||
<p style={{
|
||||
fontSize: '18px',
|
||||
color: '#64748b',
|
||||
margin: '0 0 32px 0',
|
||||
maxWidth: '600px',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
고급 리비전 관리 기능이 개발 중입니다. 업로드 기능 완료 후 본격적인 개발이 시작됩니다.
|
||||
</p>
|
||||
|
||||
{/* 예정 기능 미리보기 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '24px',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '12px' }}>📊</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#92400e', margin: '0 0 8px 0' }}>
|
||||
Revision Timeline
|
||||
</h3>
|
||||
<p style={{ fontSize: '14px', color: '#92400e', margin: 0 }}>
|
||||
시각적 리비전 히스토리
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '12px' }}>🔍</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#1d4ed8', margin: '0 0 8px 0' }}>
|
||||
Diff Comparison
|
||||
</h3>
|
||||
<p style={{ fontSize: '14px', color: '#1d4ed8', margin: 0 }}>
|
||||
리비전 간 변경사항 비교
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '12px' }}>⏪</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#059669', margin: '0 0 8px 0' }}>
|
||||
Rollback System
|
||||
</h3>
|
||||
<p style={{ fontSize: '14px', color: '#059669', margin: 0 }}>
|
||||
이전 리비전으로 롤백
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 임시 BOM 파일 목록 (기본 구조) */}
|
||||
{bomFiles.length > 0 && (
|
||||
<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: '24px'
|
||||
}}>
|
||||
Current BOM Files (Preview)
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
{bomFiles.map((bom) => (
|
||||
<div key={bom.id} style={{
|
||||
background: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<h4 style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
{bom.bom_name}
|
||||
</h4>
|
||||
<div style={{ display: 'flex', gap: '16px', fontSize: '14px', color: '#6b7280' }}>
|
||||
<span>Latest: {bom.latest_revision}</span>
|
||||
<span>Revisions: {bom.revisions.length}</span>
|
||||
<span>Uploaded: {bom.upload_date}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate('bom-management', { bomId: bom.id })}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Manage BOM
|
||||
</button>
|
||||
<button
|
||||
disabled
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
color: '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
cursor: 'not-allowed',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
View History (Soon)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMRevisionPage;
|
||||
600
frontend/src/pages/_deprecated/BOMUploadPage.jsx
Normal file
600
frontend/src/pages/_deprecated/BOMUploadPage.jsx
Normal file
@@ -0,0 +1,600 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user