Files
TK-BOM-Project/frontend/src/pages/MaterialsPage.jsx
Hyungi Ahn 5f7a6f0b3a feat: 자재 분류 시스템 개선 및 상세 테이블 추가
- 모든 자재 카테고리별 상세 테이블 생성 (fitting, valve, flange, bolt, gasket, instrument)
- PIPE, FITTING, VALVE 분류 결과를 각 상세 테이블에 저장하는 로직 구현
- 프론트엔드 라우팅 정리 및 BOM 현황 페이지 기능 개선
- 자재확인 페이지 에러 처리 개선

TODO: FLANGE, BOLT, GASKET, INSTRUMENT 저장 로직 추가 필요
2025-07-17 10:44:19 +09:00

1050 lines
41 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;