feat(tkeg): tkeg BOM 자재관리 서비스 초기 세팅 (api + web + docker-compose)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
440
tkeg/web/src/components/Dashboard.jsx
Normal file
440
tkeg/web/src/components/Dashboard.jsx
Normal file
@@ -0,0 +1,440 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user