변동이력 관리로 전환: - 도면번호 기준 변경 추적 - 리비전 업로드 시 전체 자재 저장 (차이분만 저장 방식 폐지) - 구매신청 정보 수량 기반 상속 리비전 변경 감지: - 수량/재질/크기/카테고리 변경 감지 - 변경 유형: specification_changed, quantity_changed, added, removed - 도면별 변경사항 추적 누락 도면 처리: - 리비전 업로드 시 누락된 도면 자동 감지 - 3가지 선택 옵션: 일부 업로드 / 도면 삭제 / 취소 - 구매신청 여부에 따라 다른 처리 (재고품 vs 숨김) 자재 상태 관리: - revision_status 컬럼 추가 (active/inventory/deleted_not_purchased/changed) - 재고품: 연노랑색 배경, '재고품' 배지 - 변경됨: 파란색 테두리, '변경됨' 배지 - 삭제됨: 자동 숨김 구매신청 정보 상속: - 수량 기반 상속 (그룹별 개수만큼만) - Rev.0에서 3개 구매 → Rev.1에서 처음 3개만 상속, 추가분은 미구매 - 도면번호 정확히 일치하는 경우에만 상속 기타 개선: - 구매신청 관리 페이지 수량 표시 개선 (3 EA, 소수점 제거) - 도면번호/라인번호 파싱 및 저장 (DWG_NAME, LINE_NUM 컬럼) - SPECIAL 카테고리 도면번호 표시 - 마이그레이션 스크립트 추가 (29_add_revision_status.sql)
895 lines
33 KiB
JavaScript
895 lines
33 KiB
JavaScript
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 [groupedFiles, setGroupedFiles] = useState({});
|
||
|
||
// 업로드 관련 상태
|
||
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('');
|
||
|
||
// 파일을 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;
|
||
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);
|
||
|
||
// 파일을 그룹화
|
||
const grouped = groupFilesByBOM(fileList);
|
||
setGroupedFiles(grouped);
|
||
console.log('📂 그룹화된 파일:', grouped);
|
||
|
||
// 기존 선택된 파일이 목록에 있는지 확인
|
||
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,
|
||
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) {
|
||
// 누락된 도면 확인
|
||
if (response.data.missing_drawings && response.data.missing_drawings.requires_confirmation) {
|
||
const missingDrawings = response.data.missing_drawings.drawings || [];
|
||
const materialCount = response.data.missing_drawings.materials?.length || 0;
|
||
const hasPreviousPurchase = response.data.missing_drawings.has_previous_purchase;
|
||
const fileId = response.data.file_id;
|
||
|
||
// 사용자 선택을 위한 프롬프트 메시지
|
||
let alertMessage = `⚠️ 리비전 업로드 확인\n\n` +
|
||
`다음 도면이 새 파일에 없습니다:\n` +
|
||
`${missingDrawings.slice(0, 5).join('\n')}` +
|
||
`${missingDrawings.length > 5 ? `\n...외 ${missingDrawings.length - 5}개` : ''}\n\n` +
|
||
`관련 자재: ${materialCount}개\n\n`;
|
||
|
||
if (hasPreviousPurchase) {
|
||
// 케이스 1: 이미 구매신청된 경우
|
||
alertMessage += `✅ 이전 리비전에서 구매신청이 진행되었습니다.\n\n` +
|
||
`다음 중 선택하세요:\n\n` +
|
||
`1️⃣ "일부만 업로드" - 변경된 도면만 업로드, 기존 도면 유지\n` +
|
||
` → 누락된 도면의 자재는 "재고품"으로 표시 (연노랑색)\n\n` +
|
||
`2️⃣ "도면 삭제됨" - 누락된 도면 완전 삭제\n` +
|
||
` → 해당 자재 제거 및 재고품 처리\n\n` +
|
||
`3️⃣ "취소" - 업로드 취소\n\n` +
|
||
`숫자를 입력하세요 (1, 2, 3):`;
|
||
} else {
|
||
// 케이스 2: 구매신청 전인 경우
|
||
alertMessage += `⚠️ 아직 구매신청이 진행되지 않았습니다.\n\n` +
|
||
`다음 중 선택하세요:\n\n` +
|
||
`1️⃣ "일부만 업로드" - 변경된 도면만 업로드, 기존 도면 유지\n` +
|
||
` → 누락된 도면의 자재는 그대로 유지\n\n` +
|
||
`2️⃣ "도면 삭제됨" - 누락된 도면 완전 삭제\n` +
|
||
` → 해당 자재 완전 제거 (숨김 처리)\n\n` +
|
||
`3️⃣ "취소" - 업로드 취소\n\n` +
|
||
`숫자를 입력하세요 (1, 2, 3):`;
|
||
}
|
||
|
||
const userChoice = prompt(alertMessage);
|
||
|
||
if (userChoice === '3' || userChoice === null) {
|
||
// 취소 선택
|
||
await api.delete(`/files/${fileId}`);
|
||
alert('업로드가 취소되었습니다.');
|
||
return;
|
||
} else if (userChoice === '2') {
|
||
// 도면 삭제됨 - 백엔드에 삭제 처리 요청
|
||
try {
|
||
await api.post(`/files/${fileId}/process-missing-drawings`, {
|
||
action: 'delete',
|
||
drawings: missingDrawings
|
||
});
|
||
alert(`✅ ${missingDrawings.length}개 도면이 삭제 처리되었습니다.`);
|
||
} catch (err) {
|
||
console.error('도면 삭제 처리 실패:', err);
|
||
alert('도면 삭제 처리에 실패했습니다.');
|
||
}
|
||
} else if (userChoice === '1') {
|
||
// 일부만 업로드 - 이미 처리됨 (기본 동작)
|
||
alert(`✅ 일부 업로드로 처리되었습니다.\n누락된 ${missingDrawings.length}개 도면은 기존 상태를 유지합니다.`);
|
||
} else {
|
||
// 잘못된 입력
|
||
await api.delete(`/files/${fileId}`);
|
||
alert('잘못된 입력입니다. 업로드가 취소되었습니다.');
|
||
return;
|
||
}
|
||
}
|
||
|
||
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',
|
||
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 파일 목록 ({Object.keys(groupedFiles).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>
|
||
{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 === latestFile.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(latestFile, 'bom_name');
|
||
}}
|
||
>
|
||
{bomName}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
|
||
📄 {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();
|
||
handleRevisionUpload(latestFile);
|
||
}}
|
||
style={{
|
||
padding: '4px 8px',
|
||
background: 'white',
|
||
color: '#4299e1',
|
||
border: '1px solid #4299e1',
|
||
borderRadius: '3px',
|
||
cursor: 'pointer',
|
||
fontSize: '11px'
|
||
}}
|
||
>
|
||
📝 리비전
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (window.confirm(`${bomName} BOM을 삭제하시겠습니까? (모든 리비전이 삭제됩니다)`)) {
|
||
// 모든 리비전 파일 삭제
|
||
bomFiles.forEach(file => 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={() => handleRevisionUpload(selectedFile)}
|
||
disabled={uploading}
|
||
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;
|