- 자재 비교에서 파이프 길이 합산 로직 수정 - 프론트엔드에서 혼란스러운 '평균단위' 표시 제거 - 파이프 변경사항에 실제 이전/현재 총길이 표시 - 엑셀 내보내기에서 '초기화되지 않은 변수' 오류 수정 - 리비전 비교에서 파이프 길이 변화 계산 개선
1086 lines
44 KiB
JavaScript
1086 lines
44 KiB
JavaScript
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; |