feat: 자재 관리 페이지 대규모 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산)
- UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인)
- 자재별 그룹핑 로직 개선:
  * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시
  * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등)
  * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시
  * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계)
  * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시
  * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑
- 전체 카테고리 버튼 제거 (표시 복잡도 감소)
- 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
This commit is contained in:
Hyungi Ahn
2025-09-09 09:24:45 +09:00
parent 4f8e395f87
commit 83b90ef05c
101 changed files with 10841 additions and 4813 deletions

View File

@@ -1,114 +1,70 @@
import React, { useState, useEffect } from 'react';
import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi, api } from '../api';
import { api, fetchFiles, deleteFile as deleteFileApi } from '../api';
import BOMFileUpload from '../components/BOMFileUpload';
import BOMFileTable from '../components/BOMFileTable';
import RevisionUploadDialog from '../components/RevisionUploadDialog';
const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
const BOMStatusPage = ({ jobNo, jobName, onNavigate, selectedProject }) => {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [bomName, setBomName] = useState('');
const [revisionDialog, setRevisionDialog] = useState({ open: false, bomName: '', parentId: null });
const [revisionFile, setRevisionFile] = useState(null);
const [purchaseModal, setPurchaseModal] = useState({ open: false, data: null, fileInfo: null });
// 카테고리별 색상 함수
const getCategoryColor = (category) => {
const colors = {
'pipe': '#4299e1',
'fitting': '#48bb78',
'valve': '#ed8936',
'flange': '#9f7aea',
'bolt': '#38b2ac',
'gasket': '#f56565',
'instrument': '#d69e2e',
'material': '#718096',
'integrated': '#319795',
'unknown': '#a0aec0'
};
return colors[category?.toLowerCase()] || colors.unknown;
};
useEffect(() => {
if (jobNo) {
fetchFilesList();
}
}, [jobNo]);
// 파일 목록 불러오기
const fetchFiles = async () => {
setLoading(true);
setError('');
const fetchFilesList = async () => {
try {
console.log('fetchFiles 호출 - jobNo:', jobNo);
const response = await fetchFilesApi({ job_no: jobNo });
console.log('API 응답:', response);
setLoading(true);
const response = await api.get('/files/', {
params: { job_no: jobNo }
});
if (response.data && response.data.data && Array.isArray(response.data.data)) {
setFiles(response.data.data);
} else if (response.data && Array.isArray(response.data)) {
// API가 배열로 직접 반환하는 경우
if (Array.isArray(response.data)) {
setFiles(response.data);
} else if (response.data && Array.isArray(response.data.files)) {
setFiles(response.data.files);
} else if (response.data && response.data.success) {
setFiles(response.data.files || []);
} else {
setFiles([]);
}
} catch (err) {
console.error('파일 목록 불러오기 실패:', err);
console.error('파일 목록 로딩 실패:', err);
setError('파일 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (jobNo) {
fetchFiles();
}
}, [jobNo]);
// 파일 업로드
const handleUpload = async () => {
if (!selectedFile || !bomName.trim()) {
setError('파일과 BOM 이름을 모두 입력해주세요.');
alert('파일과 BOM 이름을 모두 입력해주세요.');
return;
}
setUploading(true);
setError('');
try {
setUploading(true);
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('job_no', jobNo);
formData.append('bom_name', bomName.trim());
formData.append('job_no', jobNo);
const uploadResult = await uploadFileApi(formData);
// 업로드 성공 후 파일 목록 새로고침
await fetchFiles();
// 업로드 완료 후 자동으로 구매 수량 계산 실행
if (uploadResult && uploadResult.file_id) {
// 잠시 후 구매 수량 계산 페이지로 이동
setTimeout(async () => {
try {
// 구매 수량 계산 API 호출
const response = await fetch(`/api/purchase/calculate?job_no=${jobNo}&revision=Rev.0&file_id=${uploadResult.file_id}`);
const purchaseData = await response.json();
if (purchaseData.success) {
// 구매 수량 계산 결과를 모달로 표시하거나 별도 페이지로 이동
alert(`업로드 및 분류 완료!\n구매 수량이 계산되었습니다.\n\n파이프: ${purchaseData.purchase_items?.filter(item => item.category === 'PIPE').length || 0}개 항목\n기타 자재: ${purchaseData.purchase_items?.filter(item => item.category !== 'PIPE').length || 0}개 항목`);
}
} catch (error) {
console.error('구매 수량 계산 실패:', error);
}
}, 2000); // 2초 후 실행 (분류 완료 대기)
const response = await api.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.data && response.data.success) {
alert('파일이 성공적으로 업로드되었습니다!');
setSelectedFile(null);
setBomName('');
await fetchFilesList(); // 목록 새로고침
} else {
throw new Error(response.data?.message || '업로드 실패');
}
// 폼 초기화
setSelectedFile(null);
setBomName('');
document.getElementById('file-input').value = '';
} catch (err) {
console.error('파일 업로드 실패:', err);
setError('파일 업로드에 실패했습니다.');
@@ -125,111 +81,26 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
try {
await deleteFileApi(fileId);
await fetchFiles(); // 목록 새로고침
await fetchFilesList(); // 목록 새로고침
} catch (err) {
console.error('파일 삭제 실패:', err);
setError('파일 삭제에 실패했습니다.');
}
};
// 자재 확인 페이지로 이동
// 구매 수량 계산 (자재 목록 페이지 거치지 않음)
const handleViewMaterials = async (file) => {
try {
setLoading(true);
// 구매 수량 계산 API 호출
console.log('구매 수량 계산 API 호출:', {
job_no: file.job_no,
revision: file.revision || 'Rev.0',
file_id: file.id
// 자재 관리 페이지로 바로 이동 (단순화)
const handleViewMaterials = (file) => {
if (onNavigate) {
onNavigate('materials', {
file_id: file.id,
jobNo: file.job_no,
bomName: file.bom_name || file.original_filename,
revision: file.revision,
filename: file.original_filename
});
const response = await api.get(`/purchase/items/calculate?job_no=${file.job_no}&revision=${file.revision || 'Rev.0'}&file_id=${file.id}`);
console.log('구매 수량 계산 응답:', response.data);
const purchaseData = response.data;
if (purchaseData.success && purchaseData.items) {
// 구매 수량 계산 결과를 모달로 표시
setPurchaseModal({
open: true,
data: purchaseData.items,
fileInfo: file
});
} else {
alert('구매 수량 계산에 실패했습니다.');
}
} catch (error) {
console.error('구매 수량 계산 오류:', error);
alert('구매 수량 계산 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
// 리비전 업로드 다이얼로그 열기
const openRevisionDialog = (bomName, parentId) => {
setRevisionDialog({ open: true, bomName, parentId });
};
// 리비전 업로드
const handleRevisionUpload = async () => {
if (!revisionFile || !revisionDialog.bomName) {
setError('파일을 선택해주세요.');
return;
}
setUploading(true);
setError('');
try {
const formData = new FormData();
formData.append('file', revisionFile);
formData.append('job_no', jobNo);
formData.append('bom_name', revisionDialog.bomName);
formData.append('parent_id', revisionDialog.parentId);
await uploadFileApi(formData);
// 업로드 성공 후 파일 목록 새로고침
await fetchFiles();
// 다이얼로그 닫기
setRevisionDialog({ open: false, bomName: '', parentId: null });
setRevisionFile(null);
} catch (err) {
console.error('리비전 업로드 실패:', err);
setError('리비전 업로드에 실패했습니다.');
} finally {
setUploading(false);
}
};
// BOM별로 그룹화
const groupFilesByBOM = () => {
const grouped = {};
files.forEach(file => {
const bomKey = file.bom_name || file.original_filename || file.filename;
if (!grouped[bomKey]) {
grouped[bomKey] = [];
}
grouped[bomKey].push(file);
});
// 각 그룹을 리비전 순으로 정렬
Object.keys(grouped).forEach(key => {
grouped[key].sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA; // 최신 리비전이 먼저 오도록
});
});
return grouped;
};
return (
<div style={{
padding: '32px',
@@ -240,7 +111,11 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
{/* 헤더 */}
<div style={{ marginBottom: '24px' }}>
<button
onClick={() => onNavigate && onNavigate('bom')}
onClick={() => {
if (onNavigate) {
onNavigate('dashboard');
}
}}
style={{
padding: '8px 16px',
background: 'white',
@@ -250,7 +125,7 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
marginBottom: '16px'
}}
>
뒤로가기
메인으로 돌아가기
</button>
<h1 style={{
@@ -295,205 +170,126 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
업로드된 BOM 목록
</h3>
{/* 파일 테이블 컴포넌트 */}
<BOMFileTable
files={files}
loading={loading}
groupFilesByBOM={groupFilesByBOM}
handleViewMaterials={handleViewMaterials}
openRevisionDialog={openRevisionDialog}
handleDelete={handleDelete}
/>
{/* 리비전 업로드 다이얼로그 */}
<RevisionUploadDialog
revisionDialog={revisionDialog}
setRevisionDialog={setRevisionDialog}
revisionFile={revisionFile}
setRevisionFile={setRevisionFile}
handleRevisionUpload={handleRevisionUpload}
uploading={uploading}
/>
{/* 구매 수량 계산 결과 모달 */}
{purchaseModal.open && (
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
로딩 ...
</div>
) : (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
background: 'white',
borderRadius: '12px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
overflow: 'hidden'
}}>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
maxWidth: '1000px',
maxHeight: '80vh',
overflow: 'auto',
margin: '20px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '700',
color: '#2d3748',
margin: 0
}}>
🧮 구매 수량 계산 결과
</h3>
<button
onClick={() => setPurchaseModal({ open: false, data: null, fileInfo: null })}
style={{
background: '#e2e8f0',
border: 'none',
borderRadius: '6px',
padding: '8px 12px',
cursor: 'pointer'
}}
>
닫기
</button>
</div>
<div style={{ marginBottom: '16px', color: '#4a5568' }}>
<div><strong>프로젝트:</strong> {purchaseModal.fileInfo?.job_no}</div>
<div><strong>BOM:</strong> {purchaseModal.fileInfo?.bom_name}</div>
<div><strong>리비전:</strong> {purchaseModal.fileInfo?.revision || 'Rev.0'}</div>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f7fafc' }}>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사양</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사이즈</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>BOM 수량</th>
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>구매 수량</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>비고</th>
</tr>
</thead>
<tbody>
{purchaseModal.data?.map((item, index) => (
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
<td style={{ padding: '12px', fontSize: '14px' }}>
<span style={{
background: getCategoryColor(item.category),
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f7fafc' }}>
<th style={{ padding: '16px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>BOM 이름</th>
<th style={{ padding: '16px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>파일명</th>
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>리비전</th>
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>자재 </th>
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>업로드 일시</th>
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>작업</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
<td style={{ padding: '16px' }}>
<div style={{ fontWeight: '600', color: '#2d3748' }}>
{file.bom_name || file.original_filename}
</div>
<div style={{ fontSize: '12px', color: '#718096' }}>
{file.description || ''}
</div>
</td>
<td style={{ padding: '16px', fontSize: '14px', color: '#4a5568' }}>
{file.original_filename}
</td>
<td style={{ padding: '16px', textAlign: 'center' }}>
<span style={{
background: '#e6fffa',
color: '#065f46',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600'
}}>
{file.revision || 'Rev.0'}
</span>
</td>
<td style={{ padding: '16px', textAlign: 'center' }}>
{file.parsed_count || 0}
</td>
<td style={{ padding: '16px', textAlign: 'center', fontSize: '14px', color: '#718096' }}>
{new Date(file.created_at).toLocaleDateString()}
</td>
<td style={{ padding: '16px', textAlign: 'center' }}>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
<button
onClick={() => handleViewMaterials(file)}
style={{
padding: '6px 12px',
background: '#4299e1',
color: 'white',
padding: '4px 8px',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600'
}}>
{item.category}
</span>
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{item.specification}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{/* PIPE는 사양에 모든 정보가 포함되므로 사이즈 컬럼 비움 */}
{item.category !== 'PIPE' && (
<span style={{
background: '#e6fffa',
color: '#065f46',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
{item.size_spec || '-'}
</span>
)}
{item.category === 'PIPE' && (
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
사양에 포함
</span>
)}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{/* PIPE는 사양에 모든 정보가 포함되므로 재질 컬럼 비움 */}
{item.category !== 'PIPE' && (
<span style={{
background: '#fef7e0',
color: '#92400e',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
{item.material_spec || '-'}
</span>
)}
{item.category === 'PIPE' && (
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
사양에 포함
</span>
)}
</td>
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
{item.category === 'PIPE' ?
`${Math.round(item.bom_quantity)}mm` :
item.bom_quantity
}
</td>
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right', fontWeight: '600' }}>
{item.category === 'PIPE' ?
`${item.pipes_count}본 (${Math.round(item.calculated_qty)}mm)` :
item.calculated_qty
}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{item.unit}
</td>
<td style={{ padding: '12px', fontSize: '12px', color: '#718096' }}>
{item.category === 'PIPE' && (
<div>
<div>절단수: {item.cutting_count}</div>
<div>절단손실: {item.cutting_loss}mm</div>
<div>활용률: {Math.round(item.utilization_rate)}%</div>
</div>
)}
{item.category !== 'PIPE' && item.safety_factor && (
<div>여유율: {Math.round((item.safety_factor - 1) * 100)}%</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{
marginTop: '24px',
padding: '16px',
background: '#f7fafc',
borderRadius: '8px',
fontSize: '14px',
color: '#4a5568'
}}
>
📋 자재 보기
</button>
<button
onClick={() => {
// 리비전 업로드 기능 (추후 구현)
alert('리비전 업로드 기능은 준비 중입니다.');
}}
style={{
padding: '6px 12px',
background: 'white',
color: '#4299e1',
border: '1px solid #4299e1',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600'
}}
>
📝 리비전
</button>
<button
onClick={() => handleDelete(file.id)}
style={{
padding: '6px 12px',
background: '#f56565',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600'
}}
>
🗑 삭제
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{files.length === 0 && (
<div style={{
padding: '40px',
textAlign: 'center',
color: '#718096'
}}>
<div style={{ fontWeight: '600', marginBottom: '8px' }}>📋 계산 규칙 (올바른 규칙):</div>
<div> <strong>PIPE:</strong> 6M 단위 올림, 절단당 2mm 손실</div>
<div> <strong>FITTING:</strong> BOM 수량 그대로</div>
<div> <strong>VALVE:</strong> BOM 수량 그대로</div>
<div> <strong>BOLT:</strong> 5% 여유율 4 배수 올림</div>
<div> <strong>GASKET:</strong> 5 배수 올림</div>
<div> <strong>INSTRUMENT:</strong> BOM 수량 그대로</div>
업로드된 BOM 파일이 없습니다.
</div>
</div>
)}
</div>
)}
</div>