feat: 가스켓 카테고리 개선 및 엑셀 내보내기 최적화
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- 가스켓 카테고리 정렬 오류 수정 (FilterableHeader props 추가)
- 가스켓 엑셀 내보내기 개선:
  * 품목명을 BOM 페이지 타입과 동일하게 표시 (SPIRAL WOUND GASKET 등)
  * 재질을 재질1/재질2로 분리 (SS304/GRAPHITE → 재질1: SS304/GRAPHITE, 재질2: /SS304/SS304)
  * originalDescription에서 4개 재질 패턴 우선 추출
  * P열 납기일 규칙 준수
- 프로젝트 비활성화 기능 수정 (localStorage 영구 저장)
- 모든 카테고리 정렬 함수 안전성 강화
This commit is contained in:
hyungi
2025-10-16 15:51:24 +09:00
parent 379af6b1e3
commit a27213e0e5
7 changed files with 430 additions and 139 deletions

View File

@@ -149,17 +149,34 @@ const BoltMaterialsView = ({
});
});
if (sortConfig.key) {
if (sortConfig && sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseBoltInfo(a);
const bInfo = parseBoltInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (!aInfo || !bInfo) return 0;
const aValue = aInfo[sortConfig.key];
const bValue = bInfo[sortConfig.key];
// 값이 없는 경우 처리
if (aValue === undefined && bValue === undefined) return 0;
if (aValue === undefined) return 1;
if (bValue === undefined) return -1;
// 숫자인 경우 숫자로 비교
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
}
// 문자열로 비교
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
return aStr.localeCompare(bStr);
} else {
return aValue < bValue ? 1 : -1;
return bStr.localeCompare(aStr);
}
});
}

View File

