프론트엔드 작성중

This commit is contained in:
Hyungi Ahn
2025-07-16 15:44:50 +09:00
parent 5ac9d562d5
commit ea111433e4
25 changed files with 7286 additions and 2043 deletions

View File

@@ -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>
);
}

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
}

View 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;

View 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;