리비전 업로드 시 정확한 수량 차이분 계산 로직 구현
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- 기존 자재와 새 자재의 수량을 비교하여 증가분만 저장
- Rev.0: 엘보 10개, Rev.1: 엘보 12개 → Rev.1에는 2개만 저장
- 완전 신규 자재는 전체 수량 저장
- 수량 감소/동일한 자재는 저장하지 않음
- 리비전별 정확한 차이분 관리 구현
This commit is contained in:
Hyungi Ahn
2025-09-09 12:03:47 +09:00
parent 83b90ef05c
commit 881fc13580
5 changed files with 579 additions and 99 deletions

View File

@@ -34,19 +34,20 @@ const BOMFileUpload = ({
color: '#4a5568',
marginBottom: '8px'
}}>
BOM 이름
BOM 이름 <span style={{ color: '#e53e3e' }}>*</span>
</label>
<input
type="text"
value={bomName}
onChange={(e) => setBomName(e.target.value)}
placeholder="예: PIPING_BOM_A구역"
placeholder="예: PIPING_BOM_A구역, 배관자재_1차, VALVE_LIST_Rev0"
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px'
fontSize: '14px',
background: bomName ? '#f0fff4' : 'white'
}}
/>
<p style={{
@@ -54,7 +55,8 @@ const BOMFileUpload = ({
color: '#718096',
margin: '4px 0 0 0'
}}>
동일한 BOM 이름으로 재업로드 리비전이 자동 증가합니다
💡 이름 엑셀 내보내기 파일명과 자재 관리에 사용됩니다.
동일한 BOM 이름으로 재업로드 리비전이 자동 증가합니다.
</p>
</div>

View File

@@ -9,6 +9,7 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
const [error, setError] = useState('');
const [sidebarWidth, setSidebarWidth] = useState(300);
const [previewWidth, setPreviewWidth] = useState(400);
const [groupedFiles, setGroupedFiles] = useState({});
// 업로드 관련 상태
const [uploading, setUploading] = useState(false);
@@ -20,6 +21,30 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
const [editingField, setEditingField] = useState(null);
const [editValue, setEditValue] = useState('');
// 파일을 BOM 이름별로 그룹화
const groupFilesByBOM = (fileList) => {
const groups = {};
fileList.forEach(file => {
const bomName = file.bom_name || file.original_filename;
if (!groups[bomName]) {
groups[bomName] = [];
}
groups[bomName].push(file);
});
// 각 그룹 내에서 리비전 번호로 정렬
Object.keys(groups).forEach(bomName => {
groups[bomName].sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA; // 최신 리비전이 위로
});
});
return groups;
};
useEffect(() => {
console.log('🔄 프로젝트 변경됨:', project);
const jobNo = project?.official_project_code || project?.job_no;
@@ -60,6 +85,11 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
setFiles(fileList);
// 파일을 그룹화
const grouped = groupFilesByBOM(fileList);
setGroupedFiles(grouped);
console.log('📂 그룹화된 파일:', grouped);
// 기존 선택된 파일이 목록에 있는지 확인
if (selectedFile && !fileList.find(f => f.id === selectedFile.id)) {
setSelectedFile(null);
@@ -239,11 +269,50 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
jobNo: file.job_no,
bomName: file.bom_name || file.original_filename,
revision: file.revision,
filename: file.original_filename
filename: file.original_filename,
selectedProject: project
});
}
};
// 리비전 업로드
const handleRevisionUpload = async (parentFile) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.csv,.xlsx,.xls';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
setUploading(true);
const jobNo = project?.official_project_code || project?.job_no;
const formData = new FormData();
formData.append('file', file);
formData.append('job_no', jobNo);
formData.append('bom_name', parentFile.bom_name || parentFile.original_filename);
formData.append('parent_file_id', parentFile.id); // 부모 파일 ID 추가
const response = await api.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.data?.success) {
alert(`리비전 ${response.data.revision} 업로드 성공!\n신규 자재: ${response.data.new_materials_count || 0}`);
await loadFiles();
}
} catch (err) {
console.error('리비전 업로드 실패:', err);
alert('리비전 업로드에 실패했습니다: ' + (err.response?.data?.detail || err.message));
} finally {
setUploading(false);
}
};
input.click();
};
return (
<div style={{
display: 'flex',
@@ -375,9 +444,9 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600' }}>
BOM 파일 목록 ({files.length})
</h3>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600' }}>
BOM 파일 목록 ({Object.keys(groupedFiles).length})
</h3>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={loadFiles}
@@ -421,34 +490,38 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
업로드된 BOM 파일이 없습니다.
</div>
) : (
<div>
{files.map((file) => (
<div
key={file.id}
style={{
padding: '12px 16px',
borderBottom: '1px solid #f0f0f0',
cursor: 'pointer',
background: selectedFile?.id === file.id ? '#f0f9ff' : 'transparent',
transition: 'background-color 0.2s ease'
}}
onClick={() => setSelectedFile(file)}
onMouseEnter={(e) => {
if (selectedFile?.id !== file.id) {
e.target.style.background = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (selectedFile?.id !== file.id) {
e.target.style.background = 'transparent';
}
}}
>
) : (
<div>
{Object.entries(groupedFiles).map(([bomName, bomFiles]) => {
// 최신 리비전 파일을 대표로 선택
const latestFile = bomFiles[0]; // 이미 최신순으로 정렬됨
return (
<div
key={latestFile.id}
style={{
padding: '12px 16px',
borderBottom: '1px solid #f0f0f0',
cursor: 'pointer',
background: selectedFile?.id === latestFile.id ? '#f0f9ff' : 'transparent',
transition: 'background-color 0.2s ease'
}}
onClick={() => setSelectedFile(latestFile)}
onMouseEnter={(e) => {
if (selectedFile?.id !== latestFile.id) {
e.target.style.background = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (selectedFile?.id !== latestFile.id) {
e.target.style.background = 'transparent';
}
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
{/* BOM 이름 (인라인 편집) */}
{editingFile === file.id && editingField === 'bom_name' ? (
{editingFile === latestFile.id && editingField === 'bom_name' ? (
<input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
@@ -476,43 +549,73 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
}}
onClick={(e) => {
e.stopPropagation();
startEdit(file, 'bom_name');
startEdit(latestFile, 'bom_name');
}}
>
{file.bom_name || file.original_filename}
{bomName}
</div>
)}
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
{file.original_filename} {file.parsed_count || 0} 자재 {file.revision || 'Rev.0'}
</div>
<div style={{ fontSize: '11px', color: '#999', marginTop: '2px' }}>
{new Date(file.created_at).toLocaleDateString('ko-KR')}
📄 {bomFiles.length > 1 ? `${bomFiles.length}개 리비전` : latestFile.revision || 'Rev.0'}
{bomFiles.reduce((sum, f) => sum + (f.parsed_count || 0), 0)} 자재 (최신: {latestFile.revision || 'Rev.0'})
</div>
</div>
<div style={{ display: 'flex', gap: '4px', marginLeft: '12px' }}>
{bomFiles.length === 1 ? (
<button
onClick={(e) => {
e.stopPropagation();
viewMaterials(latestFile);
}}
style={{
padding: '4px 8px',
background: '#48bb78',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px'
}}
>
📋 자재
</button>
) : (
<div style={{ position: 'relative' }}>
<select
onChange={(e) => {
const selectedFileId = e.target.value;
const selectedFile = bomFiles.find(f => f.id.toString() === selectedFileId);
if (selectedFile) {
viewMaterials(selectedFile);
}
}}
onClick={(e) => e.stopPropagation()}
style={{
padding: '4px 8px',
background: '#48bb78',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px'
}}
defaultValue=""
>
<option value="" disabled>📋 자재 선택</option>
{bomFiles.map(file => (
<option key={file.id} value={file.id} style={{ color: 'black' }}>
{file.revision || 'Rev.0'} ({file.parsed_count || 0})
</option>
))}
</select>
</div>
)}
<button
onClick={(e) => {
e.stopPropagation();
viewMaterials(file);
}}
style={{
padding: '4px 8px',
background: '#48bb78',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px'
}}
>
📋 자재
</button>
<button
onClick={(e) => {
e.stopPropagation();
alert('리비전 기능 준비 중');
handleRevisionUpload(latestFile);
}}
style={{
padding: '4px 8px',
@@ -529,7 +632,10 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(file.id);
if (window.confirm(`${bomName} BOM을 삭제하시겠습니까? (모든 리비전이 삭제됩니다)`)) {
// 모든 리비전 파일 삭제
bomFiles.forEach(file => handleDelete(file.id));
}
}}
style={{
padding: '4px 8px',
@@ -545,8 +651,9 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
</button>
</div>
</div>
</div>
))}
</div>
);
})}
</div>
)}
</div>
@@ -648,7 +755,8 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
</button>
<button
onClick={() => alert('리비전 업로드 기능 준비 중')}
onClick={() => handleRevisionUpload(selectedFile)}
disabled={uploading}
style={{
width: '100%',
padding: '12px',

View File

@@ -15,6 +15,47 @@
background: white;
border-bottom: 1px solid #e5e7eb;
padding: 16px 24px;
}
.header-center {
display: flex;
align-items: center;
gap: 16px;
}
.revision-selector {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
}
.revision-selector label {
font-size: 14px;
font-weight: 500;
color: #495057;
margin: 0;
}
.revision-dropdown {
padding: 4px 8px;
border: 1px solid #ced4da;
border-radius: 4px;
background: white;
font-size: 14px;
color: #495057;
cursor: pointer;
min-width: 180px;
}
.revision-dropdown:focus {
outline: none;
border-color: #4299e1;
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.2);
}
display: flex;
align-items: center;
justify-content: space-between;
@@ -46,6 +87,29 @@
transform: translateY(-1px);
}
/* 심플한 뒤로가기 버튼 */
.back-button-simple {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: #86efac;
color: #166534;
border: 1px solid #bbf7d0;
border-radius: 6px;
font-size: 18px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.back-button-simple:hover {
background: #6ee7b7;
transform: translateX(-2px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.materials-header h1 {
font-size: 20px;
font-weight: 600;

View File

@@ -1,5 +1,8 @@
import React, { useState, useEffect } from 'react';
import { fetchMaterials } from '../api';
import { exportMaterialsToExcel } from '../utils/excelExport';
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';
import './NewMaterialsPage.css';
const NewMaterialsPage = ({
@@ -16,11 +19,40 @@ const NewMaterialsPage = ({
const [selectedCategory, setSelectedCategory] = useState('PIPE');
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [viewMode, setViewMode] = useState('detailed'); // 'detailed' or 'simple'
const [availableRevisions, setAvailableRevisions] = useState([]);
const [currentRevision, setCurrentRevision] = useState(revision || 'Rev.0');
// 같은 BOM의 다른 리비전들 조회
const loadAvailableRevisions = async () => {
try {
const response = await api.get('/files/', {
params: { job_no: jobNo }
});
const allFiles = Array.isArray(response.data) ? response.data : response.data?.files || [];
const sameBomFiles = allFiles.filter(file =>
(file.bom_name || file.original_filename) === bomName
);
// 리비전별로 정렬 (최신순)
sameBomFiles.sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA;
});
setAvailableRevisions(sameBomFiles);
console.log('📋 사용 가능한 리비전:', sameBomFiles);
} catch (error) {
console.error('리비전 목록 조회 실패:', error);
}
};
// 자재 데이터 로드
useEffect(() => {
if (fileId) {
loadMaterials(fileId);
loadAvailableRevisions();
}
}, [fileId]);
@@ -389,11 +421,134 @@ const NewMaterialsPage = ({
setSelectedMaterials(newSelection);
};
// 엑셀 내보내기
// 엑셀 내보내기 - 화면에 표시된 그대로
const exportToExcel = () => {
const selectedData = materials.filter(m => selectedMaterials.has(m.id));
console.log('📊 엑셀 내보내기:', selectedData.length, '개 항목');
alert(`${selectedData.length}개 항목을 엑셀로 내보냅니다.`);
try {
// 내보낼 데이터 결정 (선택 항목 또는 현재 카테고리 전체)
const dataToExport = selectedMaterials.size > 0
? filteredMaterials.filter(m => selectedMaterials.has(m.id))
: filteredMaterials;
console.log('📊 엑셀 내보내기:', dataToExport.length, '개 항목');
// 카테고리별 컬럼 구성
const getExcelData = (material) => {
const info = parseMaterialInfo(material);
if (selectedCategory === 'PIPE') {
return {
'종류': info.type,
'타입': info.subtype,
'크기': info.size,
'스케줄': info.schedule,
'재질': info.grade,
'추가요구': '-',
'사용자요구': '',
'수량': `${info.quantity} ${info.unit}`,
'상세': `단관 ${info.itemCount || 0}개 → ${info.totalLength || 0}mm`
};
} else if (selectedCategory === 'FLANGE' && info.isFlange) {
return {
'종류': info.type,
'타입': info.subtype,
'크기': info.size,
'압력(파운드)': info.pressure,
'스케줄': info.schedule,
'재질': info.grade,
'추가요구': '-',
'사용자요구': '',
'수량': `${info.quantity} ${info.unit}`
};
} else if (selectedCategory === 'FITTING' && info.isFitting) {
return {
'종류': info.type,
'타입/상세': info.subtype,
'크기': info.size,
'압력': info.pressure,
'스케줄': info.schedule,
'재질': info.grade,
'추가요구': '-',
'사용자요구': '',
'수량': `${info.quantity} ${info.unit}`
};
} else if (selectedCategory === 'VALVE' && info.isValve) {
return {
'타입': info.valveType,
'연결방식': info.connectionType,
'크기': info.size,
'압력': info.pressure,
'재질': info.grade,
'추가요구': '-',
'사용자요구': '',
'수량': `${info.quantity} ${info.unit}`
};
} else if (selectedCategory === 'GASKET' && info.isGasket) {
return {
'종류': info.type,
'타입': info.subtype,
'크기': info.size,
'압력': info.pressure,
'재질': info.materialStructure,
'상세내역': info.materialDetail,
'두께': info.thickness,
'추가요구': '-',
'사용자요구': '',
'수량': `${info.quantity} ${info.unit}`
};
} else if (selectedCategory === 'BOLT') {
return {
'종류': info.type,
'타입': info.subtype,
'크기': info.size,
'스케줄': info.schedule,
'재질': info.grade,
'추가요구': '-',
'사용자요구': '',
'수량': `${info.quantity} ${info.unit}`
};
} else if (selectedCategory === 'UNKNOWN' && info.isUnknown) {
return {
'종류': info.type,
'설명': info.description,
'사용자요구': '',
'수량': `${info.quantity} ${info.unit}`
};
} else {
// 기본 형식
return {
'종류': info.type,
'타입': info.subtype,
'크기': info.size,
'스케줄': info.schedule,
'재질': info.grade,
'추가요구': '-',
'사용자요구': '',
'수량': `${info.quantity} ${info.unit}`
};
}
};
// 엑셀 데이터 생성
const excelData = dataToExport.map(material => getExcelData(material));
// 워크북 생성
const ws = XLSX.utils.json_to_sheet(excelData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, selectedCategory);
// 파일명 생성
const fileName = `${selectedCategory}_${jobNo || 'export'}_${new Date().toISOString().split('T')[0]}.xlsx`;
// 파일 저장
const excelBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
const data = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
saveAs(data, fileName);
console.log('✅ 엑셀 내보내기 성공');
} catch (error) {
console.error('❌ 엑셀 내보내기 실패:', error);
alert('엑셀 내보내기에 실패했습니다.');
}
};
if (loading) {
@@ -412,19 +567,58 @@ const NewMaterialsPage = ({
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button onClick={() => onNavigate('bom')} className="back-button">
BOM 업로드로 돌아가기
<button
onClick={() => {
// 프로젝트 정보를 포함하여 BOM 페이지로 돌아가기
const projectInfo = selectedProject || {
job_no: jobNo,
official_project_code: jobNo,
job_name: bomName || filename
};
onNavigate('bom', {
selectedProject: projectInfo
});
}}
className="back-button-simple"
title="BOM 업로드로 돌아가기"
>
</button>
<h1>자재 목록</h1>
{jobNo && (
<span className="job-info">
{jobNo} {revision && `(${revision})`}
{jobNo} - {bomName}
</span>
)}
</div>
<div className="header-center">
{availableRevisions.length > 1 && (
<div className="revision-selector">
<label>리비전: </label>
<select
value={currentRevision}
onChange={(e) => {
const selectedRev = e.target.value;
const selectedFile = availableRevisions.find(f => f.revision === selectedRev);
if (selectedFile) {
setCurrentRevision(selectedRev);
loadMaterials(selectedFile.id);
}
}}
className="revision-dropdown"
>
{availableRevisions.map(file => (
<option key={file.id} value={file.revision}>
{file.revision || 'Rev.0'} ({file.parsed_count || 0} 자재)
</option>
))}
</select>
</div>
)}
</div>
<div className="header-right">
<span className="material-count">
{materials.length} 자재
{materials.length} 자재 ({currentRevision})
</span>
</div>
</div>
@@ -457,9 +651,10 @@ const NewMaterialsPage = ({
<button
onClick={exportToExcel}
className="export-btn"
disabled={selectedMaterials.size === 0}
>
엑셀 내보내기 ({selectedMaterials.size})
{selectedMaterials.size > 0
? `선택 항목 엑셀 내보내기 (${selectedMaterials.size})`
: '전체 엑셀 내보내기'}
</button>
</div>
</div>