리비전 업로드 시 정확한 수량 차이분 계산 로직 구현
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
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:
@@ -205,7 +205,11 @@ async def upload_file(
|
||||
parsed_count = len(materials_data)
|
||||
# 로그 제거
|
||||
|
||||
# 리비전 업로드인 경우만 자동 리비전 생성
|
||||
# 신규 자재 카운트 초기화
|
||||
new_materials_count = 0
|
||||
existing_materials_descriptions = set()
|
||||
|
||||
# 리비전 업로드인 경우만 자동 리비전 생성 및 기존 자재 조회
|
||||
if parent_file_id is not None:
|
||||
# 로그 제거
|
||||
# 부모 파일의 정보 조회
|
||||
@@ -255,6 +259,29 @@ async def upload_file(
|
||||
revision = "Rev.1"
|
||||
print(f"첫 번째 리비전: {revision}")
|
||||
|
||||
# 부모 파일의 자재 목록 조회 (기존 자재 확인용 - 수량 포함)
|
||||
existing_materials_query = text("""
|
||||
SELECT original_description, size_spec, SUM(quantity) as total_quantity
|
||||
FROM materials
|
||||
WHERE file_id = :parent_file_id
|
||||
GROUP BY original_description, size_spec
|
||||
""")
|
||||
|
||||
existing_result = db.execute(existing_materials_query, {
|
||||
"parent_file_id": parent_file_id
|
||||
})
|
||||
|
||||
existing_materials_with_quantity = {}
|
||||
for row in existing_result:
|
||||
# 설명과 사이즈를 조합하여 유니크 키 생성
|
||||
key = f"{row.original_description}|{row.size_spec or ''}"
|
||||
existing_materials_descriptions.add(key)
|
||||
existing_materials_with_quantity[key] = float(row.total_quantity or 0)
|
||||
|
||||
print(f"기존 자재 수: {len(existing_materials_descriptions)}")
|
||||
if len(existing_materials_descriptions) > 0:
|
||||
print(f"기존 자재 샘플 (처음 5개): {list(existing_materials_descriptions)[:5]}")
|
||||
|
||||
# 파일명을 부모와 동일하게 유지
|
||||
file.filename = parent_file[0]
|
||||
else:
|
||||
@@ -351,7 +378,66 @@ async def upload_file(
|
||||
})
|
||||
materials_inserted += 1
|
||||
|
||||
# 리비전 업로드인 경우 차이분 계산
|
||||
materials_diff = []
|
||||
if parent_file_id is not None:
|
||||
# 새 파일의 자재들을 수량별로 그룹화
|
||||
new_materials_grouped = {}
|
||||
for material_data in materials_to_classify:
|
||||
description = material_data["original_description"]
|
||||
size_spec = material_data["size_spec"]
|
||||
quantity = float(material_data.get("quantity", 0))
|
||||
|
||||
material_key = f"{description}|{size_spec or ''}"
|
||||
if material_key in new_materials_grouped:
|
||||
new_materials_grouped[material_key]["quantity"] += quantity
|
||||
new_materials_grouped[material_key]["items"].append(material_data)
|
||||
else:
|
||||
new_materials_grouped[material_key] = {
|
||||
"quantity": quantity,
|
||||
"items": [material_data],
|
||||
"description": description,
|
||||
"size_spec": size_spec
|
||||
}
|
||||
|
||||
# 차이분 계산
|
||||
for material_key, new_data in new_materials_grouped.items():
|
||||
existing_quantity = existing_materials_with_quantity.get(material_key, 0)
|
||||
new_quantity = new_data["quantity"]
|
||||
|
||||
if new_quantity > existing_quantity:
|
||||
# 증가분이 있는 경우
|
||||
diff_quantity = new_quantity - existing_quantity
|
||||
print(f"차이분 발견: {new_data['description'][:50]}... (증가: {diff_quantity})")
|
||||
|
||||
# 증가분만큼 자재 데이터 생성
|
||||
for item in new_data["items"]:
|
||||
if diff_quantity <= 0:
|
||||
break
|
||||
|
||||
item_quantity = float(item.get("quantity", 0))
|
||||
if item_quantity <= diff_quantity:
|
||||
# 이 아이템 전체를 포함
|
||||
materials_diff.append(item)
|
||||
diff_quantity -= item_quantity
|
||||
new_materials_count += 1
|
||||
else:
|
||||
# 이 아이템의 일부만 포함
|
||||
item_copy = item.copy()
|
||||
item_copy["quantity"] = diff_quantity
|
||||
materials_diff.append(item_copy)
|
||||
new_materials_count += 1
|
||||
break
|
||||
else:
|
||||
print(f"수량 감소/동일: {new_data['description'][:50]}... (기존:{existing_quantity} 새:{new_quantity})")
|
||||
|
||||
# 차이분만 처리하도록 materials_to_classify 교체
|
||||
materials_to_classify = materials_diff
|
||||
print(f"차이분 자재 개수: {len(materials_to_classify)}")
|
||||
|
||||
# 분류가 필요한 자재 처리
|
||||
print(f"분류할 자재 총 개수: {len(materials_to_classify)}")
|
||||
|
||||
for material_data in materials_to_classify:
|
||||
# 자재 타입 분류기 적용 (PIPE, FITTING, VALVE 등)
|
||||
description = material_data["original_description"]
|
||||
@@ -462,6 +548,8 @@ async def upload_file(
|
||||
material_id = material_result.fetchone()[0]
|
||||
materials_inserted += 1
|
||||
|
||||
# 리비전 업로드인 경우 신규 자재 카운트는 이미 위에서 처리됨
|
||||
|
||||
# PIPE 분류 결과인 경우 상세 정보 저장
|
||||
if classification_result.get("category") == "PIPE":
|
||||
print("PIPE 상세 정보 저장 시작")
|
||||
@@ -1063,13 +1151,20 @@ async def upload_file(
|
||||
print(f"활동 로그 기록 실패: {str(e)}")
|
||||
# 로그 실패는 업로드 성공에 영향을 주지 않음
|
||||
|
||||
# 리비전 업로드인 경우 메시지 다르게 표시
|
||||
if parent_file_id is not None:
|
||||
message = f"리비전 업로드 성공! {new_materials_count}개의 신규 자재가 추가되었습니다."
|
||||
else:
|
||||
message = f"업로드 성공! {materials_inserted}개 자재가 분류되었습니다."
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"업로드 성공! {materials_inserted}개 자재가 분류되었습니다.",
|
||||
"message": message,
|
||||
"original_filename": file.filename,
|
||||
"file_id": file_id,
|
||||
"materials_count": materials_inserted,
|
||||
"saved_materials_count": materials_inserted,
|
||||
"new_materials_count": new_materials_count if parent_file_id is not None else None, # 신규 자재 수
|
||||
"revision": revision, # 생성된 리비전 정보 추가
|
||||
"uploaded_by": username, # 업로드한 사용자 정보 추가
|
||||
"parsed_count": parsed_count
|
||||
@@ -1948,37 +2043,45 @@ async def compare_revisions(
|
||||
})
|
||||
new_materials = new_result.fetchall()
|
||||
|
||||
# 자재 키 생성 함수
|
||||
# 자재 키 생성 함수 (전체 수량 기준)
|
||||
def create_material_key(material):
|
||||
return f"{material.original_description}_{material.size_spec}_{material.material_grade}"
|
||||
return f"{material.original_description}|{material.size_spec or ''}|{material.material_grade or ''}"
|
||||
|
||||
# 기존 자재를 딕셔너리로 변환
|
||||
# 기존 자재를 딕셔너리로 변환 (수량 합산)
|
||||
old_materials_dict = {}
|
||||
for material in old_materials:
|
||||
key = create_material_key(material)
|
||||
old_materials_dict[key] = {
|
||||
"original_description": material.original_description,
|
||||
"quantity": float(material.quantity) if material.quantity else 0,
|
||||
"unit": material.unit,
|
||||
"size_spec": material.size_spec,
|
||||
"material_grade": material.material_grade,
|
||||
"classified_category": material.classified_category,
|
||||
"classification_confidence": material.classification_confidence
|
||||
}
|
||||
if key in old_materials_dict:
|
||||
# 동일한 자재가 있으면 수량 합산
|
||||
old_materials_dict[key]["quantity"] += float(material.quantity) if material.quantity else 0
|
||||
else:
|
||||
old_materials_dict[key] = {
|
||||
"original_description": material.original_description,
|
||||
"quantity": float(material.quantity) if material.quantity else 0,
|
||||
"unit": material.unit,
|
||||
"size_spec": material.size_spec,
|
||||
"material_grade": material.material_grade,
|
||||
"classified_category": material.classified_category,
|
||||
"classification_confidence": material.classification_confidence
|
||||
}
|
||||
|
||||
# 새 자재를 딕셔너리로 변환
|
||||
# 새 자재를 딕셔너리로 변환 (수량 합산)
|
||||
new_materials_dict = {}
|
||||
for material in new_materials:
|
||||
key = create_material_key(material)
|
||||
new_materials_dict[key] = {
|
||||
"original_description": material.original_description,
|
||||
"quantity": float(material.quantity) if material.quantity else 0,
|
||||
"unit": material.unit,
|
||||
"size_spec": material.size_spec,
|
||||
"material_grade": material.material_grade,
|
||||
"classified_category": material.classified_category,
|
||||
"classification_confidence": material.classification_confidence
|
||||
}
|
||||
if key in new_materials_dict:
|
||||
# 동일한 자재가 있으면 수량 합산
|
||||
new_materials_dict[key]["quantity"] += float(material.quantity) if material.quantity else 0
|
||||
else:
|
||||
new_materials_dict[key] = {
|
||||
"original_description": material.original_description,
|
||||
"quantity": float(material.quantity) if material.quantity else 0,
|
||||
"unit": material.unit,
|
||||
"size_spec": material.size_spec,
|
||||
"material_grade": material.material_grade,
|
||||
"classified_category": material.classified_category,
|
||||
"classification_confidence": material.classification_confidence
|
||||
}
|
||||
|
||||
# 변경 사항 분석
|
||||
all_keys = set(old_materials_dict.keys()) | set(new_materials_dict.keys())
|
||||
@@ -2006,14 +2109,22 @@ async def compare_revisions(
|
||||
"change_type": "added"
|
||||
})
|
||||
elif old_item and new_item:
|
||||
# 수량 변경 확인
|
||||
if old_item["quantity"] != new_item["quantity"]:
|
||||
# 수량 변경 확인 (전체 수량 기준)
|
||||
old_qty = old_item["quantity"]
|
||||
new_qty = new_item["quantity"]
|
||||
qty_diff = new_qty - old_qty
|
||||
|
||||
# 수량 차이가 있으면 변경된 것으로 간주 (소수점 오차 고려)
|
||||
if abs(qty_diff) > 0.001:
|
||||
change_type = "quantity_increased" if qty_diff > 0 else "quantity_decreased"
|
||||
changed_items.append({
|
||||
"key": key,
|
||||
"old_item": old_item,
|
||||
"new_item": new_item,
|
||||
"quantity_change": new_item["quantity"] - old_item["quantity"],
|
||||
"change_type": "quantity_changed"
|
||||
"quantity_change": qty_diff,
|
||||
"quantity_change_abs": abs(qty_diff),
|
||||
"change_type": change_type,
|
||||
"change_percentage": (qty_diff / old_qty * 100) if old_qty > 0 else 0
|
||||
})
|
||||
|
||||
# 분류별 통계
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user