✨ Phase 1: 도면 자재 분석 업로드 페이지 구현
This commit is contained in:
128
frontend/src/components/Dashboard.jsx
Normal file
128
frontend/src/components/Dashboard.jsx
Normal 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;
|
||||
313
frontend/src/components/FileUpload.jsx
Normal file
313
frontend/src/components/FileUpload.jsx
Normal 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;
|
||||
245
frontend/src/components/MaterialList.jsx
Normal file
245
frontend/src/components/MaterialList.jsx
Normal 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;
|
||||
187
frontend/src/components/ProjectManager.jsx
Normal file
187
frontend/src/components/ProjectManager.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user