feat: 구매신청 기능 완성 및 SUPPORT/SPECIAL 카테고리 개선
- 모든 카테고리 구매신청 기능 완성 (PIPE, FITTING, VALVE, FLANGE, GASKET, BOLT, SUPPORT, SPECIAL, UNKNOWN) - 구매신청 완료 항목: 회색 배경, 체크박스 비활성화, '구매신청완료' 배지 표시 - 전체 선택/구매신청 시 이미 구매신청된 항목 자동 제외 - 구매신청 quantity 타입 에러 수정 (문자열 -> 정수 변환) SUPPORT 카테고리 (구 U-BOLT): - U-BOLT -> SUPPORT로 카테고리명 변경 - 클램프, 유볼트, 우레탄블럭슈 분류 개선 - 테이블 헤더: 선택-종류-타입-크기-디스크립션-추가요구-사용자요구-수량 - 크기 정보 main_nom 필드에서 가져오기 (배관 인치) - 엑셀 내보내기 형식 조정 SPECIAL 카테고리: - SPECIAL 키워드 자재 자동 분류 (SPECIFICATION 제외) - 파일 업로드 시 SPECIAL 카테고리 처리 로직 추가 - 도면번호 필드 추가 (drawing_name, line_no) - 타입 필드: 크기/스케줄/재질 제외한 핵심 정보 표시 - 엑셀 DWG_NAME, LINE_NUM 컬럼 파싱 및 저장 FITTING 카테고리: - 테이블 컬럼 너비 조정 (선택 2%, 종류 8.5%, 수량 12%) 구매신청 관리: - 엑셀 재다운로드 형식 개선 (BOM 페이지와 동일한 형식) - 그룹화된 자재 정보 포함하여 저장 및 다운로드
This commit is contained in:
274
frontend/src/pages/PurchaseRequestPage.jsx
Normal file
274
frontend/src/pages/PurchaseRequestPage.jsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
import { exportMaterialsToExcel } from '../utils/excelExport';
|
||||
import './PurchaseRequestPage.css';
|
||||
|
||||
const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) => {
|
||||
const [requests, setRequests] = useState([]);
|
||||
const [selectedRequest, setSelectedRequest] = useState(null);
|
||||
const [requestMaterials, setRequestMaterials] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadRequests();
|
||||
}, [fileId, jobNo]);
|
||||
|
||||
const loadRequests = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const params = {};
|
||||
if (fileId) params.file_id = fileId;
|
||||
if (jobNo) params.job_no = jobNo;
|
||||
|
||||
const response = await api.get('/purchase-request/list', { params });
|
||||
setRequests(response.data.requests || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load requests:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRequestMaterials = async (requestId) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/purchase-request/${requestId}/materials`);
|
||||
// 그룹화된 자재가 있으면 우선 표시, 없으면 개별 자재 표시
|
||||
if (response.data.grouped_materials && response.data.grouped_materials.length > 0) {
|
||||
setRequestMaterials(response.data.grouped_materials);
|
||||
} else {
|
||||
setRequestMaterials(response.data.materials || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load materials:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestSelect = (request) => {
|
||||
setSelectedRequest(request);
|
||||
loadRequestMaterials(request.request_id);
|
||||
};
|
||||
|
||||
const handleDownloadExcel = async (requestId, requestNo) => {
|
||||
try {
|
||||
// 서버에서 자재 데이터 가져오기
|
||||
const response = await api.get(`/purchase-request/${requestId}/download-excel`);
|
||||
|
||||
if (response.data.success) {
|
||||
const materials = response.data.materials;
|
||||
const groupedMaterials = response.data.grouped_materials || [];
|
||||
const jobNo = response.data.job_no;
|
||||
|
||||
// 사용자 요구사항 매핑
|
||||
const userRequirements = {};
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement) {
|
||||
userRequirements[material.material_id || material.id] = material.user_requirement;
|
||||
}
|
||||
});
|
||||
|
||||
// 그룹화된 자재가 있으면 그것을 사용, 없으면 원본 자재 사용
|
||||
const dataToExport = groupedMaterials.length > 0 ? groupedMaterials : materials;
|
||||
|
||||
// 파일명 생성
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const fileName = `${jobNo}_${requestNo}_${timestamp}.xlsx`;
|
||||
|
||||
// 기존 엑셀 유틸리티 사용하여 엑셀 생성
|
||||
// 그룹화된 데이터와 사용자 요구사항 전달
|
||||
exportMaterialsToExcel(dataToExport, fileName, {
|
||||
jobNo,
|
||||
requestNo,
|
||||
userRequirements
|
||||
});
|
||||
} else {
|
||||
alert('데이터를 가져올 수 없습니다');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download excel:', error);
|
||||
alert('엑셀 다운로드 실패');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="purchase-request-page">
|
||||
<div className="page-header">
|
||||
<button onClick={() => onNavigate('bom', { selectedProject })} className="back-btn">
|
||||
← BOM 관리로 돌아가기
|
||||
</button>
|
||||
<h1>구매신청 관리</h1>
|
||||
<p className="subtitle">구매신청한 자재들을 그룹별로 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div className="main-content">
|
||||
{/* 구매신청 목록 */}
|
||||
<div className="requests-panel">
|
||||
<div className="panel-header">
|
||||
<h2>구매신청 목록 ({requests.length})</h2>
|
||||
</div>
|
||||
|
||||
<div className="requests-list">
|
||||
{isLoading ? (
|
||||
<div className="loading">로딩중...</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="empty-state">구매신청이 없습니다</div>
|
||||
) : (
|
||||
requests.map(request => (
|
||||
<div
|
||||
key={request.request_id}
|
||||
className={`request-card ${selectedRequest?.request_id === request.request_id ? 'selected' : ''}`}
|
||||
onClick={() => handleRequestSelect(request)}
|
||||
>
|
||||
<div className="request-header">
|
||||
<span className="request-no">{request.request_no}</span>
|
||||
<span className="request-date">
|
||||
{new Date(request.requested_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="request-info">
|
||||
<div>{request.job_no} - {request.job_name}</div>
|
||||
<div className="material-count">
|
||||
{request.category || '전체'} | {request.material_count}개 자재
|
||||
</div>
|
||||
</div>
|
||||
<div className="request-footer">
|
||||
<span className="requested-by">{request.requested_by}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownloadExcel(request.request_id, request.request_no);
|
||||
}}
|
||||
className="download-btn"
|
||||
>
|
||||
📥 엑셀
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 구매신청 상세 */}
|
||||
<div className="details-panel">
|
||||
{selectedRequest ? (
|
||||
<>
|
||||
<div className="panel-header">
|
||||
<h2>{selectedRequest.request_no}</h2>
|
||||
<button
|
||||
onClick={() => handleDownloadExcel(selectedRequest.request_id, selectedRequest.request_no)}
|
||||
className="excel-btn"
|
||||
>
|
||||
📥 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="materials-table">
|
||||
{/* 카테고리별로 그룹화하여 표시 */}
|
||||
{(() => {
|
||||
// 카테고리별로 자재 그룹화
|
||||
const groupedByCategory = requestMaterials.reduce((acc, material) => {
|
||||
const category = material.category || 'UNKNOWN';
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(material);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.entries(groupedByCategory).map(([category, materials]) => (
|
||||
<div key={category} style={{ marginBottom: '30px' }}>
|
||||
<h3 style={{
|
||||
background: '#f0f0f0',
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{category} ({materials.length}개)
|
||||
</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>No</th>
|
||||
<th>카테고리</th>
|
||||
<th>자재 설명</th>
|
||||
<th>크기</th>
|
||||
{category === 'BOLT' ? <th>길이</th> : <th>스케줄</th>}
|
||||
<th>재질</th>
|
||||
<th>수량</th>
|
||||
<th>사용자요구</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{materials.map((material, idx) => (
|
||||
<tr key={material.item_id || `${category}-${idx}`}>
|
||||
<td>{idx + 1}</td>
|
||||
<td>
|
||||
<span className="category-badge">
|
||||
{material.category}
|
||||
</span>
|
||||
</td>
|
||||
<td>{material.description}</td>
|
||||
<td>{material.size || '-'}</td>
|
||||
<td>{material.schedule || '-'}</td>
|
||||
<td>{material.material_grade || '-'}</td>
|
||||
<td>
|
||||
{material.category === 'PIPE' ? (
|
||||
<div>
|
||||
<span style={{ fontWeight: 'bold' }}>
|
||||
{(() => {
|
||||
// 총 길이와 개수 계산
|
||||
let totalLengthMm = material.total_length || 0;
|
||||
let totalCount = 0;
|
||||
|
||||
if (material.pipe_lengths && material.pipe_lengths.length > 0) {
|
||||
// pipe_lengths 배열에서 총 개수 계산
|
||||
totalCount = material.pipe_lengths.reduce((sum, p) => sum + parseFloat(p.quantity || 0), 0);
|
||||
} else if (material.material_ids && material.material_ids.length > 0) {
|
||||
totalCount = material.material_ids.length;
|
||||
if (!totalLengthMm) {
|
||||
totalLengthMm = totalCount * 6000;
|
||||
}
|
||||
} else {
|
||||
totalCount = parseFloat(material.quantity) || 1;
|
||||
if (!totalLengthMm) {
|
||||
totalLengthMm = totalCount * 6000;
|
||||
}
|
||||
}
|
||||
|
||||
// 6,000mm를 1본으로 계산
|
||||
const pipeCount = Math.ceil(totalLengthMm / 6000);
|
||||
|
||||
// 형식: 2본(11,000mm/40개)
|
||||
return `${pipeCount}본(${totalLengthMm.toLocaleString()}mm/${totalCount}개)`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
`${material.quantity} ${material.unit || 'EA'}`
|
||||
)}
|
||||
</td>
|
||||
<td>{material.user_requirement || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📦</div>
|
||||
<div>구매신청을 선택하세요</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseRequestPage;
|
||||
Reference in New Issue
Block a user