feat: 서포트 카테고리 전면 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- 서포트 카테고리 UI 개선: 좌우 스크롤, 헤더/본문 동기화, 가운데 정렬
- 동일 항목 합산 기능 구현 (Type + Size + Grade 기준)
- 헤더 구조 변경: 압력/스케줄 제거, 구매수량 단일화, User Requirements 추가
- 우레탄 블럭슈 두께 정보(40t, 27t) Material Grade에 포함
- 서포트 수량 계산 수정: 취합된 숫자 그대로 표시 (4의 배수 계산 제거)
- 서포트 분류 로직 개선: CLAMP, U-BOLT, URETHANE BLOCK SHOE 등 정확한 분류
- 백엔드 서포트 분류기에 User Requirements 추출 기능 추가
- 엑셀 내보내기에 서포트 카테고리 처리 로직 추가
This commit is contained in:
hyungi
2025-10-17 07:59:35 +09:00
parent a27213e0e5
commit 6b6360ecd5
19 changed files with 2452 additions and 278 deletions

View File

@@ -10,6 +10,8 @@ const BoltMaterialsView = ({
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
jobNo,
fileId,
user
}) => {
@@ -45,11 +47,24 @@ const BoltMaterialsView = ({
const parseBoltInfo = (material) => {
const qty = Math.round(material.quantity || 0);
const safetyQty = Math.ceil(qty * 1.05); // 5% 여유율
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
// 볼트 상세 정보 우선 사용
// 플랜지당 볼트 세트 수 추출 (예: (8), (4))
const boltDetails = material.bolt_details || {};
let boltsPerFlange = boltDetails.dimensions?.bolts_per_flange || 1;
// 백엔드에서 정보가 없으면 원본 설명에서 직접 추출
if (boltsPerFlange === 1) {
const description = material.original_description || '';
const flangePattern = description.match(/\((\d+)\)/);
if (flangePattern) {
boltsPerFlange = parseInt(flangePattern[1]);
}
}
// 실제 필요한 볼트 수 = 플랜지 수 × 플랜지당 볼트 세트 수
const totalBoltsNeeded = qty * boltsPerFlange;
const safetyQty = Math.ceil(totalBoltsNeeded * 1.05); // 5% 여유율
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
// 길이 정보 (bolt_details 우선, 없으면 원본 설명에서 추출)
let boltLength = '-';
@@ -113,19 +128,32 @@ const BoltMaterialsView = ({
}
}
// 추가요구사항 추출 (ELEC.GALV 등)
const additionalReq = extractBoltAdditionalRequirements(material.original_description || '');
// 압력 등급 추출 (150LB 등)
let boltPressure = '-';
const description = material.original_description || '';
const pressureMatch = description.match(/(\d+)\s*LB/i);
if (pressureMatch) {
boltPressure = `${pressureMatch[1]}LB`;
}
// User Requirements 추출 (ELEC.GALV 등)
const userRequirements = extractBoltAdditionalRequirements(material.original_description || '');
// Purchase Quantity (Quantity + Unit 통합) - 플랜지당 볼트 세트 수 정보 포함
const purchaseQuantity = boltsPerFlange > 1
? `${purchaseQty} SETS (${boltsPerFlange}/flange)`
: `${purchaseQty} SETS`;
return {
type: 'BOLT',
subtype: boltSubtype,
size: material.size_spec || material.main_nom || '-',
pressure: '-', // 볼트는 압력 등급 없음
pressure: boltPressure, // 압력 등급 (150LB 등)
schedule: boltLength, // 길이 정보
grade: boltGrade,
additionalReq: additionalReq, // 추가요구사항
quantity: purchaseQty,
unit: 'SETS'
userRequirements: userRequirements, // User Requirements (ELEC.GALV 등)
additionalReq: '-', // 추가요구사항 (사용자 입력)
purchaseQuantity: purchaseQuantity // 구매수량 (통합)
};
};
@@ -228,29 +256,83 @@ const BoltMaterialsView = ({
}));
try {
await api.post('/files/save-excel', {
console.log('🔄 볼트 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'BOLT',
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: 'BOLT',
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: 'BOLT',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
// 3. 엑셀 파일을 서버에 업로드
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'BOLT');
console.log('📤 엑셀 파일 서버 업로드 중...');
await api.post('/purchase-request/upload-excel', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log('✅ 엑셀 파일 서버 업로드 완료');
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
// 4. 구매된 자재 목록 업데이트 (비활성화)
onPurchasedMaterialsUpdate(allMaterialIds);
console.log('✅ 구매된 자재 목록 업데이트 완료');
// 5. 클라이언트에 파일 다운로드
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 || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} else {
throw new Error(response.data?.message || '구매신청 생성 실패');
}
} catch (error) {
console.error('엑셀 저장 실패:', error);
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'BOLT',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
// 선택 해제
setSelectedMaterials(new Set());
};
const filteredMaterials = getFilteredAndSortedMaterials();
@@ -317,21 +399,24 @@ const BoltMaterialsView = ({
<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: '1500px' }}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 150px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
textAlign: 'center'
}}>
<div>
<input
type="checkbox"
@@ -343,18 +428,83 @@ const BoltMaterialsView = ({
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">Length</FilterableHeader>
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader>
<div>Unit</div>
<div>User Requirement</div>
</div>
<FilterableHeader
sortKey="subtype"
filterKey="subtype"
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="schedule"
filterKey="schedule"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Length
</FilterableHeader>
<FilterableHeader
sortKey="grade"
filterKey="grade"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Material Grade
</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 = parseBoltInfo(material);
const isSelected = selectedMaterials.has(material.id);
@@ -365,7 +515,7 @@ const BoltMaterialsView = ({
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 150px 200px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
@@ -411,23 +561,24 @@ const BoltMaterialsView = ({
</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' }}>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
<div style={{
fontSize: '14px',
color: '#1f2937',
textAlign: 'center'
}}>
{info.userRequirements}
</div>
<div>
<input
@@ -437,7 +588,7 @@ const BoltMaterialsView = ({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
placeholder="Enter additional request..."
style={{
width: '100%',
padding: '6px 8px',
@@ -447,6 +598,9 @@ const BoltMaterialsView = ({
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.purchaseQuantity}
</div>
</div>
);
})}