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

@@ -0,0 +1,720 @@
import React, { useState, useEffect, useRef } from 'react';
import { api, fetchFiles, deleteFile as deleteFileApi } from '../api';
const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
// 상태 관리
const [files, setFiles] = useState([]);
const [selectedFile, setSelectedFile] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [sidebarWidth, setSidebarWidth] = useState(300);
const [previewWidth, setPreviewWidth] = useState(400);
// 업로드 관련 상태
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef(null);
// 편집 상태
const [editingFile, setEditingFile] = useState(null);
const [editingField, setEditingField] = useState(null);
const [editValue, setEditValue] = useState('');
useEffect(() => {
console.log('🔄 프로젝트 변경됨:', project);
const jobNo = project?.official_project_code || project?.job_no;
if (jobNo) {
console.log('✅ 프로젝트 코드 확인:', jobNo);
// 프로젝트가 변경되면 기존 선택 초기화
setSelectedFile(null);
setFiles([]);
loadFiles();
} else {
console.warn('⚠️ 프로젝트 정보가 없습니다. 받은 프로젝트:', project);
setFiles([]);
setSelectedFile(null);
}
}, [project?.official_project_code, project?.job_no]); // 두 필드 모두 감시
const loadFiles = async () => {
const jobNo = project?.official_project_code || project?.job_no;
if (!jobNo) {
console.warn('프로젝트 정보가 없어서 파일을 로드할 수 없습니다:', project);
return;
}
try {
setLoading(true);
setError(''); // 에러 초기화
console.log('📂 파일 목록 로딩 시작:', jobNo);
const response = await api.get('/files/', {
params: { job_no: jobNo }
});
console.log('📂 API 응답:', response.data);
const fileList = Array.isArray(response.data) ? response.data : response.data?.files || [];
console.log('📂 파싱된 파일 목록:', fileList);
setFiles(fileList);
// 기존 선택된 파일이 목록에 있는지 확인
if (selectedFile && !fileList.find(f => f.id === selectedFile.id)) {
setSelectedFile(null);
}
// 첫 번째 파일 자동 선택 (기존 선택이 없을 때만)
if (fileList.length > 0 && !selectedFile) {
console.log('📂 첫 번째 파일 자동 선택:', fileList[0].original_filename);
setSelectedFile(fileList[0]);
}
console.log('📂 파일 로딩 완료:', fileList.length, '개 파일');
} catch (err) {
console.error('📂 파일 로딩 실패:', err);
console.error('📂 에러 상세:', err.response?.data);
setError(`파일 목록을 불러오는데 실패했습니다: ${err.response?.data?.detail || err.message}`);
setFiles([]); // 에러 시 빈 배열로 초기화
} finally {
setLoading(false);
}
};
// 드래그 앤 드롭 핸들러
const handleDragOver = (e) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
setDragOver(false);
};
const handleDrop = async (e) => {
e.preventDefault();
setDragOver(false);
const droppedFiles = Array.from(e.dataTransfer.files);
console.log('드롭된 파일들:', droppedFiles.map(f => ({ name: f.name, type: f.type })));
const excelFiles = droppedFiles.filter(file => {
const fileName = file.name.toLowerCase();
const isExcel = fileName.endsWith('.xlsx') || fileName.endsWith('.xls');
console.log(`파일 ${file.name}: Excel 여부 = ${isExcel}`);
return isExcel;
});
if (excelFiles.length > 0) {
console.log('업로드할 Excel 파일들:', excelFiles.map(f => f.name));
await uploadFiles(excelFiles);
} else {
console.log('Excel 파일이 없음. 드롭된 파일들:', droppedFiles.map(f => f.name));
alert(`Excel 파일만 업로드 가능합니다.\n업로드하려는 파일: ${droppedFiles.map(f => f.name).join(', ')}`);
}
};
const handleFileSelect = (e) => {
const selectedFiles = Array.from(e.target.files);
console.log('선택된 파일들:', selectedFiles.map(f => ({ name: f.name, type: f.type })));
const excelFiles = selectedFiles.filter(file => {
const fileName = file.name.toLowerCase();
const isExcel = fileName.endsWith('.xlsx') || fileName.endsWith('.xls');
console.log(`파일 ${file.name}: Excel 여부 = ${isExcel}`);
return isExcel;
});
if (excelFiles.length > 0) {
console.log('업로드할 Excel 파일들:', excelFiles.map(f => f.name));
uploadFiles(excelFiles);
} else {
console.log('Excel 파일이 없음. 선택된 파일들:', selectedFiles.map(f => f.name));
alert(`Excel 파일만 업로드 가능합니다.\n선택하려는 파일: ${selectedFiles.map(f => f.name).join(', ')}`);
}
};
const uploadFiles = async (filesToUpload) => {
console.log('업로드 시작:', filesToUpload.map(f => ({ name: f.name, size: f.size, type: f.type })));
setUploading(true);
try {
for (const file of filesToUpload) {
console.log(`업로드 중: ${file.name} (${file.size} bytes, ${file.type})`);
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', file.name.replace(/\.[^/.]+$/, "")); // 확장자 제거
console.log('FormData 내용:', {
fileName: file.name,
jobNo: jobNo,
bomName: file.name.replace(/\.[^/.]+$/, "")
});
const response = await api.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log(`업로드 성공: ${file.name}`, response.data);
}
await loadFiles(); // 목록 새로고침
alert(`${filesToUpload.length}개 파일이 업로드되었습니다.`);
} catch (err) {
console.error('업로드 실패:', err);
console.error('에러 상세:', err.response?.data);
setError(`파일 업로드에 실패했습니다: ${err.response?.data?.detail || err.message}`);
} finally {
setUploading(false);
}
};
// 인라인 편집 핸들러
const startEdit = (file, field) => {
setEditingFile(file.id);
setEditingField(field);
setEditValue(file[field] || '');
};
const saveEdit = async () => {
try {
await api.put(`/files/${editingFile}`, {
[editingField]: editValue
});
// 로컬 상태 업데이트
setFiles(files.map(f =>
f.id === editingFile
? { ...f, [editingField]: editValue }
: f
));
if (selectedFile?.id === editingFile) {
setSelectedFile({ ...selectedFile, [editingField]: editValue });
}
cancelEdit();
} catch (err) {
console.error('수정 실패:', err);
alert('수정에 실패했습니다.');
}
};
const cancelEdit = () => {
setEditingFile(null);
setEditingField(null);
setEditValue('');
};
// 파일 삭제
const handleDelete = async (fileId) => {
if (!window.confirm('정말로 이 파일을 삭제하시겠습니까?')) {
return;
}
try {
await deleteFileApi(fileId);
setFiles(files.filter(f => f.id !== fileId));
if (selectedFile?.id === fileId) {
const remainingFiles = files.filter(f => f.id !== fileId);
setSelectedFile(remainingFiles.length > 0 ? remainingFiles[0] : null);
}
} catch (err) {
console.error('삭제 실패:', err);
setError('파일 삭제에 실패했습니다.');
}
};
// 자재 보기
const viewMaterials = (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
});
}
};
return (
<div style={{
display: 'flex',
height: '100vh',
background: '#f5f5f5',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
}}>
{/* 사이드바 - 프로젝트 정보 */}
<div style={{
width: `${sidebarWidth}px`,
background: '#ffffff',
borderRight: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column'
}}>
{/* 헤더 */}
<div style={{
padding: '16px',
borderBottom: '1px solid #e0e0e0',
background: '#fafafa'
}}>
<button
onClick={onBack}
style={{
background: 'none',
border: 'none',
color: '#666',
cursor: 'pointer',
fontSize: '14px',
marginBottom: '8px'
}}
>
대시보드로
</button>
<h2 style={{
margin: 0,
fontSize: '18px',
fontWeight: '600',
color: '#333'
}}>
{project?.project_name}
</h2>
<p style={{
margin: '4px 0 0 0',
fontSize: '14px',
color: '#666'
}}>
{project?.official_project_code || project?.job_no}
</p>
</div>
{/* 프로젝트 통계 */}
<div style={{
padding: '16px',
borderBottom: '1px solid #e0e0e0'
}}>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '8px' }}>
프로젝트 현황
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
<div style={{ textAlign: 'center', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
<div style={{ fontSize: '20px', fontWeight: '600', color: '#4299e1' }}>
{files.length}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>BOM 파일</div>
</div>
<div style={{ textAlign: 'center', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
<div style={{ fontSize: '20px', fontWeight: '600', color: '#48bb78' }}>
{files.reduce((sum, f) => sum + (f.parsed_count || 0), 0)}
</div>
<div style={{ fontSize: '12px', color: '#666' }}> 자재</div>
</div>
</div>
</div>
{/* 업로드 영역 */}
<div
style={{
margin: '16px',
padding: '20px',
border: dragOver ? '2px dashed #4299e1' : '2px dashed #ddd',
borderRadius: '8px',
textAlign: 'center',
background: dragOver ? '#f0f9ff' : '#fafafa',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
multiple
accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{uploading ? (
<div style={{ color: '#4299e1' }}>
📤 업로드 ...
</div>
) : (
<div>
<div style={{ fontSize: '24px', marginBottom: '8px' }}>📁</div>
<div style={{ fontSize: '14px', color: '#666' }}>
Excel 파일을 드래그하거나<br />클릭하여 업로드
</div>
</div>
)}
</div>
</div>
{/* 메인 패널 - 파일 목록 */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
background: '#ffffff'
}}>
{/* 툴바 */}
<div style={{
padding: '12px 16px',
borderBottom: '1px solid #e0e0e0',
background: '#fafafa',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600' }}>
BOM 파일 목록 ({files.length})
</h3>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={loadFiles}
disabled={loading}
style={{
padding: '6px 12px',
background: loading ? '#a0aec0' : '#48bb78',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '12px'
}}
>
{loading ? '🔄 로딩중...' : '🔄 새로고침'}
</button>
<button
onClick={() => fileInputRef.current?.click()}
style={{
padding: '6px 12px',
background: '#4299e1',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
+ 파일 추가
</button>
</div>
</div>
{/* 파일 목록 */}
<div style={{ flex: 1, overflow: 'auto' }}>
{loading ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
로딩 ...
</div>
) : files.length === 0 ? (
<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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
{/* BOM 이름 (인라인 편집) */}
{editingFile === file.id && editingField === 'bom_name' ? (
<input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit();
if (e.key === 'Escape') cancelEdit();
}}
style={{
border: '1px solid #4299e1',
borderRadius: '2px',
padding: '2px 4px',
fontSize: '14px',
fontWeight: '600'
}}
autoFocus
/>
) : (
<div
style={{
fontSize: '14px',
fontWeight: '600',
color: '#333',
cursor: 'text'
}}
onClick={(e) => {
e.stopPropagation();
startEdit(file, 'bom_name');
}}
>
{file.bom_name || file.original_filename}
</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')}
</div>
</div>
<div style={{ display: 'flex', gap: '4px', marginLeft: '12px' }}>
<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('리비전 기능 준비 중');
}}
style={{
padding: '4px 8px',
background: 'white',
color: '#4299e1',
border: '1px solid #4299e1',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px'
}}
>
📝 리비전
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(file.id);
}}
style={{
padding: '4px 8px',
background: '#f56565',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px'
}}
>
🗑
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 우측 패널 - 상세 정보 */}
{selectedFile && (
<div style={{
width: `${previewWidth}px`,
background: '#ffffff',
borderLeft: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column'
}}>
{/* 상세 정보 헤더 */}
<div style={{
padding: '16px',
borderBottom: '1px solid #e0e0e0',
background: '#fafafa'
}}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: '600' }}>
파일 상세 정보
</h3>
</div>
{/* 상세 정보 내용 */}
<div style={{ padding: '16px', flex: 1, overflow: 'auto' }}>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
BOM 이름
</label>
<div
style={{
padding: '8px',
border: '1px solid #e0e0e0',
borderRadius: '4px',
cursor: 'text',
background: '#fafafa'
}}
onClick={() => startEdit(selectedFile, 'bom_name')}
>
{selectedFile.bom_name || selectedFile.original_filename}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
파일명
</label>
<div style={{ fontSize: '14px', color: '#333' }}>
{selectedFile.original_filename}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
리비전
</label>
<div style={{ fontSize: '14px', color: '#333' }}>
{selectedFile.revision || 'Rev.0'}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
자재
</label>
<div style={{ fontSize: '14px', color: '#333' }}>
{selectedFile.parsed_count || 0}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
업로드 일시
</label>
<div style={{ fontSize: '14px', color: '#333' }}>
{new Date(selectedFile.created_at).toLocaleString('ko-KR')}
</div>
</div>
{/* 액션 버튼들 */}
<div style={{ marginTop: '24px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
<button
onClick={() => viewMaterials(selectedFile)}
style={{
width: '100%',
padding: '12px',
background: '#4299e1',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
📋 자재 목록 보기
</button>
<button
onClick={() => alert('리비전 업로드 기능 준비 중')}
style={{
width: '100%',
padding: '12px',
background: 'white',
color: '#4299e1',
border: '1px solid #4299e1',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
📝 리비전 업로드
</button>
<button
onClick={() => handleDelete(selectedFile.id)}
style={{
width: '100%',
padding: '12px',
background: '#f56565',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
🗑 파일 삭제
</button>
</div>
</div>
</div>
)}
{/* 에러 메시지 */}
{error && (
<div style={{
position: 'fixed',
top: '20px',
right: '20px',
background: '#fed7d7',
color: '#c53030',
padding: '12px 16px',
borderRadius: '6px',
border: '1px solid #fc8181',
zIndex: 1000
}}>
{error}
<button
onClick={() => setError('')}
style={{
marginLeft: '12px',
background: 'none',
border: 'none',
color: '#c53030',
cursor: 'pointer'
}}
>
</button>
</div>
)}
</div>
);
};
export default BOMWorkspacePage;