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 ( 📋 자재 분류 결과 업로드된 BOM 파일의 자재 분류 결과를 확인하세요. {/* 요약 통계 */} {materials.length > 0 && ( {/* 총 항목 수 */} 📊 총 항목 {summary.total_items.toLocaleString()}개 엑셀에서 추출된 총 항목 {/* 분류 완료된 항목 */} ✅ 분류 완료 {classificationStats.classifiedItems.toLocaleString()}개 {Math.round((classificationStats.classifiedItems / summary.total_items) * 100)}% 분류율 {/* 분류 미완료 항목 */} ⚠️ 분류 미완료 {classificationStats.unclassifiedItems.toLocaleString()}개 수동 분류 필요 {/* 총 수량 */} 📦 총 수량 {summary.total_quantity.toLocaleString()} EA 모든 자재의 총 수량 )} {/* 분류 결과 상세 */} {Object.keys(categoryStats).length > 0 && ( 🔍 분류기별 결과 {Object.entries(categoryStats).map(([category, stats]) => ( {stats.count.toLocaleString()}개 총 수량: {stats.totalQuantity.toLocaleString()} EA 비율: {Math.round((stats.count / classificationStats.totalItems) * 100)}% {/* 신뢰도 정보 */} {classificationStats.categoryBreakdown[category] && ( 신뢰도: 높음 {classificationStats.categoryBreakdown[category].highConfidence}개 중간 {classificationStats.categoryBreakdown[category].mediumConfidence}개 낮음 {classificationStats.categoryBreakdown[category].lowConfidence}개 )} ))} )} {/* 분류 신뢰도 요약 */} {classificationStats.classifiedItems > 0 && ( 🎯 분류 신뢰도 요약 {classificationStats.highConfidence}개 높은 신뢰도 (80% 이상) {classificationStats.mediumConfidence}개 중간 신뢰도 (50-80%) {classificationStats.lowConfidence}개 낮은 신뢰도 (50% 미만) )} {error && {error}} {loading && } {/* 탭 네비게이션 */} {!loading && materials.length > 0 && ( setActiveTab(newValue)}> )} {/* 차트 탭 */} {!loading && materials.length > 0 && activeTab === 0 && ( 📊 분류별 통계 (업체 견적 의뢰용) {/* 파이 차트 */} 분류별 항목 수 {/* 바 차트 */} 분류별 수량 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 } } }} /> )} {/* 상세 목록 탭 */} {!loading && materials.length > 0 && activeTab === 1 && ( 📋 상세 자재 목록 (테스트) 총 {materials.length}개 자재가 로드되었습니다. {/* 필터 */} {getAvailableCategories().map(category => ( { if (selectedCategories.includes(category)) { setSelectedCategories(selectedCategories.filter(c => c !== category)); } else { setSelectedCategories([...selectedCategories, category]); } }} clickable /> ))} {/* 자재 테이블 */} 라인 분류 품명 사이즈 재질 수량 단위 신뢰도 {getFilteredMaterials().map((material, index) => { const displayInfo = getDisplayInfo(material); return ( {material.line_number} {getClassifiedDescription(material)} {material.size_spec || '-'} {material.material_grade || '-'} {displayInfo.displayText} {displayInfo.unit} = 0.8 ? 'success.main' : material.classification_confidence >= 0.5 ? 'warning.main' : 'error.main'} > {Math.round((material.classification_confidence || 0) * 100)}% {/* PIPE 상세 정보 */} {material.classified_category === 'PIPE' && ( )} ); })}
)} {/* 리비전 비교 탭 */} {!loading && materials.length > 0 && activeTab === 2 && ( 🔄 리비전 비교 {revisionComparison && ( {/* 비교 요약 */} 📊 변경 사항 요약 {/* 발주 필요 수량 */} 📦 발주 필요 수량 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 } } }} /> {/* 발주 필요 항목 테이블 */} {showOnlyPurchaseRequired ? '📋 발주 필요 항목만' : '📋 전체 변경 사항'} setShowOnlyPurchaseRequired(e.target.checked)} /> } label="발주 필요 항목만" /> 변경 유형 분류 품명 사이즈 재질 기존 수량 새 수량 발주 수량 {!showOnlyPurchaseRequired && ( <> {/* 추가된 항목 */} {revisionComparison.changes.added.map((item, index) => ( {item.item.original_description} {item.item.size_spec || '-'} {item.item.material_grade || '-'} - {item.item.quantity} {item.item.quantity} ))} {/* 삭제된 항목 */} {revisionComparison.changes.removed.map((item, index) => ( {item.item.original_description} {item.item.size_spec || '-'} {item.item.material_grade || '-'} {item.item.quantity} - - ))} {/* 수량 변경된 항목 */} {revisionComparison.changes.changed.map((item, index) => ( 0 ? '#FFF3E0' : '#FFEBEE' }}> 0 ? '수량 증가' : '수량 감소'} color={item.quantity_change > 0 ? 'warning' : 'error'} size="small" /> {item.new_item.original_description} {item.new_item.size_spec || '-'} {item.new_item.material_grade || '-'} {item.old_item.quantity} {item.new_item.quantity} 0 ? 'warning.main' : 'error.main'} sx={{ fontWeight: 'bold' }} > {item.quantity_change > 0 ? `+${item.quantity_change}` : '-'} ))} )} {/* 발주 필요 항목만 표시 */} {showOnlyPurchaseRequired && ( <> {/* 신규 추가 항목 */} {getPurchaseRequiredItems().added.map((item, index) => ( {item.item.original_description} {item.item.size_spec || '-'} {item.item.material_grade || '-'} - {item.item.quantity} {item.item.quantity} ))} {/* 수량 증가 항목 */} {getPurchaseRequiredItems().changed.map((item, index) => ( {item.new_item.original_description} {item.new_item.size_spec || '-'} {item.new_item.material_grade || '-'} {item.old_item.quantity} {item.new_item.quantity} +{item.quantity_change} ))} )}
)}
)} {!loading && materials.length === 0 && fileId && ( 해당 파일에 자재 정보가 없습니다. )}
); }; export default MaterialsPage;