- 모든 자재 카테고리별 상세 테이블 생성 (fitting, valve, flange, bolt, gasket, instrument) - PIPE, FITTING, VALVE 분류 결과를 각 상세 테이블에 저장하는 로직 구현 - 프론트엔드 라우팅 정리 및 BOM 현황 페이지 기능 개선 - 자재확인 페이지 에러 처리 개선 TODO: FLANGE, BOLT, GASKET, INSTRUMENT 저장 로직 추가 필요
1050 lines
41 KiB
JavaScript
1050 lines
41 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
Box,
|
||
Card,
|
||
CardContent,
|
||
Typography,
|
||
Grid,
|
||
Chip,
|
||
Alert,
|
||
CircularProgress,
|
||
Tabs,
|
||
Tab,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableContainer,
|
||
TableHead,
|
||
TableRow,
|
||
Paper,
|
||
Button,
|
||
FormControlLabel,
|
||
Switch
|
||
} from '@mui/material';
|
||
import PipeDetailsCard from '../components/PipeDetailsCard';
|
||
import { Pie, Bar } from 'react-chartjs-2';
|
||
import {
|
||
Chart as ChartJS,
|
||
CategoryScale,
|
||
LinearScale,
|
||
BarElement,
|
||
Title,
|
||
Tooltip,
|
||
Legend,
|
||
ArcElement
|
||
} from 'chart.js';
|
||
import { api } from '../api';
|
||
|
||
ChartJS.register(
|
||
CategoryScale,
|
||
LinearScale,
|
||
BarElement,
|
||
Title,
|
||
Tooltip,
|
||
Legend,
|
||
ArcElement
|
||
);
|
||
|
||
const MaterialsPage = () => {
|
||
const [materials, setMaterials] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const [fileId, setFileId] = useState(null);
|
||
const [activeTab, setActiveTab] = useState(0);
|
||
const [selectedCategories, setSelectedCategories] = useState([]);
|
||
const [revisionComparison, setRevisionComparison] = useState(null);
|
||
const [showOnlyPurchaseRequired, setShowOnlyPurchaseRequired] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const id = urlParams.get('fileId');
|
||
if (id) {
|
||
setFileId(id);
|
||
loadMaterials(id);
|
||
} else {
|
||
setLoading(false);
|
||
setError('파일 ID가 지정되지 않았습니다. BOM 현황 페이지에서 파일을 선택해주세요.');
|
||
}
|
||
}, []);
|
||
|
||
const loadMaterials = async (id) => {
|
||
console.log('자재 로딩 시작, file_id:', id);
|
||
try {
|
||
setLoading(true);
|
||
const response = await api.get('/files/materials', { params: { file_id: parseInt(id) } });
|
||
console.log('자재 데이터 로딩 성공:', response.data);
|
||
setMaterials(response.data);
|
||
setError(null);
|
||
} catch (err) {
|
||
setError('자재 정보를 불러오는데 실패했습니다.');
|
||
console.error('자재 로딩 에러:', err);
|
||
console.error('에러 상세:', err.response?.data);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const groupMaterialsByItem = (materials) => {
|
||
const grouped = {};
|
||
materials.forEach(material => {
|
||
const key = `${material.original_description}_${material.size_spec}_${material.material_grade}`;
|
||
if (!grouped[key]) {
|
||
grouped[key] = [];
|
||
}
|
||
grouped[key].push(material);
|
||
});
|
||
return grouped;
|
||
};
|
||
|
||
const getFilteredMaterials = () => {
|
||
if (selectedCategories.length === 0) {
|
||
return materials;
|
||
}
|
||
return materials.filter(material =>
|
||
selectedCategories.includes(material.classified_category)
|
||
);
|
||
};
|
||
|
||
const calculateCategoryStats = () => {
|
||
const stats = {};
|
||
materials.forEach(material => {
|
||
const category = material.classified_category || 'UNKNOWN';
|
||
if (!stats[category]) {
|
||
stats[category] = { count: 0, totalQuantity: 0 };
|
||
}
|
||
stats[category].count++;
|
||
stats[category].totalQuantity += material.quantity || 0;
|
||
});
|
||
return stats;
|
||
};
|
||
|
||
const getAvailableCategories = () => {
|
||
const categories = [...new Set(materials.map(m => m.classified_category).filter(Boolean))];
|
||
return categories.sort();
|
||
};
|
||
|
||
const calculateClassificationStats = () => {
|
||
const totalItems = materials.length;
|
||
const classifiedItems = materials.filter(m => m.classified_category && m.classified_category !== 'UNKNOWN').length;
|
||
const unclassifiedItems = totalItems - classifiedItems;
|
||
|
||
const highConfidence = materials.filter(m =>
|
||
m.classification_confidence && m.classification_confidence >= 0.8
|
||
).length;
|
||
const mediumConfidence = materials.filter(m =>
|
||
m.classification_confidence && m.classification_confidence >= 0.5 && m.classification_confidence < 0.8
|
||
).length;
|
||
const lowConfidence = materials.filter(m =>
|
||
m.classification_confidence && m.classification_confidence < 0.5
|
||
).length;
|
||
|
||
const categoryBreakdown = {};
|
||
materials.forEach(material => {
|
||
const category = material.classified_category || 'UNKNOWN';
|
||
if (!categoryBreakdown[category]) {
|
||
categoryBreakdown[category] = { highConfidence: 0, mediumConfidence: 0, lowConfidence: 0 };
|
||
}
|
||
|
||
if (material.classification_confidence >= 0.8) {
|
||
categoryBreakdown[category].highConfidence++;
|
||
} else if (material.classification_confidence >= 0.5) {
|
||
categoryBreakdown[category].mediumConfidence++;
|
||
} else {
|
||
categoryBreakdown[category].lowConfidence++;
|
||
}
|
||
});
|
||
|
||
return {
|
||
totalItems,
|
||
classifiedItems,
|
||
unclassifiedItems,
|
||
highConfidence,
|
||
mediumConfidence,
|
||
lowConfidence,
|
||
categoryBreakdown
|
||
};
|
||
};
|
||
|
||
const getDisplayInfo = (material) => {
|
||
const category = material.classified_category;
|
||
let details = material.classification_details || {};
|
||
|
||
// classification_details가 문자열인 경우 JSON 파싱
|
||
if (typeof details === 'string') {
|
||
try {
|
||
details = JSON.parse(details);
|
||
} catch (e) {
|
||
console.error('분류 상세정보 파싱 실패:', e);
|
||
details = {};
|
||
}
|
||
}
|
||
|
||
switch (category) {
|
||
case 'PIPE':
|
||
// 1. classification_details에서 길이 정보 가져오기
|
||
const cuttingDimensions = details?.cutting_dimensions || {};
|
||
let lengthMm = cuttingDimensions?.length_mm;
|
||
|
||
// 2. 백엔드에서 전달된 length 필드도 확인
|
||
if (!lengthMm && material.length) {
|
||
lengthMm = material.length;
|
||
}
|
||
|
||
if (lengthMm) {
|
||
return {
|
||
value: lengthMm,
|
||
unit: 'mm',
|
||
displayText: `${lengthMm}mm`,
|
||
isLength: true
|
||
};
|
||
}
|
||
break;
|
||
case 'BOLT':
|
||
case 'NUT':
|
||
case 'WASHER':
|
||
return {
|
||
value: material.quantity,
|
||
unit: 'EA',
|
||
displayText: `${material.quantity} EA`,
|
||
isLength: false
|
||
};
|
||
default:
|
||
return {
|
||
value: material.quantity,
|
||
unit: 'EA',
|
||
displayText: `${material.quantity} EA`,
|
||
isLength: false
|
||
};
|
||
}
|
||
|
||
// 기본값
|
||
return {
|
||
value: material.quantity,
|
||
unit: 'EA',
|
||
displayText: `${material.quantity} EA`,
|
||
isLength: false
|
||
};
|
||
};
|
||
|
||
const generateCategoryChartData = (category, items) => {
|
||
switch (category) {
|
||
case 'PIPE':
|
||
const totalLength = items.reduce((sum, item) => {
|
||
const details = item.classification_details || {};
|
||
const cuttingDimensions = details?.cutting_dimensions || {};
|
||
let lengthMm = cuttingDimensions?.length_mm;
|
||
|
||
// 백엔드에서 전달된 length 필드도 확인
|
||
if (!lengthMm && item.length) {
|
||
lengthMm = item.length;
|
||
}
|
||
|
||
return sum + (lengthMm || 0);
|
||
}, 0);
|
||
return {
|
||
value: totalLength,
|
||
unit: 'mm',
|
||
displayText: `${totalLength}mm`,
|
||
isLength: true
|
||
};
|
||
case 'BOLT':
|
||
case 'NUT':
|
||
case 'WASHER':
|
||
const totalQuantity = items.reduce((sum, item) => sum + (item.quantity || 0), 0);
|
||
return {
|
||
value: totalQuantity,
|
||
unit: 'EA',
|
||
displayText: `${totalQuantity} EA`,
|
||
isLength: false
|
||
};
|
||
default:
|
||
const totalQty = items.reduce((sum, item) => sum + (item.quantity || 0), 0);
|
||
return {
|
||
value: totalQty,
|
||
unit: 'EA',
|
||
displayText: `${totalQty} EA`,
|
||
isLength: false
|
||
};
|
||
}
|
||
};
|
||
|
||
const generateChartData = () => {
|
||
const categoryStats = calculateCategoryStats();
|
||
const categories = Object.keys(categoryStats);
|
||
|
||
return {
|
||
labels: categories,
|
||
datasets: [{
|
||
label: '항목 수',
|
||
data: categories.map(cat => categoryStats[cat].count),
|
||
backgroundColor: categories.map(cat => getCategoryColor(cat) === 'primary' ? '#1976d2' :
|
||
getCategoryColor(cat) === 'secondary' ? '#9c27b0' :
|
||
getCategoryColor(cat) === 'error' ? '#d32f2f' :
|
||
getCategoryColor(cat) === 'warning' ? '#ed6c02' :
|
||
getCategoryColor(cat) === 'info' ? '#0288d1' :
|
||
getCategoryColor(cat) === 'success' ? '#2e7d32' : '#757575'),
|
||
borderWidth: 1
|
||
}]
|
||
};
|
||
};
|
||
|
||
const handleRevisionComparison = async () => {
|
||
try {
|
||
const response = await api.get(`/materials/${fileId}/revision-comparison`);
|
||
setRevisionComparison(response.data);
|
||
} catch (err) {
|
||
setError('리비전 비교를 불러오는데 실패했습니다.');
|
||
console.error('리비전 비교 에러:', err);
|
||
}
|
||
};
|
||
|
||
const handleAutoRevisionComparison = async () => {
|
||
try {
|
||
const response = await api.get(`/materials/${fileId}/auto-revision-comparison`);
|
||
setRevisionComparison(response.data);
|
||
} catch (err) {
|
||
setError('자동 리비전 비교를 불러오는데 실패했습니다.');
|
||
console.error('자동 리비전 비교 에러:', err);
|
||
}
|
||
};
|
||
|
||
const getPurchaseRequiredItems = () => {
|
||
if (!revisionComparison) return { added: [], changed: [] };
|
||
|
||
return {
|
||
added: revisionComparison.changes.added,
|
||
changed: revisionComparison.changes.changed.filter(item => item.quantity_change > 0)
|
||
};
|
||
};
|
||
|
||
const calculateTotalPurchaseQuantity = () => {
|
||
const purchaseItems = getPurchaseRequiredItems();
|
||
const addedQuantity = purchaseItems.added.reduce((sum, item) => sum + item.item.quantity, 0);
|
||
const changedQuantity = purchaseItems.changed.reduce((sum, item) => sum + item.quantity_change, 0);
|
||
return addedQuantity + changedQuantity;
|
||
};
|
||
|
||
const generateComparisonChartData = () => {
|
||
if (!revisionComparison) return null;
|
||
|
||
const { added, removed, changed } = revisionComparison.changes;
|
||
|
||
return {
|
||
labels: ['신규 추가', '삭제', '수량 증가', '수량 감소'],
|
||
datasets: [{
|
||
label: '항목 수',
|
||
data: [
|
||
added.length,
|
||
removed.length,
|
||
changed.filter(item => item.quantity_change > 0).length,
|
||
changed.filter(item => item.quantity_change < 0).length
|
||
],
|
||
backgroundColor: ['#4caf50', '#f44336', '#ff9800', '#ff5722'],
|
||
borderWidth: 1
|
||
}]
|
||
};
|
||
};
|
||
|
||
const getCategoryColor = (category) => {
|
||
const colorMap = {
|
||
'PIPE': 'primary',
|
||
'FITTING': 'secondary',
|
||
'VALVE': 'error',
|
||
'FLANGE': 'warning',
|
||
'BOLT': 'info',
|
||
'GASKET': 'success',
|
||
'INSTRUMENT': 'primary',
|
||
'OTHER': 'default'
|
||
};
|
||
return colorMap[category] || 'default';
|
||
};
|
||
|
||
const getClassifiedDescription = (material) => {
|
||
const details = material.classification_details;
|
||
if (typeof details === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(details);
|
||
return parsed.description || material.original_description;
|
||
} catch (e) {
|
||
return material.original_description;
|
||
}
|
||
}
|
||
return details?.description || material.original_description;
|
||
};
|
||
|
||
const categoryStats = calculateCategoryStats();
|
||
const classificationStats = calculateClassificationStats();
|
||
const pieData = generateChartData();
|
||
const comparisonData = generateComparisonChartData();
|
||
|
||
const summary = {
|
||
total_items: materials.length,
|
||
total_quantity: materials.reduce((sum, m) => sum + (m.quantity || 0), 0)
|
||
};
|
||
|
||
return (
|
||
<Box sx={{ maxWidth: 1400, mx: 'auto', mt: 4, p: 2 }}>
|
||
<Box sx={{ mb: 3 }}>
|
||
<Typography variant="h4" gutterBottom>
|
||
📋 자재 분류 결과
|
||
</Typography>
|
||
<Typography variant="body1" color="text.secondary">
|
||
업로드된 BOM 파일의 자재 분류 결과를 확인하세요.
|
||
</Typography>
|
||
</Box>
|
||
|
||
{/* 요약 통계 */}
|
||
{materials.length > 0 && (
|
||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||
{/* 총 항목 수 */}
|
||
<Grid item xs={12} sm={6} md={3}>
|
||
<Card sx={{ backgroundColor: '#E3F2FD' }}>
|
||
<CardContent>
|
||
<Typography color="text.secondary" gutterBottom>
|
||
📊 총 항목
|
||
</Typography>
|
||
<Typography variant="h4" color="primary.main">
|
||
{summary.total_items.toLocaleString()}개
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
엑셀에서 추출된 총 항목
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
|
||
{/* 분류 완료된 항목 */}
|
||
<Grid item xs={12} sm={6} md={3}>
|
||
<Card sx={{ backgroundColor: '#E8F5E8' }}>
|
||
<CardContent>
|
||
<Typography color="text.secondary" gutterBottom>
|
||
✅ 분류 완료
|
||
</Typography>
|
||
<Typography variant="h4" color="success.main">
|
||
{classificationStats.classifiedItems.toLocaleString()}개
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
{Math.round((classificationStats.classifiedItems / summary.total_items) * 100)}% 분류율
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
|
||
{/* 분류 미완료 항목 */}
|
||
<Grid item xs={12} sm={6} md={3}>
|
||
<Card sx={{ backgroundColor: '#FFF3E0' }}>
|
||
<CardContent>
|
||
<Typography color="text.secondary" gutterBottom>
|
||
⚠️ 분류 미완료
|
||
</Typography>
|
||
<Typography variant="h4" color="warning.main">
|
||
{classificationStats.unclassifiedItems.toLocaleString()}개
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
수동 분류 필요
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
|
||
{/* 총 수량 */}
|
||
<Grid item xs={12} sm={6} md={3}>
|
||
<Card sx={{ backgroundColor: '#F3E5F5' }}>
|
||
<CardContent>
|
||
<Typography color="text.secondary" gutterBottom>
|
||
📦 총 수량
|
||
</Typography>
|
||
<Typography variant="h4" color="secondary.main">
|
||
{summary.total_quantity.toLocaleString()} EA
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
모든 자재의 총 수량
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
</Grid>
|
||
)}
|
||
|
||
{/* 분류 결과 상세 */}
|
||
{Object.keys(categoryStats).length > 0 && (
|
||
<Card sx={{ mb: 3 }}>
|
||
<CardContent>
|
||
<Typography variant="h6" gutterBottom>
|
||
🔍 분류기별 결과
|
||
</Typography>
|
||
<Grid container spacing={2}>
|
||
{Object.entries(categoryStats).map(([category, stats]) => (
|
||
<Grid item xs={12} sm={6} md={4} lg={3} key={category}>
|
||
<Box sx={{
|
||
p: 2,
|
||
border: 1,
|
||
borderColor: 'divider',
|
||
borderRadius: 1,
|
||
backgroundColor: getCategoryColor(category) === 'primary' ? '#E3F2FD' :
|
||
getCategoryColor(category) === 'secondary' ? '#F3E5F5' :
|
||
getCategoryColor(category) === 'error' ? '#FFEBEE' :
|
||
getCategoryColor(category) === 'warning' ? '#FFF3E0' :
|
||
getCategoryColor(category) === 'info' ? '#E1F5FE' :
|
||
getCategoryColor(category) === 'success' ? '#E8F5E8' : '#F5F5F5'
|
||
}}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||
<Chip
|
||
label={category}
|
||
color={getCategoryColor(category)}
|
||
size="small"
|
||
sx={{ mr: 1 }}
|
||
/>
|
||
<Typography variant="h6">
|
||
{stats.count.toLocaleString()}개
|
||
</Typography>
|
||
</Box>
|
||
<Typography variant="body2" color="text.secondary">
|
||
총 수량: {stats.totalQuantity.toLocaleString()} EA
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
비율: {Math.round((stats.count / classificationStats.totalItems) * 100)}%
|
||
</Typography>
|
||
|
||
{/* 신뢰도 정보 */}
|
||
{classificationStats.categoryBreakdown[category] && (
|
||
<Box sx={{ mt: 1 }}>
|
||
<Typography variant="caption" color="text.secondary">
|
||
신뢰도:
|
||
<Typography component="span" color="success.main" sx={{ ml: 0.5 }}>
|
||
높음 {classificationStats.categoryBreakdown[category].highConfidence}개
|
||
</Typography>
|
||
<Typography component="span" color="warning.main" sx={{ ml: 0.5 }}>
|
||
중간 {classificationStats.categoryBreakdown[category].mediumConfidence}개
|
||
</Typography>
|
||
<Typography component="span" color="error.main" sx={{ ml: 0.5 }}>
|
||
낮음 {classificationStats.categoryBreakdown[category].lowConfidence}개
|
||
</Typography>
|
||
</Typography>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Grid>
|
||
))}
|
||
</Grid>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 분류 신뢰도 요약 */}
|
||
{classificationStats.classifiedItems > 0 && (
|
||
<Card sx={{ mb: 3 }}>
|
||
<CardContent>
|
||
<Typography variant="h6" gutterBottom>
|
||
🎯 분류 신뢰도 요약
|
||
</Typography>
|
||
<Grid container spacing={2}>
|
||
<Grid item xs={12} sm={4}>
|
||
<Box sx={{ textAlign: 'center', p: 2, backgroundColor: '#E8F5E8', borderRadius: 1 }}>
|
||
<Typography variant="h4" color="success.main">
|
||
{classificationStats.highConfidence}개
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
높은 신뢰도 (80% 이상)
|
||
</Typography>
|
||
</Box>
|
||
</Grid>
|
||
<Grid item xs={12} sm={4}>
|
||
<Box sx={{ textAlign: 'center', p: 2, backgroundColor: '#FFF3E0', borderRadius: 1 }}>
|
||
<Typography variant="h4" color="warning.main">
|
||
{classificationStats.mediumConfidence}개
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
중간 신뢰도 (50-80%)
|
||
</Typography>
|
||
</Box>
|
||
</Grid>
|
||
<Grid item xs={12} sm={4}>
|
||
<Box sx={{ textAlign: 'center', p: 2, backgroundColor: '#FFEBEE', borderRadius: 1 }}>
|
||
<Typography variant="h4" color="error.main">
|
||
{classificationStats.lowConfidence}개
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
낮은 신뢰도 (50% 미만)
|
||
</Typography>
|
||
</Box>
|
||
</Grid>
|
||
</Grid>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||
{loading && <CircularProgress sx={{ mt: 4 }} />}
|
||
|
||
{/* 탭 네비게이션 */}
|
||
{!loading && materials.length > 0 && (
|
||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||
<Tabs value={activeTab} onChange={(e, newValue) => setActiveTab(newValue)}>
|
||
<Tab label="차트 보기" />
|
||
<Tab label="상세 목록" />
|
||
<Tab label="리비전 비교" />
|
||
</Tabs>
|
||
</Box>
|
||
)}
|
||
|
||
{/* 차트 탭 */}
|
||
{!loading && materials.length > 0 && activeTab === 0 && (
|
||
<Box>
|
||
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
||
📊 분류별 통계 (업체 견적 의뢰용)
|
||
</Typography>
|
||
|
||
<Grid container spacing={3}>
|
||
{/* 파이 차트 */}
|
||
<Grid item xs={12} md={6}>
|
||
<Card>
|
||
<CardContent>
|
||
<Typography variant="h6" gutterBottom>
|
||
분류별 항목 수
|
||
</Typography>
|
||
<Box sx={{ height: 300 }}>
|
||
<Pie
|
||
data={pieData}
|
||
options={{
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
position: 'bottom'
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
|
||
{/* 바 차트 */}
|
||
<Grid item xs={12} md={6}>
|
||
<Card>
|
||
<CardContent>
|
||
<Typography variant="h6" gutterBottom>
|
||
분류별 수량
|
||
</Typography>
|
||
<Box sx={{ height: 300 }}>
|
||
<Bar
|
||
data={{
|
||
labels: Object.keys(categoryStats),
|
||
datasets: [{
|
||
label: '총 수량',
|
||
data: Object.values(categoryStats).map(stats => stats.totalQuantity),
|
||
backgroundColor: Object.keys(categoryStats).map(cat =>
|
||
getCategoryColor(cat) === 'primary' ? '#1976d2' :
|
||
getCategoryColor(cat) === 'secondary' ? '#9c27b0' :
|
||
getCategoryColor(cat) === 'error' ? '#d32f2f' :
|
||
getCategoryColor(cat) === 'warning' ? '#ed6c02' :
|
||
getCategoryColor(cat) === 'info' ? '#0288d1' :
|
||
getCategoryColor(cat) === 'success' ? '#2e7d32' : '#757575'
|
||
)
|
||
}]
|
||
}}
|
||
options={{
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: false
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
</Grid>
|
||
</Box>
|
||
)}
|
||
|
||
{/* 상세 목록 탭 */}
|
||
{!loading && materials.length > 0 && activeTab === 1 && (
|
||
<Box>
|
||
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
||
📋 상세 자재 목록 (테스트)
|
||
</Typography>
|
||
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||
총 {materials.length}개 자재가 로드되었습니다.
|
||
</Typography>
|
||
|
||
{/* 필터 */}
|
||
<Box sx={{ mb: 2, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||
{getAvailableCategories().map(category => (
|
||
<Chip
|
||
key={category}
|
||
label={category}
|
||
color={selectedCategories.includes(category) ? getCategoryColor(category) : 'default'}
|
||
onClick={() => {
|
||
if (selectedCategories.includes(category)) {
|
||
setSelectedCategories(selectedCategories.filter(c => c !== category));
|
||
} else {
|
||
setSelectedCategories([...selectedCategories, category]);
|
||
}
|
||
}}
|
||
clickable
|
||
/>
|
||
))}
|
||
</Box>
|
||
|
||
{/* 자재 테이블 */}
|
||
<TableContainer component={Paper}>
|
||
<Table>
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableCell>라인</TableCell>
|
||
<TableCell>분류</TableCell>
|
||
<TableCell>품명</TableCell>
|
||
<TableCell>사이즈</TableCell>
|
||
<TableCell>재질</TableCell>
|
||
<TableCell>수량</TableCell>
|
||
<TableCell>단위</TableCell>
|
||
<TableCell>신뢰도</TableCell>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{getFilteredMaterials().map((material, index) => {
|
||
const displayInfo = getDisplayInfo(material);
|
||
return (
|
||
<React.Fragment key={index}>
|
||
<TableRow>
|
||
<TableCell>{material.line_number}</TableCell>
|
||
<TableCell>
|
||
<Chip
|
||
label={material.classified_category || 'UNKNOWN'}
|
||
color={getCategoryColor(material.classified_category)}
|
||
size="small"
|
||
/>
|
||
</TableCell>
|
||
<TableCell>{getClassifiedDescription(material)}</TableCell>
|
||
<TableCell>{material.size_spec || '-'}</TableCell>
|
||
<TableCell>{material.material_grade || '-'}</TableCell>
|
||
<TableCell>{displayInfo.displayText}</TableCell>
|
||
<TableCell>{displayInfo.unit}</TableCell>
|
||
<TableCell>
|
||
<Typography
|
||
color={material.classification_confidence >= 0.8 ? 'success.main' :
|
||
material.classification_confidence >= 0.5 ? 'warning.main' : 'error.main'}
|
||
>
|
||
{Math.round((material.classification_confidence || 0) * 100)}%
|
||
</Typography>
|
||
</TableCell>
|
||
</TableRow>
|
||
{/* PIPE 상세 정보 */}
|
||
{material.classified_category === 'PIPE' && (
|
||
<TableRow>
|
||
<TableCell colSpan={8} sx={{ p: 0 }}>
|
||
<PipeDetailsCard material={material} fileId={fileId} />
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</TableContainer>
|
||
</Box>
|
||
)}
|
||
|
||
{/* 리비전 비교 탭 */}
|
||
{!loading && materials.length > 0 && activeTab === 2 && (
|
||
<Box>
|
||
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
||
🔄 리비전 비교
|
||
</Typography>
|
||
|
||
<Box sx={{ mb: 3 }}>
|
||
<Button
|
||
variant="contained"
|
||
onClick={handleRevisionComparison}
|
||
sx={{ mr: 2 }}
|
||
>
|
||
수동 리비전 비교
|
||
</Button>
|
||
<Button
|
||
variant="outlined"
|
||
onClick={handleAutoRevisionComparison}
|
||
>
|
||
자동 리비전 비교
|
||
</Button>
|
||
</Box>
|
||
|
||
{revisionComparison && (
|
||
<Grid container spacing={3}>
|
||
{/* 비교 요약 */}
|
||
<Grid item xs={12} md={6}>
|
||
<Card>
|
||
<CardContent>
|
||
<Typography variant="h6" gutterBottom>
|
||
📊 변경 사항 요약
|
||
</Typography>
|
||
<Box sx={{ height: 300 }}>
|
||
<Pie
|
||
data={comparisonData}
|
||
options={{
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
position: 'bottom'
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
|
||
{/* 발주 필요 수량 */}
|
||
<Grid item xs={12} md={6}>
|
||
<Card>
|
||
<CardContent>
|
||
<Typography variant="h6" gutterBottom>
|
||
📦 발주 필요 수량
|
||
</Typography>
|
||
<Box sx={{ height: 300 }}>
|
||
<Bar
|
||
data={{
|
||
labels: ['신규 추가', '수량 증가'],
|
||
datasets: [{
|
||
label: '발주 수량',
|
||
data: [
|
||
getPurchaseRequiredItems().added.reduce((sum, item) => sum + item.item.quantity, 0),
|
||
getPurchaseRequiredItems().changed.reduce((sum, item) => sum + item.quantity_change, 0)
|
||
],
|
||
backgroundColor: ['#4caf50', '#ff9800']
|
||
}]
|
||
}}
|
||
options={{
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: false
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
|
||
{/* 발주 필요 항목 테이블 */}
|
||
<Grid item xs={12}>
|
||
<Card>
|
||
<CardContent>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||
<Typography variant="h6" gutterBottom>
|
||
{showOnlyPurchaseRequired ? '📋 발주 필요 항목만' : '📋 전체 변경 사항'}
|
||
</Typography>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={showOnlyPurchaseRequired}
|
||
onChange={(e) => setShowOnlyPurchaseRequired(e.target.checked)}
|
||
/>
|
||
}
|
||
label="발주 필요 항목만"
|
||
/>
|
||
</Box>
|
||
<TableContainer component={Paper}>
|
||
<Table size="small">
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableCell>변경 유형</TableCell>
|
||
<TableCell>분류</TableCell>
|
||
<TableCell>품명</TableCell>
|
||
<TableCell>사이즈</TableCell>
|
||
<TableCell>재질</TableCell>
|
||
<TableCell align="right">기존 수량</TableCell>
|
||
<TableCell align="right">새 수량</TableCell>
|
||
<TableCell align="right">발주 수량</TableCell>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{!showOnlyPurchaseRequired && (
|
||
<>
|
||
{/* 추가된 항목 */}
|
||
{revisionComparison.changes.added.map((item, index) => (
|
||
<TableRow key={`added-${index}`} sx={{ backgroundColor: '#E8F5E8' }}>
|
||
<TableCell>
|
||
<Chip label="신규 추가" color="success" size="small" />
|
||
</TableCell>
|
||
<TableCell>
|
||
<Chip
|
||
label={item.item.classified_category || 'OTHER'}
|
||
color={getCategoryColor(item.item.classified_category)}
|
||
size="small"
|
||
/>
|
||
</TableCell>
|
||
<TableCell>{item.item.original_description}</TableCell>
|
||
<TableCell>{item.item.size_spec || '-'}</TableCell>
|
||
<TableCell>{item.item.material_grade || '-'}</TableCell>
|
||
<TableCell align="right">-</TableCell>
|
||
<TableCell align="right">{item.item.quantity}</TableCell>
|
||
<TableCell align="right">
|
||
<Typography color="success.main" sx={{ fontWeight: 'bold' }}>
|
||
{item.item.quantity}
|
||
</Typography>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
|
||
{/* 삭제된 항목 */}
|
||
{revisionComparison.changes.removed.map((item, index) => (
|
||
<TableRow key={`removed-${index}`} sx={{ backgroundColor: '#FFEBEE' }}>
|
||
<TableCell>
|
||
<Chip label="삭제" color="error" size="small" />
|
||
</TableCell>
|
||
<TableCell>
|
||
<Chip
|
||
label={item.item.classified_category || 'OTHER'}
|
||
color={getCategoryColor(item.item.classified_category)}
|
||
size="small"
|
||
/>
|
||
</TableCell>
|
||
<TableCell>{item.item.original_description}</TableCell>
|
||
<TableCell>{item.item.size_spec || '-'}</TableCell>
|
||
<TableCell>{item.item.material_grade || '-'}</TableCell>
|
||
<TableCell align="right">{item.item.quantity}</TableCell>
|
||
<TableCell align="right">-</TableCell>
|
||
<TableCell align="right">
|
||
<Typography color="error.main" sx={{ fontWeight: 'bold' }}>
|
||
-
|
||
</Typography>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
|
||
{/* 수량 변경된 항목 */}
|
||
{revisionComparison.changes.changed.map((item, index) => (
|
||
<TableRow key={`changed-${index}`} sx={{
|
||
backgroundColor: item.quantity_change > 0 ? '#FFF3E0' : '#FFEBEE'
|
||
}}>
|
||
<TableCell>
|
||
<Chip
|
||
label={item.quantity_change > 0 ? '수량 증가' : '수량 감소'}
|
||
color={item.quantity_change > 0 ? 'warning' : 'error'}
|
||
size="small"
|
||
/>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Chip
|
||
label={item.new_item.classified_category || 'OTHER'}
|
||
color={getCategoryColor(item.new_item.classified_category)}
|
||
size="small"
|
||
/>
|
||
</TableCell>
|
||
<TableCell>{item.new_item.original_description}</TableCell>
|
||
<TableCell>{item.new_item.size_spec || '-'}</TableCell>
|
||
<TableCell>{item.new_item.material_grade || '-'}</TableCell>
|
||
<TableCell align="right">{item.old_item.quantity}</TableCell>
|
||
<TableCell align="right">{item.new_item.quantity}</TableCell>
|
||
<TableCell align="right">
|
||
<Typography
|
||
color={item.quantity_change > 0 ? 'warning.main' : 'error.main'}
|
||
sx={{ fontWeight: 'bold' }}
|
||
>
|
||
{item.quantity_change > 0 ? `+${item.quantity_change}` : '-'}
|
||
</Typography>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{/* 발주 필요 항목만 표시 */}
|
||
{showOnlyPurchaseRequired && (
|
||
<>
|
||
{/* 신규 추가 항목 */}
|
||
{getPurchaseRequiredItems().added.map((item, index) => (
|
||
<TableRow key={`purchase-added-${index}`} sx={{ backgroundColor: '#E8F5E8' }}>
|
||
<TableCell>
|
||
<Chip label="신규 발주" color="success" size="small" />
|
||
</TableCell>
|
||
<TableCell>
|
||
<Chip
|
||
label={item.item.classified_category || 'OTHER'}
|
||
color={getCategoryColor(item.item.classified_category)}
|
||
size="small"
|
||
/>
|
||
</TableCell>
|
||
<TableCell>{item.item.original_description}</TableCell>
|
||
<TableCell>{item.item.size_spec || '-'}</TableCell>
|
||
<TableCell>{item.item.material_grade || '-'}</TableCell>
|
||
<TableCell align="right">-</TableCell>
|
||
<TableCell align="right">{item.item.quantity}</TableCell>
|
||
<TableCell align="right">
|
||
<Typography color="success.main" sx={{ fontWeight: 'bold' }}>
|
||
{item.item.quantity}
|
||
</Typography>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
|
||
{/* 수량 증가 항목 */}
|
||
{getPurchaseRequiredItems().changed.map((item, index) => (
|
||
<TableRow key={`purchase-changed-${index}`} sx={{ backgroundColor: '#FFF3E0' }}>
|
||
<TableCell>
|
||
<Chip label="추가 발주" color="warning" size="small" />
|
||
</TableCell>
|
||
<TableCell>
|
||
<Chip
|
||
label={item.new_item.classified_category || 'OTHER'}
|
||
color={getCategoryColor(item.new_item.classified_category)}
|
||
size="small"
|
||
/>
|
||
</TableCell>
|
||
<TableCell>{item.new_item.original_description}</TableCell>
|
||
<TableCell>{item.new_item.size_spec || '-'}</TableCell>
|
||
<TableCell>{item.new_item.material_grade || '-'}</TableCell>
|
||
<TableCell align="right">{item.old_item.quantity}</TableCell>
|
||
<TableCell align="right">{item.new_item.quantity}</TableCell>
|
||
<TableCell align="right">
|
||
<Typography color="warning.main" sx={{ fontWeight: 'bold' }}>
|
||
+{item.quantity_change}
|
||
</Typography>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</TableContainer>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
</Grid>
|
||
)}
|
||
</Box>
|
||
)}
|
||
|
||
{!loading && materials.length === 0 && fileId && (
|
||
<Alert severity="info" sx={{ mt: 4 }}>
|
||
해당 파일에 자재 정보가 없습니다.
|
||
</Alert>
|
||
)}
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default MaterialsPage; |