feat: PIPE 분석 기능 개선 및 자재 확인 페이지 UX 향상

- 자재 확인 페이지에 뒤로가기 버튼 추가
- 상세 목록 탭에 PIPE 분석 섹션 추가
  - 재질-외경-스케줄-제작방식별로 그룹화
  - 동일 속성 파이프들의 길이 합산 표시
  - 총 파이프 길이 및 규격 종류 수 요약
- 파일 삭제 기능 수정 (외래키 제약 조건 해결)
- MaterialsPage에서 전체 자재 목록 표시 (limit 10000)
- 길이 단위 변환 로직 수정 (mm 단위 유지)
- 파싱 로직에 디버그 출력 추가

TODO: MAIN_NOM/RED_NOM 별도 저장을 위한 스키마 개선 필요
This commit is contained in:
Hyungi Ahn
2025-07-17 15:55:40 +09:00
parent 5f7a6f0b3a
commit 82f057a0c9
8 changed files with 1433 additions and 156 deletions

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Card,
@@ -21,7 +22,9 @@ import {
FormControlLabel,
Switch
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import PipeDetailsCard from '../components/PipeDetailsCard';
import FittingDetailsCard from '../components/FittingDetailsCard';
import { Pie, Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
@@ -55,9 +58,14 @@ const MaterialsPage = () => {
const [revisionComparison, setRevisionComparison] = useState(null);
const [showOnlyPurchaseRequired, setShowOnlyPurchaseRequired] = useState(false);
const navigate = useNavigate();
// 컴포넌트 마운트 확인
console.log('MaterialsPage 컴포넌트 마운트됨');
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const id = urlParams.get('fileId');
const id = urlParams.get('file_id'); // fileId -> file_id로 변경
if (id) {
setFileId(id);
loadMaterials(id);
@@ -71,9 +79,20 @@ const MaterialsPage = () => {
console.log('자재 로딩 시작, file_id:', id);
try {
setLoading(true);
const response = await api.get('/files/materials', { params: { file_id: parseInt(id) } });
// limit을 충분히 크게 설정하여 모든 자재를 가져옴
const response = await api.get('/files/materials', { params: { file_id: parseInt(id), limit: 10000 } });
console.log('자재 데이터 로딩 성공:', response.data);
setMaterials(response.data);
// API 응답이 객체로 오는 경우 materials 배열 추출
if (response.data && response.data.materials) {
setMaterials(response.data.materials);
} else if (Array.isArray(response.data)) {
setMaterials(response.data);
} else {
console.error('예상치 못한 응답 형식:', response.data);
setMaterials([]);
}
setError(null);
} catch (err) {
setError('자재 정보를 불러오는데 실패했습니다.');
@@ -107,6 +126,12 @@ const MaterialsPage = () => {
const calculateCategoryStats = () => {
const stats = {};
// materials가 배열인지 확인
if (!Array.isArray(materials)) {
console.error('materials is not an array:', materials);
return stats;
}
materials.forEach(material => {
const category = material.classified_category || 'UNKNOWN';
if (!stats[category]) {
@@ -119,11 +144,24 @@ const MaterialsPage = () => {
};
const getAvailableCategories = () => {
if (!Array.isArray(materials)) return [];
const categories = [...new Set(materials.map(m => m.classified_category).filter(Boolean))];
return categories.sort();
};
const calculateClassificationStats = () => {
if (!Array.isArray(materials)) {
return {
totalItems: 0,
classifiedItems: 0,
unclassifiedItems: 0,
highConfidence: 0,
mediumConfidence: 0,
lowConfidence: 0,
categoryBreakdown: {}
};
}
const totalItems = materials.length;
const classifiedItems = materials.filter(m => m.classified_category && m.classified_category !== 'UNKNOWN').length;
const unclassifiedItems = totalItems - classifiedItems;
@@ -226,25 +264,103 @@ const MaterialsPage = () => {
};
};
// PIPE 분석용 헬퍼 함수들
const groupPipesBySpecs = (pipeItems) => {
const groups = {};
pipeItems.forEach(item => {
const details = item.pipe_details || {};
// 재질-크기-스케줄-제작방식으로 키 생성
const material = details.material_standard || item.material_grade || 'Unknown';
let size = details.nominal_size || item.size_spec || 'Unknown';
// 크기 정리 (인치 표시)
if (size && size !== 'Unknown') {
size = size.replace(/["']/g, '').trim();
if (!size.includes('"') && !size.includes('inch')) {
size += '"';
}
}
const schedule = details.schedule || 'Unknown';
const manufacturing = details.manufacturing_method || 'Unknown';
const key = `${material}|${size}|${schedule}|${manufacturing}`;
if (!groups[key]) {
groups[key] = {
material,
size,
schedule,
manufacturing,
items: [],
totalLength: 0,
count: 0
};
}
groups[key].items.push(item);
groups[key].count += 1;
// 길이 합산
if (item.pipe_details?.length_mm) {
groups[key].totalLength += item.pipe_details.length_mm;
}
});
// 배열로 변환하고 총 길이순으로 정렬
return Object.values(groups).sort((a, b) => b.totalLength - a.totalLength);
};
const generatePipeChartData = (pipeItems, property) => {
const groups = groupPipesByProperty(pipeItems, property);
const chartData = Object.entries(groups).map(([key, items]) => {
const totalLength = items.reduce((sum, item) => {
let lengthMm = 0;
if (item.pipe_details?.length_mm) {
lengthMm = item.pipe_details.length_mm;
}
return sum + lengthMm;
}, 0);
return {
label: key,
value: totalLength,
count: items.length,
items: items
};
}).sort((a, b) => b.value - a.value);
return {
labels: chartData.map(d => d.label),
datasets: [{
data: chartData.map(d => d.value),
backgroundColor: [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
'#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384'
],
borderWidth: 1
}],
chartData: chartData
};
};
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;
let lengthMm = 0;
if (item.pipe_details?.length_mm) {
lengthMm = item.pipe_details.length_mm;
}
return sum + (lengthMm || 0);
return sum + lengthMm;
}, 0);
return {
value: totalLength,
unit: 'mm',
displayText: `${totalLength}mm`,
displayText: `${(totalLength / 1000).toFixed(1)}m`,
isLength: true
};
case 'BOLT':
@@ -382,8 +498,22 @@ const MaterialsPage = () => {
total_quantity: materials.reduce((sum, m) => sum + (m.quantity || 0), 0)
};
// 에러 디버깅을 위한 로그
console.log('Rendering MaterialsPage, materials:', materials.length);
console.log('Loading:', loading, 'Error:', error);
console.log('FileId:', fileId);
return (
<Box sx={{ maxWidth: 1400, mx: 'auto', mt: 4, p: 2 }}>
{/* 뒤로가기 버튼 */}
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 2 }}
>
뒤로가기
</Button>
<Box sx={{ mb: 3 }}>
<Typography variant="h4" gutterBottom>
📋 자재 분류 결과
@@ -669,15 +799,79 @@ const MaterialsPage = () => {
)}
{/* 상세 목록 탭 */}
{!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>
{!loading && materials.length > 0 && activeTab === 1 && (() => {
const pipeItems = materials.filter(m => m.classified_category === 'PIPE');
return (
<Box>
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
📋 상세 자재 목록 (테스트)
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
{materials.length} 자재가 로드되었습니다.
</Typography>
{/* PIPE 분석 섹션 */}
{pipeItems.length > 0 && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
🔧 PIPE 분석 ({pipeItems.length})
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
동일한 재질-크기-스케줄-제작방식을 가진 파이프들을 그룹화하여 표시합니다.
</Typography>
<TableContainer component={Paper} sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell><strong>재질</strong></TableCell>
<TableCell><strong>외경</strong></TableCell>
<TableCell><strong>스케줄</strong></TableCell>
<TableCell><strong>제작방식</strong></TableCell>
<TableCell align="right"><strong> 길이</strong></TableCell>
<TableCell align="center"><strong>개수</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{groupPipesBySpecs(pipeItems).map((group, index) => (
<TableRow key={index} hover>
<TableCell>{group.material}</TableCell>
<TableCell>{group.size}</TableCell>
<TableCell>{group.schedule}</TableCell>
<TableCell>{group.manufacturing}</TableCell>
<TableCell align="right">
<strong>{(group.totalLength / 1000).toFixed(2)}m</strong>
<Typography variant="caption" display="block" color="text.secondary">
({group.totalLength.toFixed(0)}mm)
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={group.count}
size="small"
color="primary"
variant="outlined"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* 총계 */}
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
<Typography variant="body2">
<strong> 파이프 길이: {(pipeItems.reduce((sum, item) => sum + (item.pipe_details?.length_mm || 0), 0) / 1000).toFixed(2)}m</strong>
{' '}({groupPipesBySpecs(pipeItems).length}가지 규격)
</Typography>
</Box>
</CardContent>
</Card>
)}
{/* 필터 */}
<Box sx={{ mb: 2, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{getAvailableCategories().map(category => (
@@ -740,11 +934,45 @@ const MaterialsPage = () => {
</Typography>
</TableCell>
</TableRow>
{/* PIPE 상세 정보 */}
{/* 자재별 상세 정보 카드 */}
{material.classified_category === 'PIPE' && (
<TableRow>
<TableCell colSpan={8} sx={{ p: 0 }}>
<PipeDetailsCard material={material} fileId={fileId} />
<PipeDetailsCard material={material} />
</TableCell>
</TableRow>
)}
{material.classified_category === 'FITTING' && (
<TableRow>
<TableCell colSpan={8} sx={{ p: 0 }}>
<FittingDetailsCard material={material} />
</TableCell>
</TableRow>
)}
{material.classified_category === 'VALVE' && (
<TableRow>
<TableCell colSpan={8} sx={{ p: 0 }}>
<Box sx={{ m: 2, p: 2, backgroundColor: '#f5f5f5', borderRadius: 1 }}>
<Typography variant="h6" gutterBottom>🚰 VALVE 상세 정보</Typography>
<Grid container spacing={2}>
<Grid item xs={6} sm={3}>
<Typography variant="subtitle2" color="text.secondary">밸브 타입</Typography>
<Typography>{material.valve_details?.valve_type || '-'}</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="subtitle2" color="text.secondary">작동 방식</Typography>
<Typography>{material.valve_details?.actuator_type || '-'}</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="subtitle2" color="text.secondary">압력 등급</Typography>
<Typography>{material.valve_details?.pressure_rating || '-'}</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="subtitle2" color="text.secondary">크기</Typography>
<Typography>{material.valve_details?.size_inches || '-'}</Typography>
</Grid>
</Grid>
</Box>
</TableCell>
</TableRow>
)}
@@ -755,7 +983,8 @@ const MaterialsPage = () => {
</Table>
</TableContainer>
</Box>
)}
);
})()}
{/* 리비전 비교 탭 */}
{!loading && materials.length > 0 && activeTab === 2 && (