feat: 사용자 요구사항 기능 완전 구현 및 전체 카테고리 추가
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:
Hyungi Ahn
2025-09-30 08:55:20 +09:00
parent 0f9a5ad2ea
commit 50570e4624
34 changed files with 942 additions and 181 deletions

View File

@@ -237,5 +237,6 @@ export default SimpleDashboard;

View File

@@ -554,5 +554,6 @@

View File

@@ -287,5 +287,6 @@ export default NavigationBar;

View File

@@ -267,5 +267,6 @@

View File

@@ -191,5 +191,6 @@ export default NavigationMenu;

View File

@@ -99,5 +99,6 @@ export default RevisionUploadDialog;

View File

@@ -318,5 +318,6 @@ export default SimpleFileUpload;

View File

@@ -281,5 +281,6 @@ export default DashboardPage;

View File

@@ -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>

View File

@@ -236,5 +236,6 @@

View File

@@ -133,5 +133,6 @@ export default LoginPage;

View File

@@ -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;

View File

@@ -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>

View File

@@ -405,5 +405,6 @@ export default ProjectsPage;

View File

@@ -447,5 +447,6 @@

View File

@@ -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 ? '신규' : '변경'
);

View File

@@ -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',