Files
tk-factory-services/tkeg/web/src/components/FileUpload.jsx
2026-03-16 15:41:58 +09:00

580 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;