프론트엔드 작성중
This commit is contained in:
@@ -1,38 +1,213 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Typography, Box, Card, CardContent, Grid, CircularProgress } from '@mui/material';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import { fetchMaterials } from '../api';
|
||||
import { Bar, Pie, Line } from 'react-chartjs-2';
|
||||
import 'chart.js/auto';
|
||||
import Toast from './Toast';
|
||||
|
||||
function Dashboard({ selectedProject, projects }) {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [barData, setBarData] = useState(null);
|
||||
const [pieData, setPieData] = useState(null);
|
||||
const [materialGradeData, setMaterialGradeData] = useState(null);
|
||||
const [sizeData, setSizeData] = useState(null);
|
||||
const [topMaterials, setTopMaterials] = useState([]);
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchMaterialStats();
|
||||
fetchMaterialList();
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
const fetchMaterialStats = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8000/api/files/materials/summary?project_id=${selectedProject.id}`);
|
||||
const response = await fetch(`/files/materials/summary?project_id=${selectedProject.id}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data.summary);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('통계 로드 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '자재 통계 로드 실패',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMaterialList = async () => {
|
||||
try {
|
||||
// 최대 1000개까지 조회(실무에서는 서버 페이징/집계 API 권장)
|
||||
const params = { project_id: selectedProject.id, skip: 0, limit: 1000 };
|
||||
const response = await fetchMaterials(params);
|
||||
setMaterials(response.data.materials || []);
|
||||
} catch (error) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '자재 목록 로드 실패',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (materials.length > 0) {
|
||||
// 분류별 집계
|
||||
const typeCounts = {};
|
||||
const typeQuantities = {};
|
||||
const materialGrades = {};
|
||||
const sizes = {};
|
||||
const materialQuantities = {};
|
||||
|
||||
materials.forEach(mat => {
|
||||
const type = mat.item_type || 'OTHER';
|
||||
const grade = mat.material_grade || '미분류';
|
||||
const size = mat.size_spec || '미분류';
|
||||
const desc = mat.original_description;
|
||||
|
||||
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
||||
typeQuantities[type] = (typeQuantities[type] || 0) + (mat.quantity || 0);
|
||||
materialGrades[grade] = (materialGrades[grade] || 0) + 1;
|
||||
sizes[size] = (sizes[size] || 0) + 1;
|
||||
materialQuantities[desc] = (materialQuantities[desc] || 0) + (mat.quantity || 0);
|
||||
});
|
||||
|
||||
// Bar 차트 데이터
|
||||
setBarData({
|
||||
labels: Object.keys(typeCounts),
|
||||
datasets: [
|
||||
{
|
||||
label: '자재 수',
|
||||
data: Object.values(typeCounts),
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.6)',
|
||||
borderColor: 'rgba(25, 118, 210, 1)',
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: '총 수량',
|
||||
data: Object.values(typeQuantities),
|
||||
backgroundColor: 'rgba(220, 0, 78, 0.4)',
|
||||
borderColor: 'rgba(220, 0, 78, 1)',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 재질별 Pie 차트
|
||||
setMaterialGradeData({
|
||||
labels: Object.keys(materialGrades),
|
||||
datasets: [{
|
||||
data: Object.values(materialGrades),
|
||||
backgroundColor: [
|
||||
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
|
||||
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
|
||||
],
|
||||
borderWidth: 2
|
||||
}]
|
||||
});
|
||||
|
||||
// 사이즈별 Pie 차트
|
||||
setSizeData({
|
||||
labels: Object.keys(sizes),
|
||||
datasets: [{
|
||||
data: Object.values(sizes),
|
||||
backgroundColor: [
|
||||
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
|
||||
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
|
||||
],
|
||||
borderWidth: 2
|
||||
}]
|
||||
});
|
||||
|
||||
// 상위 자재 (수량 기준)
|
||||
const sortedMaterials = Object.entries(materialQuantities)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.slice(0, 10)
|
||||
.map(([desc, qty]) => ({ description: desc, quantity: qty }));
|
||||
|
||||
setTopMaterials(sortedMaterials);
|
||||
} else {
|
||||
setBarData(null);
|
||||
setMaterialGradeData(null);
|
||||
setSizeData(null);
|
||||
setTopMaterials([]);
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📊 대시보드
|
||||
</Typography>
|
||||
|
||||
|
||||
{/* 선택된 프로젝트 정보 */}
|
||||
{selectedProject && (
|
||||
<Box sx={{ mb: 3, p: 2, bgcolor: 'primary.50', borderRadius: 2, border: '1px solid', borderColor: 'primary.200' }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Box>
|
||||
<Typography variant="h6" color="primary">
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
상태: {selectedProject.status} | 생성일: {new Date(selectedProject.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Chip
|
||||
label={selectedProject.status}
|
||||
color={selectedProject.status === 'active' ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={selectedProject.is_code_matched ? '코드 매칭됨' : '코드 미매칭'}
|
||||
color={selectedProject.is_code_matched ? 'success' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* 프로젝트 현황 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
@@ -46,81 +221,218 @@ function Dashboard({ selectedProject, projects }) {
|
||||
총 프로젝트 수
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 2 }}>
|
||||
선택된 프로젝트: {selectedProject ? selectedProject.project_name : '없음'}
|
||||
선택된 프로젝트: {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}>
|
||||
{/* 자재 현황 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
📋 프로젝트 상세 정보
|
||||
<Typography variant="h6" color="secondary" 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()}
|
||||
{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>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
총 자재 수
|
||||
</Typography>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={6}>
|
||||
<Chip label={`고유 품목: ${stats.unique_descriptions}개`} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Chip label={`고유 사이즈: ${stats.unique_sizes}개`} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Chip label={`총 수량: ${stats.total_quantity.toLocaleString()}`} size="small" color="success" />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Chip label={`평균 수량: ${stats.avg_quantity}`} size="small" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Typography variant="body2" sx={{ mt: 2, fontSize: '0.8rem' }}>
|
||||
최초 업로드: {stats.earliest_upload ? new Date(stats.earliest_upload).toLocaleString() : '-'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
최신 업로드: {stats.latest_upload ? new Date(stats.latest_upload).toLocaleString() : '-'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
프로젝트를 선택하면 자재 현황을 확인할 수 있습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* 분류별 자재 통계 */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
분류별 자재 통계
|
||||
</Typography>
|
||||
{barData ? (
|
||||
<Bar data={barData} options={{
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
title: { display: true, text: '분류별 자재 수/총 수량' }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}} />
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
자재 데이터가 없습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 재질별 분포 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
재질별 분포
|
||||
</Typography>
|
||||
{materialGradeData ? (
|
||||
<Pie data={materialGradeData} options={{
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
title: { display: true, text: '재질별 자재 분포' }
|
||||
}
|
||||
}} />
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
재질 데이터가 없습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 사이즈별 분포 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
사이즈별 분포
|
||||
</Typography>
|
||||
{sizeData ? (
|
||||
<Pie data={sizeData} options={{
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
title: { display: true, text: '사이즈별 자재 분포' }
|
||||
}
|
||||
}} />
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
사이즈 데이터가 없습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 상위 자재 */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
상위 자재 (수량 기준)
|
||||
</Typography>
|
||||
{topMaterials.length > 0 ? (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell><strong>순위</strong></TableCell>
|
||||
<TableCell><strong>자재명</strong></TableCell>
|
||||
<TableCell align="right"><strong>총 수량</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{topMaterials.map((material, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ maxWidth: 300 }}>
|
||||
{material.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={material.quantity.toLocaleString()}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<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>
|
||||
<Chip label={selectedProject.status} size="small" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
468
frontend/src/components/FileManager.jsx
Normal file
468
frontend/src/components/FileManager.jsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
IconButton,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Chip,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Delete,
|
||||
Download,
|
||||
Visibility,
|
||||
FileUpload,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Error
|
||||
} from '@mui/icons-material';
|
||||
import { fetchFiles, deleteFile } from '../api';
|
||||
import Toast from './Toast';
|
||||
|
||||
function FileManager({ selectedProject }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteDialog, setDeleteDialog] = useState({ open: false, file: null });
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
const [filter, setFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchFilesList();
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
// 파일 업로드 이벤트 리스너 추가
|
||||
useEffect(() => {
|
||||
console.log('FileManager: 이벤트 리스너 등록 시작');
|
||||
|
||||
const handleFileUploaded = (event) => {
|
||||
const { jobNo } = event.detail;
|
||||
console.log('FileManager: 파일 업로드 이벤트 수신:', event.detail);
|
||||
console.log('FileManager: 현재 선택된 프로젝트:', selectedProject);
|
||||
|
||||
if (selectedProject && selectedProject.job_no === jobNo) {
|
||||
console.log('FileManager: 파일 업로드 감지됨, 목록 갱신 중...');
|
||||
fetchFilesList();
|
||||
} else {
|
||||
console.log('FileManager: job_no 불일치 또는 프로젝트 미선택');
|
||||
console.log('이벤트 jobNo:', jobNo);
|
||||
console.log('선택된 프로젝트 jobNo:', selectedProject?.job_no);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('fileUploaded', handleFileUploaded);
|
||||
console.log('FileManager: fileUploaded 이벤트 리스너 등록 완료');
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('fileUploaded', handleFileUploaded);
|
||||
console.log('FileManager: fileUploaded 이벤트 리스너 제거');
|
||||
};
|
||||
}, [selectedProject]);
|
||||
|
||||
const fetchFilesList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log('FileManager: 파일 목록 조회 시작, job_no:', selectedProject.job_no);
|
||||
const response = await fetchFiles({ job_no: selectedProject.job_no });
|
||||
console.log('FileManager: API 응답:', response.data);
|
||||
|
||||
if (response.data && response.data.files) {
|
||||
setFiles(response.data.files);
|
||||
console.log('FileManager: 파일 목록 업데이트 완료, 파일 수:', response.data.files.length);
|
||||
} else {
|
||||
console.log('FileManager: 파일 목록이 비어있음');
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('FileManager: 파일 목록 조회 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '파일 목록을 불러오는데 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async () => {
|
||||
if (!deleteDialog.file) return;
|
||||
|
||||
try {
|
||||
await deleteFile(deleteDialog.file.id);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '파일이 성공적으로 삭제되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
setDeleteDialog({ open: false, file: null });
|
||||
fetchFilesList(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error('파일 삭제 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '파일 삭제에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'processing':
|
||||
return 'warning';
|
||||
case 'failed':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle />;
|
||||
case 'processing':
|
||||
return <CircularProgress size={16} />;
|
||||
case 'failed':
|
||||
return <Error />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleString('ko-KR');
|
||||
};
|
||||
|
||||
const filteredFiles = files.filter(file => {
|
||||
const matchesFilter = !filter ||
|
||||
file.original_filename.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
file.project_name?.toLowerCase().includes(filter.toLowerCase());
|
||||
|
||||
const matchesStatus = !statusFilter || file.status === statusFilter;
|
||||
|
||||
return matchesFilter && matchesStatus;
|
||||
});
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📁 도면 관리
|
||||
</Typography>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<FileUpload 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>
|
||||
|
||||
{/* 필터 UI */}
|
||||
<Box sx={{ mb: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<TextField
|
||||
label="파일명/프로젝트명 검색"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="파일명 또는 프로젝트명으로 검색"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>상태</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
label="상태"
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
<MenuItem value="completed">완료</MenuItem>
|
||||
<MenuItem value="processing">처리 중</MenuItem>
|
||||
<MenuItem value="failed">실패</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} md={4}>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setFilter('');
|
||||
setStatusFilter('');
|
||||
}}
|
||||
>
|
||||
필터 초기화
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={fetchFilesList}
|
||||
>
|
||||
새로고침
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||
파일 목록 로딩 중...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<FileUpload sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{filter || statusFilter ? '검색 결과가 없습니다' : '업로드된 파일이 없습니다'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
{filter || statusFilter
|
||||
? '다른 검색 조건을 시도해보세요.'
|
||||
: '파일 업로드 탭에서 BOM 파일을 업로드해주세요.'
|
||||
}
|
||||
</Typography>
|
||||
{(filter || statusFilter) && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setFilter('');
|
||||
setStatusFilter('');
|
||||
}}
|
||||
>
|
||||
필터 초기화
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">
|
||||
총 {filteredFiles.length}개 파일
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${files.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 align="center"><strong>프로젝트</strong></TableCell>
|
||||
<TableCell align="center"><strong>상태</strong></TableCell>
|
||||
<TableCell align="center"><strong>파일 크기</strong></TableCell>
|
||||
<TableCell align="center"><strong>업로드 일시</strong></TableCell>
|
||||
<TableCell align="center"><strong>처리 완료</strong></TableCell>
|
||||
<TableCell align="center"><strong>작업</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredFiles.map((file, index) => (
|
||||
<TableRow
|
||||
key={file.id}
|
||||
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
|
||||
>
|
||||
<TableCell>
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ maxWidth: 300 }}>
|
||||
{file.original_filename}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={file.project_name || '-'}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={file.status === 'completed' ? '완료' :
|
||||
file.status === 'processing' ? '처리 중' :
|
||||
file.status === 'failed' ? '실패' : '대기'}
|
||||
size="small"
|
||||
color={getStatusColor(file.status)}
|
||||
icon={getStatusIcon(file.status)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="body2">
|
||||
{formatFileSize(file.file_size || 0)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="body2">
|
||||
{formatDate(file.created_at)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="body2">
|
||||
{file.processed_at ? formatDate(file.processed_at) : '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
title="다운로드"
|
||||
disabled={file.status !== 'completed'}
|
||||
>
|
||||
<Download />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="info"
|
||||
title="상세 보기"
|
||||
>
|
||||
<Visibility />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
title="삭제"
|
||||
onClick={() => setDeleteDialog({ open: true, file })}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<Dialog
|
||||
open={deleteDialog.open}
|
||||
onClose={() => setDeleteDialog({ open: false, file: null })}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Warning color="error" />
|
||||
파일 삭제 확인
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
다음 파일을 삭제하시겠습니까?
|
||||
</Typography>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>파일명:</strong> {deleteDialog.file?.original_filename}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>프로젝트:</strong> {deleteDialog.file?.project_name}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>업로드 일시:</strong> {deleteDialog.file?.created_at ? formatDate(deleteDialog.file.created_at) : '-'}
|
||||
</Typography>
|
||||
</Alert>
|
||||
<Typography variant="body2" color="error">
|
||||
⚠️ 이 작업은 되돌릴 수 없습니다. 파일과 관련된 모든 자재 데이터가 함께 삭제됩니다.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => setDeleteDialog({ open: false, file: null })}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteFile}
|
||||
color="error"
|
||||
variant="contained"
|
||||
startIcon={<Delete />}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileManager;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -6,38 +7,74 @@ import {
|
||||
CardContent,
|
||||
Button,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Chip,
|
||||
Paper,
|
||||
Divider
|
||||
Divider,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
StepContent,
|
||||
Alert,
|
||||
Grid
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CloudUpload,
|
||||
AttachFile,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Description
|
||||
Description,
|
||||
AutoAwesome,
|
||||
Category,
|
||||
Science
|
||||
} from '@mui/icons-material';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { uploadFile as uploadFileApi, fetchMaterialsSummary } from '../api';
|
||||
import Toast from './Toast';
|
||||
|
||||
function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
console.log('=== FileUpload 컴포넌트 렌더링 ===');
|
||||
console.log('selectedProject:', selectedProject);
|
||||
|
||||
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) {
|
||||
setError('프로젝트를 먼저 선택해주세요.');
|
||||
console.log('프로젝트가 선택되지 않음');
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트를 먼저 선택해주세요.',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (acceptedFiles.length > 0) {
|
||||
console.log('파일 업로드 시작');
|
||||
uploadFile(acceptedFiles[0]);
|
||||
} else {
|
||||
console.log('선택된 파일이 없음');
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
@@ -52,57 +89,185 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
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('project_id', selectedProject.id);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('revision', 'Rev.0');
|
||||
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
console.log('FormData 내용:', {
|
||||
fileName: file.name,
|
||||
jobNo: selectedProject.job_no,
|
||||
revision: 'Rev.0'
|
||||
});
|
||||
|
||||
// 업로드 진행률 추적
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
setUploadProgress(progress);
|
||||
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 + '%');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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'));
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
xhr.open('POST', 'http://localhost:8000/api/files/upload');
|
||||
xhr.send(formData);
|
||||
|
||||
const result = await uploadPromise;
|
||||
|
||||
// 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 {
|
||||
setError(result.message || '업로드에 실패했습니다.');
|
||||
setToast({
|
||||
open: true,
|
||||
message: result.message || '업로드에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('업로드 실패:', error);
|
||||
setError(`업로드 실패: ${error.message}`);
|
||||
|
||||
// 에러 타입별 상세 메시지
|
||||
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);
|
||||
@@ -118,30 +283,32 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
|
||||
const resetUpload = () => {
|
||||
setUploadResult(null);
|
||||
setError('');
|
||||
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' });
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
@@ -153,10 +320,56 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
{/* 전역 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 ? (
|
||||
@@ -165,149 +378,171 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
<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개):
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
📊 업로드 결과
|
||||
</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>
|
||||
<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 }}>
|
||||
<Button variant="outlined" onClick={resetUpload}>
|
||||
다른 파일 업로드
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => window.location.href = '/materials'}
|
||||
startIcon={<Description />}
|
||||
>
|
||||
자재 목록 보기
|
||||
</Button>
|
||||
<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>
|
||||
<>
|
||||
<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">
|
||||
• 자재명, 수량, 사이즈, 재질 등이 자동으로 분류됩니다
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -13,48 +14,211 @@ import {
|
||||
Paper,
|
||||
TablePagination,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Chip
|
||||
Chip,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Select,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
Grid,
|
||||
IconButton,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { Inventory } from '@mui/icons-material';
|
||||
import {
|
||||
Inventory,
|
||||
Clear,
|
||||
ExpandMore,
|
||||
CompareArrows,
|
||||
Add,
|
||||
Remove,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { fetchMaterials as fetchMaterialsApi, fetchJobs } from '../api';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import Toast from './Toast';
|
||||
|
||||
function MaterialList({ selectedProject }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [jobs, setJobs] = 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);
|
||||
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get('searchValue') || '');
|
||||
const [itemType, setItemType] = useState(searchParams.get('itemType') || '');
|
||||
const [materialGrade, setMaterialGrade] = useState(searchParams.get('materialGrade') || '');
|
||||
const [sizeSpec, setSizeSpec] = useState(searchParams.get('sizeSpec') || '');
|
||||
const [fileFilter, setFileFilter] = useState(searchParams.get('fileFilter') || '');
|
||||
const [selectedJob, setSelectedJob] = useState(searchParams.get('jobId') || '');
|
||||
const [selectedRevision, setSelectedRevision] = useState(searchParams.get('revision') || '');
|
||||
const [groupingType, setGroupingType] = useState(searchParams.get('grouping') || 'item');
|
||||
const [sortBy, setSortBy] = useState(searchParams.get('sortBy') || '');
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
const [revisionComparison, setRevisionComparison] = useState(null);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [fileId, setFileId] = useState(searchParams.get('file_id') || '');
|
||||
const [selectedJobNo, setSelectedJobNo] = useState(searchParams.get('job_no') || '');
|
||||
const [selectedFilename, setSelectedFilename] = useState(searchParams.get('filename') || '');
|
||||
|
||||
// URL 쿼리 파라미터 동기화
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (searchValue) params.set('searchValue', searchValue);
|
||||
if (itemType) params.set('itemType', itemType);
|
||||
if (materialGrade) params.set('materialGrade', materialGrade);
|
||||
if (sizeSpec) params.set('sizeSpec', sizeSpec);
|
||||
if (fileFilter) params.set('fileFilter', fileFilter);
|
||||
if (selectedJob) params.set('jobId', selectedJob);
|
||||
if (selectedRevision) params.set('revision', selectedRevision);
|
||||
if (groupingType) params.set('grouping', groupingType);
|
||||
if (sortBy) params.set('sortBy', sortBy);
|
||||
if (page > 0) params.set('page', page.toString());
|
||||
if (rowsPerPage !== 25) params.set('rowsPerPage', rowsPerPage.toString());
|
||||
if (fileId) params.set('file_id', fileId);
|
||||
if (selectedJobNo) params.set('job_no', selectedJobNo);
|
||||
if (selectedFilename) params.set('filename', selectedFilename);
|
||||
if (selectedRevision) params.set('revision', selectedRevision);
|
||||
|
||||
setSearchParams(params);
|
||||
}, [search, searchValue, itemType, materialGrade, sizeSpec, fileFilter, selectedJob, selectedRevision, groupingType, sortBy, page, rowsPerPage, fileId, setSearchParams, selectedJobNo, selectedFilename, selectedRevision]);
|
||||
|
||||
// URL 파라미터로 진입 시 자동 필터 적용
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlJobNo = urlParams.get('job_no');
|
||||
const urlFilename = urlParams.get('filename');
|
||||
const urlRevision = urlParams.get('revision');
|
||||
if (urlJobNo) setSelectedJobNo(urlJobNo);
|
||||
if (urlFilename) setSelectedFilename(urlFilename);
|
||||
if (urlRevision) setSelectedRevision(urlRevision);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchJobsData();
|
||||
fetchMaterials();
|
||||
} else {
|
||||
setMaterials([]);
|
||||
setTotalCount(0);
|
||||
setJobs([]);
|
||||
}
|
||||
}, [selectedProject, page, rowsPerPage]);
|
||||
}, [selectedProject, page, rowsPerPage, search, searchValue, itemType, materialGrade, sizeSpec, fileFilter, selectedJob, selectedRevision, groupingType, sortBy, fileId, selectedJobNo, selectedFilename, selectedRevision]);
|
||||
|
||||
// 파일 업로드 이벤트 리스너 추가
|
||||
useEffect(() => {
|
||||
const handleFileUploaded = (event) => {
|
||||
const { jobNo } = event.detail;
|
||||
if (selectedProject && selectedProject.job_no === jobNo) {
|
||||
console.log('파일 업로드 감지됨, 자재 목록 갱신 중...');
|
||||
fetchMaterials();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('fileUploaded', handleFileUploaded);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('fileUploaded', handleFileUploaded);
|
||||
};
|
||||
}, [selectedProject]);
|
||||
|
||||
// 파일 목록 불러오기 (선택된 프로젝트가 바뀔 때마다)
|
||||
useEffect(() => {
|
||||
async function fetchFilesForProject() {
|
||||
if (!selectedProject?.job_no) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/files?job_no=${selectedProject.job_no}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data)) setFiles(data);
|
||||
else if (data && Array.isArray(data.files)) setFiles(data.files);
|
||||
else setFiles([]);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
} catch {
|
||||
setFiles([]);
|
||||
}
|
||||
}
|
||||
fetchFilesForProject();
|
||||
}, [selectedProject]);
|
||||
|
||||
const fetchJobsData = async () => {
|
||||
try {
|
||||
const response = await fetchJobs({ project_id: selectedProject.id });
|
||||
if (response.data && response.data.jobs) {
|
||||
setJobs(response.data.jobs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Job 조회 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
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}`
|
||||
);
|
||||
const params = {
|
||||
job_no: selectedJobNo || undefined,
|
||||
filename: selectedFilename || undefined,
|
||||
revision: selectedRevision || undefined,
|
||||
skip,
|
||||
limit: rowsPerPage,
|
||||
search: search || undefined,
|
||||
search_value: searchValue || undefined,
|
||||
item_type: itemType || undefined,
|
||||
material_grade: materialGrade || undefined,
|
||||
size_spec: sizeSpec || undefined,
|
||||
// file_id, fileFilter 등은 사용하지 않음
|
||||
grouping: groupingType || undefined,
|
||||
sort_by: sortBy || undefined
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setMaterials(data.materials || []);
|
||||
setTotalCount(data.total_count || 0);
|
||||
} else {
|
||||
setError('자재 데이터를 불러오는데 실패했습니다.');
|
||||
// selectedProject가 없으면 API 호출하지 않음
|
||||
if (!selectedProject?.job_no) {
|
||||
setMaterials([]);
|
||||
setTotalCount(0);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('API 요청 파라미터:', params); // 디버깅용
|
||||
|
||||
const response = await fetchMaterialsApi(params);
|
||||
const data = response.data;
|
||||
|
||||
console.log('API 응답:', data); // 디버깅용
|
||||
|
||||
setMaterials(data.materials || []);
|
||||
setTotalCount(data.total_count || 0);
|
||||
|
||||
// 리비전 비교 데이터가 있으면 설정
|
||||
if (data.revision_comparison) {
|
||||
setRevisionComparison(data.revision_comparison);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('자재 조회 실패:', error);
|
||||
setError('네트워크 오류가 발생했습니다.');
|
||||
console.error('에러 상세:', error.response?.data); // 디버깅용
|
||||
setToast({
|
||||
open: true,
|
||||
message: `자재 데이터를 불러오는데 실패했습니다: ${error.response?.data?.detail || error.message}`,
|
||||
type: 'error'
|
||||
});
|
||||
setMaterials([]);
|
||||
setTotalCount(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -69,6 +233,20 @@ function MaterialList({ selectedProject }) {
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('');
|
||||
setSearchValue('');
|
||||
setItemType('');
|
||||
setMaterialGrade('');
|
||||
setSizeSpec('');
|
||||
setFileFilter('');
|
||||
setSelectedJob('');
|
||||
setSelectedRevision('');
|
||||
setGroupingType('item');
|
||||
setSortBy('');
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const getItemTypeColor = (itemType) => {
|
||||
const colors = {
|
||||
'PIPE': 'primary',
|
||||
@@ -81,6 +259,18 @@ function MaterialList({ selectedProject }) {
|
||||
return colors[itemType] || 'default';
|
||||
};
|
||||
|
||||
const getRevisionChangeColor = (change) => {
|
||||
if (change > 0) return 'success';
|
||||
if (change < 0) return 'error';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getRevisionChangeIcon = (change) => {
|
||||
if (change > 0) return <Add />;
|
||||
if (change < 0) return <Remove />;
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<Box>
|
||||
@@ -112,12 +302,378 @@ function MaterialList({ selectedProject }) {
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
{/* 필터/검색/정렬 UI */}
|
||||
<Box sx={{ mb: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
{/* 검색 유형 */}
|
||||
<Grid item xs={12} sm={3} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>검색 유형</InputLabel>
|
||||
<Select
|
||||
value={search}
|
||||
label="검색 유형"
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem key="all-search" value="">전체</MenuItem>
|
||||
<MenuItem key="project-search" value="project">프로젝트명</MenuItem>
|
||||
<MenuItem key="job-search" value="job">Job No.</MenuItem>
|
||||
<MenuItem key="material-search" value="material">자재명</MenuItem>
|
||||
<MenuItem key="description-search" value="description">설명</MenuItem>
|
||||
<MenuItem key="grade-search" value="grade">재질</MenuItem>
|
||||
<MenuItem key="size-search" value="size">사이즈</MenuItem>
|
||||
<MenuItem key="filename-search" value="filename">파일명</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 검색어 입력/선택 */}
|
||||
<Grid item xs={12} sm={3} md={2}>
|
||||
{search === 'project' ? (
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>프로젝트명 선택</InputLabel>
|
||||
<Select
|
||||
value={searchValue}
|
||||
label="프로젝트명 선택"
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem key="all-projects" value="">전체 프로젝트</MenuItem>
|
||||
<MenuItem key="mp7-rev2" value="MP7 PIPING PROJECT Rev.2">MP7 PIPING PROJECT Rev.2</MenuItem>
|
||||
<MenuItem key="pp5-5701" value="PP5 5701">PP5 5701</MenuItem>
|
||||
<MenuItem key="mp7" value="MP7">MP7</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : search === 'job' ? (
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Job No. 선택</InputLabel>
|
||||
<Select
|
||||
value={searchValue}
|
||||
label="Job No. 선택"
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem key="all-jobs-search" value="">전체 Job</MenuItem>
|
||||
{jobs.map((job) => (
|
||||
<MenuItem key={`job-search-${job.id}`} value={job.job_number}>
|
||||
{job.job_number}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : search === 'material' || search === 'description' ? (
|
||||
<TextField
|
||||
label="자재명/설명 검색"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="자재명 또는 설명 입력"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : search === 'grade' ? (
|
||||
<TextField
|
||||
label="재질 검색"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="재질 입력 (예: SS316)"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : search === 'size' ? (
|
||||
<TextField
|
||||
label="사이즈 검색"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="사이즈 입력 (예: 6인치)"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : search === 'filename' ? (
|
||||
<TextField
|
||||
label="파일명 검색"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="파일명 입력"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
label="검색어"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="검색어 입력"
|
||||
disabled={!search}
|
||||
InputProps={{
|
||||
endAdornment: search && (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Job No. 선택 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Job No.</InputLabel>
|
||||
<Select
|
||||
value={selectedJobNo}
|
||||
label="Job No."
|
||||
onChange={e => setSelectedJobNo(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
{jobs.map((job) => (
|
||||
<MenuItem key={job.job_number} value={job.job_number}>
|
||||
{job.job_number}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 도면명(파일명) 선택 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>도면명(파일명)</InputLabel>
|
||||
<Select
|
||||
value={selectedFilename}
|
||||
label="도면명(파일명)"
|
||||
onChange={e => setSelectedFilename(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
{files.map((file) => (
|
||||
<MenuItem key={file.id} value={file.original_filename}>
|
||||
{file.bom_name || file.original_filename || file.filename}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 리비전 선택 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>리비전</InputLabel>
|
||||
<Select
|
||||
value={selectedRevision}
|
||||
label="리비전"
|
||||
onChange={e => setSelectedRevision(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
{files
|
||||
.filter(file => file.original_filename === selectedFilename)
|
||||
.map((file) => (
|
||||
<MenuItem key={file.id} value={file.revision}>
|
||||
{file.revision}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 그룹핑 타입 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>그룹핑</InputLabel>
|
||||
<Select
|
||||
value={groupingType}
|
||||
label="그룹핑"
|
||||
onChange={e => setGroupingType(e.target.value)}
|
||||
>
|
||||
<MenuItem key="item" value="item">품목별</MenuItem>
|
||||
<MenuItem key="material" value="material">재질별</MenuItem>
|
||||
<MenuItem key="size" value="size">사이즈별</MenuItem>
|
||||
<MenuItem key="job" value="job">Job별</MenuItem>
|
||||
<MenuItem key="revision" value="revision">리비전별</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 품목 필터 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>품목</InputLabel>
|
||||
<Select
|
||||
value={itemType}
|
||||
label="품목"
|
||||
onChange={e => setItemType(e.target.value)}
|
||||
>
|
||||
<MenuItem key="all" value="">전체</MenuItem>
|
||||
<MenuItem key="PIPE" value="PIPE">PIPE</MenuItem>
|
||||
<MenuItem key="FITTING" value="FITTING">FITTING</MenuItem>
|
||||
<MenuItem key="VALVE" value="VALVE">VALVE</MenuItem>
|
||||
<MenuItem key="FLANGE" value="FLANGE">FLANGE</MenuItem>
|
||||
<MenuItem key="BOLT" value="BOLT">BOLT</MenuItem>
|
||||
<MenuItem key="OTHER" value="OTHER">기타</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 재질 필터 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<TextField
|
||||
label="재질"
|
||||
value={materialGrade}
|
||||
onChange={e => setMaterialGrade(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="예: SS316"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 사이즈 필터 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<TextField
|
||||
label="사이즈"
|
||||
value={sizeSpec}
|
||||
onChange={e => setSizeSpec(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder={'예: 6"'}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 파일명 필터 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<TextField
|
||||
label="파일명"
|
||||
value={fileFilter}
|
||||
onChange={e => setFileFilter(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="파일명 검색"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 파일(도면) 선택 드롭다운 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>도면(파일)</InputLabel>
|
||||
<Select
|
||||
value={fileId}
|
||||
label="도면(파일)"
|
||||
onChange={e => setFileId(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
{files.map((file) => (
|
||||
<MenuItem key={file.id} value={file.id}>
|
||||
{file.bom_name || file.original_filename || file.filename}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>정렬</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="정렬"
|
||||
onChange={e => setSortBy(e.target.value)}
|
||||
>
|
||||
<MenuItem key="default" value="">기본</MenuItem>
|
||||
<MenuItem key="quantity_desc" value="quantity_desc">수량 내림차순</MenuItem>
|
||||
<MenuItem key="quantity_asc" value="quantity_asc">수량 오름차순</MenuItem>
|
||||
<MenuItem key="name_asc" value="name_asc">이름 오름차순</MenuItem>
|
||||
<MenuItem key="name_desc" value="name_desc">이름 내림차순</MenuItem>
|
||||
<MenuItem key="created_desc" value="created_desc">최신 업로드</MenuItem>
|
||||
<MenuItem key="created_asc" value="created_asc">오래된 업로드</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 필터 초기화 */}
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<Clear />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
필터 초기화
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{/* 리비전 비교 알림 */}
|
||||
{revisionComparison && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>리비전 비교:</strong> {revisionComparison.summary}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 필터 상태 표시 */}
|
||||
{(search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision || groupingType !== 'item' || sortBy) && (
|
||||
<Box sx={{ mb: 2, p: 1, bgcolor: 'info.50', borderRadius: 1, border: '1px solid', borderColor: 'info.200' }}>
|
||||
<Typography variant="body2" color="info.main">
|
||||
필터 적용 중:
|
||||
{search && ` 검색 유형: ${search}`}
|
||||
{searchValue && ` 검색어: "${searchValue}"`}
|
||||
{selectedJobNo && ` Job No: ${selectedJobNo}`}
|
||||
{selectedFilename && ` 파일: ${selectedFilename}`}
|
||||
{selectedRevision && ` 리비전: ${selectedRevision}`}
|
||||
{groupingType !== 'item' && ` 그룹핑: ${groupingType}`}
|
||||
{itemType && ` 품목: ${itemType}`}
|
||||
{materialGrade && ` 재질: ${materialGrade}`}
|
||||
{sizeSpec && ` 사이즈: ${sizeSpec}`}
|
||||
{fileFilter && ` 파일: ${fileFilter}`}
|
||||
{sortBy && ` 정렬: ${sortBy}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
@@ -132,11 +688,19 @@ function MaterialList({ selectedProject }) {
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
자재 데이터가 없습니다
|
||||
{search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision ? '검색 결과가 없습니다' : '자재 데이터가 없습니다'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
파일 업로드 탭에서 BOM 파일을 업로드해주세요.
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
{search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision
|
||||
? '다른 검색 조건을 시도해보세요.'
|
||||
: '파일 업로드 탭에서 BOM 파일을 업로드해주세요.'
|
||||
}
|
||||
</Typography>
|
||||
{(search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision) && (
|
||||
<Button variant="outlined" onClick={clearFilters}>
|
||||
필터 초기화
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -164,13 +728,16 @@ function MaterialList({ selectedProject }) {
|
||||
<TableCell align="center"><strong>단위</strong></TableCell>
|
||||
<TableCell align="center"><strong>사이즈</strong></TableCell>
|
||||
<TableCell><strong>재질</strong></TableCell>
|
||||
<TableCell align="center"><strong>Job No.</strong></TableCell>
|
||||
<TableCell align="center"><strong>리비전</strong></TableCell>
|
||||
<TableCell align="center"><strong>변경</strong></TableCell>
|
||||
<TableCell align="center"><strong>라인 수</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{materials.map((material, index) => (
|
||||
<TableRow
|
||||
key={material.id}
|
||||
key={`${material.id}-${index}`}
|
||||
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
|
||||
>
|
||||
<TableCell>
|
||||
@@ -208,6 +775,30 @@ function MaterialList({ selectedProject }) {
|
||||
{material.material_grade || '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={material.job_number || '-'}
|
||||
size="small"
|
||||
color="info"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={material.revision || 'Rev.0'}
|
||||
size="small"
|
||||
color="warning"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{material.quantity_change && (
|
||||
<Chip
|
||||
label={`${material.quantity_change > 0 ? '+' : ''}${material.quantity_change}`}
|
||||
size="small"
|
||||
color={getRevisionChangeColor(material.quantity_change)}
|
||||
icon={getRevisionChangeIcon(material.quantity_change)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={`${material.line_count || 1}개 라인`}
|
||||
@@ -242,4 +833,12 @@ function MaterialList({ selectedProject }) {
|
||||
);
|
||||
}
|
||||
|
||||
MaterialList.propTypes = {
|
||||
selectedProject: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
project_name: PropTypes.string.isRequired,
|
||||
official_project_code: PropTypes.string.isRequired,
|
||||
}),
|
||||
};
|
||||
|
||||
export default MaterialList;
|
||||
|
||||
@@ -11,67 +11,228 @@ import {
|
||||
DialogActions,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress
|
||||
CircularProgress,
|
||||
Snackbar,
|
||||
IconButton,
|
||||
Chip,
|
||||
Grid,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
Divider,
|
||||
Menu,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import { Add, Assignment } from '@mui/icons-material';
|
||||
import {
|
||||
Add,
|
||||
Assignment,
|
||||
Edit,
|
||||
Delete,
|
||||
MoreVert,
|
||||
Visibility,
|
||||
CheckCircle,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
import { createJob, updateProject, deleteProject } from '../api';
|
||||
import Toast from './Toast';
|
||||
|
||||
function ProjectManager({ projects, selectedProject, setSelectedProject, onProjectsChange }) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState(null);
|
||||
const [projectCode, setProjectCode] = useState('');
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||
const [selectedProjectForMenu, setSelectedProjectForMenu] = useState(null);
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!projectCode.trim() || !projectName.trim()) {
|
||||
setError('프로젝트 코드와 이름을 모두 입력해주세요.');
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트 코드와 이름을 모두 입력해주세요.',
|
||||
type: 'warning'
|
||||
});
|
||||
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();
|
||||
const data = {
|
||||
official_project_code: projectCode.trim(),
|
||||
project_name: projectName.trim(),
|
||||
design_project_code: projectCode.trim(),
|
||||
is_code_matched: true,
|
||||
status: 'active'
|
||||
};
|
||||
const response = await createJob(data);
|
||||
const result = response.data;
|
||||
if (result && result.job) {
|
||||
onProjectsChange();
|
||||
setSelectedProject(newProject);
|
||||
setSelectedProject(result.job);
|
||||
setDialogOpen(false);
|
||||
setProjectCode('');
|
||||
setProjectName('');
|
||||
setError('');
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트가 성공적으로 생성되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.detail || '프로젝트 생성에 실패했습니다.');
|
||||
setToast({
|
||||
open: true,
|
||||
message: result.message || '프로젝트 생성에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 생성 실패:', error);
|
||||
setError('네트워크 오류가 발생했습니다. 백엔드 서버가 실행 중인지 확인해주세요.');
|
||||
setToast({
|
||||
open: true,
|
||||
message: '네트워크 오류가 발생했습니다. 백엔드 서버가 실행 중인지 확인해주세요.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditProject = async () => {
|
||||
if (!editingProject || !editingProject.project_name.trim()) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트명을 입력해주세요.',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await updateProject(editingProject.id, {
|
||||
project_name: editingProject.project_name.trim(),
|
||||
status: editingProject.status
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
onProjectsChange();
|
||||
setEditDialogOpen(false);
|
||||
setEditingProject(null);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트가 성공적으로 수정되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: response.data?.message || '프로젝트 수정에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 수정 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트 수정 중 오류가 발생했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async (project) => {
|
||||
if (!window.confirm(`정말로 프로젝트 "${project.project_name}"을 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await deleteProject(project.id);
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
onProjectsChange();
|
||||
if (selectedProject?.id === project.id) {
|
||||
setSelectedProject(null);
|
||||
}
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트가 성공적으로 삭제되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: response.data?.message || '프로젝트 삭제에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 삭제 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트 삭제 중 오류가 발생했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMenu = (event, project) => {
|
||||
setMenuAnchor(event.currentTarget);
|
||||
setSelectedProjectForMenu(project);
|
||||
};
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setMenuAnchor(null);
|
||||
setSelectedProjectForMenu(null);
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
setEditingProject({ ...selectedProjectForMenu });
|
||||
setEditDialogOpen(true);
|
||||
handleCloseMenu();
|
||||
};
|
||||
|
||||
const handleDetailClick = () => {
|
||||
setDetailDialogOpen(true);
|
||||
handleCloseMenu();
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setProjectCode('');
|
||||
setProjectName('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleCloseEditDialog = () => {
|
||||
setEditDialogOpen(false);
|
||||
setEditingProject(null);
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'inactive': return 'warning';
|
||||
case 'completed': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'active': return <CheckCircle />;
|
||||
case 'inactive': return <Warning />;
|
||||
default: return <Assignment />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -89,6 +250,14 @@ function ProjectManager({ projects, selectedProject, setSelectedProject, onProje
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
@@ -109,42 +278,89 @@ function ProjectManager({ projects, selectedProject, setSelectedProject, onProje
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
{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>
|
||||
<Grid item xs={12} md={6} lg={4} key={project.id}>
|
||||
<Card
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: selectedProject?.id === project.id ? 2 : 1,
|
||||
borderColor: selectedProject?.id === project.id ? 'primary.main' : 'divider',
|
||||
'&:hover': {
|
||||
boxShadow: 3,
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
onClick={() => setSelectedProject(project)}
|
||||
>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{project.project_name || project.official_project_code}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="primary" sx={{ mb: 1 }}>
|
||||
코드: {project.official_project_code}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={project.status}
|
||||
size="small"
|
||||
color={getStatusColor(project.status)}
|
||||
icon={getStatusIcon(project.status)}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
생성일: {new Date(project.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenMenu(e, project);
|
||||
}}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 프로젝트 메뉴 */}
|
||||
<Menu
|
||||
anchorEl={menuAnchor}
|
||||
open={Boolean(menuAnchor)}
|
||||
onClose={handleCloseMenu}
|
||||
>
|
||||
<MenuItem onClick={handleDetailClick}>
|
||||
<Visibility sx={{ mr: 1 }} />
|
||||
상세 보기
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleEditClick}>
|
||||
<Edit sx={{ mr: 1 }} />
|
||||
수정
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleDeleteProject(selectedProjectForMenu);
|
||||
handleCloseMenu();
|
||||
}}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<Delete sx={{ mr: 1 }} />
|
||||
삭제
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* 새 프로젝트 생성 다이얼로그 */}
|
||||
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>새 프로젝트 생성</DialogTitle>
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
@@ -180,6 +396,121 @@ function ProjectManager({ projects, selectedProject, setSelectedProject, onProje
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 프로젝트 수정 다이얼로그 */}
|
||||
<Dialog open={editDialogOpen} onClose={handleCloseEditDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>프로젝트 수정</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="프로젝트 코드"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={editingProject?.official_project_code || ''}
|
||||
disabled
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="프로젝트명"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={editingProject?.project_name || ''}
|
||||
onChange={(e) => setEditingProject({
|
||||
...editingProject,
|
||||
project_name: e.target.value
|
||||
})}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
margin="dense"
|
||||
label="상태"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={editingProject?.status || 'active'}
|
||||
onChange={(e) => setEditingProject({
|
||||
...editingProject,
|
||||
status: e.target.value
|
||||
})}
|
||||
>
|
||||
<MenuItem key="active" value="active">활성</MenuItem>
|
||||
<MenuItem key="inactive" value="inactive">비활성</MenuItem>
|
||||
<MenuItem key="completed" value="completed">완료</MenuItem>
|
||||
</TextField>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseEditDialog} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEditProject}
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{loading ? '수정 중...' : '수정'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 프로젝트 상세 보기 다이얼로그 */}
|
||||
<Dialog open={detailDialogOpen} onClose={() => setDetailDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>프로젝트 상세 정보</DialogTitle>
|
||||
<DialogContent>
|
||||
{selectedProjectForMenu && (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">프로젝트 코드</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{selectedProjectForMenu.official_project_code}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">프로젝트명</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{selectedProjectForMenu.project_name}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">상태</Typography>
|
||||
<Chip
|
||||
label={selectedProjectForMenu.status}
|
||||
color={getStatusColor(selectedProjectForMenu.status)}
|
||||
icon={getStatusIcon(selectedProjectForMenu.status)}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">생성일</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{new Date(selectedProjectForMenu.created_at).toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">설계 프로젝트 코드</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{selectedProjectForMenu.design_project_code || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">코드 매칭</Typography>
|
||||
<Chip
|
||||
label={selectedProjectForMenu.is_code_matched ? '매칭됨' : '매칭 안됨'}
|
||||
color={selectedProjectForMenu.is_code_matched ? 'success' : 'warning'}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDetailDialogOpen(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
426
frontend/src/components/SpoolManager.jsx
Normal file
426
frontend/src/components/SpoolManager.jsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Build,
|
||||
CheckCircle,
|
||||
Error,
|
||||
Visibility,
|
||||
Edit,
|
||||
Delete,
|
||||
MoreVert
|
||||
} from '@mui/icons-material';
|
||||
import { fetchProjectSpools, validateSpoolIdentifier, generateSpoolIdentifier } from '../api';
|
||||
import Toast from './Toast';
|
||||
|
||||
function SpoolManager({ selectedProject }) {
|
||||
const [spools, setSpools] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
|
||||
// 스풀 생성 폼 상태
|
||||
const [newSpool, setNewSpool] = useState({
|
||||
dwg_name: '',
|
||||
area_number: '',
|
||||
spool_number: ''
|
||||
});
|
||||
|
||||
// 유효성 검증 상태
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [validating, setValidating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchSpools();
|
||||
} else {
|
||||
setSpools([]);
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
const fetchSpools = async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchProjectSpools(selectedProject.id);
|
||||
if (response.data && response.data.spools) {
|
||||
setSpools(response.data.spools);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('스풀 조회 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '스풀 데이터를 불러오는데 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSpool = async () => {
|
||||
if (!newSpool.dwg_name || !newSpool.area_number || !newSpool.spool_number) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '도면명, 에리어 번호, 스풀 번호를 모두 입력해주세요.',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await generateSpoolIdentifier(
|
||||
newSpool.dwg_name,
|
||||
newSpool.area_number,
|
||||
newSpool.spool_number
|
||||
);
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '스풀이 성공적으로 생성되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
setDialogOpen(false);
|
||||
setNewSpool({ dwg_name: '', area_number: '', spool_number: '' });
|
||||
fetchSpools(); // 목록 새로고침
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: response.data?.message || '스풀 생성에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('스풀 생성 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '스풀 생성 중 오류가 발생했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateSpool = async (identifier) => {
|
||||
setValidating(true);
|
||||
try {
|
||||
const response = await validateSpoolIdentifier(identifier);
|
||||
setValidationResult(response.data);
|
||||
setValidationDialogOpen(true);
|
||||
} catch (error) {
|
||||
console.error('스풀 유효성 검증 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '스풀 유효성 검증에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'inactive': return 'warning';
|
||||
case 'completed': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
🔧 스풀 관리
|
||||
</Typography>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Build sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
프로젝트를 선택해주세요
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
프로젝트 관리 탭에서 프로젝트를 선택하면 스풀을 관리할 수 있습니다.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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' })}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||
스풀 데이터 로딩 중...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : spools.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Build 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>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">
|
||||
총 {spools.length}개 스풀
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${spools.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><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>
|
||||
{spools.map((spool) => (
|
||||
<TableRow
|
||||
key={spool.id}
|
||||
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{spool.spool_identifier}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{spool.dwg_name}</TableCell>
|
||||
<TableCell>{spool.area_number}</TableCell>
|
||||
<TableCell>{spool.spool_number}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={spool.material_count || 0}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={(spool.total_quantity || 0).toLocaleString()}
|
||||
size="small"
|
||||
color="success"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={spool.status || 'active'}
|
||||
size="small"
|
||||
color={getStatusColor(spool.status)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleValidateSpool(spool.spool_identifier)}
|
||||
disabled={validating}
|
||||
>
|
||||
<Visibility />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 새 스풀 생성 다이얼로그 */}
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>새 스풀 생성</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="도면명"
|
||||
placeholder="예: MP7-PIPING-001"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newSpool.dwg_name}
|
||||
onChange={(e) => setNewSpool({ ...newSpool, dwg_name: e.target.value })}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="에리어 번호"
|
||||
placeholder="예: A1"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newSpool.area_number}
|
||||
onChange={(e) => setNewSpool({ ...newSpool, area_number: e.target.value })}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="스풀 번호"
|
||||
placeholder="예: 001"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newSpool.spool_number}
|
||||
onChange={(e) => setNewSpool({ ...newSpool, spool_number: e.target.value })}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateSpool}
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{loading ? '생성 중...' : '생성'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 스풀 유효성 검증 결과 다이얼로그 */}
|
||||
<Dialog open={validationDialogOpen} onClose={() => setValidationDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>스풀 유효성 검증 결과</DialogTitle>
|
||||
<DialogContent>
|
||||
{validationResult && (
|
||||
<Box>
|
||||
<Alert
|
||||
severity={validationResult.validation.is_valid ? 'success' : 'error'}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{validationResult.validation.is_valid ? '유효한 스풀 식별자입니다.' : '유효하지 않은 스풀 식별자입니다.'}
|
||||
</Alert>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">스풀 식별자</Typography>
|
||||
<Typography variant="body1" sx={{ fontFamily: 'monospace', mb: 2 }}>
|
||||
{validationResult.spool_identifier}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">검증 시간</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{new Date(validationResult.timestamp).toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">검증 세부사항</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{validationResult.validation.details &&
|
||||
Object.entries(validationResult.validation.details).map(([key, value]) => (
|
||||
<Chip
|
||||
key={key}
|
||||
label={`${key}: ${value}`}
|
||||
size="small"
|
||||
color={value ? 'success' : 'error'}
|
||||
sx={{ mr: 1, mb: 1 }}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setValidationDialogOpen(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpoolManager;
|
||||
74
frontend/src/components/Toast.jsx
Normal file
74
frontend/src/components/Toast.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Snackbar, Alert, AlertTitle } from '@mui/material';
|
||||
import {
|
||||
CheckCircle,
|
||||
Error,
|
||||
Warning,
|
||||
Info
|
||||
} from '@mui/icons-material';
|
||||
|
||||
const Toast = React.memo(({
|
||||
open,
|
||||
message,
|
||||
type = 'info',
|
||||
title,
|
||||
autoHideDuration = 4000,
|
||||
onClose,
|
||||
anchorOrigin = { vertical: 'top', horizontal: 'center' }
|
||||
}) => {
|
||||
const getSeverity = () => {
|
||||
switch (type) {
|
||||
case 'success': return 'success';
|
||||
case 'error': return 'error';
|
||||
case 'warning': return 'warning';
|
||||
case 'info':
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'success': return <CheckCircle />;
|
||||
case 'error': return <Error />;
|
||||
case 'warning': return <Warning />;
|
||||
case 'info':
|
||||
default: return <Info />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={autoHideDuration}
|
||||
onClose={onClose}
|
||||
anchorOrigin={anchorOrigin}
|
||||
>
|
||||
<Alert
|
||||
onClose={onClose}
|
||||
severity={getSeverity()}
|
||||
icon={getIcon()}
|
||||
sx={{
|
||||
width: '100%',
|
||||
minWidth: 300,
|
||||
maxWidth: 600
|
||||
}}
|
||||
>
|
||||
{title && <AlertTitle>{title}</AlertTitle>}
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
});
|
||||
|
||||
Toast.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
type: PropTypes.oneOf(['success', 'error', 'warning', 'info']),
|
||||
title: PropTypes.string,
|
||||
autoHideDuration: PropTypes.number,
|
||||
onClose: PropTypes.func,
|
||||
anchorOrigin: PropTypes.object,
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
Reference in New Issue
Block a user