Phase 1: 도면 자재 분석 업로드 페이지 구현

This commit is contained in:
Hyungi Ahn
2025-07-14 14:20:54 +09:00
parent 13c375477a
commit f3189dc050
10 changed files with 1595 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
import React, { useState, useEffect } from 'react';
import { Typography, Box, Card, CardContent, Grid, CircularProgress } from '@mui/material';
function Dashboard({ selectedProject, projects }) {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (selectedProject) {
fetchMaterialStats();
}
}, [selectedProject]);
const fetchMaterialStats = async () => {
setLoading(true);
try {
const response = await fetch(`http://localhost:8000/api/files/materials/summary?project_id=${selectedProject.id}`);
if (response.ok) {
const data = await response.json();
setStats(data.summary);
}
} catch (error) {
console.error('통계 로드 실패:', error);
} finally {
setLoading(false);
}
};
return (
<Box>
<Typography variant="h4" gutterBottom>
📊 대시보드
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" color="primary" gutterBottom>
프로젝트 현황
</Typography>
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
{projects.length}
</Typography>
<Typography variant="body2" color="textSecondary">
프로젝트
</Typography>
<Typography variant="body1" sx={{ mt: 2 }}>
선택된 프로젝트: {selectedProject ? selectedProject.project_name : '없음'}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" color="secondary" gutterBottom>
자재 현황
</Typography>
{loading ? (
<Box display="flex" justifyContent="center" py={3}>
<CircularProgress />
</Box>
) : stats ? (
<Box>
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
{stats.total_items.toLocaleString()}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
자재
</Typography>
<Typography variant="body2">
고유 품목: {stats.unique_descriptions}
</Typography>
<Typography variant="body2">
고유 사이즈: {stats.unique_sizes}
</Typography>
<Typography variant="body2">
수량: {stats.total_quantity.toLocaleString()}
</Typography>
</Box>
) : (
<Typography variant="body2" color="textSecondary">
프로젝트를 선택하면 자재 현황을 확인할 있습니다.
</Typography>
)}
</CardContent>
</Card>
</Grid>
{selectedProject && (
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
📋 프로젝트 상세 정보
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">프로젝트 코드</Typography>
<Typography variant="body1">{selectedProject.official_project_code}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">프로젝트명</Typography>
<Typography variant="body1">{selectedProject.project_name}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">상태</Typography>
<Typography variant="body1">{selectedProject.status}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">생성일</Typography>
<Typography variant="body1">
{new Date(selectedProject.created_at).toLocaleDateString()}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
</Grid>
)}
</Grid>
</Box>
);
}
export default Dashboard;

View File

