diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index d0682e5..d21a92b 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -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)): """파일 및 자재 통계 조회""" diff --git a/frontend/PAGES_GUIDE.md b/frontend/PAGES_GUIDE.md index d292939..28032db 100644 --- a/frontend/PAGES_GUIDE.md +++ b/frontend/PAGES_GUIDE.md @@ -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` 컴포넌트 추가 - 특수 제작 자재 관리 기능 구현 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a99bf71..361bb6a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.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 ( - navigateToPage('dashboard')} + selectedProject={pageParams.selectedProject} + user={user} /> ); case 'materials': diff --git a/frontend/src/components/bom/tabs/BOMFilesTab.jsx b/frontend/src/components/bom/tabs/BOMFilesTab.jsx new file mode 100644 index 0000000..9ed7742 --- /dev/null +++ b/frontend/src/components/bom/tabs/BOMFilesTab.jsx @@ -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 ( +
+
+
Loading BOM files...
+
+ ); + } + + if (error) { + return ( +
+
+
⚠️
+ {error} +
+
+ ); + } + + if (bomFiles.length === 0) { + return ( +
+
📄
+

+ No BOM Files Found +

+

+ Upload your first BOM file using the Upload tab +

+
+ ); + } + + return ( +
+
+
+

+ BOM Files & Revisions +

+

+ Select a BOM file to manage its materials +

+
+ +
+ {Object.keys(groupedFiles).length} BOM Groups • {bomFiles.length} Total Files +
+
+ + {/* BOM 파일 그룹 목록 */} +
+ {Object.entries(groupedFiles).map(([bomName, files]) => { + const latestFile = files[0]; // 최신 리비전 + const isSelected = selectedBOM?.id === latestFile.id; + + return ( +
handleBOMClick(latestFile)} + > +
+
+

+ 📋 + {bomName} + {isSelected && ( + + SELECTED + + )} +

+ +
+
+ Latest: {latestFile.revision || 'Rev.0'} +
+
+ Revisions: {files.length} +
+
+ Updated: {formatDate(latestFile.upload_date)} +
+
+ Size: {latestFile.file_size ? `${Math.round(latestFile.file_size / 1024)} KB` : 'N/A'} +
+
+
+ +
+ + + +
+
+ + {/* 리비전 히스토리 */} + {files.length > 1 && ( +
+

+ Revision History +

+
+ {files.map((file, index) => ( +
+ {file.revision || 'Rev.0'} + {index === 0 && ' (Latest)'} +
+ ))} +
+
+ )} + + {/* 선택 안내 */} + {!isSelected && ( +
+ Click to select this BOM for material management +
+ )} +
+ ); + })} +
+ + {/* 향후 기능 안내 */} +
+

+ 🚧 Coming Soon: Advanced Revision Features +

+
+
+
📊
+
+ Visual Timeline +
+
+ Interactive revision history +
+
+
+
🔍
+
+ Diff Comparison +
+
+ Compare changes between revisions +
+
+
+
+
+ Rollback System +
+
+ Restore previous versions +
+
+
+
+
+ ); +}; + +export default BOMFilesTab; diff --git a/frontend/src/components/bom/tabs/BOMMaterialsTab.jsx b/frontend/src/components/bom/tabs/BOMMaterialsTab.jsx new file mode 100644 index 0000000..25da6e4 --- /dev/null +++ b/frontend/src/components/bom/tabs/BOMMaterialsTab.jsx @@ -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 ( +
+ {/* 헤더 정보 */} +
+
+
+

+ Material Management +

+

+ BOM: {selectedBOM?.bom_name || selectedBOM?.original_filename} • {selectedBOM?.revision || 'Rev.0'} +

+
+ +
+
+
+ {selectedBOM?.id || 'N/A'} +
+
+ File ID +
+
+ +
+
+ {selectedBOM?.revision || 'Rev.0'} +
+
+ Revision +
+
+
+
+
+ + {/* BOM 관리 페이지 임베드 */} +
div': { + padding: '0 !important', + background: 'transparent !important', + minHeight: 'auto !important' + } + }}> + +
+
+ ); +}; + +export default BOMMaterialsTab; diff --git a/frontend/src/components/bom/tabs/BOMUploadTab.jsx b/frontend/src/components/bom/tabs/BOMUploadTab.jsx new file mode 100644 index 0000000..807b59c --- /dev/null +++ b/frontend/src/components/bom/tabs/BOMUploadTab.jsx @@ -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 ( +
+ {/* BOM 이름 입력 */} +
+ + 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'} + /> +
+ + {/* 파일 드롭 영역 */} +
+
+ {dragOver ? '📁' : '📄'} +
+

+ {dragOver ? 'Drop files here' : 'Upload BOM Files'} +

+

+ Drag and drop your Excel or CSV files here, or click to browse +

+
+ 📋 + Supported: .xlsx, .xls, .csv (Max 50MB) +
+
+ + {/* 숨겨진 파일 입력 */} + handleFileSelect(e.target.files)} + style={{ display: 'none' }} + /> + + {/* 선택된 파일 목록 */} + {selectedFiles.length > 0 && ( +
+

+ Selected Files ({selectedFiles.length}) +

+
+ {selectedFiles.map((file, index) => ( +
+
+ 📄 +
+
+ {file.name} +
+
+ {formatFileSize(file.size)} +
+
+
+ +
+ ))} +
+
+ )} + + {/* 업로드 진행률 */} + {uploading && ( +
+
+ + Uploading... + + + {uploadProgress}% + +
+
+
+
+
+ )} + + {/* 에러 메시지 */} + {error && ( +
+
+ ⚠️ +
+ {error} +
+
+
+ )} + + {/* 성공 메시지 */} + {success && ( +
+
+ +
+ {success} +
+
+
+ )} + + {/* 업로드 버튼 */} +
+ +
+ + {/* 가이드 정보 */} +
+

+ 📋 Upload Guidelines +

+
+
+

+ ✅ Supported Formats +

+
    +
  • Excel files (.xlsx, .xls)
  • +
  • CSV files (.csv)
  • +
  • Maximum file size: 50MB
  • +
+
+
+

+ 📊 Required Columns +

+
    +
  • Description (자재명/품명)
  • +
  • Quantity (수량)
  • +
  • Size information (사이즈)
  • +
+
+
+

+ ⚡ Auto Processing +

+
    +
  • Automatic material classification
  • +
  • WELD GAP items excluded
  • +
  • Ready for material management
  • +
+
+
+
+
+ ); +}; + +export default BOMUploadTab; diff --git a/frontend/src/pages/BOMManagementPage.jsx b/frontend/src/pages/BOMManagementPage.jsx index 2aed5d5..fb8de56 100644 --- a/frontend/src/pages/BOMManagementPage.jsx +++ b/frontend/src/pages/BOMManagementPage.jsx @@ -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; diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 019c78e..5ed50d3 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -479,9 +479,9 @@ const DashboardPage = ({ gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '24px' }}> - {/* BOM 관리 */} + {/* 통합 BOM 관리 */}
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

+ + {/* 기능 미리보기 */} +
+
+ 📤 Upload +
+
+ 📊 Revisions +
+
+ 📋 Materials +
+
{/* 구매신청 관리 */} diff --git a/frontend/src/pages/UnifiedBOMPage.jsx b/frontend/src/pages/UnifiedBOMPage.jsx new file mode 100644 index 0000000..0018b62 --- /dev/null +++ b/frontend/src/pages/UnifiedBOMPage.jsx @@ -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 ( +
+ {/* 헤더 */} +
+
+
+

+ BOM Management System +

+

+ Project: {selectedProject?.job_name || 'No Project Selected'} + {selectedBOM && ( + + → {selectedBOM.bom_name || selectedBOM.original_filename} + + )} +

+
+ +
+ + {/* 프로젝트 정보 */} +
+
+
+ {selectedProject?.official_project_code || selectedProject?.job_no || 'N/A'} +
+
+ Project Code +
+
+ +
+
+ {user?.username || 'Unknown'} +
+
+ Current User +
+
+ +
+
+ {bomFiles.length} +
+
+ BOM Files +
+
+
+
+ + {/* 탭 네비게이션 */} +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ + {/* 탭 컨텐츠 */} +
+ {activeTab === 'upload' && ( + + )} + + {activeTab === 'files' && ( + + )} + + {activeTab === 'materials' && selectedBOM && ( + + )} +
+
+ ); +}; + +export default UnifiedBOMPage; diff --git a/frontend/src/pages/_deprecated/BOMRevisionPage.jsx b/frontend/src/pages/_deprecated/BOMRevisionPage.jsx new file mode 100644 index 0000000..181fded --- /dev/null +++ b/frontend/src/pages/_deprecated/BOMRevisionPage.jsx @@ -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 ( +
+ {/* 헤더 */} +
+
+
+

+ BOM Revision Management +

+

+ Project: {selectedProject?.job_name || 'No Project Selected'} +

+
+
+ + +
+
+ + {/* 프로젝트 정보 */} +
+
+
+ {selectedProject?.official_project_code || selectedProject?.job_no || 'N/A'} +
+
+ Project Code +
+
+ +
+
+ {bomFiles.length} +
+
+ BOM Files +
+
+ +
+
+ {bomFiles.reduce((total, bom) => total + bom.revisions.length, 0)} +
+
+ Total Revisions +
+
+
+
+ + {/* 개발 예정 배너 */} +
+
🚧
+

+ Advanced Revision Management +

+

+ 고급 리비전 관리 기능이 개발 중입니다. 업로드 기능 완료 후 본격적인 개발이 시작됩니다. +

+ + {/* 예정 기능 미리보기 */} +
+
+
📊
+

+ Revision Timeline +

+

+ 시각적 리비전 히스토리 +

+
+ +
+
🔍
+

+ Diff Comparison +

+

+ 리비전 간 변경사항 비교 +

+
+ +
+
+

+ Rollback System +

+

+ 이전 리비전으로 롤백 +

+
+
+
+ + {/* 임시 BOM 파일 목록 (기본 구조) */} + {bomFiles.length > 0 && ( +
+

+ Current BOM Files (Preview) +

+ +
+ {bomFiles.map((bom) => ( +
+
+

+ {bom.bom_name} +

+
+ Latest: {bom.latest_revision} + Revisions: {bom.revisions.length} + Uploaded: {bom.upload_date} +
+
+ +
+ + +
+
+ ))} +
+
+ )} +
+ ); +}; + +export default BOMRevisionPage; diff --git a/frontend/src/pages/_deprecated/BOMUploadPage.jsx b/frontend/src/pages/_deprecated/BOMUploadPage.jsx new file mode 100644 index 0000000..16ab320 --- /dev/null +++ b/frontend/src/pages/_deprecated/BOMUploadPage.jsx @@ -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 ( +
+ {/* 헤더 */} +
+
+
+

+ BOM File Upload +

+

+ Project: {selectedProject?.job_name || 'No Project Selected'} +

+
+ +
+ + {/* 프로젝트 정보 */} +
+
+
+ {selectedProject?.official_project_code || selectedProject?.job_no || 'N/A'} +
+
+ Project Code +
+
+ +
+
+ {user?.username || 'Unknown'} +
+
+ Uploaded by +
+
+
+
+ + {/* 업로드 영역 */} +
+ {/* BOM 이름 입력 */} +
+ + 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'} + /> +
+ + {/* 파일 드롭 영역 */} +
+
+ {dragOver ? '📁' : '📄'} +
+

+ {dragOver ? 'Drop files here' : 'Upload BOM Files'} +

+

+ Drag and drop your Excel or CSV files here, or click to browse +

+
+ 📋 + Supported: .xlsx, .xls, .csv (Max 50MB) +
+
+ + {/* 숨겨진 파일 입력 */} + handleFileSelect(e.target.files)} + style={{ display: 'none' }} + /> + + {/* 선택된 파일 목록 */} + {selectedFiles.length > 0 && ( +
+

+ Selected Files ({selectedFiles.length}) +

+
+ {selectedFiles.map((file, index) => ( +
+
+ 📄 +
+
+ {file.name} +
+
+ {formatFileSize(file.size)} +
+
+
+ +
+ ))} +
+
+ )} + + {/* 업로드 진행률 */} + {uploading && ( +
+
+ + Uploading... + + + {uploadProgress}% + +
+
+
+
+
+ )} + + {/* 에러 메시지 */} + {error && ( +
+
+ ⚠️ +
+ {error} +
+
+
+ )} + + {/* 성공 메시지 */} + {success && ( +
+
+ +
+ {success} +
+
+
+ )} + + {/* 업로드 버튼 */} +
+ + +
+
+ + {/* 가이드 정보 */} +
+

+ 📋 Upload Guidelines +

+
+
+

+ ✅ Supported Formats +

+
    +
  • Excel files (.xlsx, .xls)
  • +
  • CSV files (.csv)
  • +
  • Maximum file size: 50MB
  • +
+
+
+

+ 📊 Required Columns +

+
    +
  • Description (자재명/품명)
  • +
  • Quantity (수량)
  • +
  • Size information (사이즈)
  • +
+
+
+

+ ⚡ Auto Processing +

+
    +
  • Automatic material classification
  • +
  • WELD GAP items excluded
  • +
  • Ready for BOM management
  • +
+
+
+
+
+ ); +}; + +export default BOMUploadPage; diff --git a/frontend/src/pages/BOMWorkspacePage.jsx b/frontend/src/pages/_deprecated/BOMWorkspacePage.jsx similarity index 100% rename from frontend/src/pages/BOMWorkspacePage.jsx rename to frontend/src/pages/_deprecated/BOMWorkspacePage.jsx