441 lines
16 KiB
JavaScript
441 lines
16 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
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(`/files/materials/summary?project_id=${selectedProject.id}`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setStats(data.summary);
|
|
}
|
|
} catch (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>
|
|
<Typography variant="h6" color="primary" gutterBottom>
|
|
프로젝트 현황
|
|
</Typography>
|
|
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
|
|
{projects.length}
|
|
</Typography>
|
|
<Typography variant="body2" color="textSecondary">
|
|
총 프로젝트 수
|
|
</Typography>
|
|
<Typography variant="body1" sx={{ mt: 2 }}>
|
|
선택된 프로젝트: {selectedProject.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>
|
|
<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 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>
|
|
);
|
|
}
|
|
|
|
export default Dashboard;
|