feat(tkeg): tkeg BOM 자재관리 서비스 초기 세팅 (api + web + docker-compose)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
579
tkeg/web/src/components/FileUpload.jsx
Normal file
579
tkeg/web/src/components/FileUpload.jsx
Normal file
@@ -0,0 +1,579 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
LinearProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Chip,
|
||||
Paper,
|
||||
Divider,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
StepContent,
|
||||
Alert,
|
||||
Grid
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CloudUpload,
|
||||
AttachFile,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Description,
|
||||
AutoAwesome,
|
||||
Category,
|
||||
Science,
|
||||
Compare
|
||||
} from '@mui/icons-material';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { uploadFile as uploadFileApi, fetchMaterialsSummary } from '../api';
|
||||
import Toast from './Toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
console.log('=== FileUpload 컴포넌트 렌더링 ===');
|
||||
console.log('selectedProject:', selectedProject);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadResult, setUploadResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [materialsSummary, setMaterialsSummary] = useState(null);
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
const [uploadSteps, setUploadSteps] = useState([
|
||||
{ label: '파일 업로드', completed: false, active: false },
|
||||
{ label: '데이터 파싱', completed: false, active: false },
|
||||
{ label: '자재 분류', completed: false, active: false },
|
||||
{ label: '분류기 실행', completed: false, active: false },
|
||||
{ label: '데이터베이스 저장', completed: false, active: false }
|
||||
]);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles) => {
|
||||
console.log('=== FileUpload: onDrop 함수 호출됨 ===');
|
||||
console.log('받은 파일들:', acceptedFiles);
|
||||
console.log('선택된 프로젝트:', selectedProject);
|
||||
|
||||
if (!selectedProject) {
|
||||
console.log('프로젝트가 선택되지 않음');
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트를 먼저 선택해주세요.',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (acceptedFiles.length > 0) {
|
||||
console.log('파일 업로드 시작');
|
||||
uploadFile(acceptedFiles[0]);
|
||||
} else {
|
||||
console.log('선택된 파일이 없음');
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, acceptedFiles } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'text/csv': ['.csv']
|
||||
},
|
||||
multiple: false,
|
||||
maxSize: 10 * 1024 * 1024 // 10MB
|
||||
});
|
||||
|
||||
const updateUploadStep = (stepIndex, completed = false, active = false) => {
|
||||
setUploadSteps(prev => prev.map((step, index) => ({
|
||||
...step,
|
||||
completed: index < stepIndex ? true : (index === stepIndex ? completed : false),
|
||||
active: index === stepIndex ? active : false
|
||||
})));
|
||||
};
|
||||
|
||||
const uploadFile = async (file) => {
|
||||
console.log('=== FileUpload: uploadFile 함수 시작 ===');
|
||||
console.log('파일 정보:', {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
});
|
||||
console.log('선택된 프로젝트:', selectedProject);
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
setError('');
|
||||
setUploadResult(null);
|
||||
setMaterialsSummary(null);
|
||||
|
||||
console.log('업로드 시작:', {
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
jobNo: selectedProject?.job_no,
|
||||
projectName: selectedProject?.project_name
|
||||
});
|
||||
|
||||
// 업로드 단계 초기화
|
||||
setUploadSteps([
|
||||
{ label: '파일 업로드', completed: false, active: true },
|
||||
{ label: '데이터 파싱', completed: false, active: false },
|
||||
{ label: '자재 분류', completed: false, active: false },
|
||||
{ label: '분류기 실행', completed: false, active: false },
|
||||
{ label: '데이터베이스 저장', completed: false, active: false }
|
||||
]);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('revision', 'Rev.0'); // 새 BOM은 항상 Rev.0
|
||||
formData.append('bom_name', file.name); // BOM 이름으로 파일명 사용
|
||||
formData.append('bom_type', 'excel'); // 파일 타입
|
||||
formData.append('description', ''); // 설명 (빈 문자열)
|
||||
|
||||
console.log('FormData 내용:', {
|
||||
fileName: file.name,
|
||||
jobNo: selectedProject.job_no,
|
||||
revision: 'Rev.0', // 새 BOM은 항상 Rev.0
|
||||
bomName: file.name,
|
||||
bomType: 'excel'
|
||||
});
|
||||
|
||||
try {
|
||||
// 1단계: 파일 업로드
|
||||
updateUploadStep(0, true, false);
|
||||
updateUploadStep(1, false, true);
|
||||
|
||||
console.log('API 호출 시작: /upload');
|
||||
const response = await uploadFileApi(formData, {
|
||||
onUploadProgress: (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
setUploadProgress(progress);
|
||||
console.log('업로드 진행률:', progress + '%');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('API 응답:', response.data);
|
||||
const result = response.data;
|
||||
|
||||
console.log('응답 데이터 구조:', {
|
||||
success: result.success,
|
||||
file_id: result.file_id,
|
||||
message: result.message,
|
||||
hasFileId: 'file_id' in result
|
||||
});
|
||||
|
||||
// 2단계: 데이터 파싱 완료
|
||||
updateUploadStep(1, true, false);
|
||||
updateUploadStep(2, false, true);
|
||||
|
||||
if (result.success) {
|
||||
// 3단계: 자재 분류 완료
|
||||
updateUploadStep(2, true, false);
|
||||
updateUploadStep(3, false, true);
|
||||
|
||||
// 4단계: 분류기 실행 완료
|
||||
updateUploadStep(3, true, false);
|
||||
updateUploadStep(4, false, true);
|
||||
|
||||
// 5단계: 데이터베이스 저장 완료
|
||||
updateUploadStep(4, true, false);
|
||||
|
||||
setUploadResult(result);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '파일 업로드 및 분류가 성공했습니다!',
|
||||
type: 'success'
|
||||
});
|
||||
|
||||
console.log('업로드 성공 결과:', result);
|
||||
console.log('파일 ID:', result.file_id);
|
||||
console.log('선택된 프로젝트:', selectedProject);
|
||||
|
||||
// 업로드 성공 후 자재 통계 미리보기 호출
|
||||
try {
|
||||
const summaryRes = await fetchMaterialsSummary({ file_id: result.file_id });
|
||||
if (summaryRes.data && summaryRes.data.success) {
|
||||
setMaterialsSummary(summaryRes.data.summary);
|
||||
}
|
||||
} catch (e) {
|
||||
// 통계 조회 실패는 무시(UX만)
|
||||
}
|
||||
|
||||
if (onUploadSuccess) {
|
||||
console.log('onUploadSuccess 콜백 호출');
|
||||
onUploadSuccess(result);
|
||||
}
|
||||
|
||||
// 파일 목록 갱신을 위한 이벤트 발생
|
||||
console.log('파일 업로드 이벤트 발생:', {
|
||||
fileId: result.file_id,
|
||||
jobNo: selectedProject.job_no
|
||||
});
|
||||
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('fileUploaded', {
|
||||
detail: { fileId: result.file_id, jobNo: selectedProject.job_no }
|
||||
}));
|
||||
console.log('CustomEvent dispatch 성공');
|
||||
} catch (error) {
|
||||
console.error('CustomEvent dispatch 실패:', error);
|
||||
}
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: result.message || '업로드에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업로드 실패:', error);
|
||||
|
||||
// 에러 타입별 상세 메시지
|
||||
let errorMessage = '업로드에 실패했습니다.';
|
||||
|
||||
if (error.response) {
|
||||
// 서버 응답이 있는 경우
|
||||
const status = error.response.status;
|
||||
const data = error.response.data;
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
errorMessage = `잘못된 요청: ${data?.detail || '파일 형식이나 데이터를 확인해주세요.'}`;
|
||||
break;
|
||||
case 413:
|
||||
errorMessage = '파일 크기가 너무 큽니다. (최대 10MB)';
|
||||
break;
|
||||
case 422:
|
||||
errorMessage = `데이터 검증 실패: ${data?.detail || '파일 내용을 확인해주세요.'}`;
|
||||
break;
|
||||
case 500:
|
||||
errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||
break;
|
||||
default:
|
||||
errorMessage = `서버 오류 (${status}): ${data?.detail || error.message}`;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 네트워크 오류
|
||||
errorMessage = '서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.';
|
||||
} else {
|
||||
// 기타 오류
|
||||
errorMessage = `오류 발생: ${error.message}`;
|
||||
}
|
||||
|
||||
setToast({
|
||||
open: true,
|
||||
message: errorMessage,
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
uploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const resetUpload = () => {
|
||||
setUploadResult(null);
|
||||
setUploadProgress(0);
|
||||
setUploadSteps([
|
||||
{ label: '파일 업로드', completed: false, active: false },
|
||||
{ label: '데이터 파싱', completed: false, active: false },
|
||||
{ label: '자재 분류', completed: false, active: false },
|
||||
{ label: '분류기 실행', completed: false, active: false },
|
||||
{ label: '데이터베이스 저장', completed: false, active: false }
|
||||
]);
|
||||
setToast({ open: false, message: '', type: 'info' });
|
||||
};
|
||||
|
||||
const getClassificationStats = () => {
|
||||
if (!uploadResult?.classification_stats) return null;
|
||||
|
||||
const stats = uploadResult.classification_stats;
|
||||
const total = Object.values(stats).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
return Object.entries(stats)
|
||||
.filter(([category, count]) => count > 0)
|
||||
.map(([category, count]) => ({
|
||||
category,
|
||||
count,
|
||||
percentage: total > 0 ? Math.round((count / total) * 100) : 0
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📁 파일 업로드
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{uploading && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<AutoAwesome sx={{ mr: 1, color: 'primary.main' }} />
|
||||
업로드 및 분류 진행 중...
|
||||
</Typography>
|
||||
|
||||
<Stepper orientation="vertical" sx={{ mt: 2 }}>
|
||||
{uploadSteps.map((step, index) => (
|
||||
<Step key={index} active={step.active} completed={step.completed}>
|
||||
<StepLabel>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{step.completed ? (
|
||||
<CheckCircle color="success" sx={{ mr: 1 }} />
|
||||
) : step.active ? (
|
||||
<Science color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<Category color="disabled" sx={{ mr: 1 }} />
|
||||
)}
|
||||
{step.label}
|
||||
</Box>
|
||||
</StepLabel>
|
||||
{step.active && (
|
||||
<StepContent>
|
||||
<LinearProgress sx={{ mt: 1 }} />
|
||||
</StepContent>
|
||||
)}
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{uploadProgress > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
파일 업로드 진행률: {uploadProgress}%
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{uploadResult ? (
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<CheckCircle color="success" sx={{ mr: 1 }} />
|
||||
<Typography variant="h6" color="success.main">
|
||||
업로드 및 분류 성공!
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
📊 업로드 결과
|
||||
</Typography>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Description />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="파일명"
|
||||
secondary={uploadResult.original_filename}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircle />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="파싱된 자재 수"
|
||||
secondary={`${uploadResult.parsed_materials_count}개`}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircle />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="저장된 자재 수"
|
||||
secondary={`${uploadResult.saved_materials_count}개`}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
🏷️ 분류 결과
|
||||
</Typography>
|
||||
{getClassificationStats() && (
|
||||
<Box>
|
||||
{getClassificationStats().map((stat, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Chip
|
||||
label={stat.category}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Typography variant="body2">
|
||||
{stat.count}개 ({stat.percentage}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{materialsSummary && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
💡 <strong>자재 통계 미리보기:</strong><br/>
|
||||
• 총 자재 수: {materialsSummary.total_items || 0}개<br/>
|
||||
• 고유 자재: {materialsSummary.unique_descriptions || 0}종류<br/>
|
||||
• 총 수량: {materialsSummary.total_quantity || 0}개
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
// 상태 기반 라우팅을 위한 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent('navigateToMaterials', {
|
||||
detail: {
|
||||
jobNo: selectedProject?.job_no,
|
||||
revision: uploadResult?.revision || 'Rev.0',
|
||||
bomName: uploadResult?.original_filename || uploadResult?.filename,
|
||||
message: '파일 업로드 완료',
|
||||
file_id: uploadResult?.file_id // file_id 추가
|
||||
}
|
||||
}));
|
||||
}}
|
||||
startIcon={<Description />}
|
||||
>
|
||||
자재 목록 보기
|
||||
</Button>
|
||||
|
||||
{uploadResult?.revision && uploadResult.revision !== 'Rev.0' && uploadResult.revision !== undefined && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => navigate(`/material-comparison?job_no=${selectedProject.job_no}&revision=${uploadResult.revision}&filename=${encodeURIComponent(uploadResult.original_filename || uploadResult.filename)}`)}
|
||||
startIcon={<Compare />}
|
||||
>
|
||||
이전 리비전과 비교 ({uploadResult.revision})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={resetUpload}
|
||||
>
|
||||
새로 업로드
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Paper
|
||||
{...getRootProps()}
|
||||
onClick={() => console.log('드래그 앤 드롭 영역 클릭됨')}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: 2,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: isDragActive ? 'primary.main' : 'grey.300',
|
||||
bgcolor: isDragActive ? 'primary.50' : 'grey.50',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: 'primary.50'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<CloudUpload sx={{
|
||||
fontSize: 64,
|
||||
color: isDragActive ? 'primary.main' : 'grey.400',
|
||||
mb: 2
|
||||
}} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{isDragActive
|
||||
? "파일을 여기에 놓으세요!"
|
||||
: "Excel 파일을 드래그하거나 클릭하여 선택"
|
||||
}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
지원 형식: .xlsx, .xls, .csv (최대 10MB)
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AttachFile />}
|
||||
component="span"
|
||||
disabled={uploading}
|
||||
onClick={() => console.log('파일 선택 버튼 클릭됨')}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '파일 선택'}
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
💡 <strong>업로드 및 분류 프로세스:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
• BOM(Bill of Materials) 파일을 업로드하면 자동으로 자재 정보를 추출합니다
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
• 각 자재는 8가지 분류기(볼트, 플랜지, 피팅, 가스켓, 계기, 파이프, 밸브, 재질)로 자동 분류됩니다
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
• 분류 결과는 신뢰도 점수와 함께 저장되며, 필요시 수동 검증이 가능합니다
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
FileUpload.propTypes = {
|
||||
selectedProject: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
project_name: PropTypes.string.isRequired,
|
||||
official_project_code: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
onUploadSuccess: PropTypes.func,
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
Reference in New Issue
Block a user