@@ -10,7 +10,9 @@ const GasketMaterialsView = ({
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
fileId,
jobNo,
user
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
@@ -21,27 +23,57 @@ const GasketMaterialsView = ({
const qty = Math.round(material.quantity || 0);
const purchaseQty = Math.ceil(qty * 1.05 / 5) * 5; // 5% 여유율 + 5의 배수
// original_description에서 재질 정보 파싱 (기존 NewMaterialsPage와 동일)
const description = material.original_description || '';
let materialStructure = '-'; // H/F/I/O 부분
let materialDetail = '-'; // SS304/GRAPHITE/CS/CS 부분
// H/F/I/O와 재질 상세 정보 추출
const materialMatch = description.match(/H\/F\/I\/O\s+(.+?)(?:,|$)/);
if (materialMatch) {
materialStructure = 'H/F/I/O';
materialDetail = materialMatch[1].trim();
// 두께 정보 제거 (별도 추출)
materialDetail = materialDetail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim();
// 가스켓 타입 풀네임 매핑
const gasketTypeMap = {
'SWG': 'SPIRAL WOUND GASKET',
'RTJ': 'RING TYPE JOINT',
'FF': 'FULL FACE GASKET',
'RF': 'RAISED FACE GASKET',
'SHEET': 'SHEET GASKET',
'O-RING': 'O-RING GASKET'
};
// 타입 추출 및 풀네임 변환
let gasketType = '-';
const typeMatch = description.match(/\b(SWG|RTJ|FF|RF|SHEET|O-RING)\b/i);
if (typeMatch) {
const shortType = typeMatch[1].toUpperCase();
gasketType = gasketTypeMap[shortType] || shortType;
}
// 압력 정보 추출
// 크기 정보 추출 (예: 1 1/2")
let size = material.size_spec || material.size_inch || '-';
if (size === '-') {
const sizeMatch = description.match(/(\d+(?:\s+\d+\/\d+)?(?:\.\d+)?)\s*"/);
if (sizeMatch) {
size = sizeMatch[1] + '"';
}
}
// 압력등급 추출
let pressure = '-';
const pressureMatch = description.match(/(\d+LB)/);
const pressureMatch = description.match(/(\d+LB)/i);
if (pressureMatch) {
pressure = pressureMatch[1];
}
// 구조 정보 추출 (H/F/I/O)
let structure = '-';
if (description.includes('H/F/I/O')) {
structure = 'H/F/I/O';
}
// 재질 상세 정보 추출 (SS304/GRAPHITE/SS304/SS304)
let material_detail = '-';
const materialMatch = description.match(/H\/F\/I\/O\s+([^,]+)/);
if (materialMatch) {
material_detail = materialMatch[1].trim();
// 두께 정보 제거
material_detail = material_detail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim();
}
// 두께 정보 추출
let thickness = '-';
const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i);
@@ -50,17 +82,14 @@ const GasketMaterialsView = ({
}
return {
type: 'GASKET',
subtype: 'SWG', // 항상 SWG로 표시
size: material.size_spec || '-',
type: gasketType, // 풀네임으로 표시 (SPIRAL WOUND GASKET)
size: size,
pressure: pressure,
schedule: thickness, // 두께를 schedule 열에 표시
materialStructure: materialStructure,
materialDetail: materialDetail,
structure: structure, // H/F/I/O
material: material_detail, // SS304/GRAPHITE/SS304/SS304
thickness: thickness,
grade: materialDetail, // 재질 상세를 grade로 표시
quantity: purchaseQty,
unit: '개',
userRequirements: material.user_requirements?.join(', ') || '-',
purchaseQuantity: purchaseQty,
isGasket: true
};
};
@@ -85,17 +114,34 @@ const GasketMaterialsView = ({
});
});
if (sortConfig.key) {
if (sortConfig && sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseGasketInfo(a);
const bInfo = parseGasketInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (!aInfo || !bInfo) return 0;
const aValue = aInfo[sortConfig.key];
const bValue = bInfo[sortConfig.key];
// 값이 없는 경우 처리
if (aValue === undefined && bValue === undefined) return 0;
if (aValue === undefined) return 1;
if (bValue === undefined) return -1;
// 숫자인 경우 숫자로 비교
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
}
// 문자열로 비교
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
return aStr.localeCompare(bStr);
} else {
return aValue < bValue ? 1 : -1;
return bStr.localeCompare(aStr);
}
});
}
@@ -147,28 +193,79 @@ const GasketMaterialsView = ({
}));
try {
await api.post('/files/save-excel', {
console.log('🔄 가스켓 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'GASKET',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
job_no: jobNo,
category: 'GASKET',
materials: dataWithRequirements,
filename: excelFileName,
user_id: user?.id
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] || ''
}))
});
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'GASKET',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
// 3. 생성된 엑셀 파일을 서버에 업로드
console.log('📤 서버에 엑셀 파일 업로드 중...');
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'GASKET');
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 4. 클라이언트 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 실패:', error);
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'GASKET',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
};
@@ -236,20 +333,23 @@ const GasketMaterialsView = ({
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
overflow: 'auto',
maxHeight: '600px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
<div style={{ minWidth: '1400px' }}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 150px 120px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
color: '#374151',
textAlign: 'center'
}}>
<div>
<input
@@ -262,18 +362,95 @@ const GasketMaterialsView = ({
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
<FilterableHeader sortKey="schedule" filterKey="schedule">Thickness</FilterableHeader>
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader>
<div>Unit</div>
<div>User Requirement</div>
<FilterableHeader
sortKey="type"
filterKey="type"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Type
</FilterableHeader>
<FilterableHeader
sortKey="size"
filterKey="size"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Size
</FilterableHeader>
<FilterableHeader
sortKey="pressure"
filterKey="pressure"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Pressure
</FilterableHeader>
<FilterableHeader
sortKey="structure"
filterKey="structure"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Structure
</FilterableHeader>
<FilterableHeader
sortKey="material"
filterKey="material"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Material
</FilterableHeader>
<FilterableHeader
sortKey="thickness"
filterKey="thickness"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Thickness
</FilterableHeader>
<div>User Requirements</div>
<div>Additional Request</div>
<FilterableHeader
sortKey="purchaseQuantity"
filterKey="purchaseQuantity"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Purchase Quantity
</FilterableHeader>
</div>
{/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
{/* 데이터 행들 */}
{filteredMaterials.map((material, index) => {
const info = parseGasketInfo(material);
const isSelected = selectedMaterials.has(material.id);
@@ -284,7 +461,7 @@ const GasketMaterialsView = ({
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 150px 120px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
@@ -314,8 +491,8 @@ const GasketMaterialsView = ({
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.subtype}
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500', textAlign: 'center' }}>
{info.type}
{isPurchased && (
<span style={{
marginLeft: '8px',
@@ -330,23 +507,30 @@ const GasketMaterialsView = ({
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.pressure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.schedule}
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.structure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.material}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.thickness}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
<div style={{
fontSize: '14px',
color: '#1f2937',
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{info.userRequirements}
</div>
<div>
<input
@@ -356,7 +540,7 @@ const GasketMaterialsView = ({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
placeholder="Enter additional request..."
style={{
width: '100%',
padding: '6px 8px',
@@ -366,6 +550,9 @@ const GasketMaterialsView = ({
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center', fontWeight: '500' }}>
{info.purchaseQuantity.toLocaleString()}
</div>
</div>
);
})}

View File

@@ -66,17 +66,34 @@ const SupportMaterialsView = ({
});
});
if (sortConfig.key) {
if (sortConfig && sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseSupportInfo(a);
const bInfo = parseSupportInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (!aInfo || !bInfo) return 0;
const aValue = aInfo[sortConfig.key];
const bValue = bInfo[sortConfig.key];
// 값이 없는 경우 처리
if (aValue === undefined && bValue === undefined) return 0;
if (aValue === undefined) return 1;
if (bValue === undefined) return -1;
// 숫자인 경우 숫자로 비교
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
}
// 문자열로 비교
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
return aStr.localeCompare(bStr);
} else {
return aValue < bValue ? 1 : -1;
return bStr.localeCompare(aStr);
}
});
}

View File

@@ -94,17 +94,34 @@ const ValveMaterialsView = ({
});
});
if (sortConfig.key) {
if (sortConfig && sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseValveInfo(a);
const bInfo = parseValveInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (!aInfo || !bInfo) return 0;
const aValue = aInfo[sortConfig.key];
const bValue = bInfo[sortConfig.key];
// 값이 없는 경우 처리
if (aValue === undefined && bValue === undefined) return 0;
if (aValue === undefined) return 1;
if (bValue === undefined) return -1;
// 숫자인 경우 숫자로 비교
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
}
// 문자열로 비교
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
return aStr.localeCompare(bStr);
} else {
return aValue < bValue ? 1 : -1;
return bStr.localeCompare(aStr);
}
});
}

View File

@@ -19,7 +19,7 @@ const FilterableHeader = ({
style={{ cursor: 'pointer', flex: 1 }}
>
{children}
{sortConfig.key === sortKey && (
{sortConfig && sortConfig.key === sortKey && (
<span style={{ marginLeft: '4px' }}>
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>