@@ -0,0 +1,313 @@
import React, { useState, useCallback } from 'react';
import {
Typography,
Box,
Card,
CardContent,
Button,
LinearProgress,
Alert,
List,
ListItem,
ListItemText,
ListItemIcon,
Chip,
Paper,
Divider
} from '@mui/material';
import {
CloudUpload,
AttachFile,
CheckCircle,
Error as ErrorIcon,
Description
} from '@mui/icons-material';
import { useDropzone } from 'react-dropzone';
function FileUpload({ selectedProject, onUploadSuccess }) {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadResult, setUploadResult] = useState(null);
const [error, setError] = useState('');
const onDrop = useCallback((acceptedFiles) => {
if (!selectedProject) {
setError('프로젝트를 먼저 선택해주세요.');
return;
}
if (acceptedFiles.length > 0) {
uploadFile(acceptedFiles[0]);
}
}, [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 uploadFile = async (file) => {
setUploading(true);
setUploadProgress(0);
setError('');
setUploadResult(null);
const formData = new FormData();
formData.append('file', file);
formData.append('project_id', selectedProject.id);
formData.append('revision', 'Rev.0');
try {
const xhr = new XMLHttpRequest();
// 업로드 진행률 추적
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
setUploadProgress(progress);
}
});
// Promise로 XMLHttpRequest 래핑
const uploadPromise = new Promise((resolve, reject) => {
xhr.onload = () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.responseText}`));
}
};
xhr.onerror = () => reject(new Error('Network error'));
});
xhr.open('POST', 'http://localhost:8000/api/files/upload');
xhr.send(formData);
const result = await uploadPromise;
if (result.success) {
setUploadResult(result);
if (onUploadSuccess) {
onUploadSuccess(result);
}
} else {
setError(result.message || '업로드에 실패했습니다.');
}
} catch (error) {
console.error('업로드 실패:', error);
setError(`업로드 실패: ${error.message}`);
} finally {
setUploading(false);
setUploadProgress(0);
}
};
const handleFileSelect = (event) => {
const file = event.target.files[0];
if (file) {
uploadFile(file);
}
};
const resetUpload = () => {
setUploadResult(null);
setError('');
setUploadProgress(0);
};
if (!selectedProject) {
return (
<Box>
<Typography variant="h4" gutterBottom>
📁 파일 업로드
</Typography>
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<CloudUpload sx={{ fontSize: 64, color: 'grey.400', mb: 2 }} />
<Typography variant="h6" gutterBottom>
프로젝트를 선택해주세요
</Typography>
<Typography variant="body2" color="textSecondary">
프로젝트 관리 탭에서 프로젝트를 선택한 파일을 업로드할 있습니다.
</Typography>
</CardContent>
</Card>
</Box>
);
}
return (
<Box>
<Typography variant="h4" gutterBottom>
📁 파일 업로드
</Typography>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
{selectedProject.project_name} ({selectedProject.official_project_code})
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
{error}
</Alert>
)}
{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>
<List>
<ListItem>
<ListItemIcon>
<Description color="primary" />
</ListItemIcon>
<ListItemText
primary={uploadResult.original_filename}
secondary={`파일 ID: ${uploadResult.file_id}`}
/>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="파싱 결과"
secondary={
<Box sx={{ mt: 1 }}>
<Chip
label={`${uploadResult.parsed_materials_count}개 자재 파싱`}
color="primary"
sx={{ mr: 1 }}
/>
<Chip
label={`${uploadResult.saved_materials_count}개 DB 저장`}
color="success"
/>
</Box>
}
/>
</ListItem>
</List>
{uploadResult.sample_materials && uploadResult.sample_materials.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="textSecondary" gutterBottom>
샘플 자재 (처음 3):
</Typography>
{uploadResult.sample_materials.map((material, index) => (
<Typography key={index} variant="body2" sx={{
bgcolor: 'grey.50',
p: 1,
mb: 0.5,
borderRadius: 1,
fontSize: '0.8rem'
}}>
{index + 1}. {material.original_description} - {material.quantity} {material.unit}
{material.size_spec && ` (${material.size_spec})`}
</Typography>
))}
</Box>
)}
<Box sx={{ mt: 2 }}>
<Button variant="outlined" onClick={resetUpload}>
다른 파일 업로드
</Button>
</Box>
</CardContent>
</Card>
) : (
<Card>
<CardContent>
{uploading ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<CloudUpload sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
파일 업로드 ...
</Typography>
<Box sx={{ width: '100%', maxWidth: 400, mx: 'auto', mt: 2 }}>
<LinearProgress
variant="determinate"
value={uploadProgress}
sx={{ height: 8, borderRadius: 4 }}
/>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
{uploadProgress}% 완료
</Typography>
</Box>
</Box>
) : (
<>
<Paper
{...getRootProps()}
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"
>
파일 선택
</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">
자재명, 수량, 사이즈, 재질 등이 자동으로 분류됩니다
</Typography>
</Box>
</>
)}
</CardContent>
</Card>
)}
</Box>
);
}
export default FileUpload;

View File

@@ -0,0 +1,245 @@
import React, { useState, useEffect } from 'react';
import {
Typography,
Box,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
TablePagination,
CircularProgress,
Alert,
Chip
} from '@mui/material';
import { Inventory } from '@mui/icons-material';
function MaterialList({ selectedProject }) {
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(25);
const [totalCount, setTotalCount] = useState(0);
useEffect(() => {
if (selectedProject) {
fetchMaterials();
} else {
setMaterials([]);
setTotalCount(0);
}
}, [selectedProject, page, rowsPerPage]);
const fetchMaterials = async () => {
setLoading(true);
setError('');
try {
const skip = page * rowsPerPage;
const response = await fetch(
`http://localhost:8000/api/files/materials?project_id=${selectedProject.id}&skip=${skip}&limit=${rowsPerPage}`
);
if (response.ok) {
const data = await response.json();
setMaterials(data.materials || []);
setTotalCount(data.total_count || 0);
} else {
setError('자재 데이터를 불러오는데 실패했습니다.');
}
} catch (error) {
console.error('자재 조회 실패:', error);
setError('네트워크 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const getItemTypeColor = (itemType) => {
const colors = {
'PIPE': 'primary',
'FITTING': 'secondary',
'VALVE': 'success',
'FLANGE': 'warning',
'BOLT': 'info',
'OTHER': 'default'
};
return colors[itemType] || 'default';
};
if (!selectedProject) {
return (
<Box>
<Typography variant="h4" gutterBottom>
📋 자재 목록
</Typography>
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
프로젝트를 선택해주세요
</Typography>
<Typography variant="body2" color="textSecondary">
프로젝트 관리 탭에서 프로젝트를 선택하면 자재 목록을 확인할 있습니다.
</Typography>
</CardContent>
</Card>
</Box>
);
}
return (
<Box>
<Typography variant="h4" gutterBottom>
📋 자재 목록 (그룹핑)
</Typography>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
{selectedProject.project_name} ({selectedProject.official_project_code})
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{loading ? (
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<CircularProgress size={60} />
<Typography variant="h6" sx={{ mt: 2 }}>
자재 데이터 로딩 ...
</Typography>
</CardContent>
</Card>
) : materials.length === 0 ? (
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
자재 데이터가 없습니다
</Typography>
<Typography variant="body2" color="textSecondary">
파일 업로드 탭에서 BOM 파일을 업로드해주세요.
</Typography>
</CardContent>
</Card>
) : (
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">
{totalCount.toLocaleString()} 자재 그룹
</Typography>
<Chip
label={`${materials.length}개 표시 중`}
color="primary"
variant="outlined"
/>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><strong>번호</strong></TableCell>
<TableCell><strong>유형</strong></TableCell>
<TableCell><strong>자재명</strong></TableCell>
<TableCell align="center"><strong> 수량</strong></TableCell>
<TableCell align="center"><strong>단위</strong></TableCell>
<TableCell align="center"><strong>사이즈</strong></TableCell>
<TableCell><strong>재질</strong></TableCell>
<TableCell align="center"><strong>라인 </strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{materials.map((material, index) => (
<TableRow
key={material.id}
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
>
<TableCell>
{page * rowsPerPage + index + 1}
</TableCell>
<TableCell>
<Chip
label={material.item_type || 'OTHER'}
size="small"
color={getItemTypeColor(material.item_type)}
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ maxWidth: 300 }}>
{material.original_description}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="h6" color="primary">
{material.quantity.toLocaleString()}
</Typography>
</TableCell>
<TableCell align="center">
<Chip label={material.unit} size="small" />
</TableCell>
<TableCell align="center">
<Chip
label={material.size_spec || '-'}
size="small"
color="secondary"
/>
</TableCell>
<TableCell>
<Typography variant="body2" color="primary">
{material.material_grade || '-'}
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={`${material.line_count || 1}개 라인`}
size="small"
variant="outlined"
title={material.line_numbers_str}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
labelRowsPerPage="페이지당 행 수:"
labelDisplayedRows={({ from, to, count }) =>
`${from}-${to} / 총 ${count !== -1 ? count : to}`
}
/>
</CardContent>
</Card>
)}
</Box>
);
}
export default MaterialList;

View File

@@ -0,0 +1,187 @@
import React, { useState } from 'react';
import {
Typography,
Box,
Card,
CardContent,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Alert,
CircularProgress
} from '@mui/material';
import { Add, Assignment } from '@mui/icons-material';
function ProjectManager({ projects, selectedProject, setSelectedProject, onProjectsChange }) {
const [dialogOpen, setDialogOpen] = useState(false);
const [projectCode, setProjectCode] = useState('');
const [projectName, setProjectName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleCreateProject = async () => {
if (!projectCode.trim() || !projectName.trim()) {
setError('프로젝트 코드와 이름을 모두 입력해주세요.');
return;
}
setLoading(true);
setError('');
try {
const response = await fetch('http://localhost:8000/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
official_project_code: projectCode.trim(),
project_name: projectName.trim(),
design_project_code: projectCode.trim(),
is_code_matched: true,
status: 'active'
})
});
if (response.ok) {
const newProject = await response.json();
onProjectsChange();
setSelectedProject(newProject);
setDialogOpen(false);
setProjectCode('');
setProjectName('');
setError('');
} else {
const errorData = await response.json();
setError(errorData.detail || '프로젝트 생성에 실패했습니다.');
}
} catch (error) {
console.error('프로젝트 생성 실패:', error);
setError('네트워크 오류가 발생했습니다. 백엔드 서버가 실행 중인지 확인해주세요.');
} finally {
setLoading(false);
}
};
const handleCloseDialog = () => {
setDialogOpen(false);
setProjectCode('');
setProjectName('');
setError('');
};
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h4">
🗂 프로젝트 관리
</Typography>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setDialogOpen(true)}
>
프로젝트
</Button>
</Box>
{projects.length === 0 ? (
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Assignment sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
프로젝트가 없습니다
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
프로젝트를 생성하여 시작하세요!
</Typography>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setDialogOpen(true)}
>
번째 프로젝트 생성
</Button>
</CardContent>
</Card>
) : (
<Box>
{projects.map((project) => (
<Card
key={project.id}
sx={{
mb: 2,
cursor: 'pointer',
border: selectedProject?.id === project.id ? 2 : 1,
borderColor: selectedProject?.id === project.id ? 'primary.main' : 'divider'
}}
onClick={() => setSelectedProject(project)}
>
<CardContent>
<Typography variant="h6">
{project.project_name || project.official_project_code}
</Typography>
<Typography variant="body2" color="primary" sx={{ mb: 1 }}>
코드: {project.official_project_code}
</Typography>
<Typography variant="body2" color="textSecondary">
상태: {project.status} | 생성일: {new Date(project.created_at).toLocaleDateString()}
</Typography>
</CardContent>
</Card>
))}
</Box>
)}
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle> 프로젝트 생성</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
autoFocus
margin="dense"
label="프로젝트 코드"
placeholder="예: MP7-PIPING-R3"
fullWidth
variant="outlined"
value={projectCode}
onChange={(e) => setProjectCode(e.target.value)}
sx={{ mb: 2 }}
/>
<TextField
margin="dense"
label="프로젝트명"
placeholder="예: MP7 PIPING PROJECT Rev.3"
fullWidth
variant="outlined"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog} disabled={loading}>
취소
</Button>
<Button
onClick={handleCreateProject}
variant="contained"
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{loading ? '생성 중...' : '생성'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default ProjectManager;