feat: 피팅류 엑셀 내보내기 개선 및 프로젝트 비활성화 버그 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

 피팅류 엑셀 내보내기 개선:
- 품목명에 상세 피팅 타입 표시 (SOCK-O-LET, ELBOW 90° LR 등)
- G열부터 압력등급/스케줄/재질/사용자요구/추가요청사항 체계적 배치
- 분류기 추출 요구사항(J열)과 사용자 입력 요구사항(K열) 분리
- P열 납기일 고정 규칙 유지, 관리항목 자동 채움

🐛 프로젝트 비활성화 버그 수정:
- 백엔드: job_no 필드 추가로 프론트엔드 호환성 확보
- 프론트엔드: 안전한 프로젝트 식별자 처리 로직 구현
- 개별 프로젝트 비활성화 시 전체 프로젝트 영향 문제 해결
- 디버깅 로그 추가로 상태 변경 추적 가능

🔧 기타 개선사항:
- BOM 페이지 이모지 제거
- 구매신청 후 자재 비활성화 기능 구현
- 모든 카테고리 뷰에 onPurchasedMaterialsUpdate 콜백 추가
This commit is contained in:
hyungi
2025-10-16 14:00:44 +09:00
parent 22baea38e1
commit c7297c6fb7
9 changed files with 339 additions and 86 deletions

View File

@@ -10,7 +10,9 @@ const FittingMaterialsView = ({
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
fileId,
jobNo,
user,
onNavigate
}) => {
@@ -245,16 +247,73 @@ const FittingMaterialsView = ({
}
}
// 스케줄 표시 (분리 스케줄 지원)
// 스케줄 표시 (분리 스케줄 지원) - 개선된 로직
// 레듀싱 자재인지 확인
const isReducingFitting = displayType.includes('REDUCING') || displayType.includes('RED') ||
description.toUpperCase().includes('RED') ||
description.toUpperCase().includes('REDUCING');
if (hasDifferentSchedules && mainSchedule && redSchedule) {
schedule = `${mainSchedule}×${redSchedule}`;
} else if (mainSchedule) {
} else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN') {
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
schedule = `${mainSchedule}×${mainSchedule}`;
} else if (mainSchedule && mainSchedule !== 'UNKNOWN') {
schedule = mainSchedule;
} else {
// Description에서 스케줄 추출
const scheduleMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
if (scheduleMatch) {
schedule = `SCH ${scheduleMatch[1]}`;
// Description에서 스케줄 추출 - 더 강력한 패턴 매칭
const schedulePatterns = [
/SCH\s*(\d+S?)/i, // SCH 40, SCH 80S
/SCHEDULE\s*(\d+S?)/i, // SCHEDULE 40
/스케줄\s*(\d+S?)/i, // 스케줄 40
/(\d+S?)\s*SCH/i, // 40 SCH (역순)
/SCH\.?\s*(\d+S?)/i, // SCH.40
/SCH\s*(\d+S?)\s*[xX×]\s*SCH\s*(\d+S?)/i // SCH 40 x SCH 80
];
for (const pattern of schedulePatterns) {
const match = description.match(pattern);
if (match) {
if (match.length > 2) {
// 분리 스케줄 패턴 (SCH 40 x SCH 80)
schedule = `SCH ${match[1]}×SCH ${match[2]}`;
} else {
const scheduleNum = match[1];
if (isReducingFitting) {
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
schedule = `SCH ${scheduleNum}×SCH ${scheduleNum}`;
} else {
schedule = `SCH ${scheduleNum}`;
}
}
break;
}
}
// 여전히 찾지 못했다면 더 넓은 패턴 시도
if (schedule === '-') {
const broadPatterns = [
/\b(\d+)\s*LB/i, // 압력 등급에서 유추
/\b(40|80|120|160)\b/i, // 일반적인 스케줄 숫자
/\b(10|20|30|40|60|80|100|120|140|160)\b/i // 모든 표준 스케줄
];
for (const pattern of broadPatterns) {
const match = description.match(pattern);
if (match) {
const num = match[1];
// 압력 등급이 아닌 경우만 스케줄로 간주
if (!description.includes(`${num}LB`)) {
if (isReducingFitting) {
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
schedule = `SCH ${num}×SCH ${num}`;
} else {
schedule = `SCH ${num}`;
}
break;
}
}
}
}
}
@@ -353,6 +412,36 @@ const FittingMaterialsView = ({
}));
try {
// 1. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
job_no: jobNo,
category: 'FITTING',
material_ids: allMaterialIds,
materials_data: dataWithRequirements.map(m => ({
material_id: m.id,
description: m.original_description,
category: m.classified_category,
size: m.size_inch || m.size_spec,
schedule: m.schedule,
material_grade: m.material_grade || m.full_material_grade,
quantity: m.quantity,
unit: m.unit,
user_requirement: userRequirements[m.id] || ''
}))
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}`);
// 2. 구매신청된 자재 ID를 purchasedMaterials에 추가
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 3. 서버에 엑셀 파일 저장 요청
await api.post('/files/save-excel', {
file_id: fileId,
category: 'FITTING',
@@ -361,21 +450,27 @@ const FittingMaterialsView = ({
user_id: user?.id
});
// 4. 클라이언트에서 다운로드
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FITTING',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 실패:', error);
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패해도 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FITTING',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
// 선택 해제
setSelectedMaterials(new Set());
};
const filteredMaterials = getFilteredAndSortedMaterials();
@@ -506,21 +601,24 @@ const FittingMaterialsView = ({
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
overflow: 'auto',
maxHeight: '600px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
<div style={{ minWidth: '1380px' }}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
textAlign: 'center'
}}>
<div>
<input
type="checkbox"
@@ -537,13 +635,12 @@ const FittingMaterialsView = ({
<div>Pressure</div>
<div>Schedule</div>
<div>Material Grade</div>
<div>Quantity</div>
<div>Unit</div>
<div>User Requirement</div>
<div>User Requirements</div>
<div>Purchase Quantity</div>
<div>Additional Request</div>
</div>
{/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
{/* 데이터 행들 */}
{filteredMaterials.map((material, index) => {
const info = parseFittingInfo(material);
const isSelected = selectedMaterials.has(material.id);
@@ -554,12 +651,13 @@ const FittingMaterialsView = ({
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 200px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease'
transition: 'background 0.15s ease',
textAlign: 'center'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
@@ -612,11 +710,17 @@ const FittingMaterialsView = ({
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
<div style={{
fontSize: '14px',
color: '#1f2937',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{material.user_requirements?.join(', ') || '-'}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity} {info.unit}
</div>
<div>
<input
@@ -626,7 +730,7 @@ const FittingMaterialsView = ({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
placeholder="Enter additional request..."
style={{
width: '100%',
padding: '6px 8px',