Files
tk-factory-services/tkeg/web/src/components/Dashboard.jsx
2026-03-16 15:41:58 +09:00

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;