🔄 전반적인 시스템 리팩토링 완료
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

 백엔드 구조 개선:
- DatabaseService: 공통 DB 쿼리 로직 통합
- FileUploadService: 파일 업로드 로직 모듈화 및 트랜잭션 관리 개선
- 서비스 레이어 패턴 도입으로 코드 재사용성 향상

 프론트엔드 컴포넌트 개선:
- LoadingSpinner, ErrorMessage, ConfirmDialog 공통 컴포넌트 생성
- 재사용 가능한 컴포넌트 라이브러리 구축
- deprecated/backup 파일들 완전 제거

 성능 최적화:
- optimize_database.py: 핵심 DB 인덱스 자동 생성
- 쿼리 최적화 및 통계 업데이트 자동화
- VACUUM ANALYZE 자동 실행

 코드 정리:
- 개별 SQL 마이그레이션 파일들을 legacy/ 폴더로 정리
- 중복된 마이그레이션 스크립트 정리
- 깔끔하고 체계적인 프로젝트 구조 완성

 자동 마이그레이션 시스템 강화:
- complete_migrate.py: SQLAlchemy 기반 완전한 마이그레이션
- analyze_and_fix_schema.py: 백엔드 코드 분석 기반 스키마 수정
- fix_missing_tables.py: 누락된 테이블/컬럼 자동 생성
- start.sh: 배포 시 자동 실행 순서 최적화
This commit is contained in:
Hyungi Ahn
2025-10-20 08:41:06 +09:00
parent 0c99697a6f
commit 3398f71b80
61 changed files with 3370 additions and 4512 deletions

View File

@@ -0,0 +1,123 @@
import React from 'react';
const ConfirmDialog = ({
isOpen,
title = '확인',
message,
onConfirm,
onCancel,
confirmText = '확인',
cancelText = '취소',
type = 'default' // 'default', 'danger', 'warning'
}) => {
if (!isOpen) return null;
const typeStyles = {
default: {
confirmBg: '#007bff',
confirmHover: '#0056b3'
},
danger: {
confirmBg: '#dc3545',
confirmHover: '#c82333'
},
warning: {
confirmBg: '#ffc107',
confirmHover: '#e0a800'
}
};
const style = typeStyles[type];
const overlayStyle = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000
};
const dialogStyle = {
backgroundColor: 'white',
borderRadius: '8px',
padding: '24px',
minWidth: '400px',
maxWidth: '500px',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)'
};
const titleStyle = {
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '16px',
color: '#333'
};
const messageStyle = {
fontSize: '14px',
lineHeight: '1.5',
color: '#666',
marginBottom: '24px'
};
const buttonContainerStyle = {
display: 'flex',
justifyContent: 'flex-end',
gap: '12px'
};
const baseButtonStyle = {
padding: '8px 16px',
borderRadius: '4px',
border: 'none',
fontSize: '14px',
cursor: 'pointer',
transition: 'background-color 0.2s'
};
const cancelButtonStyle = {
...baseButtonStyle,
backgroundColor: '#6c757d',
color: 'white'
};
const confirmButtonStyle = {
...baseButtonStyle,
backgroundColor: style.confirmBg,
color: 'white'
};
return (
<div style={overlayStyle} onClick={onCancel}>
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
<h3 style={titleStyle}>{title}</h3>
<p style={messageStyle}>{message}</p>
<div style={buttonContainerStyle}>
<button
style={cancelButtonStyle}
onClick={onCancel}
onMouseOver={(e) => e.target.style.backgroundColor = '#5a6268'}
onMouseOut={(e) => e.target.style.backgroundColor = '#6c757d'}
>
{cancelText}
</button>
<button
style={confirmButtonStyle}
onClick={onConfirm}
onMouseOver={(e) => e.target.style.backgroundColor = style.confirmHover}
onMouseOut={(e) => e.target.style.backgroundColor = style.confirmBg}
>
{confirmText}
</button>
</div>
</div>
</div>
);
};
export default ConfirmDialog;

View File

@@ -0,0 +1,115 @@
import React from 'react';
const ErrorMessage = ({
error,
onRetry = null,
onDismiss = null,
type = 'error' // 'error', 'warning', 'info'
}) => {
const typeStyles = {
error: {
backgroundColor: '#fee',
borderColor: '#fcc',
color: '#c33',
icon: '❌'
},
warning: {
backgroundColor: '#fff3cd',
borderColor: '#ffeaa7',
color: '#856404',
icon: '⚠️'
},
info: {
backgroundColor: '#d1ecf1',
borderColor: '#bee5eb',
color: '#0c5460',
icon: ''
}
};
const style = typeStyles[type];
const containerStyle = {
backgroundColor: style.backgroundColor,
border: `1px solid ${style.borderColor}`,
borderRadius: '4px',
padding: '12px 16px',
margin: '10px 0',
display: 'flex',
alignItems: 'flex-start',
gap: '10px'
};
const messageStyle = {
color: style.color,
fontSize: '14px',
lineHeight: '1.4',
flex: 1
};
const buttonStyle = {
backgroundColor: style.color,
color: 'white',
border: 'none',
borderRadius: '3px',
padding: '4px 8px',
fontSize: '12px',
cursor: 'pointer',
marginLeft: '8px'
};
const getErrorMessage = (error) => {
if (typeof error === 'string') return error;
if (error?.message) return error.message;
if (error?.detail) return error.detail;
if (error?.response?.data?.detail) return error.response.data.detail;
if (error?.response?.data?.message) return error.response.data.message;
return '알 수 없는 오류가 발생했습니다.';
};
return (
<div style={containerStyle}>
<span style={{ fontSize: '16px' }}>{style.icon}</span>
<div style={{ flex: 1 }}>
<div style={messageStyle}>
{getErrorMessage(error)}
</div>
<div style={{ marginTop: '8px' }}>
{onRetry && (
<button
onClick={onRetry}
style={buttonStyle}
onMouseOver={(e) => e.target.style.opacity = '0.8'}
onMouseOut={(e) => e.target.style.opacity = '1'}
>
다시 시도
</button>
)}
{onDismiss && (
<button
onClick={onDismiss}
style={{
...buttonStyle,
backgroundColor: 'transparent',
color: style.color,
border: `1px solid ${style.color}`
}}
onMouseOver={(e) => {
e.target.style.backgroundColor = style.color;
e.target.style.color = 'white';
}}
onMouseOut={(e) => {
e.target.style.backgroundColor = 'transparent';
e.target.style.color = style.color;
}}
>
닫기
</button>
)}
</div>
</div>
</div>
);
};
export default ErrorMessage;

View File

@@ -0,0 +1,72 @@
import React from 'react';
const LoadingSpinner = ({
size = 'medium',
message = '로딩 중...',
fullScreen = false,
color = '#007bff'
}) => {
const sizeClasses = {
small: 'w-4 h-4',
medium: 'w-8 h-8',
large: 'w-12 h-12'
};
const spinnerStyle = {
border: `3px solid #f3f3f3`,
borderTop: `3px solid ${color}`,
borderRadius: '50%',
animation: 'spin 1s linear infinite'
};
const containerStyle = fullScreen ? {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255, 255, 255, 0.8)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999
} : {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
};
return (
<>
<style>
{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}
</style>
<div style={containerStyle}>
<div
className={sizeClasses[size]}
style={spinnerStyle}
></div>
{message && (
<p style={{
marginTop: '10px',
color: '#666',
fontSize: '14px',
textAlign: 'center'
}}>
{message}
</p>
)}
</div>
</>
);
};
export default LoadingSpinner;

View File

@@ -1,3 +1,8 @@
// Common Components
export { default as UserMenu } from './UserMenu';
export { default as ErrorBoundary } from './ErrorBoundary';
// 공통 컴포넌트 export
export { default as LoadingSpinner } from './LoadingSpinner';
export { default as ErrorMessage } from './ErrorMessage';
export { default as ConfirmDialog } from './ConfirmDialog';
// 기존 컴포넌트들도 re-export
export { default as UserMenu } from '../UserMenu';
export { default as ErrorBoundary } from '../ErrorBoundary';

View File

