Files
TK-BOM-Project/frontend/src/components/bom/materials/PipeMaterialsView.jsx
hyungi 64fd9ad3d2
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
feat: BOM 관리 시스템 대폭 개선 및 Docker 배포 가이드 추가
- 🎨 UI/UX 개선: 데본씽크 스타일 모던 디자인 적용
- 📁 컴포넌트 구조 개선: 폴더별 체계적 관리 (common/, bom/, materials/)
- 🔧 BOM 관리 페이지 리팩토링: NewMaterialsPage → BOMManagementPage + 카테고리별 컴포넌트 분리
- 💾 구매신청 기능 개선: 선택된 자재 비활성화, 제목 편집, 엑셀 다운로드
- 📊 자재 표시 개선: 타입/서브타입 컬럼 정리, 상세 정보 복원
- 🐛 CSS 빌드 오류 수정: NewMaterialsPage.css 문법 오류 해결
- 📚 문서화: PAGES_GUIDE.md 추가, README에 Docker 캐시 문제 해결 가이드 추가
- 🔄 API 개선: 구매신청 자재 조회, 제목 수정 엔드포인트 추가
2025-10-16 12:45:23 +09:00

526 lines
17 KiB
JavaScript

import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader, MaterialTable } from '../shared';
const PipeMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
fileId,
user,
onNavigate
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
// 파이프 구매 수량 계산 (기존 로직 복원)
const calculatePipePurchase = (material) => {
const pipeDetails = material.pipe_details || {};
const totalLength = pipeDetails.length || material.length || 0;
const standardLength = 6; // 표준 6M
const purchaseCount = Math.ceil(totalLength / standardLength);
const totalPurchaseLength = purchaseCount * standardLength;
const wasteLength = totalPurchaseLength - totalLength;
const wastePercentage = totalLength > 0 ? (wasteLength / totalLength * 100) : 0;
return {
totalLength,
standardLength,
purchaseCount,
totalPurchaseLength,
wasteLength,
wastePercentage
};
};
// 파이프 정보 파싱 (기존 상세 로직 복원)
const parsePipeInfo = (material) => {
const calc = calculatePipePurchase(material);
const pipeDetails = material.pipe_details || {};
return {
type: 'PIPE',
subtype: pipeDetails.manufacturing_method || 'SMLS',
size: material.size_spec || '-',
schedule: pipeDetails.schedule || material.schedule || '-',
grade: material.full_material_grade || material.material_grade || '-',
length: calc.totalLength,
quantity: calc.purchaseCount,
unit: '본',
details: calc,
isPipe: true
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parsePipeInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parsePipeInfo(a);
const bInfo = parsePipeInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
}
return filtered;
};
// 전체 선택/해제
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
if (selectedMaterials.size === filteredMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(filteredMaterials.map(m => m.id)));
}
};
// 개별 선택
const handleMaterialSelect = (materialId) => {
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `PIPE_Materials_${timestamp}.xlsx`;
// 사용자 요구사항 포함
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: userRequirements[material.id] || ''
}));
try {
// 서버에 엑셀 파일 저장 요청
await api.post('/files/save-excel', {
file_id: fileId,
category: 'PIPE',
materials: dataWithRequirements,
filename: excelFileName,
user_id: user?.id
});
// 클라이언트에서 다운로드
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'PIPE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
} catch (error) {
console.error('엑셀 저장 실패:', error);
// 실패해도 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'PIPE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
}
};
const filteredMaterials = getFilteredAndSortedMaterials();
// 필터 헤더 컴포넌트
const FilterableHeader = ({ sortKey, filterKey, children }) => (
<div className="filterable-header" style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
onClick={() => handleSort(sortKey)}
style={{ cursor: 'pointer', flex: 1 }}
>
{children}
{sortConfig.key === sortKey && (
<span style={{ marginLeft: '4px' }}>
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
<button
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px',
fontSize: '12px',
color: '#6b7280'
}}
>
🔍
</button>
</div>
{showFilterDropdown === filterKey && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
minWidth: '150px'
}}>
<input
type="text"
placeholder={`Filter ${children}...`}
value={columnFilters[filterKey] || ''}
onChange={(e) => setColumnFilters({
...columnFilters,
[filterKey]: e.target.value
})}
style={{
width: '100%',
padding: '4px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
autoFocus
/>
</div>
)}
</div>
);
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Pipe Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 1fr 120px 120px 120px 150px 100px 80px 80px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
<div>
<input
type="checkbox"
checked={selectedMaterials.size === filteredMaterials.length && filteredMaterials.length > 0}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader
sortKey="type"
filterKey="type"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Type
</FilterableHeader>
<FilterableHeader
sortKey="subtype"
filterKey="subtype"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Subtype
</FilterableHeader>
<FilterableHeader
sortKey="size"
filterKey="size"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Size
</FilterableHeader>
<FilterableHeader
sortKey="schedule"
filterKey="schedule"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Schedule
</FilterableHeader>
<FilterableHeader
sortKey="grade"
filterKey="grade"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Material Grade
</FilterableHeader>
<FilterableHeader
sortKey="length"
filterKey="length"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Length (M)
</FilterableHeader>
<FilterableHeader
sortKey="quantity"
filterKey="quantity"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Quantity
</FilterableHeader>
<div>Unit</div>
<div>User Requirement</div>
</div>
{/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
{filteredMaterials.map((material, index) => {
const info = parsePipeInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 1fr 120px 120px 120px 150px 100px 80px 80px 200px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
style={{ cursor: 'pointer' }}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
PIPE
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.subtype}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'right' }}>
{info.length.toFixed(2)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
</div>
<div>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
/>
</div>
</div>
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔧</div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Pipe Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No pipe materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default PipeMaterialsView;