Files
TK-BOM-Project/frontend/src/pages/MaterialsPage.jsx
Hyungi Ahn 2d178f8161 파이프 길이 계산 및 엑셀 내보내기 버그 수정
- 자재 비교에서 파이프 길이 합산 로직 수정
- 프론트엔드에서 혼란스러운 '평균단위' 표시 제거
- 파이프 변경사항에 실제 이전/현재 총길이 표시
- 엑셀 내보내기에서 '초기화되지 않은 변수' 오류 수정
- 리비전 비교에서 파이프 길이 변화 계산 개선
2025-07-23 08:12:19 +09:00

1086 lines
44 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 { useNavigate } from 'react-router-dom';
import {
Box,
Card,
CardContent,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Button,
Alert,
CircularProgress,
Chip,
Divider,
FormControl,
InputLabel,
Select,
MenuItem,
Grid
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ShoppingCart from '@mui/icons-material/ShoppingCart';
import { Compare as CompareIcon, Download } from '@mui/icons-material';
import { api, fetchFiles } from '../api';
import { exportMaterialsToExcel } from '../utils/excelExport';
const MaterialsPage = () => {
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [fileId, setFileId] = useState(null);
const [fileName, setFileName] = useState('');
const [jobNo, setJobNo] = useState('');
const [bomName, setBomName] = useState('');
const [currentRevision, setCurrentRevision] = useState('');
const [availableRevisions, setAvailableRevisions] = useState([]);
const navigate = useNavigate();
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const id = urlParams.get('file_id');
const name = urlParams.get('filename') || '';
const job_no = urlParams.get('job_no') || '';
if (id && job_no) {
setFileId(id);
setFileName(decodeURIComponent(name));
setJobNo(job_no);
loadMaterials(id);
loadAvailableRevisions(job_no, name);
} else {
setLoading(false);
setError('파일 ID 또는 Job No가 지정되지 않았습니다.');
}
}, []);
// 같은 BOM의 다른 리비전들 로드
const loadAvailableRevisions = async (job_no, filename) => {
try {
const response = await fetchFiles({ job_no });
if (Array.isArray(response.data)) {
// 같은 BOM 이름의 파일들만 필터링
const sameNameFiles = response.data.filter(file =>
file.original_filename === filename ||
file.bom_name === filename ||
file.filename === filename
);
// 리비전 순으로 정렬 (최신부터)
const sortedFiles = sameNameFiles.sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA;
});
setAvailableRevisions(sortedFiles);
// 현재 파일 정보 설정
const currentFile = sortedFiles.find(file => file.id === parseInt(fileId));
if (currentFile) {
setCurrentRevision(currentFile.revision || 'Rev.0');
setBomName(currentFile.bom_name || currentFile.original_filename);
}
}
} catch (err) {
console.error('리비전 목록 로드 실패:', err);
}
};
const loadMaterials = async (id) => {
try {
setLoading(true);
const response = await api.get('/files/materials', {
params: { file_id: parseInt(id), limit: 10000 }
});
if (response.data && response.data.materials) {
setMaterials(response.data.materials);
} else {
setMaterials([]);
}
setError(null);
} catch (err) {
setError('자재 정보를 불러오는데 실패했습니다.');
console.error('자재 로딩 에러:', err);
} finally {
setLoading(false);
}
};
// 자재 사양서 생성 로직
const generateMaterialSpecs = (materials) => {
const specs = {};
materials.forEach(material => {
const category = material.classified_category || 'UNKNOWN';
let specKey = '';
let specData = {};
if (category === 'PIPE') {
// PIPE: 재질 + 외경 + 스케줄 + 제작방식
// 재질 정보 - pipe_details에서 이미 정제된 것만 사용
const material_spec = material.pipe_details?.material_spec || material.material_grade || '';
const outer_diameter = material.main_nom || material.pipe_details?.outer_diameter || '';
const schedule = material.pipe_details?.schedule || '';
const manufacturing = material.pipe_details?.manufacturing_method || '';
specKey = `${category}|${material_spec}|${outer_diameter}|${schedule}|${manufacturing}`;
specData = {
category: 'PIPE',
material_spec,
outer_diameter,
schedule,
manufacturing_method: manufacturing,
unit: 'mm',
isLength: true
};
} else if (category === 'FITTING') {
// FITTING: 타입 + 서브타입 + 연결방식 + 압력등급 + 사이즈 + 재질
const material_spec = material.fitting_details?.material_spec || material.material_grade || '';
const main_nom = material.main_nom || '';
const red_nom = material.red_nom || '';
const size_display = red_nom ? `${main_nom} x ${red_nom}` : main_nom;
const fitting_type = material.fitting_details?.fitting_type || 'UNKNOWN';
const fitting_subtype = material.fitting_details?.fitting_subtype || '';
const connection_method = material.fitting_details?.connection_method || '';
const pressure_rating = material.fitting_details?.pressure_rating || '';
// 전체 피팅 스펙 생성 - 중복 제거 (OLET 특별 처리)
const spec_parts = [];
// OLET 계열 특별 처리
if (fitting_type === 'OLET' && fitting_subtype && fitting_subtype !== 'UNKNOWN') {
// SOCKOLET, WELDOLET 등 서브타입만 표시 (OLET 생략)
spec_parts.push(fitting_subtype);
// OLET 계열은 연결방식이 서브타입에 이미 내포됨
// SOCKOLET = SOCKET_WELD, WELDOLET = BUTT_WELD 등
// 따라서 connection_method 생략
} else if (fitting_type === 'NIPPLE') {
// NIPPLE 특별 처리 - 스케줄 + 길이 정보 포함
spec_parts.push(fitting_type);
// 서브타입 (CLOSE, SHORT, LONG 등)
if (fitting_subtype && fitting_subtype !== 'UNKNOWN' && fitting_subtype !== fitting_type) {
spec_parts.push(fitting_subtype);
}
// NIPPLE 스케줄 정보 추가 (fitting_details에서 가져옴)
const nipple_schedule = material.fitting_details?.schedule;
if (nipple_schedule && nipple_schedule !== 'UNKNOWN') {
spec_parts.push(nipple_schedule);
}
// 연결방식
if (connection_method && connection_method !== 'UNKNOWN' &&
!spec_parts.some(part => part.includes(connection_method))) {
spec_parts.push(connection_method);
}
// NIPPLE 길이 정보 추가 (fitting_details에서 가져옴)
const length_mm = material.fitting_details?.length_mm;
if (length_mm && length_mm > 0) {
if (length_mm >= 1000) {
spec_parts.push(`${(length_mm / 1000).toFixed(2)}m`);
} else {
spec_parts.push(`${length_mm}mm`);
}
}
} else {
// 일반 피팅 처리
// 기본 타입 (CAP, TEE, ELBOW 등)
if (fitting_type && fitting_type !== 'UNKNOWN') {
spec_parts.push(fitting_type);
}
// 서브타입 (CONCENTRIC, HEXAGON 등) - 단, 타입과 중복되지 않을 때만
if (fitting_subtype && fitting_subtype !== 'UNKNOWN' && fitting_subtype !== fitting_type) {
spec_parts.push(fitting_subtype);
}
// 연결방식 (THREADED, NPT 등) - 단, 이미 포함되지 않았을 때만
if (connection_method && connection_method !== 'UNKNOWN' &&
!spec_parts.some(part => part.includes(connection_method))) {
spec_parts.push(connection_method);
}
}
// 압력등급 (3000LB, 6000LB 등) - 모든 경우에 표시
if (pressure_rating && pressure_rating !== 'UNKNOWN' &&
!spec_parts.some(part => part.includes(pressure_rating))) {
spec_parts.push(pressure_rating);
}
const full_fitting_spec = spec_parts.join(', ');
specKey = `${category}|${full_fitting_spec}|${material_spec}|${size_display}`;
specData = {
category: 'FITTING',
fitting_type,
fitting_subtype,
connection_method,
pressure_rating,
full_fitting_spec,
material_spec,
size_display,
main_nom,
red_nom,
unit: 'EA',
isLength: false
};
} else if (category === 'FLANGE') {
// FLANGE: 타입 + 압력등급 + 면가공 + 재질
const material_spec = material.flange_details?.material_spec || material.material_grade || '';
const main_nom = material.main_nom || '';
const flange_type = material.flange_details?.flange_type || 'UNKNOWN';
const pressure_rating = material.flange_details?.pressure_rating || '';
const facing_type = material.flange_details?.facing_type || '';
// 플랜지 스펙 생성
const flange_spec_parts = [];
// 플랜지 타입 (WN, BL, SO 등)
if (flange_type && flange_type !== 'UNKNOWN') {
flange_spec_parts.push(flange_type);
}
// 면 가공 (RF, FF, RTJ 등)
if (facing_type && facing_type !== 'UNKNOWN') {
flange_spec_parts.push(facing_type);
}
// 압력등급 (150LB, 300LB 등)
if (pressure_rating && pressure_rating !== 'UNKNOWN') {
flange_spec_parts.push(pressure_rating);
}
const full_flange_spec = flange_spec_parts.join(', ');
specKey = `${category}|${full_flange_spec}|${material_spec}|${main_nom}`;
specData = {
category: 'FLANGE',
flange_type,
pressure_rating,
facing_type,
full_flange_spec,
material_spec,
size_display: main_nom,
main_nom,
unit: 'EA',
isLength: false
};
} else if (category === 'GASKET') {
// GASKET: 타입 + 소재 + 압력등급 + 사이즈
const main_nom = material.main_nom || '';
const gasket_type = material.gasket_details?.gasket_type || 'UNKNOWN';
const material_type = material.gasket_details?.material_type || 'UNKNOWN';
const pressure_rating = material.gasket_details?.pressure_rating || '';
// 가스켓 재질은 gasket_details에서 가져옴
const material_spec = material_type !== 'UNKNOWN' ? material_type : (material.material_grade || 'Unknown');
// SWG 상세 정보 파싱 (additional_info에서)
let detailed_construction = 'N/A';
let face_type = '';
let thickness = material.gasket_details?.thickness || null;
// API에서 gasket_details의 추가 정보를 확인 (브라우저 콘솔에서 확인용)
if (material.gasket_details && Object.keys(material.gasket_details).length > 0) {
console.log('Gasket details:', material.gasket_details);
}
// 상세 구성 정보 생성 (Face Type + Construction)
// H/F/I/O SS304/GRAPHITE/CS/CS 형태로 표시
if (material.original_description) {
const desc = material.original_description.toUpperCase();
// H/F/I/O 다음에 오는 재질 구성만 찾기 (H/F/I/O는 제외)
const fullMatch = desc.match(/H\/F\/I\/O\s+([A-Z0-9]+\/[A-Z]+\/[A-Z0-9]+\/[A-Z0-9]+)/);
if (fullMatch) {
// H/F/I/O와 재질 구성 둘 다 있는 경우
face_type = 'H/F/I/O';
const construction = fullMatch[1];
detailed_construction = `${face_type} ${construction}`;
} else {
// H/F/I/O만 있는 경우
const faceMatch = desc.match(/H\/F\/I\/O/);
if (faceMatch) {
detailed_construction = 'H/F/I/O';
} else {
// 재질 구성만 있는 경우 (H/F/I/O 없이)
const constructionOnlyMatch = desc.match(/([A-Z0-9]+\/[A-Z]+\/[A-Z0-9]+\/[A-Z0-9]+)/);
if (constructionOnlyMatch) {
detailed_construction = constructionOnlyMatch[1];
}
}
}
}
// 가스켓 스펙 생성
const gasket_spec_parts = [];
// 가스켓 타입 (SPIRAL_WOUND, O_RING 등)
if (gasket_type && gasket_type !== 'UNKNOWN') {
gasket_spec_parts.push(gasket_type.replace('_', ' '));
}
// 소재 (GRAPHITE, PTFE 등)
if (material_type && material_type !== 'UNKNOWN') {
gasket_spec_parts.push(material_type);
}
// 압력등급 (150LB, 300LB 등)
if (pressure_rating && pressure_rating !== 'UNKNOWN') {
gasket_spec_parts.push(pressure_rating);
}
const full_gasket_spec = gasket_spec_parts.join(', ');
specKey = `${category}|${full_gasket_spec}|${material_spec}|${main_nom}|${detailed_construction}`;
specData = {
category: 'GASKET',
gasket_type,
material_type,
pressure_rating,
full_gasket_spec,
material_spec,
size_display: main_nom,
main_nom,
detailed_construction,
thickness,
unit: 'EA',
isLength: false
};
} else if (category === 'BOLT') {
// BOLT: 타입 + 재질 + 사이즈 + 길이
const material_spec = material.material_grade || '';
const main_nom = material.main_nom || '';
const bolt_type = material.bolt_details?.bolt_type || 'BOLT';
const material_standard = material.bolt_details?.material_standard || '';
const material_grade = material.bolt_details?.material_grade || '';
const thread_type = material.bolt_details?.thread_type || '';
const diameter = material.bolt_details?.diameter || main_nom;
const length = material.bolt_details?.length || '';
const pressure_rating = material.bolt_details?.pressure_rating || '';
const coating_type = material.bolt_details?.coating_type || '';
// 볼트 스펙 생성
const bolt_spec_parts = [];
// 볼트 타입 (HEX_BOLT, STUD_BOLT 등)
if (bolt_type && bolt_type !== 'UNKNOWN') {
bolt_spec_parts.push(bolt_type.replace('_', ' '));
}
// 재질 (ASTM A193, ASTM A194 등)
if (material_standard) {
bolt_spec_parts.push(material_standard);
if (material_grade && material_grade !== material_standard) {
bolt_spec_parts.push(material_grade);
}
} else if (material_spec) {
bolt_spec_parts.push(material_spec);
}
// 나사 규격 (M12, 1/2" 등)
if (diameter) {
bolt_spec_parts.push(diameter);
}
// 코팅 타입 (ELECTRO_GALVANIZED 등)
if (coating_type && coating_type !== 'PLAIN') {
bolt_spec_parts.push(coating_type.replace('_', ' '));
}
const full_bolt_spec = bolt_spec_parts.join(', ');
specKey = `${category}|${full_bolt_spec}|${diameter}|${length}|${coating_type}|${pressure_rating}`;
specData = {
category: 'BOLT',
bolt_type,
thread_type,
full_bolt_spec,
material_standard,
material_grade,
diameter,
length,
coating_type,
pressure_rating,
size_display: diameter,
main_nom: diameter,
unit: 'EA',
isLength: false
};
} else if (category === 'INSTRUMENT') {
// INSTRUMENT: 타입 + 연결사이즈 + 측정범위
const main_nom = material.main_nom || '';
const instrument_type = material.instrument_details?.instrument_type || 'INSTRUMENT';
const measurement_range = material.instrument_details?.measurement_range || '';
const signal_type = material.instrument_details?.signal_type || '';
// 계기 스펙 생성
const instrument_spec_parts = [];
// 계기 타입 (PRESSURE_GAUGE, TEMPERATURE_TRANSMITTER 등)
if (instrument_type && instrument_type !== 'UNKNOWN') {
instrument_spec_parts.push(instrument_type.replace('_', ' '));
}
// 측정 범위 (0-100 PSI, 4-20mA 등)
if (measurement_range) {
instrument_spec_parts.push(measurement_range);
}
// 연결 사이즈 (1/4", 1/2" 등)
if (main_nom) {
instrument_spec_parts.push(`${main_nom} CONNECTION`);
}
const full_instrument_spec = instrument_spec_parts.join(', ');
specKey = `${category}|${full_instrument_spec}|${main_nom}`;
specData = {
category: 'INSTRUMENT',
instrument_type,
measurement_range,
signal_type,
full_instrument_spec,
size_display: main_nom,
main_nom,
unit: 'EA',
isLength: false
};
} else if (category === 'VALVE') {
// VALVE: 타입 + 연결방식 + 압력등급 + 재질 + 사이즈
const main_nom = material.main_nom || '';
const valve_type = material.valve_details?.valve_type || 'VALVE';
const valve_subtype = material.valve_details?.valve_subtype || '';
const connection_method = material.valve_details?.connection_method || '';
const pressure_rating = material.valve_details?.pressure_rating || '';
const body_material = material.valve_details?.body_material || material.material_grade || '';
const actuator_type = material.valve_details?.actuator_type || 'MANUAL';
const fire_safe = material.valve_details?.fire_safe || false;
// 밸브 스펙 생성
const valve_spec_parts = [];
// 밸브 타입 (GATE_VALVE, BALL_VALVE 등)
if (valve_type && valve_type !== 'UNKNOWN') {
valve_spec_parts.push(valve_type.replace('_', ' '));
}
// 연결 방식 (FLANGED, THREADED, SOCKET_WELD 등)
if (connection_method && connection_method !== 'UNKNOWN') {
valve_spec_parts.push(connection_method.replace('_', ' '));
}
// 압력 등급 (150LB, 300LB 등)
if (pressure_rating && pressure_rating !== 'UNKNOWN') {
valve_spec_parts.push(pressure_rating);
}
// 작동 방식 (수동이 아닌 경우만 표시)
if (actuator_type && actuator_type !== 'MANUAL' && actuator_type !== 'UNKNOWN') {
valve_spec_parts.push(actuator_type.replace('_', ' '));
}
// 특수 기능 (Fire Safe 등)
if (fire_safe) {
valve_spec_parts.push('FIRE SAFE');
}
if (valve_subtype && valve_subtype !== 'UNKNOWN') {
valve_spec_parts.push(valve_subtype);
}
const full_valve_spec = valve_spec_parts.join(', ');
specKey = `${category}|${full_valve_spec}|${body_material}|${main_nom}`;
specData = {
category: 'VALVE',
valve_type,
valve_subtype,
connection_method,
pressure_rating,
body_material,
actuator_type,
fire_safe,
full_valve_spec,
size_display: main_nom,
main_nom,
unit: 'EA',
isLength: false
};
} else {
// 기타 자재: 기본 분류
const material_spec = material.material_grade || '';
const size_display = material.main_nom || material.size_spec || '';
specKey = `${category}|${material_spec}|${size_display}`;
specData = {
category,
material_spec,
size_display,
unit: 'EA',
isLength: false
};
}
if (!specs[specKey]) {
specs[specKey] = {
...specData,
totalQuantity: 0,
totalLength: 0,
count: 0,
items: []
};
}
specs[specKey].totalQuantity += material.quantity || 0;
specs[specKey].count += 1;
specs[specKey].items.push(material);
// PIPE의 경우 길이 합산
if (category === 'PIPE' && material.pipe_details?.length_mm) {
specs[specKey].totalLength += material.pipe_details.length_mm;
}
});
return Object.values(specs);
};
const formatLength = (lengthMm) => {
if (!lengthMm || lengthMm === 0) return '0mm';
if (lengthMm >= 1000) {
return `${(lengthMm / 1000).toFixed(2)}m (${lengthMm.toLocaleString()}mm)`;
}
return `${lengthMm.toLocaleString()}mm`;
};
const getCategoryColor = (category) => {
const colors = {
'PIPE': 'primary',
'FITTING': 'secondary',
'VALVE': 'success',
'BOLT': 'warning',
'GASKET': 'info',
'INSTRUMENT': 'error',
'UNKNOWN': 'default'
};
return colors[category] || 'default';
};
// 엑셀 내보내기 함수
const handleExportToExcel = () => {
if (materials.length === 0) {
alert('내보낼 자재 데이터가 없습니다.');
return;
}
const additionalInfo = {
filename: fileName,
jobNo: jobNo,
revision: currentRevision,
uploadDate: new Date().toLocaleDateString()
};
const baseFilename = `자재목록_${jobNo}_${currentRevision}`;
exportMaterialsToExcel(materials, baseFilename, additionalInfo);
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
<Typography variant="h6" sx={{ ml: 2 }}>
자재 정보를 불러오는 ...
</Typography>
</Box>
);
}
if (error) {
return (
<Box sx={{ maxWidth: 1200, mx: 'auto', mt: 4, px: 2 }}>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 2 }}
>
뒤로가기
</Button>
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
<Alert severity="info" sx={{ mt: 2 }}>
💡 해당 파일에 자재 정보가 없습니다.
</Alert>
</Box>
);
}
const materialSpecs = generateMaterialSpecs(materials);
const totalSpecs = materialSpecs.length;
const categoryStats = materialSpecs.reduce((acc, spec) => {
acc[spec.category] = (acc[spec.category] || 0) + 1;
return acc;
}, {});
return (
<Box sx={{ maxWidth: 1400, mx: 'auto', mt: 4, px: 2 }}>
{/* 헤더 */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
>
뒤로가기
</Button>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
{/* 리비전 비교 버튼 */}
{availableRevisions.length > 1 && currentRevision !== 'Rev.0' && (
<Button
variant="outlined"
color="secondary"
startIcon={<CompareIcon />}
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${currentRevision}&filename=${encodeURIComponent(fileName)}`)}
>
리비전 비교
</Button>
)}
<Button
variant="outlined"
color="primary"
startIcon={<Download />}
onClick={handleExportToExcel}
disabled={materials.length === 0}
>
엑셀 내보내기
</Button>
<Button
variant="contained"
color="success"
size="large"
startIcon={<ShoppingCart />}
onClick={() => {
const params = new URLSearchParams(window.location.search);
navigate(`/purchase-confirmation?${params.toString()}`);
}}
disabled={materialSpecs.length === 0}
sx={{ minWidth: 150 }}
>
구매 확정
</Button>
</Box>
</Box>
{/* 리비전 선택 */}
{availableRevisions.length > 1 && (
<Card sx={{ mb: 3, p: 2 }}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={6}>
<Typography variant="h6" gutterBottom>
📋 {bomName}
</Typography>
<Typography variant="body2" color="textSecondary">
Job No: {jobNo} | 현재 리비전: <strong>{currentRevision}</strong>
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth size="small">
<InputLabel>리비전 선택</InputLabel>
<Select
value={fileId || ''}
label="리비전 선택"
onChange={(e) => {
const selectedFileId = e.target.value;
const selectedFile = availableRevisions.find(file => file.id === selectedFileId);
if (selectedFile) {
// 새로운 리비전 페이지로 이동
navigate(`/materials?file_id=${selectedFileId}&job_no=${jobNo}&filename=${encodeURIComponent(selectedFile.original_filename || selectedFile.filename)}`);
window.location.reload(); // 페이지 새로고침으로 데이터 갱신
}
}}
>
{availableRevisions.map((file) => (
<MenuItem key={file.id} value={file.id}>
{file.revision || 'Rev.0'} ({file.parsed_count || 0} 자재) - {new Date(file.upload_date).toLocaleDateString('ko-KR')}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</Card>
)}
<Typography variant="h4" component="h1" gutterBottom>
📋 자재 사양서
</Typography>
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
업체 견적 요청용 자재 사양 목록
</Typography>
{fileName && (
<Typography variant="body2" color="text.secondary">
파일명: {fileName}
</Typography>
)}
</Box>
{/* 통계 요약 */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
📊 사양 요약
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip
label={`${totalSpecs}개 사양`}
color="primary"
variant="outlined"
/>
{Object.entries(categoryStats).map(([category, count]) => (
<Chip
key={category}
label={`${category}: ${count}`}
color={getCategoryColor(category)}
size="small"
/>
))}
</Box>
</CardContent>
</Card>
{/* 카테고리별 자재 사양서 */}
{Object.entries(categoryStats).map(([category, count]) => {
const categorySpecs = materialSpecs.filter(spec => spec.category === category);
return (
<Card key={category} sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
🔧 {category} 사양 ({count})
<Chip
label={category === 'PIPE' ? '총길이 기준' : '수량 기준'}
size="small"
color={getCategoryColor(category)}
/>
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
{category === 'PIPE'
? '동일한 재질·외경·스케줄·제작방식의 파이프들을 그룹화하여 표시합니다.'
: '동일한 사양의 자재들을 그룹화하여 수량을 합산합니다.'
}
</Typography>
<TableContainer component={Paper} variant="outlined" sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
{category === 'PIPE' && (
<>
<TableCell><strong>사양</strong></TableCell>
<TableCell><strong>외경</strong></TableCell>
<TableCell><strong>스케줄</strong></TableCell>
<TableCell><strong>제작방식</strong></TableCell>
<TableCell align="right"><strong> 길이</strong></TableCell>
</>
)}
{category === 'FITTING' && (
<>
<TableCell><strong>품목</strong></TableCell>
<TableCell><strong>재질</strong></TableCell>
<TableCell><strong>사이즈</strong></TableCell>
<TableCell align="right"><strong>수량</strong></TableCell>
</>
)}
{category === 'FLANGE' && (
<>
<TableCell><strong>플랜지 타입</strong></TableCell>
<TableCell><strong>재질</strong></TableCell>
<TableCell><strong>사이즈</strong></TableCell>
<TableCell align="right"><strong>수량</strong></TableCell>
</>
)}
{category === 'GASKET' && (
<>
<TableCell><strong>가스켓 타입</strong></TableCell>
<TableCell><strong>상세 구성</strong></TableCell>
<TableCell><strong>재질</strong></TableCell>
<TableCell><strong>두께</strong></TableCell>
<TableCell><strong>사이즈</strong></TableCell>
<TableCell align="right"><strong>수량</strong></TableCell>
</>
)}
{category === 'BOLT' && (
<>
<TableCell><strong>볼트 타입</strong></TableCell>
<TableCell><strong>재질</strong></TableCell>
<TableCell><strong>사이즈</strong></TableCell>
<TableCell><strong>길이</strong></TableCell>
<TableCell><strong>코팅</strong></TableCell>
<TableCell><strong>압력등급</strong></TableCell>
<TableCell align="right"><strong>수량</strong></TableCell>
</>
)}
{category === 'INSTRUMENT' && (
<>
<TableCell><strong>계기 타입</strong></TableCell>
<TableCell><strong>측정범위</strong></TableCell>
<TableCell><strong>연결사이즈</strong></TableCell>
<TableCell align="right"><strong>수량</strong></TableCell>
</>
)}
{category === 'VALVE' && (
<>
<TableCell><strong>밸브 타입</strong></TableCell>
<TableCell><strong>연결방식</strong></TableCell>
<TableCell><strong>압력등급</strong></TableCell>
<TableCell><strong>재질</strong></TableCell>
<TableCell><strong>사이즈</strong></TableCell>
<TableCell><strong>작동방식</strong></TableCell>
<TableCell align="right"><strong>수량</strong></TableCell>
</>
)}
{!['PIPE', 'FITTING', 'FLANGE', 'GASKET', 'BOLT', 'INSTRUMENT', 'VALVE'].includes(category) && (
<>
<TableCell><strong>재질</strong></TableCell>
<TableCell><strong>사이즈</strong></TableCell>
<TableCell align="right"><strong>수량</strong></TableCell>
</>
)}
<TableCell align="center"><strong>개수</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{categorySpecs.map((spec, index) => (
<TableRow key={index} hover>
{category === 'PIPE' && (
<>
<TableCell>
<Typography variant="body2" fontWeight="bold">
{spec.material_spec || 'Unknown'}
</Typography>
</TableCell>
<TableCell>{spec.outer_diameter || 'Unknown'}</TableCell>
<TableCell>{spec.schedule || 'Unknown'}</TableCell>
<TableCell>{spec.manufacturing_method || 'Unknown'}</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{formatLength(spec.totalLength)}
</Typography>
</TableCell>
</>
)}
{category === 'FITTING' && (
<>
<TableCell>
<Typography variant="body2" sx={{ maxWidth: 300, fontWeight: 'bold' }}>
{spec.full_fitting_spec || spec.fitting_type || 'UNKNOWN'}
</Typography>
</TableCell>
<TableCell>{spec.material_spec || 'Unknown'}</TableCell>
<TableCell>{spec.size_display || 'Unknown'}</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{spec.totalQuantity} {spec.unit}
</Typography>
</TableCell>
</>
)}
{category === 'FLANGE' && (
<>
<TableCell>
<Typography variant="body2" sx={{ maxWidth: 300, fontWeight: 'bold' }}>
{spec.full_flange_spec || spec.flange_type || 'UNKNOWN'}
</Typography>
</TableCell>
<TableCell>{spec.material_spec || 'Unknown'}</TableCell>
<TableCell>{spec.size_display || 'Unknown'}</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{spec.totalQuantity} {spec.unit}
</Typography>
</TableCell>
</>
)}
{category === 'GASKET' && (
<>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{spec.gasket_type?.replace('_', ' ') || 'UNKNOWN'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ maxWidth: 200, fontSize: '0.85rem' }}>
{spec.detailed_construction || 'N/A'}
</Typography>
</TableCell>
<TableCell>{spec.material_spec || 'Unknown'}</TableCell>
<TableCell>
<Typography variant="body2">
{spec.thickness ? `${spec.thickness}mm` : 'N/A'}
</Typography>
</TableCell>
<TableCell>{spec.size_display || 'Unknown'}</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{spec.totalQuantity} {spec.unit}
</Typography>
</TableCell>
</>
)}
{category === 'BOLT' && (
<>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{spec.bolt_type?.replace('_', ' ') || 'UNKNOWN'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{spec.material_standard || 'Unknown'} {spec.material_grade || ''}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{spec.diameter ? (spec.diameter.includes('"') ? spec.diameter : spec.diameter.replace('0.5', '1/2"').replace('0.75', '3/4"').replace('1.0', '1"').replace('1.5', '1 1/2"')) : 'Unknown'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{spec.length || 'N/A'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{spec.coating_type ? spec.coating_type.replace('_', ' ') : 'N/A'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{spec.pressure_rating || 'N/A'}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{spec.totalQuantity} {spec.unit}
</Typography>
</TableCell>
</>
)}
{category === 'INSTRUMENT' && (
<>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{spec.instrument_type?.replace('_', ' ') || 'UNKNOWN'}
</Typography>
</TableCell>
<TableCell>{spec.measurement_range || 'Unknown'}</TableCell>
<TableCell>{spec.size_display || 'Unknown'}</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{spec.totalQuantity} {spec.unit}
</Typography>
</TableCell>
</>
)}
{category === 'VALVE' && (
<>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{spec.valve_type?.replace('_', ' ') || 'VALVE'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{spec.connection_method?.replace('_', ' ') || 'Unknown'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{spec.pressure_rating || 'Unknown'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{spec.body_material || 'Unknown'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{spec.size_display || 'Unknown'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{spec.actuator_type?.replace('_', ' ') || 'MANUAL'}
{spec.fire_safe && ' + FIRE SAFE'}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{spec.totalQuantity} {spec.unit}
</Typography>
</TableCell>
</>
)}
{(!['PIPE', 'FITTING', 'FLANGE', 'GASKET', 'BOLT', 'INSTRUMENT', 'VALVE'].includes(category)) && (
<>
<TableCell>{spec.material_spec || 'Unknown'}</TableCell>
<TableCell>{spec.size_display || 'Unknown'}</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{spec.totalQuantity} {spec.unit}
</Typography>
</TableCell>
</>
)}
<TableCell align="center">
<Chip
label={spec.count}
size="small"
color="default"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
);
})}
{materialSpecs.length === 0 && (
<Alert severity="info" sx={{ mt: 2 }}>
💡 해당 파일에 자재 정보가 없습니다.
</Alert>
)}
</Box>
);
};
export default MaterialsPage;