@@ -1,431 +0,0 @@
import React, { useState, useEffect } from 'react';
import SimpleFileUpload from '../components/SimpleFileUpload';
import MaterialList from '../components/MaterialList';
import { fetchMaterials, fetchFiles, fetchJobs } from '../api';
const BOMManagementPage = ({ user }) => {
const [activeTab, setActiveTab] = useState('upload');
const [projects, setProjects] = useState([]);
const [selectedProject, setSelectedProject] = useState(null);
const [files, setFiles] = useState([]);
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState({
totalFiles: 0,
totalMaterials: 0,
recentUploads: []
});
useEffect(() => {
loadProjects();
}, []);
useEffect(() => {
if (selectedProject) {
loadProjectFiles();
}
}, [selectedProject]);
useEffect(() => {
loadStats();
}, [files, materials]);
const loadProjects = async () => {
try {
// ✅ API 함수 사용 (권장)
const response = await fetchJobs();
if (response.data.success) {
setProjects(response.data.jobs);
}
} catch (error) {
console.error('프로젝트 로딩 실패:', error);
}
};
const loadProjectFiles = async () => {
if (!selectedProject) return;
setLoading(true);
try {
// 기존 API 함수 사용 - 파일 목록 로딩
const filesResponse = await fetchFiles({ job_no: selectedProject.job_no });
setFiles(Array.isArray(filesResponse.data) ? filesResponse.data : []);
// 기존 API 함수 사용 - 자재 목록 로딩
const materialsResponse = await fetchMaterials({ job_no: selectedProject.job_no, limit: 1000 });
setMaterials(materialsResponse.data?.materials || []);
} catch (error) {
console.error('프로젝트 데이터 로딩 실패:', error);
} finally {
setLoading(false);
}
};
const loadStats = async () => {
try {
// 실제 통계 계산 - 더미 데이터 없이
const totalFiles = files.length;
const totalMaterials = materials.length;
setStats({
totalFiles,
totalMaterials,
recentUploads: files.slice(0, 5) // 최근 5개 파일
});
} catch (error) {
console.error('통계 로딩 실패:', error);
}
};
const handleFileUpload = async (uploadData) => {
try {
setLoading(true);
// 기존 FileUpload 컴포넌트의 업로드 로직 활용
await loadProjectFiles(); // 업로드 후 데이터 새로고침
await loadStats();
} catch (error) {
console.error('파일 업로드 후 새로고침 실패:', error);
} finally {
setLoading(false);
}
};
const StatCard = ({ title, value, icon, color = '#667eea' }) => (
<div style={{
background: 'white',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
gap: '16px'
}}>
<div style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: color + '20',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px'
}}>
{icon}
</div>
<div>
<div style={{
fontSize: '24px',
fontWeight: '700',
color: '#2d3748',
marginBottom: '4px'
}}>
{value}
</div>
<div style={{
fontSize: '14px',
color: '#718096'
}}>
{title}
</div>
</div>
</div>
);
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '32px' }}>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
🔧 BOM 관리
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0'
}}>
Bill of Materials 업로드, 분석 관리를 수행하세요.
</p>
</div>
{/* 통계 카드들 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
<StatCard
title="총 업로드 파일"
value={stats.totalFiles}
icon="📄"
color="#667eea"
/>
<StatCard
title="분석된 자재"
value={stats.totalMaterials}
icon="🔧"
color="#48bb78"
/>
<StatCard
title="활성 프로젝트"
value={projects.length}
icon="📋"
color="#ed8936"
/>
</div>
{/* 탭 네비게이션 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
overflow: 'hidden',
marginBottom: '24px'
}}>
<div style={{
display: 'flex',
borderBottom: '1px solid #e2e8f0'
}}>
{[
{ id: 'upload', label: '📤 파일 업로드', icon: '📤' },
{ id: 'files', label: '📁 파일 관리', icon: '📁' },
{ id: 'materials', label: '🔧 자재 목록', icon: '🔧' },
{ id: 'analysis', label: '📊 분석 결과', icon: '📊' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
flex: 1,
padding: '16px 20px',
background: activeTab === tab.id ? '#f7fafc' : 'transparent',
border: 'none',
borderBottom: activeTab === tab.id ? '2px solid #667eea' : '2px solid transparent',
cursor: 'pointer',
fontSize: '14px',
fontWeight: activeTab === tab.id ? '600' : '500',
color: activeTab === tab.id ? '#667eea' : '#4a5568',
transition: 'all 0.2s ease'
}}
>
{tab.label}
</button>
))}
</div>
{/* 탭 콘텐츠 */}
<div style={{ padding: '24px' }}>
{activeTab === 'upload' && (
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
📤 BOM 파일 업로드
</h3>
{/* 프로젝트 선택 */}
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
프로젝트 선택
</label>
<select
value={selectedProject?.job_no || ''}
onChange={(e) => {
const project = projects.find(p => p.job_no === e.target.value);
setSelectedProject(project);
}}
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px',
background: 'white'
}}
>
<option value="">프로젝트를 선택하세요</option>
{projects.map(project => (
<option key={project.job_no} value={project.job_no}>
{project.job_no} - {project.job_name}
</option>
))}
</select>
</div>
{selectedProject ? (
<div>
<div style={{
background: '#f7fafc',
border: '1px solid #e2e8f0',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px'
}}>
<h4 style={{ margin: '0 0 8px 0', color: '#2d3748' }}>
선택된 프로젝트: {selectedProject.job_name}
</h4>
<p style={{ margin: '0', fontSize: '14px', color: '#718096' }}>
Job No: {selectedProject.job_no} |
고객사: {selectedProject.client_name} |
상태: {selectedProject.status}
</p>
</div>
<SimpleFileUpload
selectedProject={selectedProject}
onUploadComplete={handleFileUpload}
/>
</div>
) : (
<div style={{
textAlign: 'center',
padding: '40px',
color: '#718096'
}}>
먼저 프로젝트를 선택해주세요.
</div>
)}
</div>
)}
{activeTab === 'files' && (
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
📁 업로드된 파일 목록
</h3>
{selectedProject ? (
loading ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
파일 목록을 불러오는 ...
</div>
) : files.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{files.map((file, index) => (
<div key={index} style={{
background: '#f7fafc',
border: '1px solid #e2e8f0',
borderRadius: '8px',
padding: '16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<div style={{ fontWeight: '600', color: '#2d3748' }}>
{file.original_filename || file.filename}
</div>
<div style={{ fontSize: '12px', color: '#718096' }}>
업로드: {new Date(file.created_at).toLocaleString()} |
자재 : {file.parsed_count || 0}
</div>
</div>
<div style={{
padding: '4px 8px',
background: '#48bb78',
color: 'white',
borderRadius: '4px',
fontSize: '12px'
}}>
{file.revision || 'Rev.0'}
</div>
</div>
))}
</div>
) : (
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
업로드된 파일이 없습니다.
</div>
)
) : (
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
프로젝트를 선택해주세요.
</div>
)}
</div>
)}
{activeTab === 'materials' && (
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
🔧 자재 목록
</h3>
{selectedProject ? (
<MaterialList
selectedProject={selectedProject}
key={selectedProject.job_no} // 프로젝트 변경 컴포넌트 재렌더링
/>
) : (
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
프로젝트를 선택해주세요.
</div>
)}
</div>
)}
{activeTab === 'analysis' && (
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
📊 분석 결과
</h3>
<div style={{
background: '#fff3cd',
border: '1px solid #ffeaa7',
borderRadius: '8px',
padding: '16px',
textAlign: 'center'
}}>
<div style={{ fontSize: '16px', color: '#856404' }}>
🚧 분석 결과 페이지는 구현될 예정입니다.
</div>
<div style={{ fontSize: '14px', color: '#856404', marginTop: '8px' }}>
자재 분류, 통계, 비교 분석 기능이 추가됩니다.
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default BOMManagementPage;

View File

@@ -1,518 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import {
Box,
Container,
Typography,
Button,
CircularProgress,
Alert,
Breadcrumbs,
Link,
Stack,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
Grid,
Divider,
Tabs,
Tab
} from '@mui/material';
import {
ArrowBack,
Refresh,
History,
Download
} from '@mui/icons-material';
import MaterialComparisonResult from '../components/MaterialComparisonResult';
import { compareMaterialRevisions, confirmMaterialPurchase, fetchMaterials, api } from '../api';
import { exportComparisonToExcel } from '../utils/excelExport';
const MaterialComparisonPage = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [loading, setLoading] = useState(true);
const [confirmLoading, setConfirmLoading] = useState(false);
const [error, setError] = useState(null);
const [comparisonResult, setComparisonResult] = useState(null);
const [selectedTab, setSelectedTab] = useState(0);
// URL 파라미터에서 정보 추출
const jobNo = searchParams.get('job_no');
const currentRevision = searchParams.get('revision');
const previousRevision = searchParams.get('prev_revision');
const filename = searchParams.get('filename');
useEffect(() => {
if (jobNo && currentRevision) {
loadComparison();
} else {
setError('필수 파라미터가 누락되었습니다 (job_no, revision)');
setLoading(false);
}
}, [jobNo, currentRevision, previousRevision]);
const loadComparison = async () => {
try {
setLoading(true);
setError(null);
console.log('🔍 자재 비교 실행 - 파라미터:', {
jobNo,
currentRevision,
previousRevision,
filename
});
// 🚨 테스트: MaterialsPage API 직접 호출해서 길이 정보 확인
try {
// ✅ API 함수 사용 - 테스트용 자재 조회
const testResult = await fetchMaterials({
job_no: jobNo,
revision: currentRevision,
limit: 10
});
const pipeData = testResult.data.materials?.filter(m => m.classified_category === 'PIPE');
console.log('🧪 MaterialsPage API 테스트 (길이 있는지 확인):', pipeData);
if (pipeData && pipeData.length > 0) {
console.log('🧪 첫 번째 파이프 상세:', JSON.stringify(pipeData[0], null, 2));
}
} catch (e) {
console.log('🧪 MaterialsPage API 테스트 실패:', e);
}
const result = await compareMaterialRevisions(
jobNo,
currentRevision,
previousRevision,
true // 결과 저장
);
console.log('✅ 비교 결과 성공:', result);
console.log('🔍 전체 데이터 구조:', JSON.stringify(result.data || result, null, 2));
setComparisonResult(result.data || result);
} catch (err) {
console.error('❌ 자재 비교 실패:', {
message: err.message,
response: err.response?.data,
status: err.response?.status,
params: { jobNo, currentRevision, previousRevision }
});
setError(err.response?.data?.detail || err.message || '자재 비교 중 오류가 발생했습니다');
} finally {
setLoading(false);
}
};
const handleConfirmPurchase = async (confirmations) => {
try {
setConfirmLoading(true);
console.log('발주 확정 실행:', { jobNo, currentRevision, confirmations });
const result = await confirmMaterialPurchase(
jobNo,
currentRevision,
confirmations,
'user'
);
console.log('발주 확정 결과:', result);
// 성공 메시지 표시 후 비교 결과 새로고침
alert(`${result.confirmed_items?.length || confirmations.length}개 항목의 발주가 확정되었습니다!`);
// 비교 결과 새로고침 (재고 상태가 변경되었을 수 있음)
await loadComparison();
} catch (err) {
console.error('발주 확정 실패:', err);
alert('발주 확정 중 오류가 발생했습니다: ' + (err.response?.data?.detail || err.message));
} finally {
setConfirmLoading(false);
}
};
const handleRefresh = () => {
loadComparison();
};
const handleGoBack = () => {
// BOM 상태 페이지로 이동
if (jobNo) {
navigate(`/bom-status?job_no=${jobNo}`);
} else {
navigate(-1);
}
};
const handleExportToExcel = () => {
if (!comparisonResult) {
alert('내보낼 비교 데이터가 없습니다.');
return;
}
const additionalInfo = {
jobNo: jobNo,
currentRevision: currentRevision,
previousRevision: previousRevision,
filename: filename
};
const baseFilename = `리비전비교_${jobNo}_${currentRevision}_vs_${previousRevision}`;
exportComparisonToExcel(comparisonResult, baseFilename, additionalInfo);
};
const renderComparisonResults = () => {
const { summary, new_items = [], modified_items = [], removed_items = [] } = comparisonResult;
return (
<Box>
{/* 요약 통계 카드 */}
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} md={3}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="primary" gutterBottom>
{summary?.new_items_count || 0}
</Typography>
<Typography variant="h6">신규 자재</Typography>
<Typography variant="body2" color="text.secondary">
새로 추가된 자재
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="warning.main" gutterBottom>
{summary?.modified_items_count || 0}
</Typography>
<Typography variant="h6">변경 자재</Typography>
<Typography variant="body2" color="text.secondary">
수량이 변경된 자재
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="error.main" gutterBottom>
{summary?.removed_items_count || 0}
</Typography>
<Typography variant="h6">삭제 자재</Typography>
<Typography variant="body2" color="text.secondary">
제거된 자재
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="success.main" gutterBottom>
{summary?.total_current_items || 0}
</Typography>
<Typography variant="h6"> 자재</Typography>
<Typography variant="body2" color="text.secondary">
현재 리비전 전체
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* 탭으로 구분된 자재 목록 */}
<Card>
<Tabs
value={selectedTab}
onChange={(e, newValue) => setSelectedTab(newValue)}
variant="fullWidth"
>
<Tab label={`신규 자재 (${new_items.length})`} />
<Tab label={`변경 자재 (${modified_items.length})`} />
<Tab label={`삭제 자재 (${removed_items.length})`} />
</Tabs>
<CardContent>
{selectedTab === 0 && renderMaterialTable(new_items, 'new')}
{selectedTab === 1 && renderMaterialTable(modified_items, 'modified')}
{selectedTab === 2 && renderMaterialTable(removed_items, 'removed')}
</CardContent>
</Card>
</Box>
);
};
const renderMaterialTable = (items, type) => {
if (items.length === 0) {
return (
<Alert severity="info">
{type === 'new' && '새로 추가된 자재가 없습니다.'}
{type === 'modified' && '수량이 변경된 자재가 없습니다.'}
{type === 'removed' && '삭제된 자재가 없습니다.'}
</Alert>
);
}
console.log(`🔍 ${type} 테이블 렌더링:`, items); // 디버깅용
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>카테고리</TableCell>
<TableCell>자재 설명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell>재질</TableCell>
{type === 'modified' && (
<>
<TableCell align="center">이전 수량</TableCell>
<TableCell align="center">현재 수량</TableCell>
<TableCell align="center">변경량</TableCell>
</>
)}
{type !== 'modified' && <TableCell align="center">수량</TableCell>}
<TableCell>단위</TableCell>
<TableCell>길이(mm)</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => {
console.log(`🔍 항목 ${index}:`, item); // 각 항목 확인
// 파이프인 경우 길이 정보 표시
console.log(`🔧 길이 확인 - ${item.category}:`, item.pipe_details); // 디버깅
console.log(`🔧 전체 아이템:`, item); // 전체 구조 확인
let lengthInfo = '-';
if (item.category === 'PIPE' && item.pipe_details?.length_mm && item.pipe_details.length_mm > 0) {
const avgUnitLength = item.pipe_details.length_mm;
const currentTotalLength = item.pipe_details.total_length_mm || (item.quantity || 0) * avgUnitLength;
if (type === 'modified') {
// 변경된 파이프: 백엔드에서 계산된 실제 길이 사용
let prevTotalLength, lengthChange;
if (item.previous_pipe_details && item.previous_pipe_details.total_length_mm) {
// 백엔드에서 실제 이전 총길이를 제공한 경우
prevTotalLength = item.previous_pipe_details.total_length_mm;
lengthChange = currentTotalLength - prevTotalLength;
} else {
// 백업: 비율 계산
const prevRatio = (item.previous_quantity || 0) / (item.current_quantity || item.quantity || 1);
prevTotalLength = currentTotalLength * prevRatio;
lengthChange = currentTotalLength - prevTotalLength;
}
lengthInfo = (
<Box>
<Typography variant="body2">
이전: {Math.round(prevTotalLength).toLocaleString()}mm 현재: {Math.round(currentTotalLength).toLocaleString()}mm
</Typography>
<Typography
variant="body2"
fontWeight="bold"
color={lengthChange > 0 ? 'success.main' : lengthChange < 0 ? 'error.main' : 'text.primary'}
>
변화: {lengthChange > 0 ? '+' : ''}{Math.round(lengthChange).toLocaleString()}mm
</Typography>
</Box>
);
} else {
// 신규/삭제된 파이프: 실제 총길이 사용
lengthInfo = (
<Box>
<Typography variant="body2" fontWeight="bold">
길이: {Math.round(currentTotalLength).toLocaleString()}mm
</Typography>
</Box>
);
}
} else if (item.category === 'PIPE') {
lengthInfo = '길이 정보 없음';
}
return (
<TableRow key={index}>
<TableCell>
<Chip
label={item.category}
size="small"
color={type === 'new' ? 'primary' : type === 'modified' ? 'warning' : 'error'}
/>
</TableCell>
<TableCell>{item.description}</TableCell>
<TableCell>{item.size_spec || '-'}</TableCell>
<TableCell>{item.material_grade || '-'}</TableCell>
{type === 'modified' && (
<>
<TableCell align="center">{item.previous_quantity}</TableCell>
<TableCell align="center">{item.current_quantity}</TableCell>
<TableCell align="center">
<Typography
variant="body2"
fontWeight="bold"
color={item.quantity_change > 0 ? 'success.main' : 'error.main'}
>
{item.quantity_change > 0 ? '+' : ''}{item.quantity_change}
</Typography>
</TableCell>
</>
)}
{type !== 'modified' && (
<TableCell align="center">
<Typography variant="body2" fontWeight="bold">
{item.quantity}
</Typography>
</TableCell>
)}
<TableCell>{item.unit || 'EA'}</TableCell>
<TableCell align="center">
<Typography variant="body2" color={lengthInfo !== '-' ? 'primary.main' : 'text.secondary'}>
{lengthInfo}
</Typography>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
};
const renderHeader = () => (
<Box sx={{ mb: 3 }}>
<Breadcrumbs sx={{ mb: 2 }}>
<Link
component="button"
variant="body2"
onClick={() => navigate('/jobs')}
sx={{ textDecoration: 'none' }}
>
프로젝트 목록
</Link>
<Link
component="button"
variant="body2"
onClick={() => navigate(`/materials?job_no=${jobNo}`)}
sx={{ textDecoration: 'none' }}
>
{jobNo}
</Link>
<Typography variant="body2" color="textPrimary">
자재 비교
</Typography>
</Breadcrumbs>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="h4" gutterBottom>
자재 리비전 비교
</Typography>
<Typography variant="body1" color="textSecondary">
{filename && `파일: ${filename}`}
<br />
{previousRevision ?
`${previousRevision}${currentRevision} 비교` :
`${currentRevision} (이전 리비전 없음)`
}
</Typography>
</Box>
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={handleRefresh}
disabled={loading}
>
새로고침
</Button>
<Button
variant="outlined"
color="primary"
startIcon={<Download />}
onClick={handleExportToExcel}
disabled={!comparisonResult}
>
엑셀 내보내기
</Button>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={handleGoBack}
>
BOM 목록으로
</Button>
</Stack>
</Stack>
</Box>
);
if (loading) {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
{renderHeader()}
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<Stack alignItems="center" spacing={2}>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
자재 비교 ...
</Typography>
<Typography variant="body2" color="textSecondary">
리비전간 차이점을 분석하고 있습니다
</Typography>
</Stack>
</Box>
</Container>
);
}
if (error) {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
{renderHeader()}
<Alert severity="error" sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
자재 비교 실패
</Typography>
<Typography variant="body2">
{error}
</Typography>
</Alert>
<Button
variant="contained"
startIcon={<Refresh />}
onClick={handleRefresh}
>
다시 시도
</Button>
</Container>
);
}
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
{renderHeader()}
{comparisonResult && renderComparisonResults()}
</Container>
);
};
export default MaterialComparisonPage;

