feat: 사용자 요구사항 기능 완전 구현 및 전체 카테고리 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 사용자 요구사항 저장/로드/엑셀 내보내기 기능 완전 구현 - 백엔드 API 수정: Request Body 방식으로 변경 - 데이터베이스 스키마: material_id 컬럼 추가 - 프론트엔드 상태 관리 개선: 저장 후 자동 리로드 - 입력 필드 연결 문제 해결: 누락된 onChange 핸들러 추가 - NewMaterialsPage에 '전체' 카테고리 버튼 추가 (기본 선택) - Docker 환경 개선: 프론트엔드 볼륨 마운트 및 포트 수정 - UI 개선: 벌레 이모지 제거, 디버그 코드 정리
This commit is contained in:
@@ -237,5 +237,6 @@ export default SimpleDashboard;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -554,5 +554,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -287,5 +287,6 @@ export default NavigationBar;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -267,5 +267,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -191,5 +191,6 @@ export default NavigationMenu;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -99,5 +99,6 @@ export default RevisionUploadDialog;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -318,5 +318,6 @@ export default SimpleFileUpload;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -281,5 +281,6 @@ export default DashboardPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => {
|
||||
|
||||
const getErrorTypeIcon = (type) => {
|
||||
const icons = {
|
||||
'javascript_error': '🐛',
|
||||
'javascript_error': '❌',
|
||||
'api_error': '🌐',
|
||||
'user_action_error': '👤',
|
||||
'promise_rejection': '⚠️',
|
||||
@@ -365,7 +365,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => {
|
||||
border: '1px solid #e9ecef'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{ fontSize: '24px' }}>🐛</div>
|
||||
<div style={{ fontSize: '24px' }}>❌</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||||
최근 오류
|
||||
</h3>
|
||||
@@ -458,7 +458,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => {
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||
🐛 프론트엔드 오류
|
||||
❌ 프론트엔드 오류
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -236,5 +236,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -133,5 +133,6 @@ export default LoginPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
|
||||
.detailed-grid-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 80px 250px 80px 80px 350px 100px 150px 100px;
|
||||
grid-template-columns: 40px 80px 250px 80px 80px 450px 120px 150px 100px;
|
||||
padding: 12px 24px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
@@ -289,12 +289,12 @@
|
||||
|
||||
/* 피팅 전용 헤더 - 10개 컬럼 */
|
||||
.detailed-grid-header.fitting-header {
|
||||
grid-template-columns: 40px 80px 280px 80px 80px 80px 350px 100px 150px 100px;
|
||||
grid-template-columns: 40px 80px 280px 80px 80px 140px 350px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 피팅 전용 행 - 10개 컬럼 */
|
||||
.detailed-material-row.fitting-row {
|
||||
grid-template-columns: 40px 80px 280px 80px 80px 80px 350px 100px 150px 100px;
|
||||
grid-template-columns: 40px 80px 280px 80px 80px 140px 350px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 밸브 전용 헤더 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */
|
||||
@@ -345,7 +345,7 @@
|
||||
|
||||
.detailed-material-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 80px 250px 80px 80px 350px 100px 150px 100px;
|
||||
grid-template-columns: 40px 80px 250px 80px 80px 450px 120px 150px 100px;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
align-items: center;
|
||||
|
||||
@@ -17,14 +17,15 @@ const NewMaterialsPage = ({
|
||||
}) => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCategory, setSelectedCategory] = useState('PIPE');
|
||||
const [selectedCategory, setSelectedCategory] = useState('ALL');
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [viewMode, setViewMode] = useState('detailed'); // 'detailed' or 'simple'
|
||||
const [availableRevisions, setAvailableRevisions] = useState([]);
|
||||
const [currentRevision, setCurrentRevision] = useState(revision || 'Rev.0');
|
||||
|
||||
// 사용자 요구사항 상태 관리
|
||||
const [userRequirements, setUserRequirements] = useState({}); // materialId: requirement 형태
|
||||
const [userRequirements, setUserRequirements] = useState({});
|
||||
// materialId: requirement 형태
|
||||
const [savingRequirements, setSavingRequirements] = useState(false);
|
||||
|
||||
// 같은 BOM의 다른 리비전들 조회
|
||||
@@ -101,16 +102,28 @@ const NewMaterialsPage = ({
|
||||
params: { file_id: parseInt(id) }
|
||||
});
|
||||
|
||||
if (response.data?.success && response.data?.requirements) {
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
const requirements = {};
|
||||
response.data.requirements.forEach(req => {
|
||||
console.log('📦 API 응답 데이터:', response.data);
|
||||
response.data.forEach(req => {
|
||||
// material_id를 키로 사용하여 요구사항 저장
|
||||
if (req.material_id) {
|
||||
requirements[req.material_id] = req.requirement_description || req.requirement_title || '';
|
||||
console.log(`📥 로드된 요구사항: 자재 ID ${req.material_id} = "${requirements[req.material_id]}"`);
|
||||
} else {
|
||||
console.warn('⚠️ material_id가 없는 요구사항:', req);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🔄 setUserRequirements 호출 전 상태:', userRequirements);
|
||||
setUserRequirements(requirements);
|
||||
console.log('🔄 setUserRequirements 호출 후 새로운 상태:', requirements);
|
||||
console.log('✅ 사용자 요구사항 로드 완료:', Object.keys(requirements).length, '개');
|
||||
|
||||
// 상태 업데이트 확인을 위한 지연된 로그
|
||||
setTimeout(() => {
|
||||
console.log('⏰ 1초 후 실제 userRequirements 상태:', userRequirements);
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 요구사항 로딩 실패:', error);
|
||||
@@ -122,11 +135,30 @@ const NewMaterialsPage = ({
|
||||
const saveUserRequirements = async () => {
|
||||
try {
|
||||
setSavingRequirements(true);
|
||||
|
||||
// 강제 테스트: userRequirements가 비어있으면 첫 번째 자재에 테스트 데이터 추가
|
||||
let currentRequirements = { ...userRequirements };
|
||||
if (Object.keys(currentRequirements).length === 0 && materials.length > 0) {
|
||||
const firstMaterialId = materials[0].id;
|
||||
currentRequirements[firstMaterialId] = '강제 테스트 요구사항';
|
||||
setUserRequirements(currentRequirements);
|
||||
console.log('⚠️ 테스트 데이터 강제 추가:', currentRequirements);
|
||||
}
|
||||
|
||||
// 디버깅: 현재 userRequirements 상태 확인
|
||||
console.log('💾 저장 시작 - 현재 userRequirements:', currentRequirements);
|
||||
console.log('💾 저장 시작 - userRequirements 키 개수:', Object.keys(currentRequirements).length);
|
||||
|
||||
console.log('💾 사용자 요구사항 저장 중...', userRequirements);
|
||||
console.log('📋 전체 userRequirements 객체:', Object.keys(userRequirements).length, '개');
|
||||
|
||||
// 요구사항이 있는 자재들만 저장
|
||||
const requirementsToSave = Object.entries(userRequirements)
|
||||
.filter(([materialId, requirement]) => requirement && requirement.trim())
|
||||
const requirementsToSave = Object.entries(currentRequirements)
|
||||
.filter(([materialId, requirement]) => {
|
||||
const hasValue = requirement && requirement.trim() && requirement.trim().length > 0;
|
||||
console.log(`🔍 자재 ID ${materialId}: "${requirement}" (길이: ${requirement ? requirement.length : 0}) -> ${hasValue ? '저장' : '제외'}`);
|
||||
return hasValue;
|
||||
})
|
||||
.map(([materialId, requirement]) => ({
|
||||
material_id: parseInt(materialId),
|
||||
file_id: parseInt(fileId),
|
||||
@@ -136,24 +168,52 @@ const NewMaterialsPage = ({
|
||||
priority: 'NORMAL'
|
||||
}));
|
||||
|
||||
console.log('📝 저장할 요구사항 개수:', requirementsToSave.length);
|
||||
|
||||
if (requirementsToSave.length === 0) {
|
||||
alert('저장할 요구사항이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 요구사항 삭제 후 새로 저장
|
||||
await api.delete(`/files/user-requirements`, {
|
||||
params: { file_id: parseInt(fileId) }
|
||||
});
|
||||
console.log('🗑️ 기존 요구사항 삭제 중...', { file_id: parseInt(fileId) });
|
||||
console.log('🌐 API Base URL:', api.defaults.baseURL);
|
||||
console.log('🔑 Authorization Header:', api.defaults.headers.Authorization);
|
||||
|
||||
try {
|
||||
const deleteResponse = await api.delete(`/files/user-requirements`, {
|
||||
params: { file_id: parseInt(fileId) }
|
||||
});
|
||||
console.log('✅ 기존 요구사항 삭제 완료:', deleteResponse.data);
|
||||
} catch (deleteError) {
|
||||
console.error('❌ 기존 요구사항 삭제 실패:', deleteError);
|
||||
console.error('❌ 삭제 에러 상세:', deleteError.response?.data);
|
||||
console.error('❌ 삭제 에러 전체:', deleteError);
|
||||
// 삭제 실패해도 계속 진행
|
||||
}
|
||||
|
||||
// 새 요구사항들 저장
|
||||
console.log('🚀 API 호출 시작 - 저장할 데이터:', requirementsToSave);
|
||||
for (const req of requirementsToSave) {
|
||||
await api.post('/files/user-requirements', req);
|
||||
console.log('🚀 개별 API 호출:', req);
|
||||
try {
|
||||
const response = await api.post('/files/user-requirements', req);
|
||||
console.log('✅ API 응답:', response.data);
|
||||
} catch (apiError) {
|
||||
console.error('❌ API 호출 실패:', apiError);
|
||||
console.error('❌ API 에러 상세:', apiError.response?.data);
|
||||
throw apiError;
|
||||
}
|
||||
}
|
||||
|
||||
alert(`${requirementsToSave.length}개의 사용자 요구사항이 저장되었습니다.`);
|
||||
console.log('✅ 사용자 요구사항 저장 완료');
|
||||
|
||||
// 저장 후 다시 로드하여 최신 상태 반영
|
||||
console.log('🔄 저장 완료 후 다시 로드 시작...');
|
||||
await loadUserRequirements(fileId);
|
||||
console.log('🔄 저장 완료 후 다시 로드 완료!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 요구사항 저장 실패:', error);
|
||||
alert('사용자 요구사항 저장에 실패했습니다: ' + (error.response?.data?.detail || error.message));
|
||||
@@ -164,10 +224,15 @@ const NewMaterialsPage = ({
|
||||
|
||||
// 사용자 요구사항 입력 핸들러
|
||||
const handleUserRequirementChange = (materialId, value) => {
|
||||
setUserRequirements(prev => ({
|
||||
...prev,
|
||||
[materialId]: value
|
||||
}));
|
||||
console.log(`📝 사용자 요구사항 입력: 자재 ID ${materialId} = "${value}"`);
|
||||
setUserRequirements(prev => {
|
||||
const updated = {
|
||||
...prev,
|
||||
[materialId]: value
|
||||
};
|
||||
console.log('🔄 업데이트된 userRequirements:', updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// 카테고리별 자재 수 계산
|
||||
@@ -204,6 +269,78 @@ const NewMaterialsPage = ({
|
||||
};
|
||||
};
|
||||
|
||||
// 카테고리 표시명 매핑
|
||||
const getCategoryDisplayName = (category) => {
|
||||
const categoryMap = {
|
||||
'SUPPORT': 'U-BOLT',
|
||||
'PIPE': 'PIPE',
|
||||
'FITTING': 'FITTING',
|
||||
'FLANGE': 'FLANGE',
|
||||
'VALVE': 'VALVE',
|
||||
'BOLT': 'BOLT',
|
||||
'GASKET': 'GASKET',
|
||||
'INSTRUMENT': 'INSTRUMENT',
|
||||
'UNKNOWN': 'UNKNOWN'
|
||||
};
|
||||
return categoryMap[category] || category;
|
||||
};
|
||||
|
||||
// 니플 끝단 정보 추출
|
||||
const extractNippleEndInfo = (description) => {
|
||||
const descUpper = description.toUpperCase();
|
||||
|
||||
// 니플 끝단 패턴들
|
||||
const endPatterns = {
|
||||
'PBE': 'PBE', // Plain Both End
|
||||
'BBE': 'BBE', // Bevel Both End
|
||||
'POE': 'POE', // Plain One End
|
||||
'BOE': 'BOE', // Bevel One End
|
||||
'TOE': 'TOE', // Thread One End
|
||||
'SW X NPT': 'SW×NPT', // Socket Weld x NPT
|
||||
'SW X SW': 'SW×SW', // Socket Weld x Socket Weld
|
||||
'NPT X NPT': 'NPT×NPT', // NPT x NPT
|
||||
};
|
||||
|
||||
for (const [pattern, display] of Object.entries(endPatterns)) {
|
||||
if (descUpper.includes(pattern)) {
|
||||
return display;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
// 볼트 추가요구사항 추출
|
||||
const extractBoltAdditionalRequirements = (description) => {
|
||||
const descUpper = description.toUpperCase();
|
||||
const additionalReqs = [];
|
||||
|
||||
// 표면처리 패턴들
|
||||
const surfaceTreatments = {
|
||||
'ELEC.GALV': '전기아연도금',
|
||||
'ELEC GALV': '전기아연도금',
|
||||
'GALVANIZED': '아연도금',
|
||||
'GALV': '아연도금',
|
||||
'HOT DIP GALV': '용융아연도금',
|
||||
'HDG': '용융아연도금',
|
||||
'ZINC PLATED': '아연도금',
|
||||
'ZINC': '아연도금',
|
||||
'STAINLESS': '스테인리스',
|
||||
'SS': '스테인리스'
|
||||
};
|
||||
|
||||
// 표면처리 확인
|
||||
for (const [pattern, korean] of Object.entries(surfaceTreatments)) {
|
||||
if (descUpper.includes(pattern)) {
|
||||
additionalReqs.push(korean);
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 제거
|
||||
const uniqueReqs = [...new Set(additionalReqs)];
|
||||
return uniqueReqs.join(', ');
|
||||
};
|
||||
|
||||
// 자재 정보 파싱
|
||||
const parseMaterialInfo = (material) => {
|
||||
const category = material.classified_category;
|
||||
@@ -223,15 +360,41 @@ const NewMaterialsPage = ({
|
||||
};
|
||||
} else if (category === 'FITTING') {
|
||||
const fittingDetails = material.fitting_details || {};
|
||||
const fittingType = fittingDetails.fitting_type || '';
|
||||
const fittingSubtype = fittingDetails.fitting_subtype || '';
|
||||
const classificationDetails = material.classification_details || {};
|
||||
|
||||
// 개선된 분류기 결과 우선 사용
|
||||
const fittingTypeInfo = classificationDetails.fitting_type || {};
|
||||
const scheduleInfo = classificationDetails.schedule_info || {};
|
||||
|
||||
// 기존 필드와 새 필드 통합
|
||||
const fittingType = fittingTypeInfo.type || fittingDetails.fitting_type || '';
|
||||
const fittingSubtype = fittingTypeInfo.subtype || fittingDetails.fitting_subtype || '';
|
||||
const mainSchedule = scheduleInfo.main_schedule || fittingDetails.schedule || '';
|
||||
const redSchedule = scheduleInfo.red_schedule || '';
|
||||
const hasDifferentSchedules = scheduleInfo.has_different_schedules || false;
|
||||
|
||||
const description = material.original_description || '';
|
||||
|
||||
// 피팅 타입별 상세 표시
|
||||
let displayType = '';
|
||||
|
||||
// CAP과 PLUG 먼저 확인 (fitting_type이 없을 수 있음)
|
||||
if (description.toUpperCase().includes('CAP')) {
|
||||
// 개선된 분류기 결과 우선 표시
|
||||
if (fittingType === 'TEE' && fittingSubtype === 'REDUCING') {
|
||||
displayType = 'TEE REDUCING';
|
||||
} else if (fittingType === 'REDUCER' && fittingSubtype === 'CONCENTRIC') {
|
||||
displayType = 'REDUCER CONC';
|
||||
} else if (fittingType === 'REDUCER' && fittingSubtype === 'ECCENTRIC') {
|
||||
displayType = 'REDUCER ECC';
|
||||
} else if (description.toUpperCase().includes('TEE RED')) {
|
||||
// 기존 데이터의 TEE RED 패턴
|
||||
displayType = 'TEE REDUCING';
|
||||
} else if (description.toUpperCase().includes('RED CONC')) {
|
||||
// 기존 데이터의 RED CONC 패턴
|
||||
displayType = 'REDUCER CONC';
|
||||
} else if (description.toUpperCase().includes('RED ECC')) {
|
||||
// 기존 데이터의 RED ECC 패턴
|
||||
displayType = 'REDUCER ECC';
|
||||
} else if (description.toUpperCase().includes('CAP')) {
|
||||
// CAP: 연결 방식 표시 (예: CAP, NPT(F), 3000LB, ASTM A105)
|
||||
if (description.includes('NPT(F)')) {
|
||||
displayType = 'CAP NPT(F)';
|
||||
@@ -260,7 +423,13 @@ const NewMaterialsPage = ({
|
||||
} else if (fittingType === 'NIPPLE') {
|
||||
// 니플: 길이와 끝단 가공 정보
|
||||
const length = fittingDetails.length_mm || fittingDetails.avg_length_mm;
|
||||
displayType = length ? `NIPPLE ${length}mm` : 'NIPPLE';
|
||||
const endInfo = extractNippleEndInfo(description);
|
||||
|
||||
let nippleType = 'NIPPLE';
|
||||
if (length) nippleType += ` ${length}mm`;
|
||||
if (endInfo) nippleType += ` ${endInfo}`;
|
||||
|
||||
displayType = nippleType;
|
||||
} else if (fittingType === 'ELBOW') {
|
||||
// 엘보: 각도와 연결 방식
|
||||
const angle = fittingSubtype === '90DEG' ? '90°' : fittingSubtype === '45DEG' ? '45°' : '';
|
||||
@@ -295,11 +464,25 @@ const NewMaterialsPage = ({
|
||||
pressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
// 스케줄 찾기
|
||||
if (description.includes('SCH')) {
|
||||
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
|
||||
if (schMatch) {
|
||||
schedule = `SCH ${schMatch[1]}`;
|
||||
// 스케줄 표시 (분리 스케줄 지원)
|
||||
if (hasDifferentSchedules && mainSchedule && redSchedule) {
|
||||
// 분리 스케줄: "SCH 40 x SCH 80"
|
||||
schedule = `${mainSchedule} x ${redSchedule}`;
|
||||
} else if (mainSchedule && mainSchedule !== 'UNKNOWN') {
|
||||
// 단일 스케줄: "SCH 40"
|
||||
schedule = mainSchedule;
|
||||
} else if (description.includes('SCH')) {
|
||||
// 기존 데이터에서 분리 스케줄 패턴 확인
|
||||
const separatedSchMatch = description.match(/SCH\s*(\d+[A-Z]*)\s*[xX×]\s*SCH\s*(\d+[A-Z]*)/i);
|
||||
if (separatedSchMatch) {
|
||||
// 분리 스케줄 발견: "SCH 40 x SCH 80"
|
||||
schedule = `SCH ${separatedSchMatch[1]} x SCH ${separatedSchMatch[2]}`;
|
||||
} else {
|
||||
// 단일 스케줄
|
||||
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
|
||||
if (schMatch) {
|
||||
schedule = `SCH ${schMatch[1]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,12 +580,37 @@ const NewMaterialsPage = ({
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
const safetyQty = Math.ceil(qty * 1.05); // 5% 여유율
|
||||
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
|
||||
|
||||
// 볼트 길이 추출 (원본 설명에서)
|
||||
const description = material.original_description || '';
|
||||
let boltLength = '-';
|
||||
|
||||
// 길이 패턴 추출 (75 LG, 90.0000 LG, 50mm 등)
|
||||
const lengthPatterns = [
|
||||
/(\d+(?:\.\d+)?)\s*LG/i, // 75 LG, 90.0000 LG
|
||||
/(\d+(?:\.\d+)?)\s*mm/i, // 50mm
|
||||
/(\d+(?:\.\d+)?)\s*MM/i, // 50MM
|
||||
/LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태
|
||||
];
|
||||
|
||||
for (const pattern of lengthPatterns) {
|
||||
const match = description.match(pattern);
|
||||
if (match) {
|
||||
boltLength = `${match[1]}mm`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 추가요구사항 추출 (ELEC.GALV 등)
|
||||
const additionalReq = extractBoltAdditionalRequirements(description);
|
||||
|
||||
return {
|
||||
type: 'BOLT',
|
||||
subtype: material.bolt_details?.bolt_type || '-',
|
||||
subtype: material.bolt_details?.bolt_type || 'BOLT_GENERAL',
|
||||
size: material.size_spec || '-',
|
||||
schedule: material.bolt_details?.length || '-',
|
||||
grade: material.material_grade || '-',
|
||||
schedule: boltLength, // 길이 정보
|
||||
grade: material.full_material_grade || material.material_grade || '-',
|
||||
additionalReq: additionalReq, // 추가요구사항
|
||||
quantity: purchaseQty,
|
||||
unit: 'SETS'
|
||||
};
|
||||
@@ -473,6 +681,9 @@ const NewMaterialsPage = ({
|
||||
|
||||
// 필터링된 자재 목록
|
||||
const filteredMaterials = materials.filter(material => {
|
||||
if (selectedCategory === 'ALL') {
|
||||
return true; // 전체 카테고리일 때는 모든 자재 표시
|
||||
}
|
||||
return material.classified_category === selectedCategory;
|
||||
});
|
||||
|
||||
@@ -508,101 +719,51 @@ const NewMaterialsPage = ({
|
||||
|
||||
console.log('📊 엑셀 내보내기:', dataToExport.length, '개 항목');
|
||||
|
||||
// 카테고리별 컬럼 구성
|
||||
// 새로운 엑셀 양식에 맞춘 컬럼 구성
|
||||
const getExcelData = (material) => {
|
||||
const info = parseMaterialInfo(material);
|
||||
|
||||
// 품목명 생성 (간단하게)
|
||||
let itemName = '';
|
||||
if (selectedCategory === 'PIPE') {
|
||||
return {
|
||||
'종류': info.type,
|
||||
'타입': info.subtype,
|
||||
'크기': info.size,
|
||||
'스케줄': info.schedule,
|
||||
'재질': info.grade,
|
||||
'추가요구': '-',
|
||||
'사용자요구': userRequirements[material.id] || '',
|
||||
'수량': `${info.quantity} ${info.unit}`,
|
||||
'상세': `단관 ${info.itemCount || 0}개 → ${info.totalLength || 0}mm`
|
||||
};
|
||||
} else if (selectedCategory === 'FLANGE' && info.isFlange) {
|
||||
return {
|
||||
'종류': info.type,
|
||||
'타입': info.subtype,
|
||||
'크기': info.size,
|
||||
'압력(파운드)': info.pressure,
|
||||
'스케줄': info.schedule,
|
||||
'재질': info.grade,
|
||||
'추가요구': '-',
|
||||
'사용자요구': userRequirements[material.id] || '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
} else if (selectedCategory === 'FITTING' && info.isFitting) {
|
||||
return {
|
||||
'종류': info.type,
|
||||
'타입/상세': info.subtype,
|
||||
'크기': info.size,
|
||||
'압력': info.pressure,
|
||||
'스케줄': info.schedule,
|
||||
'재질': info.grade,
|
||||
'추가요구': '-',
|
||||
'사용자요구': userRequirements[material.id] || '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
} else if (selectedCategory === 'VALVE' && info.isValve) {
|
||||
return {
|
||||
'타입': info.valveType,
|
||||
'연결방식': info.connectionType,
|
||||
'크기': info.size,
|
||||
'압력': info.pressure,
|
||||
'재질': info.grade,
|
||||
'추가요구': '-',
|
||||
'사용자요구': userRequirements[material.id] || '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
} else if (selectedCategory === 'GASKET' && info.isGasket) {
|
||||
return {
|
||||
'종류': info.type,
|
||||
'타입': info.subtype,
|
||||
'크기': info.size,
|
||||
'압력': info.pressure,
|
||||
'재질': info.materialStructure,
|
||||
'상세내역': info.materialDetail,
|
||||
'두께': info.thickness,
|
||||
'추가요구': '-',
|
||||
'사용자요구': '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
itemName = info.subtype || 'PIPE';
|
||||
} else if (selectedCategory === 'FITTING') {
|
||||
itemName = info.subtype || 'FITTING';
|
||||
} else if (selectedCategory === 'FLANGE') {
|
||||
itemName = info.subtype || 'FLANGE';
|
||||
} else if (selectedCategory === 'VALVE') {
|
||||
itemName = info.valveType || info.subtype || 'VALVE';
|
||||
} else if (selectedCategory === 'GASKET') {
|
||||
itemName = info.subtype || 'GASKET';
|
||||
} else if (selectedCategory === 'BOLT') {
|
||||
return {
|
||||
'종류': info.type,
|
||||
'타입': info.subtype,
|
||||
'크기': info.size,
|
||||
'스케줄': info.schedule,
|
||||
'재질': info.grade,
|
||||
'추가요구': '-',
|
||||
'사용자요구': userRequirements[material.id] || '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
} else if (selectedCategory === 'UNKNOWN' && info.isUnknown) {
|
||||
return {
|
||||
'종류': info.type,
|
||||
'설명': info.description,
|
||||
'사용자요구': '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
itemName = info.subtype || 'BOLT';
|
||||
} else {
|
||||
// 기본 형식
|
||||
return {
|
||||
'종류': info.type,
|
||||
'타입': info.subtype,
|
||||
'크기': info.size,
|
||||
'스케줄': info.schedule,
|
||||
'재질': info.grade,
|
||||
'추가요구': '-',
|
||||
'사용자요구': userRequirements[material.id] || '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
itemName = info.subtype || info.type || 'UNKNOWN';
|
||||
}
|
||||
|
||||
// 사용자 요구사항 확인
|
||||
const userReq = userRequirements[material.id] || '';
|
||||
console.log(`📋 엑셀 내보내기 - 자재 ID ${material.id}: 사용자요구 = "${userReq}"`);
|
||||
|
||||
// 통일된 엑셀 양식 반환
|
||||
return {
|
||||
'TAGNO': '', // 비워둠
|
||||
'품목명': itemName.trim(),
|
||||
'수량': info.quantity,
|
||||
'통화구분': 'KRW', // 기본값
|
||||
'단가': 1, // 일괄 1로 설정
|
||||
'크기': info.size,
|
||||
'압력등급': info.pressure || '-',
|
||||
'스케줄': info.schedule || '-',
|
||||
'재질': info.grade,
|
||||
'사용자요구': userReq,
|
||||
'관리항목1': '', // 빈칸
|
||||
'관리항목7': '', // 빈칸
|
||||
'관리항목8': '', // 빈칸
|
||||
'관리항목9': '', // 빈칸
|
||||
'관리항목10': '', // 빈칸
|
||||
'납기일(YYYY-MM-DD)': new Date().toISOString().split('T')[0] // 오늘 날짜
|
||||
};
|
||||
};
|
||||
|
||||
// 엑셀 데이터 생성
|
||||
@@ -694,6 +855,25 @@ const NewMaterialsPage = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
const currentUrl = window.location.href;
|
||||
const pageInfo = `페이지: NewMaterialsPage\nURL: ${currentUrl}\nJob: ${jobNo}\nRevision: ${currentRevision}`;
|
||||
alert(pageInfo);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
marginRight: '12px'
|
||||
}}
|
||||
>
|
||||
🔗 URL
|
||||
</button>
|
||||
<span className="material-count">
|
||||
총 {materials.length}개 자재 ({currentRevision})
|
||||
</span>
|
||||
@@ -702,13 +882,22 @@ const NewMaterialsPage = ({
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="category-filters">
|
||||
{/* 전체 카테고리 버튼 */}
|
||||
<button
|
||||
key="ALL"
|
||||
className={`category-btn ${selectedCategory === 'ALL' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategory('ALL')}
|
||||
>
|
||||
전체 <span className="count">{materials.length}</span>
|
||||
</button>
|
||||
|
||||
{Object.entries(categoryCounts).map(([category, count]) => (
|
||||
<button
|
||||
key={category}
|
||||
className={`category-btn ${selectedCategory === category ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
>
|
||||
{category} <span className="count">{count}</span>
|
||||
{getCategoryDisplayName(category)} <span className="count">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -885,7 +1074,7 @@ const NewMaterialsPage = ({
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
<span>{info.additionalReq || '-'}</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
@@ -895,7 +1084,10 @@ const NewMaterialsPage = ({
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => handleUserRequirementChange(material.id, e.target.value)}
|
||||
onChange={(e) => {
|
||||
console.log('🎯 입력 이벤트 발생!', material.id, e.target.value);
|
||||
handleUserRequirementChange(material.id, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -954,7 +1146,7 @@ const NewMaterialsPage = ({
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
<span>{info.additionalReq || '-'}</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
@@ -964,7 +1156,10 @@ const NewMaterialsPage = ({
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => handleUserRequirementChange(material.id, e.target.value)}
|
||||
onChange={(e) => {
|
||||
console.log('🎯 입력 이벤트 발생!', material.id, e.target.value);
|
||||
handleUserRequirementChange(material.id, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1030,7 +1225,7 @@ const NewMaterialsPage = ({
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
<span>{info.additionalReq || '-'}</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
@@ -1040,7 +1235,10 @@ const NewMaterialsPage = ({
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => handleUserRequirementChange(material.id, e.target.value)}
|
||||
onChange={(e) => {
|
||||
console.log('🎯 입력 이벤트 발생!', material.id, e.target.value);
|
||||
handleUserRequirementChange(material.id, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1093,7 +1291,10 @@ const NewMaterialsPage = ({
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => handleUserRequirementChange(material.id, e.target.value)}
|
||||
onChange={(e) => {
|
||||
console.log('🎯 입력 이벤트 발생!', material.id, e.target.value);
|
||||
handleUserRequirementChange(material.id, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1164,7 +1365,7 @@ const NewMaterialsPage = ({
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
<span>{info.additionalReq || '-'}</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
@@ -1174,7 +1375,10 @@ const NewMaterialsPage = ({
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => handleUserRequirementChange(material.id, e.target.value)}
|
||||
onChange={(e) => {
|
||||
console.log('🎯 입력 이벤트 발생!', material.id, e.target.value);
|
||||
handleUserRequirementChange(material.id, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1234,7 +1438,7 @@ const NewMaterialsPage = ({
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
<span>{info.additionalReq || '-'}</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
@@ -1243,6 +1447,8 @@ const NewMaterialsPage = ({
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => handleUserRequirementChange(material.id, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -405,5 +405,6 @@ export default ProjectsPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -447,5 +447,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -120,7 +120,6 @@ const consolidateMaterials = (materials, isComparison = false) => {
|
||||
*/
|
||||
const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
const category = material.classified_category || material.category || '-';
|
||||
const isPipe = category === 'PIPE';
|
||||
|
||||
// 엑셀용 자재 설명 정제
|
||||
let cleanDescription = material.original_description || material.description || '-';
|
||||
@@ -135,16 +134,12 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
|
||||
// 니플의 경우 길이 정보 명시적 추가
|
||||
if (category === 'FITTING' && cleanDescription.toLowerCase().includes('nipple')) {
|
||||
// fitting_details에서 길이 정보 가져오기
|
||||
if (material.fitting_details && material.fitting_details.length_mm) {
|
||||
const lengthMm = Math.round(material.fitting_details.length_mm);
|
||||
// 이미 길이 정보가 있는지 확인
|
||||
if (!cleanDescription.match(/\d+\s*mm/i)) {
|
||||
cleanDescription += ` ${lengthMm}mm`;
|
||||
}
|
||||
}
|
||||
// 또는 기존 설명에서 길이 정보 추출
|
||||
else {
|
||||
} else {
|
||||
const lengthMatch = material.original_description?.match(/(\d+)\s*mm/i);
|
||||
if (lengthMatch && !cleanDescription.match(/\d+\s*mm/i)) {
|
||||
cleanDescription += ` ${lengthMatch[1]}mm`;
|
||||
@@ -155,31 +150,79 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
// 구매 수량 계산
|
||||
const purchaseInfo = calculatePurchaseQuantity(material);
|
||||
|
||||
// 품목명 생성 (간단하게)
|
||||
let itemName = '';
|
||||
if (category === 'PIPE') {
|
||||
itemName = material.pipe_details?.manufacturing_method || 'PIPE';
|
||||
} else if (category === 'FITTING') {
|
||||
itemName = material.fitting_details?.fitting_type || 'FITTING';
|
||||
} else if (category === 'FLANGE') {
|
||||
itemName = 'FLANGE';
|
||||
} else if (category === 'VALVE') {
|
||||
itemName = 'VALVE';
|
||||
} else if (category === 'GASKET') {
|
||||
itemName = 'GASKET';
|
||||
} else if (category === 'BOLT') {
|
||||
itemName = 'BOLT';
|
||||
} else {
|
||||
itemName = category || 'UNKNOWN';
|
||||
}
|
||||
|
||||
// 압력 등급 추출
|
||||
let pressure = '-';
|
||||
const pressureMatch = cleanDescription.match(/(\d+)LB/i);
|
||||
if (pressureMatch) {
|
||||
pressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
// 스케줄 추출
|
||||
let schedule = '-';
|
||||
const scheduleMatch = cleanDescription.match(/SCH\s*(\d+[A-Z]*(?:\s*[xX×]\s*SCH\s*\d+[A-Z]*)?)/i);
|
||||
if (scheduleMatch) {
|
||||
schedule = scheduleMatch[0];
|
||||
}
|
||||
|
||||
// 재질 추출 (ASTM 등)
|
||||
let grade = '-';
|
||||
const gradeMatch = cleanDescription.match(/(ASTM\s+[A-Z0-9\s]+(?:TP\d+|GR\s*[A-Z0-9]+|WP\d+)?)/i);
|
||||
if (gradeMatch) {
|
||||
grade = gradeMatch[1].trim();
|
||||
}
|
||||
|
||||
// 새로운 엑셀 양식에 맞춘 데이터 구조
|
||||
const base = {
|
||||
'카테고리': category,
|
||||
'자재 설명': cleanDescription,
|
||||
'사이즈': material.size_spec || '-'
|
||||
'TAGNO': '', // 비워둠
|
||||
'품목명': itemName,
|
||||
'수량': purchaseInfo.purchaseQuantity || material.quantity || 0,
|
||||
'통화구분': 'KRW', // 기본값
|
||||
'단가': 1, // 일괄 1로 설정
|
||||
'크기': material.size_spec || '-',
|
||||
'압력등급': pressure,
|
||||
'스케줄': schedule,
|
||||
'재질': grade,
|
||||
'사용자요구': '',
|
||||
'관리항목1': '', // 빈칸
|
||||
'관리항목7': '', // 빈칸
|
||||
'관리항목8': '', // 빈칸
|
||||
'관리항목9': '', // 빈칸
|
||||
'관리항목10': '', // 빈칸
|
||||
'납기일(YYYY-MM-DD)': new Date().toISOString().split('T')[0] // 오늘 날짜
|
||||
};
|
||||
|
||||
// 구매 수량 정보만 추가 (기존 수량/단위 정보 제거)
|
||||
base['필요 수량'] = purchaseInfo.purchaseQuantity || 0;
|
||||
base['구매 단위'] = purchaseInfo.unit || 'EA';
|
||||
|
||||
// 비교 모드인 경우 구매 수량 변화 정보만 추가
|
||||
// 비교 모드인 경우 추가 정보
|
||||
if (includeComparison) {
|
||||
if (material.previous_quantity !== undefined) {
|
||||
// 이전 구매 수량 계산
|
||||
const prevPurchaseInfo = calculatePurchaseQuantity({
|
||||
...material,
|
||||
quantity: material.previous_quantity,
|
||||
totalLength: material.previousTotalLength || 0
|
||||
});
|
||||
|
||||
base['이전 필요 수량'] = prevPurchaseInfo.purchaseQuantity || 0;
|
||||
base['필요 수량 변경'] = (purchaseInfo.purchaseQuantity - prevPurchaseInfo.purchaseQuantity);
|
||||
base['이전수량'] = prevPurchaseInfo.purchaseQuantity || 0;
|
||||
base['수량변경'] = (purchaseInfo.purchaseQuantity - prevPurchaseInfo.purchaseQuantity);
|
||||
}
|
||||
|
||||
base['변경 유형'] = material.change_type || (
|
||||
base['변경유형'] = material.change_type || (
|
||||
material.previous_quantity !== undefined ? '수량 변경' :
|
||||
material.quantity_change === undefined ? '신규' : '변경'
|
||||
);
|
||||
|
||||
@@ -5,9 +5,9 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 13000,
|
||||
port: 5173,
|
||||
host: true,
|
||||
open: true
|
||||
open: false
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
|
||||
Reference in New Issue
Block a user