View File

@@ -1,486 +0,0 @@
import React, { useState, useEffect } from 'react';
import MaterialList from '../components/MaterialList';
import { fetchMaterials, fetchJobs } from '../api';
const MaterialsManagementPage = ({ user }) => {
const [materials, setMaterials] = useState([]);
const [filteredMaterials, setFilteredMaterials] = useState([]);
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState({
project: '',
category: '',
status: '',
search: ''
});
const [stats, setStats] = useState({
totalMaterials: 0,
categorizedMaterials: 0,
uncategorizedMaterials: 0,
categories: {}
});
useEffect(() => {
loadProjects();
loadAllMaterials();
}, []);
useEffect(() => {
applyFilters();
}, [materials, filters]);
const loadProjects = async () => {
try {
// ✅ API 함수 사용 (권장)
const response = await fetchJobs();
if (response.data.success) {
setProjects(response.data.jobs);
}
} catch (error) {
console.error('프로젝트 로딩 실패:', error);
}
};
const loadAllMaterials = async () => {
setLoading(true);
try {
// 기존 API 함수 사용 - 모든 자재 데이터 로딩
const response = await fetchMaterials({ limit: 10000 }); // 충분히 큰 limit
const materialsData = response.data?.materials || [];
setMaterials(materialsData);
calculateStats(materialsData);
} catch (error) {
console.error('자재 데이터 로딩 실패:', error);
} finally {
setLoading(false);
}
};
const calculateStats = (materialsData) => {
const totalMaterials = materialsData.length;
const categorizedMaterials = materialsData.filter(m => m.classified_category && m.classified_category !== 'Unknown').length;
const uncategorizedMaterials = totalMaterials - categorizedMaterials;
// 카테고리별 통계
const categories = {};
materialsData.forEach(material => {
const category = material.classified_category || 'Unknown';
categories[category] = (categories[category] || 0) + 1;
});
setStats({
totalMaterials,
categorizedMaterials,
uncategorizedMaterials,
categories
});
};
const applyFilters = () => {
let filtered = [...materials];
// 프로젝트 필터
if (filters.project) {
filtered = filtered.filter(m => m.job_no === filters.project);
}
// 카테고리 필터
if (filters.category) {
filtered = filtered.filter(m => m.classified_category === filters.category);
}
// 상태 필터
if (filters.status) {
if (filters.status === 'categorized') {
filtered = filtered.filter(m => m.classified_category && m.classified_category !== 'Unknown');
} else if (filters.status === 'uncategorized') {
filtered = filtered.filter(m => !m.classified_category || m.classified_category === 'Unknown');
}
}
// 검색 필터
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
filtered = filtered.filter(m =>
(m.original_description && m.original_description.toLowerCase().includes(searchTerm)) ||
(m.size_spec && m.size_spec.toLowerCase().includes(searchTerm)) ||
(m.classified_category && m.classified_category.toLowerCase().includes(searchTerm))
);
}
setFilteredMaterials(filtered);
};
const handleFilterChange = (filterType, value) => {
setFilters(prev => ({
...prev,
[filterType]: value
}));
};
const clearFilters = () => {
setFilters({
project: '',
category: '',
status: '',
search: ''
});
};
const StatCard = ({ title, value, icon, color = '#667eea', subtitle }) => (
<div style={{
background: 'white',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '10px',
background: color + '20',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px'
}}>
{icon}
</div>
<div>
<div style={{
fontSize: '24px',
fontWeight: '700',
color: '#2d3748'
}}>
{value.toLocaleString()}
</div>
<div style={{
fontSize: '14px',
color: '#718096'
}}>
{title}
</div>
</div>
</div>
{subtitle && (
<div style={{
fontSize: '12px',
color: '#718096',
marginTop: '4px'
}}>
{subtitle}
</div>
)}
</div>
);
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '32px' }}>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📦 자재 관리
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0'
}}>
전체 프로젝트의 자재 정보를 통합 관리하고 분석하세요.
</p>
</div>
{/* 통계 카드들 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
<StatCard
title="전체 자재"
value={stats.totalMaterials}
icon="📦"
color="#667eea"
subtitle={`${projects.length}개 프로젝트`}
/>
<StatCard
title="분류 완료"
value={stats.categorizedMaterials}
icon="✅"
color="#48bb78"
subtitle={`${Math.round((stats.categorizedMaterials / stats.totalMaterials) * 100) || 0}% 완료`}
/>
<StatCard
title="미분류"
value={stats.uncategorizedMaterials}
icon="⚠️"
color="#ed8936"
subtitle="분류 작업 필요"
/>
<StatCard
title="카테고리"
value={Object.keys(stats.categories).length}
icon="🏷️"
color="#9f7aea"
subtitle="자재 분류 유형"
/>
</div>
{/* 필터 섹션 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '24px',
marginBottom: '24px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0'
}}>
🔍 필터 검색
</h3>
<button
onClick={clearFilters}
style={{
padding: '8px 16px',
background: '#e2e8f0',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
color: '#4a5568'
}}
>
필터 초기화
</button>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px'
}}>
{/* 프로젝트 필터 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '6px'
}}>
프로젝트
</label>
<select
value={filters.project}
onChange={(e) => handleFilterChange('project', e.target.value)}
style={{
width: '100%',
padding: '10px',
border: '1px solid #e2e8f0',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="">전체 프로젝트</option>
{projects.map(project => (
<option key={project.job_no} value={project.job_no}>
{project.job_no} - {project.job_name}
</option>
))}
</select>
</div>
{/* 카테고리 필터 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '6px'
}}>
카테고리
</label>
<select
value={filters.category}
onChange={(e) => handleFilterChange('category', e.target.value)}
style={{
width: '100%',
padding: '10px',
border: '1px solid #e2e8f0',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="">전체 카테고리</option>
{Object.keys(stats.categories).map(category => (
<option key={category} value={category}>
{category} ({stats.categories[category]})
</option>
))}
</select>
</div>
{/* 상태 필터 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '6px'
}}>
분류 상태
</label>
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
style={{
width: '100%',
padding: '10px',
border: '1px solid #e2e8f0',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="">전체</option>
<option value="categorized">분류 완료</option>
<option value="uncategorized">미분류</option>
</select>
</div>
{/* 검색 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '6px'
}}>
검색
</label>
<input
type="text"
placeholder="자재명, 코드, 카테고리 검색..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
style={{
width: '100%',
padding: '10px',
border: '1px solid #e2e8f0',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
</div>
{/* 필터 결과 요약 */}
<div style={{
marginTop: '16px',
padding: '12px',
background: '#f7fafc',
borderRadius: '6px',
fontSize: '14px',
color: '#4a5568'
}}>
<strong>{filteredMaterials.length.toLocaleString()}</strong>개의 자재가 검색되었습니다.
{filters.project && ` (프로젝트: ${filters.project})`}
{filters.category && ` (카테고리: ${filters.category})`}
{filters.status && ` (상태: ${filters.status === 'categorized' ? '분류완료' : '미분류'})`}
{filters.search && ` (검색: "${filters.search}")`}
</div>
</div>
{/* 자재 목록 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
overflow: 'hidden'
}}>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
background: '#f7fafc',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h3 style={{
margin: '0',
fontSize: '16px',
fontWeight: '600',
color: '#2d3748'
}}>
자재 목록 ({filteredMaterials.length.toLocaleString()})
</h3>
<div style={{ display: 'flex', gap: '8px' }}>
<button
style={{
padding: '8px 16px',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
📊 분석 리포트
</button>
<button
style={{
padding: '8px 16px',
background: '#48bb78',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
📤 Excel 내보내기
</button>
</div>
</div>
<MaterialList
selectedProject={null} // 전체 자재 보기
showProjectInfo={true}
enableSelection={true}
key="all-materials" // 전체 자재 모드
/>
</div>
</div>
</div>
);
};
export default MaterialsManagementPage;

View File

@@ -1,736 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api';
const PurchaseConfirmationPage = () => {
const location = useLocation();
const navigate = useNavigate();
const [purchaseItems, setPurchaseItems] = useState([]);
const [revisionComparison, setRevisionComparison] = useState(null);
const [loading, setLoading] = useState(true);
const [editingItem, setEditingItem] = useState(null);
const [confirmDialog, setConfirmDialog] = useState(false);
// URL에서 job_no, revision 정보 가져오기
const searchParams = new URLSearchParams(location.search);
const jobNo = searchParams.get('job_no');
const revision = searchParams.get('revision');
const filename = searchParams.get('filename');
const previousRevision = searchParams.get('prev_revision');
useEffect(() => {
if (jobNo && revision) {
loadPurchaseItems();
if (previousRevision) {
loadRevisionComparison();
}
}
}, [jobNo, revision, previousRevision]);
const loadPurchaseItems = async () => {
try {
setLoading(true);
const response = await api.get('/purchase/items/calculate', {
params: { job_no: jobNo, revision: revision }
});
setPurchaseItems(response.data.items || []);
} catch (error) {
console.error('구매 품목 로딩 실패:', error);
} finally {
setLoading(false);
}
};
const loadRevisionComparison = async () => {
try {
const response = await api.get('/purchase/revision-diff', {
params: {
job_no: jobNo,
current_revision: revision,
previous_revision: previousRevision
}
});
setRevisionComparison(response.data.comparison);
} catch (error) {
console.error('리비전 비교 실패:', error);
}
};
const updateItemQuantity = async (itemId, field, value) => {
try {
await api.patch(`/purchase/items/${itemId}`, {
[field]: parseFloat(value)
});
// 로컬 상태 업데이트
setPurchaseItems(prev =>
prev.map(item =>
item.id === itemId
? { ...item, [field]: parseFloat(value) }
: item
)
);
setEditingItem(null);
} catch (error) {
console.error('수량 업데이트 실패:', error);
}
};
const confirmPurchase = async () => {
try {
// 입력 데이터 검증
if (!jobNo || !revision) {
alert('Job 번호와 리비전 정보가 없습니다.');
return;
}
if (purchaseItems.length === 0) {
alert('구매할 품목이 없습니다.');
return;
}
// 각 품목의 수량 검증
const invalidItems = purchaseItems.filter(item =>
!item.calculated_qty || item.calculated_qty <= 0
);
if (invalidItems.length > 0) {
alert(`다음 품목들의 구매 수량이 유효하지 않습니다:\n${invalidItems.map(item => `- ${item.specification}`).join('\n')}`);
return;
}
setConfirmDialog(false);
const response = await api.post('/purchase/orders/create', {
job_no: jobNo,
revision: revision,
items: purchaseItems.map(item => ({
purchase_item_id: item.id,
ordered_quantity: item.calculated_qty,
required_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30일 후
}))
});
const successMessage = `구매 주문이 성공적으로 생성되었습니다!\n\n` +
`- Job: ${jobNo}\n` +
`- Revision: ${revision}\n` +
`- 품목 수: ${purchaseItems.length}\n` +
`- 생성 시간: ${new Date().toLocaleString('ko-KR')}`;
alert(successMessage);
// 자재 목록 페이지로 이동 (상태 기반 라우팅 사용)
// App.jsx의 상태 기반 라우팅을 위해 window 이벤트 발생
window.dispatchEvent(new CustomEvent('navigateToMaterials', {
detail: {
jobNo: jobNo,
revision: revision,
bomName: `${jobNo} ${revision}`,
message: '구매 주문 생성 완료'
}
}));
} catch (error) {
console.error('구매 주문 생성 실패:', error);
let errorMessage = '구매 주문 생성에 실패했습니다.';
if (error.response?.data?.detail) {
errorMessage += `\n\n오류 내용: ${error.response.data.detail}`;
} else if (error.message) {
errorMessage += `\n\n오류 내용: ${error.message}`;
}
if (error.response?.status === 400) {
errorMessage += '\n\n입력 데이터를 확인해주세요.';
} else if (error.response?.status === 404) {
errorMessage += '\n\n해당 Job이나 리비전을 찾을 수 없습니다.';
} else if (error.response?.status >= 500) {
errorMessage += '\n\n서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
alert(errorMessage);
}
};
const getCategoryColor = (category) => {
const colors = {
'PIPE': '#1976d2',
'FITTING': '#9c27b0',
'VALVE': '#2e7d32',
'FLANGE': '#ed6c02',
'BOLT': '#0288d1',
'GASKET': '#d32f2f',
'INSTRUMENT': '#7b1fa2'
};
return colors[category] || '#757575';
};
const formatPipeInfo = (item) => {
if (item.category !== 'PIPE') return null;
return (
<div style={{ marginTop: '8px', fontSize: '12px', color: '#666' }}>
절단손실: {item.cutting_loss || 0}mm |
구매: {item.pipes_count || 0} |
여유분: {item.waste_length || 0}mm
</div>
);
};
const formatBoltInfo = (item) => {
if (item.category !== 'BOLT') return null;
// 특수 용도 볼트 정보 (백엔드에서 제공되어야 함)
const specialApplications = item.special_applications || {};
const psvCount = specialApplications.PSV || 0;
const ltCount = specialApplications.LT || 0;
const ckCount = specialApplications.CK || 0;
const oriCount = specialApplications.ORI || 0;
return (
<div style={{ marginTop: '8px' }}>
<div style={{ fontSize: '12px', color: '#666' }}>
분수 사이즈: {(item.size_fraction || item.size_spec || '').replace(/"/g, '')} |
표면처리: {item.surface_treatment || '없음'}
</div>
{/* 특수 용도 볼트 정보 */}
<div style={{
marginTop: '8px',
padding: '8px',
backgroundColor: '#e3f2fd',
borderRadius: '4px'
}}>
<div style={{ fontSize: '12px', fontWeight: 'bold', color: '#0288d1' }}>
특수 용도 볼트 현황:
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '8px',
marginTop: '4px'
}}>
<div style={{ fontSize: '12px', color: psvCount > 0 ? '#d32f2f' : '#666' }}>
PSV용: {psvCount}
</div>
<div style={{ fontSize: '12px', color: ltCount > 0 ? '#ed6c02' : '#666' }}>
저온용: {ltCount}
</div>
<div style={{ fontSize: '12px', color: ckCount > 0 ? '#0288d1' : '#666' }}>
체크밸브용: {ckCount}
</div>
<div style={{ fontSize: '12px', color: oriCount > 0 ? '#9c27b0' : '#666' }}>
오리피스용: {oriCount}
</div>
</div>
{(psvCount + ltCount + ckCount + oriCount) === 0 && (
<div style={{ fontSize: '12px', color: '#2e7d32', fontStyle: 'italic' }}>
특수 용도 볼트 없음 (일반 볼트만 포함)
</div>
)}
</div>
</div>
);
};
const exportToExcel = () => {
if (purchaseItems.length === 0) {
alert('내보낼 구매 품목이 없습니다.');
return;
}
// 상세한 구매 확정 데이터 생성
const data = purchaseItems.map((item, index) => {
const baseData = {
'순번': index + 1,
'품목코드': item.item_code || '',
'카테고리': item.category || '',
'사양': item.specification || '',
'재질': item.material_spec || '',
'사이즈': item.size_spec || '',
'단위': item.unit || '',
'BOM수량': item.bom_quantity || 0,
'구매수량': item.calculated_qty || 0,
'여유율': ((item.safety_factor || 1) - 1) * 100 + '%',
'최소주문': item.min_order_qty || 0,
'예상여유분': ((item.calculated_qty || 0) - (item.bom_quantity || 0)).toFixed(1),
'활용률': (((item.bom_quantity || 0) / (item.calculated_qty || 1)) * 100).toFixed(1) + '%'
};
// 파이프 특수 정보 추가
if (item.category === 'PIPE') {
baseData['절단손실'] = item.cutting_loss || 0;
baseData['구매본수'] = item.pipes_count || 0;
baseData['여유길이'] = item.waste_length || 0;
}
// 볼트 특수 정보 추가
if (item.category === 'BOLT') {
const specialApps = item.special_applications || {};
baseData['PSV용'] = specialApps.PSV || 0;
baseData['저온용'] = specialApps.LT || 0;
baseData['체크밸브용'] = specialApps.CK || 0;
baseData['오리피스용'] = specialApps.ORI || 0;
baseData['분수사이즈'] = item.size_fraction || '';
baseData['표면처리'] = item.surface_treatment || '';
}
// 리비전 비교 정보 추가 (있는 경우)
if (previousRevision) {
baseData['기구매수량'] = item.purchased_quantity || 0;
baseData['추가구매필요'] = Math.max(item.additional_needed || 0, 0);
}
return baseData;
});
// 헤더 정보 추가
const headerInfo = [
`구매 확정서`,
`Job No: ${jobNo}`,
`Revision: ${revision}`,
`파일명: ${filename || ''}`,
`생성일: ${new Date().toLocaleString('ko-KR')}`,
`총 품목수: ${purchaseItems.length}`,
''
];
// 요약 정보 계산
const totalBomQty = purchaseItems.reduce((sum, item) => sum + (item.bom_quantity || 0), 0);
const totalPurchaseQty = purchaseItems.reduce((sum, item) => sum + (item.calculated_qty || 0), 0);
const categoryCount = purchaseItems.reduce((acc, item) => {
acc[item.category] = (acc[item.category] || 0) + 1;
return acc;
}, {});
const summaryInfo = [
'=== 요약 정보 ===',
`전체 BOM 수량: ${totalBomQty.toFixed(1)}`,
`전체 구매 수량: ${totalPurchaseQty.toFixed(1)}`,
`카테고리별 품목수: ${Object.entries(categoryCount).map(([cat, count]) => `${cat}(${count})`).join(', ')}`,
''
];
// CSV 형태로 데이터 구성
const csvContent = [
...headerInfo,
...summaryInfo,
'=== 상세 품목 목록 ===',
Object.keys(data[0]).join(','),
...data.map(row => Object.values(row).map(val =>
typeof val === 'string' && val.includes(',') ? `"${val}"` : val
).join(','))
].join('\n');
// 파일 다운로드
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
const timestamp = new Date().toISOString().split('T')[0];
const fileName = `구매확정서_${jobNo}_${revision}_${timestamp}.csv`;
link.setAttribute('download', fileName);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 성공 메시지
alert(`구매 확정서가 다운로드되었습니다.\n파일명: ${fileName}`);
};
if (loading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '200px'
}}>
<div>로딩 ...</div>
</div>
);
}
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '24px' }}>
<button
onClick={() => navigate(-1)}
style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
marginRight: '16px',
padding: '8px'
}}
>
</button>
<div style={{ flex: 1 }}>
<h1 style={{ margin: '0 0 8px 0', fontSize: '28px', fontWeight: 'bold' }}>
🛒 구매 확정
</h1>
<h2 style={{ margin: 0, fontSize: '18px', color: '#1976d2' }}>
Job: {jobNo} | {filename} | {revision}
</h2>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={exportToExcel}
style={{
padding: '12px 24px',
backgroundColor: '#2e7d32',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 'bold'
}}
>
📊 엑셀 내보내기
</button>
<button
onClick={() => setConfirmDialog(true)}
disabled={purchaseItems.length === 0}
style={{
padding: '12px 24px',
backgroundColor: purchaseItems.length === 0 ? '#ccc' : '#1976d2',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: purchaseItems.length === 0 ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 'bold'
}}
>
🛒 구매 주문 생성
</button>
</div>
</div>
{/* 리비전 비교 알림 */}
{revisionComparison && (
<div style={{
padding: '16px',
marginBottom: '24px',
backgroundColor: revisionComparison.has_changes ? '#fff3e0' : '#e3f2fd',
border: `1px solid ${revisionComparison.has_changes ? '#ed6c02' : '#0288d1'}`,
borderRadius: '4px',
display: 'flex',
alignItems: 'center'
}}>
<span style={{ marginRight: '8px', fontSize: '20px' }}>🔄</span>
<div>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
리비전 변경사항: {revisionComparison.summary}
</div>
{revisionComparison.additional_items && (
<div style={{ fontSize: '14px' }}>
추가 구매 필요: {revisionComparison.additional_items} 품목
</div>
)}
</div>
</div>
)}
{/* 구매 품목 목록 */}
{purchaseItems.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '48px',
backgroundColor: '#f5f5f5',
borderRadius: '8px'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📦</div>
<div style={{ fontSize: '18px', color: '#666' }}>
구매할 품목이 없습니다.
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{purchaseItems.map(item => (
<div key={item.id} style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '20px',
backgroundColor: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<span style={{
display: 'inline-block',
padding: '4px 12px',
backgroundColor: getCategoryColor(item.category),
color: 'white',
borderRadius: '16px',
fontSize: '12px',
fontWeight: 'bold',
marginRight: '16px'
}}>
{item.category}
</span>
<h3 style={{ margin: 0, flex: 1, fontSize: '18px' }}>
{item.specification}
</h3>
{item.is_additional && (
<span style={{
padding: '4px 12px',
backgroundColor: '#fff3e0',
color: '#ed6c02',
border: '1px solid #ed6c02',
borderRadius: '16px',
fontSize: '12px',
fontWeight: 'bold'
}}>
추가 구매
</span>
)}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '24px'
}}>
{/* BOM 수량 */}
<div>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
BOM 필요량
</div>
<div style={{ fontSize: '20px', fontWeight: 'bold' }}>
{item.bom_quantity} {item.unit}
</div>
{formatPipeInfo(item)}
{formatBoltInfo(item)}
</div>
{/* 구매 수량 */}
<div>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
구매 수량
</div>
{editingItem === item.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="number"
value={item.calculated_qty}
onChange={(e) =>
setPurchaseItems(prev =>
prev.map(i =>
i.id === item.id
? { ...i, calculated_qty: parseFloat(e.target.value) || 0 }
: i
)
)
}
style={{
width: '100px',
padding: '4px 8px',
border: '1px solid #ddd',
borderRadius: '4px'
}}
/>
<button
onClick={() => updateItemQuantity(item.id, 'calculated_qty', item.calculated_qty)}
style={{
background: 'none',
border: 'none',
color: '#1976d2',
cursor: 'pointer',
fontSize: '16px'
}}
>
</button>
<button
onClick={() => setEditingItem(null)}
style={{
background: 'none',
border: 'none',
color: '#666',
cursor: 'pointer',
fontSize: '16px'
}}
>
</button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#1976d2' }}>
{item.calculated_qty} {item.unit}
</div>
<button
onClick={() => setEditingItem(item.id)}
style={{
background: 'none',
border: 'none',
color: '#666',
cursor: 'pointer',
fontSize: '14px'
}}
>
</button>
</div>
)}
</div>
{/* 이미 구매한 수량 */}
{previousRevision && (
<div>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
기구매 수량
</div>
<div style={{ fontSize: '20px', fontWeight: 'bold' }}>
{item.purchased_quantity || 0} {item.unit}
</div>
</div>
)}
{/* 추가 구매 필요량 */}
{previousRevision && (
<div>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
추가 구매 필요
</div>
<div style={{
fontSize: '20px',
fontWeight: 'bold',
color: item.additional_needed > 0 ? '#d32f2f' : '#2e7d32'
}}>
{Math.max(item.additional_needed || 0, 0)} {item.unit}
</div>
</div>
)}
</div>
{/* 여유율 및 최소 주문 정보 */}
<div style={{
marginTop: '16px',
padding: '16px',
backgroundColor: '#f5f5f5',
borderRadius: '4px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '16px'
}}>
<div>
<div style={{ fontSize: '12px', color: '#666' }}>여유율</div>
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
{((item.safety_factor || 1) - 1) * 100}%
</div>
</div>
<div>
<div style={{ fontSize: '12px', color: '#666' }}>최소 주문</div>
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
{item.min_order_qty || 0} {item.unit}
</div>
</div>
<div>
<div style={{ fontSize: '12px', color: '#666' }}>예상 여유분</div>
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
{(item.calculated_qty - item.bom_quantity).toFixed(1)} {item.unit}
</div>
</div>
<div>
<div style={{ fontSize: '12px', color: '#666' }}>활용률</div>
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
{((item.bom_quantity / item.calculated_qty) * 100).toFixed(1)}%
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* 구매 주문 확인 다이얼로그 */}
{confirmDialog && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: 'white',
padding: '24px',
borderRadius: '8px',
minWidth: '400px',
maxWidth: '500px'
}}>
<h3 style={{ margin: '0 0 16px 0' }}>구매 주문 생성 확인</h3>
<div style={{ marginBottom: '16px' }}>
{purchaseItems.length} 품목에 대한 구매 주문을 생성하시겠습니까?
</div>
{revisionComparison && revisionComparison.has_changes && (
<div style={{
padding: '12px',
marginBottom: '16px',
backgroundColor: '#fff3e0',
border: '1px solid #ed6c02',
borderRadius: '4px'
}}>
리비전 변경으로 인한 추가 구매가 포함되어 있습니다.
</div>
)}
<div style={{ fontSize: '14px', color: '#666', marginBottom: '24px' }}>
구매 주문 생성 후에는 수량 변경이 제한됩니다.
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<button
onClick={() => setConfirmDialog(false)}
style={{
padding: '8px 16px',
backgroundColor: 'white',
color: '#666',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer'
}}
>
취소
</button>
<button
onClick={confirmPurchase}
style={{
padding: '8px 16px',
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
주문 생성
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PurchaseConfirmationPage;

View File

@@ -1,437 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Box,
Card,
CardContent,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Button,
Alert,
CircularProgress,
Chip,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
Tabs,
Tab,
Divider
} from '@mui/material';
import {
ArrowBack,
ShoppingCart,
Compare,
Add as AddIcon,
Remove as RemoveIcon,
TrendingUp,
Assessment
} from '@mui/icons-material';
import { compareMaterialRevisions, fetchFiles } from '../api';
const RevisionPurchasePage = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [comparisonResult, setComparisonResult] = useState(null);
const [availableRevisions, setAvailableRevisions] = useState([]);
const [selectedTab, setSelectedTab] = useState(0);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
// URL 파라미터에서 정보 추출
const jobNo = searchParams.get('job_no');
const currentRevision = searchParams.get('current_revision') || searchParams.get('revision');
const previousRevision = searchParams.get('previous_revision');
const bomName = searchParams.get('bom_name');
useEffect(() => {
if (jobNo && currentRevision) {
loadAvailableRevisions();
loadComparisonData();
} else {
setError('필수 파라미터가 누락되었습니다. (job_no, current_revision)');
setLoading(false);
}
}, [jobNo, currentRevision, previousRevision]);
const loadAvailableRevisions = async () => {
try {
const response = await fetchFiles({ job_no: jobNo });
if (Array.isArray(response.data)) {
// BOM별로 그룹화
const bomGroups = response.data.reduce((acc, file) => {
const key = file.bom_name || file.original_filename;
if (!acc[key]) acc[key] = [];
acc[key].push(file);
return acc;
}, {});
// 현재 BOM과 관련된 리비전들만 필터링
let relevantFiles = [];
if (bomName) {
relevantFiles = bomGroups[bomName] || [];
} else {
// bomName이 없으면 현재 리비전과 같은 원본파일명을 가진 것들
const currentFile = response.data.find(file => file.revision === currentRevision);
if (currentFile) {
const key = currentFile.bom_name || currentFile.original_filename;
relevantFiles = bomGroups[key] || [];
}
}
// 리비전 순으로 정렬
relevantFiles.sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA;
});
setAvailableRevisions(relevantFiles);
}
} catch (err) {
console.error('리비전 목록 로드 실패:', err);
}
};
const loadComparisonData = async () => {
try {
setLoading(true);
const result = await compareMaterialRevisions(
jobNo,
currentRevision,
previousRevision,
true
);
setComparisonResult(result);
} catch (err) {
setError(`리비전 비교 실패: ${err.message}`);
} finally {
setLoading(false);
}
};
const handleRevisionChange = (type, newRevision) => {
const params = new URLSearchParams(searchParams);
if (type === 'current') {
params.set('current_revision', newRevision);
} else {
params.set('previous_revision', newRevision);
}
navigate(`?${params.toString()}`, { replace: true });
};
const calculatePurchaseNeeds = () => {
if (!comparisonResult) return { newItems: [], increasedItems: [] };
const newItems = comparisonResult.new_items || [];
const modifiedItems = comparisonResult.modified_items || [];
// 수량이 증가한 항목들만 필터링
const increasedItems = modifiedItems.filter(item =>
item.quantity_change > 0
);
return { newItems, increasedItems };
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW'
}).format(amount);
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
<Typography variant="h6" sx={{ ml: 2 }}>
리비전 비교 분석 ...
</Typography>
</Box>
);
}
if (error) {
return (
<Box sx={{ maxWidth: 1200, mx: 'auto', mt: 4, px: 2 }}>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={() => navigate(`/bom-status?job_no=${jobNo}`)}
sx={{ mb: 2 }}
>
BOM 목록으로
</Button>
<Alert severity="error">{error}</Alert>
</Box>
);
}
const { newItems, increasedItems } = calculatePurchaseNeeds();
const totalNewItems = newItems.length;
const totalIncreasedItems = increasedItems.length;
const totalPurchaseItems = totalNewItems + totalIncreasedItems;
return (
<Box sx={{ maxWidth: 1400, mx: 'auto', mt: 4, px: 2 }}>
{/* 헤더 */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={() => navigate(`/bom-status?job_no=${jobNo}`)}
>
BOM 목록으로
</Button>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="outlined"
startIcon={<Assessment />}
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${currentRevision}&prev_revision=${previousRevision}`)}
>
상세 비교 보기
</Button>
<Button
variant="contained"
color="success"
startIcon={<ShoppingCart />}
disabled={totalPurchaseItems === 0}
>
구매 목록 생성
</Button>
</Box>
</Box>
<Typography variant="h4" gutterBottom>
🛒 리비전간 추가 구매 필요 자재
</Typography>
<Typography variant="h6" color="textSecondary">
Job No: {jobNo} | {currentRevision} vs {previousRevision || '이전 리비전'}
</Typography>
</Box>
{/* 리비전 선택 카드 */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
리비전 비교 설정
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>현재 리비전</InputLabel>
<Select
value={currentRevision}
label="현재 리비전"
onChange={(e) => handleRevisionChange('current', e.target.value)}
>
{availableRevisions.map((file) => (
<MenuItem key={file.id} value={file.revision}>
{file.revision} ({file.parsed_count || 0} 자재)
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>이전 리비전</InputLabel>
<Select
value={previousRevision || ''}
label="이전 리비전"
onChange={(e) => handleRevisionChange('previous', e.target.value)}
>
<MenuItem value="">자동 선택 (직전 리비전)</MenuItem>
{availableRevisions
.filter(file => file.revision !== currentRevision)
.map((file) => (
<MenuItem key={file.id} value={file.revision}>
{file.revision} ({file.parsed_count || 0} 자재)
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</CardContent>
</Card>
{/* 구매 요약 */}
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} md={4}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<AddIcon color="primary" sx={{ fontSize: 48, mb: 1 }} />
<Typography variant="h4" color="primary">
{totalNewItems}
</Typography>
<Typography variant="h6">
신규 자재
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<TrendingUp color="warning" sx={{ fontSize: 48, mb: 1 }} />
<Typography variant="h4" color="warning.main">
{totalIncreasedItems}
</Typography>
<Typography variant="h6">
수량 증가
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<ShoppingCart color="success" sx={{ fontSize: 48, mb: 1 }} />
<Typography variant="h4" color="success.main">
{totalPurchaseItems}
</Typography>
<Typography variant="h6">
구매 항목
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* 탭으로 구분된 자재 목록 */}
<Card>
<Tabs
value={selectedTab}
onChange={(e, newValue) => setSelectedTab(newValue)}
variant="fullWidth"
>
<Tab label={`신규 자재 (${totalNewItems})`} />
<Tab label={`수량 증가 (${totalIncreasedItems})`} />
</Tabs>
<CardContent>
{selectedTab === 0 && (
<Box>
<Typography variant="h6" gutterBottom color="primary">
🆕 신규 추가 자재
</Typography>
{newItems.length === 0 ? (
<Alert severity="info">새로 추가된 자재가 없습니다.</Alert>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>카테고리</TableCell>
<TableCell>자재 설명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell>재질</TableCell>
<TableCell>수량</TableCell>
<TableCell>단위</TableCell>
</TableRow>
</TableHead>
<TableBody>
{newItems.map((item, index) => (
<TableRow key={index}>
<TableCell>
<Chip
label={item.category}
size="small"
color="primary"
/>
</TableCell>
<TableCell>{item.description}</TableCell>
<TableCell>{item.size_spec || '-'}</TableCell>
<TableCell>{item.material_grade || '-'}</TableCell>
<TableCell>
<Typography variant="body2" fontWeight="bold" color="primary">
+{item.quantity}
</Typography>
</TableCell>
<TableCell>{item.unit || 'EA'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
)}
{selectedTab === 1 && (
<Box>
<Typography variant="h6" gutterBottom color="warning.main">
📈 수량 증가 자재
</Typography>
{increasedItems.length === 0 ? (
<Alert severity="info">수량이 증가한 자재가 없습니다.</Alert>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>카테고리</TableCell>
<TableCell>자재 설명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell>재질</TableCell>
<TableCell>이전 수량</TableCell>
<TableCell>현재 수량</TableCell>
<TableCell>증가량</TableCell>
<TableCell>단위</TableCell>
</TableRow>
</TableHead>
<TableBody>
{increasedItems.map((item, index) => (
<TableRow key={index}>
<TableCell>
<Chip
label={item.category}
size="small"
color="warning"
/>
</TableCell>
<TableCell>{item.description}</TableCell>
<TableCell>{item.size_spec || '-'}</TableCell>
<TableCell>{item.material_grade || '-'}</TableCell>
<TableCell>{item.previous_quantity}</TableCell>
<TableCell>{item.current_quantity}</TableCell>
<TableCell>
<Typography variant="body2" fontWeight="bold" color="warning.main">
+{item.quantity_change}
</Typography>
</TableCell>
<TableCell>{item.unit || 'EA'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
)}
</CardContent>
</Card>
{totalPurchaseItems === 0 && (
<Alert severity="success" sx={{ mt: 3 }}>
🎉 추가로 구매가 필요한 자재가 없습니다!
</Alert>
)}
</Box>
);
};
export default RevisionPurchasePage;

View File

@@ -1,350 +0,0 @@
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;

View File

@@ -1,600 +0,0 @@
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;

View File

@@ -1,894 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { api, fetchFiles, deleteFile as deleteFileApi } from '../api';
const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
// 상태 관리
const [files, setFiles] = useState([]);
const [selectedFile, setSelectedFile] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [sidebarWidth, setSidebarWidth] = useState(300);
const [previewWidth, setPreviewWidth] = useState(400);
const [groupedFiles, setGroupedFiles] = useState({});
// 업로드 관련 상태
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef(null);
// 편집 상태
const [editingFile, setEditingFile] = useState(null);
const [editingField, setEditingField] = useState(null);
const [editValue, setEditValue] = useState('');
// 파일을 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;
};
useEffect(() => {
console.log('🔄 프로젝트 변경됨:', project);
const jobNo = project?.official_project_code || project?.job_no;
if (jobNo) {
console.log('✅ 프로젝트 코드 확인:', jobNo);
// 프로젝트가 변경되면 기존 선택 초기화
setSelectedFile(null);
setFiles([]);
loadFiles();
} else {
console.warn('⚠️ 프로젝트 정보가 없습니다. 받은 프로젝트:', project);
setFiles([]);
setSelectedFile(null);
}
}, [project?.official_project_code, project?.job_no]); // 두 필드 모두 감시
const loadFiles = async () => {
const jobNo = project?.official_project_code || project?.job_no;
if (!jobNo) {
console.warn('프로젝트 정보가 없어서 파일을 로드할 수 없습니다:', project);
return;
}
try {
setLoading(true);
setError(''); // 에러 초기화
console.log('📂 파일 목록 로딩 시작:', jobNo);
const response = await api.get('/files/', {
params: { job_no: jobNo }
});
console.log('📂 API 응답:', response.data);
const fileList = Array.isArray(response.data) ? response.data : response.data?.files || [];
console.log('📂 파싱된 파일 목록:', fileList);
setFiles(fileList);
// 파일을 그룹화
const grouped = groupFilesByBOM(fileList);
setGroupedFiles(grouped);
console.log('📂 그룹화된 파일:', grouped);
// 기존 선택된 파일이 목록에 있는지 확인
if (selectedFile && !fileList.find(f => f.id === selectedFile.id)) {
setSelectedFile(null);
}
// 첫 번째 파일 자동 선택 (기존 선택이 없을 때만)
if (fileList.length > 0 && !selectedFile) {
console.log('📂 첫 번째 파일 자동 선택:', fileList[0].original_filename);
setSelectedFile(fileList[0]);
}
console.log('📂 파일 로딩 완료:', fileList.length, '개 파일');
} catch (err) {
console.error('📂 파일 로딩 실패:', err);
console.error('📂 에러 상세:', err.response?.data);
setError(`파일 목록을 불러오는데 실패했습니다: ${err.response?.data?.detail || err.message}`);
setFiles([]); // 에러 시 빈 배열로 초기화
} finally {
setLoading(false);
}
};
// 드래그 앤 드롭 핸들러
const handleDragOver = (e) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
setDragOver(false);
};
const handleDrop = async (e) => {
e.preventDefault();
setDragOver(false);
const droppedFiles = Array.from(e.dataTransfer.files);
console.log('드롭된 파일들:', droppedFiles.map(f => ({ name: f.name, type: f.type })));
const excelFiles = droppedFiles.filter(file => {
const fileName = file.name.toLowerCase();
const isExcel = fileName.endsWith('.xlsx') || fileName.endsWith('.xls');
console.log(`파일 ${file.name}: Excel 여부 = ${isExcel}`);
return isExcel;
});
if (excelFiles.length > 0) {
console.log('업로드할 Excel 파일들:', excelFiles.map(f => f.name));
await uploadFiles(excelFiles);
} else {
console.log('Excel 파일이 없음. 드롭된 파일들:', droppedFiles.map(f => f.name));
alert(`Excel 파일만 업로드 가능합니다.\n업로드하려는 파일: ${droppedFiles.map(f => f.name).join(', ')}`);
}
};
const handleFileSelect = (e) => {
const selectedFiles = Array.from(e.target.files);
console.log('선택된 파일들:', selectedFiles.map(f => ({ name: f.name, type: f.type })));
const excelFiles = selectedFiles.filter(file => {
const fileName = file.name.toLowerCase();
const isExcel = fileName.endsWith('.xlsx') || fileName.endsWith('.xls');
console.log(`파일 ${file.name}: Excel 여부 = ${isExcel}`);
return isExcel;
});
if (excelFiles.length > 0) {
console.log('업로드할 Excel 파일들:', excelFiles.map(f => f.name));
uploadFiles(excelFiles);
} else {
console.log('Excel 파일이 없음. 선택된 파일들:', selectedFiles.map(f => f.name));
alert(`Excel 파일만 업로드 가능합니다.\n선택하려는 파일: ${selectedFiles.map(f => f.name).join(', ')}`);
}
};
const uploadFiles = async (filesToUpload) => {
console.log('업로드 시작:', filesToUpload.map(f => ({ name: f.name, size: f.size, type: f.type })));
setUploading(true);
try {
for (const file of filesToUpload) {
console.log(`업로드 중: ${file.name} (${file.size} bytes, ${file.type})`);
const jobNo = project?.official_project_code || project?.job_no;
const formData = new FormData();
formData.append('file', file);
formData.append('job_no', jobNo);
formData.append('bom_name', file.name.replace(/\.[^/.]+$/, "")); // 확장자 제거
console.log('FormData 내용:', {
fileName: file.name,
jobNo: jobNo,
bomName: file.name.replace(/\.[^/.]+$/, "")
});
const response = await api.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log(`업로드 성공: ${file.name}`, response.data);
}
await loadFiles(); // 목록 새로고침
alert(`${filesToUpload.length}개 파일이 업로드되었습니다.`);
} catch (err) {
console.error('업로드 실패:', err);
console.error('에러 상세:', err.response?.data);
setError(`파일 업로드에 실패했습니다: ${err.response?.data?.detail || err.message}`);
} finally {
setUploading(false);
}
};
// 인라인 편집 핸들러
const startEdit = (file, field) => {
setEditingFile(file.id);
setEditingField(field);
setEditValue(file[field] || '');
};
const saveEdit = async () => {
try {
await api.put(`/files/${editingFile}`, {
[editingField]: editValue
});
// 로컬 상태 업데이트
setFiles(files.map(f =>
f.id === editingFile
? { ...f, [editingField]: editValue }
: f
));
if (selectedFile?.id === editingFile) {
setSelectedFile({ ...selectedFile, [editingField]: editValue });
}
cancelEdit();
} catch (err) {
console.error('수정 실패:', err);
alert('수정에 실패했습니다.');
}
};
const cancelEdit = () => {
setEditingFile(null);
setEditingField(null);
setEditValue('');
};
// 파일 삭제
const handleDelete = async (fileId) => {
if (!window.confirm('정말로 이 파일을 삭제하시겠습니까?')) {
return;
}
try {
await deleteFileApi(fileId);
setFiles(files.filter(f => f.id !== fileId));
if (selectedFile?.id === fileId) {
const remainingFiles = files.filter(f => f.id !== fileId);
setSelectedFile(remainingFiles.length > 0 ? remainingFiles[0] : null);
}
} catch (err) {
console.error('삭제 실패:', err);
setError('파일 삭제에 실패했습니다.');
}
};
// 자재 보기
const viewMaterials = (file) => {
if (onNavigate) {
onNavigate('materials', {
file_id: file.id,
jobNo: file.job_no,
bomName: file.bom_name || file.original_filename,
revision: file.revision,
filename: file.original_filename,
selectedProject: project
});
}
};
// 리비전 업로드
const handleRevisionUpload = async (parentFile) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.csv,.xlsx,.xls';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
setUploading(true);
const jobNo = project?.official_project_code || project?.job_no;
const formData = new FormData();
formData.append('file', file);
formData.append('job_no', jobNo);
formData.append('bom_name', parentFile.bom_name || parentFile.original_filename);
formData.append('parent_file_id', parentFile.id); // 부모 파일 ID 추가
const response = await api.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.data?.success) {
// 누락된 도면 확인
if (response.data.missing_drawings && response.data.missing_drawings.requires_confirmation) {
const missingDrawings = response.data.missing_drawings.drawings || [];
const materialCount = response.data.missing_drawings.materials?.length || 0;
const hasPreviousPurchase = response.data.missing_drawings.has_previous_purchase;
const fileId = response.data.file_id;
// 사용자 선택을 위한 프롬프트 메시지
let alertMessage = `⚠️ 리비전 업로드 확인\n\n` +
`다음 도면이 새 파일에 없습니다:\n` +
`${missingDrawings.slice(0, 5).join('\n')}` +
`${missingDrawings.length > 5 ? `\n...외 ${missingDrawings.length - 5}` : ''}\n\n` +
`관련 자재: ${materialCount}\n\n`;
if (hasPreviousPurchase) {
// 케이스 1: 이미 구매신청된 경우
alertMessage += `✅ 이전 리비전에서 구매신청이 진행되었습니다.\n\n` +
`다음 중 선택하세요:\n\n` +
`1⃣ "일부만 업로드" - 변경된 도면만 업로드, 기존 도면 유지\n` +
` → 누락된 도면의 자재는 "재고품"으로 표시 (연노랑색)\n\n` +
`2⃣ "도면 삭제됨" - 누락된 도면 완전 삭제\n` +
` → 해당 자재 제거 및 재고품 처리\n\n` +
`3⃣ "취소" - 업로드 취소\n\n` +
`숫자를 입력하세요 (1, 2, 3):`;
} else {
// 케이스 2: 구매신청 전인 경우
alertMessage += `⚠️ 아직 구매신청이 진행되지 않았습니다.\n\n` +
`다음 중 선택하세요:\n\n` +
`1⃣ "일부만 업로드" - 변경된 도면만 업로드, 기존 도면 유지\n` +
` → 누락된 도면의 자재는 그대로 유지\n\n` +
`2⃣ "도면 삭제됨" - 누락된 도면 완전 삭제\n` +
` → 해당 자재 완전 제거 (숨김 처리)\n\n` +
`3⃣ "취소" - 업로드 취소\n\n` +
`숫자를 입력하세요 (1, 2, 3):`;
}
const userChoice = prompt(alertMessage);
if (userChoice === '3' || userChoice === null) {
// 취소 선택
await api.delete(`/files/${fileId}`);
alert('업로드가 취소되었습니다.');
return;
} else if (userChoice === '2') {
// 도면 삭제됨 - 백엔드에 삭제 처리 요청
try {
await api.post(`/files/${fileId}/process-missing-drawings`, {
action: 'delete',
drawings: missingDrawings
});
alert(`${missingDrawings.length}개 도면이 삭제 처리되었습니다.`);
} catch (err) {
console.error('도면 삭제 처리 실패:', err);
alert('도면 삭제 처리에 실패했습니다.');
}
} else if (userChoice === '1') {
// 일부만 업로드 - 이미 처리됨 (기본 동작)
alert(`✅ 일부 업로드로 처리되었습니다.\n누락된 ${missingDrawings.length}개 도면은 기존 상태를 유지합니다.`);
} else {
// 잘못된 입력
await api.delete(`/files/${fileId}`);
alert('잘못된 입력입니다. 업로드가 취소되었습니다.');
return;
}
}
alert(`리비전 ${response.data.revision} 업로드 성공!\n신규 자재: ${response.data.new_materials_count || 0}`);
await loadFiles();
}
} catch (err) {
console.error('리비전 업로드 실패:', err);
alert('리비전 업로드에 실패했습니다: ' + (err.response?.data?.detail || err.message));
} finally {
setUploading(false);
}
};
input.click();
};
return (
<div style={{
display: 'flex',
height: '100vh',
background: '#f5f5f5',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
}}>
{/* 사이드바 - 프로젝트 정보 */}
<div style={{
width: `${sidebarWidth}px`,
background: '#ffffff',
borderRight: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column'
}}>
{/* 헤더 */}
<div style={{
padding: '16px',
borderBottom: '1px solid #e0e0e0',
background: '#fafafa'
}}>
<button
onClick={onBack}
style={{
background: 'none',
border: 'none',
color: '#666',
cursor: 'pointer',
fontSize: '14px',
marginBottom: '8px'
}}
>
대시보드로
</button>
<h2 style={{
margin: 0,
fontSize: '18px',
fontWeight: '600',
color: '#333'
}}>
{project?.project_name}
</h2>
<p style={{
margin: '4px 0 0 0',
fontSize: '14px',
color: '#666'
}}>
{project?.official_project_code || project?.job_no}
</p>
</div>
{/* 프로젝트 통계 */}
<div style={{
padding: '16px',
borderBottom: '1px solid #e0e0e0'
}}>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '8px' }}>
프로젝트 현황
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
<div style={{ textAlign: 'center', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
<div style={{ fontSize: '20px', fontWeight: '600', color: '#4299e1' }}>
{files.length}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>BOM 파일</div>
</div>
<div style={{ textAlign: 'center', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
<div style={{ fontSize: '20px', fontWeight: '600', color: '#48bb78' }}>
{files.reduce((sum, f) => sum + (f.parsed_count || 0), 0)}
</div>
<div style={{ fontSize: '12px', color: '#666' }}> 자재</div>
</div>
</div>
</div>
{/* 업로드 영역 */}
<div
style={{
margin: '16px',
padding: '20px',
border: dragOver ? '2px dashed #4299e1' : '2px dashed #ddd',
borderRadius: '8px',
textAlign: 'center',
background: dragOver ? '#f0f9ff' : '#fafafa',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
multiple
accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{uploading ? (
<div style={{ color: '#4299e1' }}>
📤 업로드 ...
</div>
) : (
<div>
<div style={{ fontSize: '24px', marginBottom: '8px' }}>📁</div>
<div style={{ fontSize: '14px', color: '#666' }}>
Excel 파일을 드래그하거나<br />클릭하여 업로드
</div>
</div>
)}
</div>
</div>
{/* 메인 패널 - 파일 목록 */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
background: '#ffffff'
}}>
{/* 툴바 */}
<div style={{
padding: '12px 16px',
borderBottom: '1px solid #e0e0e0',
background: '#fafafa',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600' }}>
BOM 파일 목록 ({Object.keys(groupedFiles).length})
</h3>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={loadFiles}
disabled={loading}
style={{
padding: '6px 12px',
background: loading ? '#a0aec0' : '#48bb78',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '12px'
}}
>
{loading ? '🔄 로딩중...' : '🔄 새로고침'}
</button>
<button
onClick={() => fileInputRef.current?.click()}
style={{
padding: '6px 12px',
background: '#4299e1',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
+ 파일 추가
</button>
</div>
</div>
{/* 파일 목록 */}
<div style={{ flex: 1, overflow: 'auto' }}>
{loading ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
로딩 ...
</div>
) : files.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
업로드된 BOM 파일이 없습니다.
</div>
) : (
<div>
{Object.entries(groupedFiles).map(([bomName, bomFiles]) => {
// 최신 리비전 파일을 대표로 선택
const latestFile = bomFiles[0]; // 이미 최신순으로 정렬됨
return (
<div
key={latestFile.id}
style={{
padding: '12px 16px',
borderBottom: '1px solid #f0f0f0',
cursor: 'pointer',
background: selectedFile?.id === latestFile.id ? '#f0f9ff' : 'transparent',
transition: 'background-color 0.2s ease'
}}
onClick={() => setSelectedFile(latestFile)}
onMouseEnter={(e) => {
if (selectedFile?.id !== latestFile.id) {
e.target.style.background = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (selectedFile?.id !== latestFile.id) {
e.target.style.background = 'transparent';
}
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
{/* BOM 이름 (인라인 편집) */}
{editingFile === latestFile.id && editingField === 'bom_name' ? (
<input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit();
if (e.key === 'Escape') cancelEdit();
}}
style={{
border: '1px solid #4299e1',
borderRadius: '2px',
padding: '2px 4px',
fontSize: '14px',
fontWeight: '600'
}}
autoFocus
/>
) : (
<div
style={{
fontSize: '14px',
fontWeight: '600',
color: '#333',
cursor: 'text'
}}
onClick={(e) => {
e.stopPropagation();
startEdit(latestFile, 'bom_name');
}}
>
{bomName}
</div>
)}
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
📄 {bomFiles.length > 1 ? `${bomFiles.length}개 리비전` : latestFile.revision || 'Rev.0'}
{bomFiles.reduce((sum, f) => sum + (f.parsed_count || 0), 0)} 자재 (최신: {latestFile.revision || 'Rev.0'})
</div>
</div>
<div style={{ display: 'flex', gap: '4px', marginLeft: '12px' }}>
{bomFiles.length === 1 ? (
<button
onClick={(e) => {
e.stopPropagation();
viewMaterials(latestFile);
}}
style={{
padding: '4px 8px',
background: '#48bb78',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px'
}}
>
📋 자재
</button>
) : (
<div style={{ position: 'relative' }}>
<select
onChange={(e) => {
const selectedFileId = e.target.value;
const selectedFile = bomFiles.find(f => f.id.toString() === selectedFileId);
if (selectedFile) {
viewMaterials(selectedFile);
}
}}
onClick={(e) => e.stopPropagation()}
style={{
padding: '4px 8px',
background: '#48bb78',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px'
}}
defaultValue=""
>
<option value="" disabled>📋 자재 선택</option>
{bomFiles.map(file => (
<option key={file.id} value={file.id} style={{ color: 'black' }}>
{file.revision || 'Rev.0'} ({file.parsed_count || 0})
</option>
))}
</select>
</div>
)}
<button
onClick={(e) => {
e.stopPropagation();
handleRevisionUpload(latestFile);
}}
style={{
padding: '4px 8px',
background: 'white',
color: '#4299e1',
border: '1px solid #4299e1',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px'
}}
>
📝 리비전
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (window.confirm(`${bomName} BOM을 삭제하시겠습니까? (모든 리비전이 삭제됩니다)`)) {
// 모든 리비전 파일 삭제
bomFiles.forEach(file => handleDelete(file.id));
}
}}
style={{
padding: '4px 8px',
background: '#f56565',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px'
}}
>
🗑
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* 우측 패널 - 상세 정보 */}
{selectedFile && (
<div style={{
width: `${previewWidth}px`,
background: '#ffffff',
borderLeft: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column'
}}>
{/* 상세 정보 헤더 */}
<div style={{
padding: '16px',
borderBottom: '1px solid #e0e0e0',
background: '#fafafa'
}}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: '600' }}>
파일 상세 정보
</h3>
</div>
{/* 상세 정보 내용 */}
<div style={{ padding: '16px', flex: 1, overflow: 'auto' }}>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
BOM 이름
</label>
<div
style={{
padding: '8px',
border: '1px solid #e0e0e0',
borderRadius: '4px',
cursor: 'text',
background: '#fafafa'
}}
onClick={() => startEdit(selectedFile, 'bom_name')}
>
{selectedFile.bom_name || selectedFile.original_filename}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
파일명
</label>
<div style={{ fontSize: '14px', color: '#333' }}>
{selectedFile.original_filename}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
리비전
</label>
<div style={{ fontSize: '14px', color: '#333' }}>
{selectedFile.revision || 'Rev.0'}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
자재
</label>
<div style={{ fontSize: '14px', color: '#333' }}>
{selectedFile.parsed_count || 0}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
업로드 일시
</label>
<div style={{ fontSize: '14px', color: '#333' }}>
{new Date(selectedFile.created_at).toLocaleString('ko-KR')}
</div>
</div>
{/* 액션 버튼들 */}
<div style={{ marginTop: '24px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
<button
onClick={() => viewMaterials(selectedFile)}
style={{
width: '100%',
padding: '12px',
background: '#4299e1',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
📋 자재 목록 보기
</button>
<button
onClick={() => handleRevisionUpload(selectedFile)}
disabled={uploading}
style={{
width: '100%',
padding: '12px',
background: 'white',
color: '#4299e1',
border: '1px solid #4299e1',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
📝 리비전 업로드
</button>
<button
onClick={() => handleDelete(selectedFile.id)}
style={{
width: '100%',
padding: '12px',
background: '#f56565',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
🗑 파일 삭제
</button>
</div>
</div>
</div>
)}
{/* 에러 메시지 */}
{error && (
<div style={{
position: 'fixed',
top: '20px',
right: '20px',
background: '#fed7d7',
color: '#c53030',
padding: '12px 16px',
borderRadius: '6px',
border: '1px solid #fc8181',
zIndex: 1000
}}>
{error}
<button
onClick={() => setError('')}
style={{
marginLeft: '12px',
background: 'none',
border: 'none',
color: '#c53030',
cursor: 'pointer'
}}
>
</button>
</div>
)}
</div>
);
};
export default BOMWorkspacePage;