feat: 자재 관리 페이지 대규모 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산) - UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인) - 자재별 그룹핑 로직 개선: * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시 * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등) * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시 * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계) * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시 * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑 - 전체 카테고리 버튼 제거 (표시 복잡도 감소) - 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
This commit is contained in:
@@ -1,114 +1,70 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi, api } from '../api';
|
||||
import { api, fetchFiles, deleteFile as deleteFileApi } from '../api';
|
||||
import BOMFileUpload from '../components/BOMFileUpload';
|
||||
import BOMFileTable from '../components/BOMFileTable';
|
||||
import RevisionUploadDialog from '../components/RevisionUploadDialog';
|
||||
|
||||
const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
|
||||
const BOMStatusPage = ({ jobNo, jobName, onNavigate, selectedProject }) => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [bomName, setBomName] = useState('');
|
||||
const [revisionDialog, setRevisionDialog] = useState({ open: false, bomName: '', parentId: null });
|
||||
const [revisionFile, setRevisionFile] = useState(null);
|
||||
const [purchaseModal, setPurchaseModal] = useState({ open: false, data: null, fileInfo: null });
|
||||
|
||||
// 카테고리별 색상 함수
|
||||
const getCategoryColor = (category) => {
|
||||
const colors = {
|
||||
'pipe': '#4299e1',
|
||||
'fitting': '#48bb78',
|
||||
'valve': '#ed8936',
|
||||
'flange': '#9f7aea',
|
||||
'bolt': '#38b2ac',
|
||||
'gasket': '#f56565',
|
||||
'instrument': '#d69e2e',
|
||||
'material': '#718096',
|
||||
'integrated': '#319795',
|
||||
'unknown': '#a0aec0'
|
||||
};
|
||||
return colors[category?.toLowerCase()] || colors.unknown;
|
||||
};
|
||||
useEffect(() => {
|
||||
if (jobNo) {
|
||||
fetchFilesList();
|
||||
}
|
||||
}, [jobNo]);
|
||||
|
||||
// 파일 목록 불러오기
|
||||
const fetchFiles = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const fetchFilesList = async () => {
|
||||
try {
|
||||
console.log('fetchFiles 호출 - jobNo:', jobNo);
|
||||
const response = await fetchFilesApi({ job_no: jobNo });
|
||||
console.log('API 응답:', response);
|
||||
setLoading(true);
|
||||
const response = await api.get('/files/', {
|
||||
params: { job_no: jobNo }
|
||||
});
|
||||
|
||||
if (response.data && response.data.data && Array.isArray(response.data.data)) {
|
||||
setFiles(response.data.data);
|
||||
} else if (response.data && Array.isArray(response.data)) {
|
||||
// API가 배열로 직접 반환하는 경우
|
||||
if (Array.isArray(response.data)) {
|
||||
setFiles(response.data);
|
||||
} else if (response.data && Array.isArray(response.data.files)) {
|
||||
setFiles(response.data.files);
|
||||
} else if (response.data && response.data.success) {
|
||||
setFiles(response.data.files || []);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('파일 목록 불러오기 실패:', err);
|
||||
console.error('파일 목록 로딩 실패:', err);
|
||||
setError('파일 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (jobNo) {
|
||||
fetchFiles();
|
||||
}
|
||||
}, [jobNo]);
|
||||
|
||||
// 파일 업로드
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || !bomName.trim()) {
|
||||
setError('파일과 BOM 이름을 모두 입력해주세요.');
|
||||
alert('파일과 BOM 이름을 모두 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('bom_name', bomName.trim());
|
||||
formData.append('job_no', jobNo);
|
||||
|
||||
const uploadResult = await uploadFileApi(formData);
|
||||
|
||||
// 업로드 성공 후 파일 목록 새로고침
|
||||
await fetchFiles();
|
||||
|
||||
// 업로드 완료 후 자동으로 구매 수량 계산 실행
|
||||
if (uploadResult && uploadResult.file_id) {
|
||||
// 잠시 후 구매 수량 계산 페이지로 이동
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// 구매 수량 계산 API 호출
|
||||
const response = await fetch(`/api/purchase/calculate?job_no=${jobNo}&revision=Rev.0&file_id=${uploadResult.file_id}`);
|
||||
const purchaseData = await response.json();
|
||||
|
||||
if (purchaseData.success) {
|
||||
// 구매 수량 계산 결과를 모달로 표시하거나 별도 페이지로 이동
|
||||
alert(`업로드 및 분류 완료!\n구매 수량이 계산되었습니다.\n\n파이프: ${purchaseData.purchase_items?.filter(item => item.category === 'PIPE').length || 0}개 항목\n기타 자재: ${purchaseData.purchase_items?.filter(item => item.category !== 'PIPE').length || 0}개 항목`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('구매 수량 계산 실패:', error);
|
||||
}
|
||||
}, 2000); // 2초 후 실행 (분류 완료 대기)
|
||||
const response = await api.post('/files/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
alert('파일이 성공적으로 업로드되었습니다!');
|
||||
setSelectedFile(null);
|
||||
setBomName('');
|
||||
await fetchFilesList(); // 목록 새로고침
|
||||
} else {
|
||||
throw new Error(response.data?.message || '업로드 실패');
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
setSelectedFile(null);
|
||||
setBomName('');
|
||||
document.getElementById('file-input').value = '';
|
||||
|
||||
} catch (err) {
|
||||
console.error('파일 업로드 실패:', err);
|
||||
setError('파일 업로드에 실패했습니다.');
|
||||
@@ -125,111 +81,26 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
|
||||
|
||||
try {
|
||||
await deleteFileApi(fileId);
|
||||
await fetchFiles(); // 목록 새로고침
|
||||
await fetchFilesList(); // 목록 새로고침
|
||||
} catch (err) {
|
||||
console.error('파일 삭제 실패:', err);
|
||||
setError('파일 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 자재 확인 페이지로 이동
|
||||
// 구매 수량 계산 (자재 목록 페이지 거치지 않음)
|
||||
const handleViewMaterials = async (file) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 구매 수량 계산 API 호출
|
||||
console.log('구매 수량 계산 API 호출:', {
|
||||
job_no: file.job_no,
|
||||
revision: file.revision || 'Rev.0',
|
||||
file_id: file.id
|
||||
// 자재 관리 페이지로 바로 이동 (단순화)
|
||||
const handleViewMaterials = (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
|
||||
});
|
||||
|
||||
const response = await api.get(`/purchase/items/calculate?job_no=${file.job_no}&revision=${file.revision || 'Rev.0'}&file_id=${file.id}`);
|
||||
|
||||
console.log('구매 수량 계산 응답:', response.data);
|
||||
const purchaseData = response.data;
|
||||
|
||||
if (purchaseData.success && purchaseData.items) {
|
||||
// 구매 수량 계산 결과를 모달로 표시
|
||||
setPurchaseModal({
|
||||
open: true,
|
||||
data: purchaseData.items,
|
||||
fileInfo: file
|
||||
});
|
||||
} else {
|
||||
alert('구매 수량 계산에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('구매 수량 계산 오류:', error);
|
||||
alert('구매 수량 계산 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 리비전 업로드 다이얼로그 열기
|
||||
const openRevisionDialog = (bomName, parentId) => {
|
||||
setRevisionDialog({ open: true, bomName, parentId });
|
||||
};
|
||||
|
||||
// 리비전 업로드
|
||||
const handleRevisionUpload = async () => {
|
||||
if (!revisionFile || !revisionDialog.bomName) {
|
||||
setError('파일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', revisionFile);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('bom_name', revisionDialog.bomName);
|
||||
formData.append('parent_id', revisionDialog.parentId);
|
||||
|
||||
await uploadFileApi(formData);
|
||||
|
||||
// 업로드 성공 후 파일 목록 새로고침
|
||||
await fetchFiles();
|
||||
|
||||
// 다이얼로그 닫기
|
||||
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||
setRevisionFile(null);
|
||||
|
||||
} catch (err) {
|
||||
console.error('리비전 업로드 실패:', err);
|
||||
setError('리비전 업로드에 실패했습니다.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// BOM별로 그룹화
|
||||
const groupFilesByBOM = () => {
|
||||
const grouped = {};
|
||||
files.forEach(file => {
|
||||
const bomKey = file.bom_name || file.original_filename || file.filename;
|
||||
if (!grouped[bomKey]) {
|
||||
grouped[bomKey] = [];
|
||||
}
|
||||
grouped[bomKey].push(file);
|
||||
});
|
||||
|
||||
// 각 그룹을 리비전 순으로 정렬
|
||||
Object.keys(grouped).forEach(key => {
|
||||
grouped[key].sort((a, b) => {
|
||||
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
|
||||
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
|
||||
return revB - revA; // 최신 리비전이 먼저 오도록
|
||||
});
|
||||
});
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
@@ -240,7 +111,11 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate && onNavigate('bom')}
|
||||
onClick={() => {
|
||||
if (onNavigate) {
|
||||
onNavigate('dashboard');
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
@@ -250,7 +125,7 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
← 뒤로가기
|
||||
← 메인으로 돌아가기
|
||||
</button>
|
||||
|
||||
<h1 style={{
|
||||
@@ -295,205 +170,126 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
|
||||
업로드된 BOM 목록
|
||||
</h3>
|
||||
|
||||
{/* 파일 테이블 컴포넌트 */}
|
||||
<BOMFileTable
|
||||
files={files}
|
||||
loading={loading}
|
||||
groupFilesByBOM={groupFilesByBOM}
|
||||
handleViewMaterials={handleViewMaterials}
|
||||
openRevisionDialog={openRevisionDialog}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 리비전 업로드 다이얼로그 */}
|
||||
<RevisionUploadDialog
|
||||
revisionDialog={revisionDialog}
|
||||
setRevisionDialog={setRevisionDialog}
|
||||
revisionFile={revisionFile}
|
||||
setRevisionFile={setRevisionFile}
|
||||
handleRevisionUpload={handleRevisionUpload}
|
||||
uploading={uploading}
|
||||
/>
|
||||
|
||||
{/* 구매 수량 계산 결과 모달 */}
|
||||
{purchaseModal.open && (
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '1000px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
margin: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: 0
|
||||
}}>
|
||||
🧮 구매 수량 계산 결과
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setPurchaseModal({ open: false, data: null, fileInfo: null })}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
✕ 닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px', color: '#4a5568' }}>
|
||||
<div><strong>프로젝트:</strong> {purchaseModal.fileInfo?.job_no}</div>
|
||||
<div><strong>BOM:</strong> {purchaseModal.fileInfo?.bom_name}</div>
|
||||
<div><strong>리비전:</strong> {purchaseModal.fileInfo?.revision || 'Rev.0'}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사양</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사이즈</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>BOM 수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>구매 수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{purchaseModal.data?.map((item, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
<span style={{
|
||||
background: getCategoryColor(item.category),
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
<th style={{ padding: '16px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>BOM 이름</th>
|
||||
<th style={{ padding: '16px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>파일명</th>
|
||||
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>리비전</th>
|
||||
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>자재 수</th>
|
||||
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>업로드 일시</th>
|
||||
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<tr key={file.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ fontWeight: '600', color: '#2d3748' }}>
|
||||
{file.bom_name || file.original_filename}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#718096' }}>
|
||||
{file.description || ''}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px', fontSize: '14px', color: '#4a5568' }}>
|
||||
{file.original_filename}
|
||||
</td>
|
||||
<td style={{ padding: '16px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
background: '#e6fffa',
|
||||
color: '#065f46',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{file.revision || 'Rev.0'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px', textAlign: 'center' }}>
|
||||
{file.parsed_count || 0}개
|
||||
</td>
|
||||
<td style={{ padding: '16px', textAlign: 'center', fontSize: '14px', color: '#718096' }}>
|
||||
{new Date(file.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td style={{ padding: '16px', textAlign: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={() => handleViewMaterials(file)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#4299e1',
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{item.category}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{item.specification}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{/* PIPE는 사양에 모든 정보가 포함되므로 사이즈 컬럼 비움 */}
|
||||
{item.category !== 'PIPE' && (
|
||||
<span style={{
|
||||
background: '#e6fffa',
|
||||
color: '#065f46',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{item.size_spec || '-'}
|
||||
</span>
|
||||
)}
|
||||
{item.category === 'PIPE' && (
|
||||
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
사양에 포함
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{/* PIPE는 사양에 모든 정보가 포함되므로 재질 컬럼 비움 */}
|
||||
{item.category !== 'PIPE' && (
|
||||
<span style={{
|
||||
background: '#fef7e0',
|
||||
color: '#92400e',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{item.material_spec || '-'}
|
||||
</span>
|
||||
)}
|
||||
{item.category === 'PIPE' && (
|
||||
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
사양에 포함
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
|
||||
{item.category === 'PIPE' ?
|
||||
`${Math.round(item.bom_quantity)}mm` :
|
||||
item.bom_quantity
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right', fontWeight: '600' }}>
|
||||
{item.category === 'PIPE' ?
|
||||
`${item.pipes_count}본 (${Math.round(item.calculated_qty)}mm)` :
|
||||
item.calculated_qty
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{item.unit}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '12px', color: '#718096' }}>
|
||||
{item.category === 'PIPE' && (
|
||||
<div>
|
||||
<div>절단수: {item.cutting_count}회</div>
|
||||
<div>절단손실: {item.cutting_loss}mm</div>
|
||||
<div>활용률: {Math.round(item.utilization_rate)}%</div>
|
||||
</div>
|
||||
)}
|
||||
{item.category !== 'PIPE' && item.safety_factor && (
|
||||
<div>여유율: {Math.round((item.safety_factor - 1) * 100)}%</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568'
|
||||
}}
|
||||
>
|
||||
📋 자재 보기
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 리비전 업로드 기능 (추후 구현)
|
||||
alert('리비전 업로드 기능은 준비 중입니다.');
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: 'white',
|
||||
color: '#4299e1',
|
||||
border: '1px solid #4299e1',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
📝 리비전
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(file.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#f56565',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{files.length === 0 && (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: '#718096'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '8px' }}>📋 계산 규칙 (올바른 규칙):</div>
|
||||
<div>• <strong>PIPE:</strong> 6M 단위 올림, 절단당 2mm 손실</div>
|
||||
<div>• <strong>FITTING:</strong> BOM 수량 그대로</div>
|
||||
<div>• <strong>VALVE:</strong> BOM 수량 그대로</div>
|
||||
<div>• <strong>BOLT:</strong> 5% 여유율 후 4의 배수 올림</div>
|
||||
<div>• <strong>GASKET:</strong> 5의 배수 올림</div>
|
||||
<div>• <strong>INSTRUMENT:</strong> BOM 수량 그대로</div>
|
||||
업로드된 BOM 파일이 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
720
frontend/src/pages/BOMWorkspacePage.jsx
Normal file
720
frontend/src/pages/BOMWorkspacePage.jsx
Normal 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;
|
||||
@@ -262,3 +262,19 @@ const DashboardPage = ({ user }) => {
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -217,3 +217,19 @@
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -114,3 +114,19 @@ const LoginPage = () => {
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
464
frontend/src/pages/NewMaterialsPage.css
Normal file
464
frontend/src/pages/NewMaterialsPage.css
Normal file
@@ -0,0 +1,464 @@
|
||||
/* NewMaterialsPage - DevonThink 스타일 */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.materials-page {
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.materials-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: #5558e3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.materials-header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.job-info {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.material-count {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
background: #f3f4f6;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 카테고리 필터 */
|
||||
.category-filters {
|
||||
background: white;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.category-filters::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.category-filters::-webkit-scrollbar-track {
|
||||
background: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.category-filters::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.category-btn.active {
|
||||
background: #eef2ff;
|
||||
border-color: #6366f1;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.category-btn .count {
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-btn.active .count {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 액션 바 */
|
||||
.action-bar {
|
||||
background: white;
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.select-all-btn,
|
||||
.export-btn {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.select-all-btn {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.select-all-btn:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.export-btn:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.export-btn:disabled {
|
||||
background: #e5e7eb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 자재 테이블 */
|
||||
.materials-grid {
|
||||
background: white;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detailed-grid-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 80px 120px 80px 80px 140px 100px 150px 100px;
|
||||
padding: 12px 24px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* 플랜지 전용 헤더 - 10개 컬럼 */
|
||||
.detailed-grid-header.flange-header {
|
||||
grid-template-columns: 40px 80px 80px 80px 100px 80px 140px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 플랜지 전용 행 - 10개 컬럼 */
|
||||
.detailed-material-row.flange-row {
|
||||
grid-template-columns: 40px 80px 80px 80px 100px 80px 140px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 피팅 전용 헤더 - 10개 컬럼 */
|
||||
.detailed-grid-header.fitting-header {
|
||||
grid-template-columns: 40px 80px 150px 80px 80px 80px 140px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 피팅 전용 행 - 10개 컬럼 */
|
||||
.detailed-material-row.fitting-row {
|
||||
grid-template-columns: 40px 80px 150px 80px 80px 80px 140px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 밸브 전용 헤더 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */
|
||||
.detailed-grid-header.valve-header {
|
||||
grid-template-columns: 40px 120px 100px 80px 80px 140px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 밸브 전용 행 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */
|
||||
.detailed-material-row.valve-row {
|
||||
grid-template-columns: 40px 120px 100px 80px 80px 140px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 가스켓 전용 헤더 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */
|
||||
.detailed-grid-header.gasket-header {
|
||||
grid-template-columns: 40px 80px 60px 80px 80px 100px 180px 60px 80px 150px 100px;
|
||||
}
|
||||
|
||||
/* 가스켓 전용 행 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */
|
||||
.detailed-material-row.gasket-row {
|
||||
grid-template-columns: 40px 80px 60px 80px 80px 100px 180px 60px 80px 150px 100px;
|
||||
}
|
||||
|
||||
/* UNKNOWN 전용 헤더 - 5개 컬럼 */
|
||||
.detailed-grid-header.unknown-header {
|
||||
grid-template-columns: 40px 100px 1fr 150px 100px;
|
||||
}
|
||||
|
||||
/* UNKNOWN 전용 행 - 5개 컬럼 */
|
||||
.detailed-material-row.unknown-row {
|
||||
grid-template-columns: 40px 100px 1fr 150px 100px;
|
||||
}
|
||||
|
||||
/* UNKNOWN 설명 셀 스타일 */
|
||||
.description-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailed-material-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 80px 120px 80px 80px 140px 100px 150px 100px;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
align-items: center;
|
||||
transition: background 0.15s;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detailed-material-row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.detailed-material-row.selected {
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
.material-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.material-cell input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 타입 배지 */
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.type-badge.pipe {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.fitting {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.valve {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.flange {
|
||||
background: #8b5cf6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.bolt {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.gasket {
|
||||
background: #06b6d4;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.unknown {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.instrument {
|
||||
background: #78716c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.unknown {
|
||||
background: #9ca3af;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 텍스트 스타일 */
|
||||
.subtype-text,
|
||||
.size-text,
|
||||
.material-grade {
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 입력 필드 */
|
||||
.user-req-input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: #fafbfc;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.user-req-input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.user-req-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 수량 정보 */
|
||||
.quantity-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* 플랜지 압력 정보 */
|
||||
.pressure-info {
|
||||
font-weight: 600;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.quantity-value {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.quantity-details {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 로딩 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #f3f4f6;
|
||||
border-top: 3px solid #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-container p {
|
||||
margin-top: 16px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
971
frontend/src/pages/NewMaterialsPage.jsx
Normal file
971
frontend/src/pages/NewMaterialsPage.jsx
Normal file
@@ -0,0 +1,971 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { fetchMaterials } from '../api';
|
||||
import './NewMaterialsPage.css';
|
||||
|
||||
const NewMaterialsPage = ({
|
||||
onNavigate,
|
||||
selectedProject,
|
||||
fileId,
|
||||
jobNo,
|
||||
bomName,
|
||||
revision,
|
||||
filename
|
||||
}) => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCategory, setSelectedCategory] = useState('PIPE');
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [viewMode, setViewMode] = useState('detailed'); // 'detailed' or 'simple'
|
||||
|
||||
// 자재 데이터 로드
|
||||
useEffect(() => {
|
||||
if (fileId) {
|
||||
loadMaterials(fileId);
|
||||
}
|
||||
}, [fileId]);
|
||||
|
||||
const loadMaterials = async (id) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log('🔍 자재 데이터 로딩 중...', { file_id: id });
|
||||
|
||||
const response = await fetchMaterials({
|
||||
file_id: parseInt(id),
|
||||
limit: 10000
|
||||
});
|
||||
|
||||
if (response.data?.materials) {
|
||||
const materialsData = response.data.materials;
|
||||
console.log(`✅ ${materialsData.length}개 자재 로드 완료`);
|
||||
|
||||
// 파이프 데이터 검증
|
||||
const pipes = materialsData.filter(m => m.classified_category === 'PIPE');
|
||||
if (pipes.length > 0) {
|
||||
console.log('📊 파이프 데이터 샘플:', pipes[0]);
|
||||
}
|
||||
|
||||
setMaterials(materialsData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 자재 로딩 실패:', error);
|
||||
setMaterials([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리별 자재 수 계산
|
||||
const getCategoryCounts = () => {
|
||||
const counts = {};
|
||||
materials.forEach(material => {
|
||||
const category = material.classified_category || 'UNKNOWN';
|
||||
counts[category] = (counts[category] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
};
|
||||
|
||||
// 파이프 구매 수량 계산 함수
|
||||
const calculatePipePurchase = (material) => {
|
||||
// 백엔드에서 이미 그룹핑된 데이터 사용
|
||||
const totalLength = material.pipe_details?.total_length_mm || 0;
|
||||
const pipeCount = material.pipe_details?.pipe_count || material.quantity || 0;
|
||||
|
||||
// 절단 손실: 각 단관마다 2mm
|
||||
const cuttingLoss = pipeCount * 2;
|
||||
|
||||
// 총 필요 길이
|
||||
const requiredLength = totalLength + cuttingLoss;
|
||||
|
||||
// 6M(6000mm) 단위로 구매 본수 계산
|
||||
const purchaseCount = Math.ceil(requiredLength / 6000);
|
||||
|
||||
return {
|
||||
pipeCount, // 단관 개수
|
||||
totalLength, // 총 BOM 길이
|
||||
cuttingLoss, // 절단 손실
|
||||
requiredLength, // 필요 길이
|
||||
purchaseCount // 구매 본수
|
||||
};
|
||||
};
|
||||
|
||||
// 자재 정보 파싱
|
||||
const parseMaterialInfo = (material) => {
|
||||
const category = material.classified_category;
|
||||
|
||||
if (category === 'PIPE') {
|
||||
const calc = calculatePipePurchase(material);
|
||||
return {
|
||||
type: 'PIPE',
|
||||
subtype: material.pipe_details?.manufacturing_method || 'SMLS',
|
||||
size: material.size_spec || '-',
|
||||
schedule: material.pipe_details?.schedule || '-',
|
||||
grade: material.material_grade || '-',
|
||||
quantity: calc.purchaseCount,
|
||||
unit: '본',
|
||||
details: calc
|
||||
};
|
||||
} else if (category === 'FITTING') {
|
||||
const fittingDetails = material.fitting_details || {};
|
||||
const fittingType = fittingDetails.fitting_type || '';
|
||||
const fittingSubtype = fittingDetails.fitting_subtype || '';
|
||||
const description = material.original_description || '';
|
||||
|
||||
// 피팅 타입별 상세 표시
|
||||
let displayType = '';
|
||||
|
||||
// CAP과 PLUG 먼저 확인 (fitting_type이 없을 수 있음)
|
||||
if (description.toUpperCase().includes('CAP')) {
|
||||
// CAP: 연결 방식 표시 (예: CAP, NPT(F), 3000LB, ASTM A105)
|
||||
if (description.includes('NPT(F)')) {
|
||||
displayType = 'CAP NPT(F)';
|
||||
} else if (description.includes('SW')) {
|
||||
displayType = 'CAP SW';
|
||||
} else if (description.includes('BW')) {
|
||||
displayType = 'CAP BW';
|
||||
} else {
|
||||
displayType = 'CAP';
|
||||
}
|
||||
} else if (description.toUpperCase().includes('PLUG')) {
|
||||
// PLUG: 타입과 연결 방식 표시 (예: HEX.PLUG, NPT(M), 6000LB, ASTM A105)
|
||||
if (description.toUpperCase().includes('HEX')) {
|
||||
if (description.includes('NPT(M)')) {
|
||||
displayType = 'HEX PLUG NPT(M)';
|
||||
} else {
|
||||
displayType = 'HEX PLUG';
|
||||
}
|
||||
} else if (description.includes('NPT(M)')) {
|
||||
displayType = 'PLUG NPT(M)';
|
||||
} else if (description.includes('NPT')) {
|
||||
displayType = 'PLUG NPT';
|
||||
} else {
|
||||
displayType = 'PLUG';
|
||||
}
|
||||
} else if (fittingType === 'NIPPLE') {
|
||||
// 니플: 길이와 끝단 가공 정보
|
||||
const length = fittingDetails.length_mm || fittingDetails.avg_length_mm;
|
||||
displayType = length ? `NIPPLE ${length}mm` : 'NIPPLE';
|
||||
} else if (fittingType === 'ELBOW') {
|
||||
// 엘보: 각도와 연결 방식
|
||||
const angle = fittingSubtype === '90DEG' ? '90°' : fittingSubtype === '45DEG' ? '45°' : '';
|
||||
const connection = description.includes('SW') ? 'SW' : description.includes('BW') ? 'BW' : '';
|
||||
displayType = `ELBOW ${angle} ${connection}`.trim();
|
||||
} else if (fittingType === 'TEE') {
|
||||
// 티: 타입과 연결 방식
|
||||
const teeType = fittingSubtype === 'EQUAL' ? 'EQ' : fittingSubtype === 'REDUCING' ? 'RED' : '';
|
||||
const connection = description.includes('SW') ? 'SW' : description.includes('BW') ? 'BW' : '';
|
||||
displayType = `TEE ${teeType} ${connection}`.trim();
|
||||
} else if (fittingType === 'REDUCER') {
|
||||
// 레듀서: 콘센트릭/에센트릭
|
||||
const reducerType = fittingSubtype === 'CONCENTRIC' ? 'CONC' : fittingSubtype === 'ECCENTRIC' ? 'ECC' : '';
|
||||
const sizes = fittingDetails.reduced_size ? `${material.size_spec}×${fittingDetails.reduced_size}` : material.size_spec;
|
||||
displayType = `RED ${reducerType} ${sizes}`.trim();
|
||||
} else if (fittingType === 'SWAGE') {
|
||||
// 스웨이지: 타입 명시
|
||||
const swageType = fittingSubtype || '';
|
||||
displayType = `SWAGE ${swageType}`.trim();
|
||||
} else if (!displayType) {
|
||||
// 기타 피팅 타입
|
||||
displayType = fittingType || 'FITTING';
|
||||
}
|
||||
|
||||
// 압력 등급과 스케줄 추출
|
||||
let pressure = '-';
|
||||
let schedule = '-';
|
||||
|
||||
// 압력 등급 찾기 (3000LB, 6000LB 등)
|
||||
const pressureMatch = description.match(/(\d+)LB/i);
|
||||
if (pressureMatch) {
|
||||
pressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
// 스케줄 찾기
|
||||
if (description.includes('SCH')) {
|
||||
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
|
||||
if (schMatch) {
|
||||
schedule = `SCH ${schMatch[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FITTING',
|
||||
subtype: displayType,
|
||||
size: material.size_spec || '-',
|
||||
pressure: pressure,
|
||||
schedule: schedule,
|
||||
grade: material.material_grade || '-',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개',
|
||||
isFitting: true
|
||||
};
|
||||
} else if (category === 'VALVE') {
|
||||
const valveDetails = material.valve_details || {};
|
||||
const description = material.original_description || '';
|
||||
|
||||
// 밸브 타입 파싱 (GATE, BALL, CHECK, GLOBE 등)
|
||||
let valveType = valveDetails.valve_type || '';
|
||||
if (!valveType && description) {
|
||||
if (description.includes('GATE')) valveType = 'GATE';
|
||||
else if (description.includes('BALL')) valveType = 'BALL';
|
||||
else if (description.includes('CHECK')) valveType = 'CHECK';
|
||||
else if (description.includes('GLOBE')) valveType = 'GLOBE';
|
||||
else if (description.includes('BUTTERFLY')) valveType = 'BUTTERFLY';
|
||||
}
|
||||
|
||||
// 연결 방식 파싱 (FLG, SW, THRD 등)
|
||||
let connectionType = '';
|
||||
if (description.includes('FLG')) {
|
||||
connectionType = 'FLG';
|
||||
} else if (description.includes('SW X THRD')) {
|
||||
connectionType = 'SW×THRD';
|
||||
} else if (description.includes('SW')) {
|
||||
connectionType = 'SW';
|
||||
} else if (description.includes('THRD')) {
|
||||
connectionType = 'THRD';
|
||||
} else if (description.includes('BW')) {
|
||||
connectionType = 'BW';
|
||||
}
|
||||
|
||||
// 압력 등급 파싱
|
||||
let pressure = '-';
|
||||
const pressureMatch = description.match(/(\d+)LB/i);
|
||||
if (pressureMatch) {
|
||||
pressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
// 스케줄은 밸브에는 일반적으로 없음
|
||||
let schedule = '-';
|
||||
|
||||
return {
|
||||
type: 'VALVE',
|
||||
valveType: valveType,
|
||||
connectionType: connectionType,
|
||||
size: material.size_spec || '-',
|
||||
pressure: pressure,
|
||||
schedule: schedule,
|
||||
grade: material.material_grade || '-',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개',
|
||||
isValve: true
|
||||
};
|
||||
} else if (category === 'FLANGE') {
|
||||
// 플랜지 타입 변환
|
||||
const flangeTypeMap = {
|
||||
'WELD_NECK': 'WN',
|
||||
'SLIP_ON': 'SO',
|
||||
'BLIND': 'BL',
|
||||
'SOCKET_WELD': 'SW',
|
||||
'LAP_JOINT': 'LJ',
|
||||
'THREADED': 'TH',
|
||||
'ORIFICE': 'ORIFICE' // 오리피스는 풀네임 표시
|
||||
};
|
||||
const flangeType = material.flange_details?.flange_type;
|
||||
const displayType = flangeTypeMap[flangeType] || flangeType || '-';
|
||||
|
||||
// 원본 설명에서 스케줄 추출
|
||||
let schedule = '-';
|
||||
const description = material.original_description || '';
|
||||
|
||||
// SCH 40, SCH 80 등의 패턴 찾기
|
||||
if (description.toUpperCase().includes('SCH')) {
|
||||
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
|
||||
if (schMatch && schMatch[1]) {
|
||||
schedule = `SCH ${schMatch[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FLANGE',
|
||||
subtype: displayType,
|
||||
size: material.size_spec || '-',
|
||||
pressure: material.flange_details?.pressure_rating || '-',
|
||||
schedule: schedule,
|
||||
grade: material.material_grade || '-',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개',
|
||||
isFlange: true // 플랜지 구분용 플래그
|
||||
};
|
||||
} else if (category === 'BOLT') {
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
const safetyQty = Math.ceil(qty * 1.05); // 5% 여유율
|
||||
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
|
||||
return {
|
||||
type: 'BOLT',
|
||||
subtype: material.bolt_details?.bolt_type || '-',
|
||||
size: material.size_spec || '-',
|
||||
schedule: material.bolt_details?.length || '-',
|
||||
grade: material.material_grade || '-',
|
||||
quantity: purchaseQty,
|
||||
unit: 'SETS'
|
||||
};
|
||||
} else if (category === 'GASKET') {
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
const purchaseQty = Math.ceil(qty / 5) * 5; // 5의 배수
|
||||
|
||||
// original_description에서 재질 정보 파싱
|
||||
const description = material.original_description || '';
|
||||
let materialStructure = '-'; // H/F/I/O 부분
|
||||
let materialDetail = '-'; // SS304/GRAPHITE/CS/CS 부분
|
||||
|
||||
// H/F/I/O와 재질 상세 정보 추출
|
||||
const materialMatch = description.match(/H\/F\/I\/O\s+(.+?)(?:,|$)/);
|
||||
if (materialMatch) {
|
||||
materialStructure = 'H/F/I/O';
|
||||
materialDetail = materialMatch[1].trim();
|
||||
// 두께 정보 제거 (별도 추출)
|
||||
materialDetail = materialDetail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim();
|
||||
}
|
||||
|
||||
// 압력 정보 추출
|
||||
let pressure = '-';
|
||||
const pressureMatch = description.match(/(\d+LB)/);
|
||||
if (pressureMatch) {
|
||||
pressure = pressureMatch[1];
|
||||
}
|
||||
|
||||
// 두께 정보 추출
|
||||
let thickness = '-';
|
||||
const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i);
|
||||
if (thicknessMatch) {
|
||||
thickness = thicknessMatch[1] + 'mm';
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'GASKET',
|
||||
subtype: 'SWG', // 항상 SWG로 표시
|
||||
size: material.size_spec || '-',
|
||||
pressure: pressure,
|
||||
materialStructure: materialStructure,
|
||||
materialDetail: materialDetail,
|
||||
thickness: thickness,
|
||||
quantity: purchaseQty,
|
||||
unit: '개',
|
||||
isGasket: true
|
||||
};
|
||||
} else if (category === 'UNKNOWN') {
|
||||
return {
|
||||
type: 'UNKNOWN',
|
||||
description: material.original_description || 'Unknown Item',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개',
|
||||
isUnknown: true
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: category || 'UNKNOWN',
|
||||
subtype: '-',
|
||||
size: material.size_spec || '-',
|
||||
schedule: '-',
|
||||
grade: material.material_grade || '-',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 필터링된 자재 목록
|
||||
const filteredMaterials = materials.filter(material => {
|
||||
return material.classified_category === selectedCategory;
|
||||
});
|
||||
|
||||
// 카테고리 색상 (제거 - CSS에서 처리)
|
||||
|
||||
// 전체 선택/해제
|
||||
const toggleAllSelection = () => {
|
||||
if (selectedMaterials.size === filteredMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(filteredMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택
|
||||
const toggleMaterialSelection = (id) => {
|
||||
const newSelection = new Set(selectedMaterials);
|
||||
if (newSelection.has(id)) {
|
||||
newSelection.delete(id);
|
||||
} else {
|
||||
newSelection.add(id);
|
||||
}
|
||||
setSelectedMaterials(newSelection);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const exportToExcel = () => {
|
||||
const selectedData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
console.log('📊 엑셀 내보내기:', selectedData.length, '개 항목');
|
||||
alert(`${selectedData.length}개 항목을 엑셀로 내보냅니다.`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>자재 목록을 불러오는 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const categoryCounts = getCategoryCounts();
|
||||
|
||||
return (
|
||||
<div className="materials-page">
|
||||
{/* 헤더 */}
|
||||
<div className="materials-header">
|
||||
<div className="header-left">
|
||||
<button onClick={() => onNavigate('bom')} className="back-button">
|
||||
← BOM 업로드로 돌아가기
|
||||
</button>
|
||||
<h1>자재 목록</h1>
|
||||
{jobNo && (
|
||||
<span className="job-info">
|
||||
{jobNo} {revision && `(${revision})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<span className="material-count">
|
||||
총 {materials.length}개 자재
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="category-filters">
|
||||
{Object.entries(categoryCounts).map(([category, count]) => (
|
||||
<button
|
||||
key={category}
|
||||
className={`category-btn ${selectedCategory === category ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
>
|
||||
{category} <span className="count">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 액션 바 */}
|
||||
<div className="action-bar">
|
||||
<div className="selection-info">
|
||||
{selectedMaterials.size}개 중 {filteredMaterials.length}개 선택
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button
|
||||
onClick={toggleAllSelection}
|
||||
className="select-all-btn"
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? '전체 해제' : '전체 선택'}
|
||||
</button>
|
||||
<button
|
||||
onClick={exportToExcel}
|
||||
className="export-btn"
|
||||
disabled={selectedMaterials.size === 0}
|
||||
>
|
||||
엑셀 내보내기 ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자재 테이블 */}
|
||||
<div className="materials-grid">
|
||||
{/* 플랜지 전용 헤더 */}
|
||||
{selectedCategory === 'FLANGE' ? (
|
||||
<div className="detailed-grid-header flange-header">
|
||||
<div>선택</div>
|
||||
<div>종류</div>
|
||||
<div>타입</div>
|
||||
<div>크기</div>
|
||||
<div>압력(파운드)</div>
|
||||
<div>스케줄</div>
|
||||
<div>재질</div>
|
||||
<div>추가요구</div>
|
||||
<div>사용자요구</div>
|
||||
<div>수량</div>
|
||||
</div>
|
||||
) : selectedCategory === 'FITTING' ? (
|
||||
<div className="detailed-grid-header fitting-header">
|
||||
<div>선택</div>
|
||||
<div>종류</div>
|
||||
<div>타입/상세</div>
|
||||
<div>크기</div>
|
||||
<div>압력</div>
|
||||
<div>스케줄</div>
|
||||
<div>재질</div>
|
||||
<div>추가요구</div>
|
||||
<div>사용자요구</div>
|
||||
<div>수량</div>
|
||||
</div>
|
||||
) : selectedCategory === 'GASKET' ? (
|
||||
<div className="detailed-grid-header gasket-header">
|
||||
<div>선택</div>
|
||||
<div>종류</div>
|
||||
<div>타입</div>
|
||||
<div>크기</div>
|
||||
<div>압력</div>
|
||||
<div>재질</div>
|
||||
<div>상세내역</div>
|
||||
<div>두께</div>
|
||||
<div>추가요구</div>
|
||||
<div>사용자요구</div>
|
||||
<div>수량</div>
|
||||
</div>
|
||||
) : selectedCategory === 'VALVE' ? (
|
||||
<div className="detailed-grid-header valve-header">
|
||||
<div>선택</div>
|
||||
<div>타입</div>
|
||||
<div>연결방식</div>
|
||||
<div>크기</div>
|
||||
<div>압력</div>
|
||||
<div>재질</div>
|
||||
<div>추가요구</div>
|
||||
<div>사용자요구</div>
|
||||
<div>수량</div>
|
||||
</div>
|
||||
) : selectedCategory === 'UNKNOWN' ? (
|
||||
<div className="detailed-grid-header unknown-header">
|
||||
<div>선택</div>
|
||||
<div>종류</div>
|
||||
<div>설명</div>
|
||||
<div>사용자요구</div>
|
||||
<div>수량</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="detailed-grid-header">
|
||||
<div>선택</div>
|
||||
<div>종류</div>
|
||||
<div>타입</div>
|
||||
<div>크기</div>
|
||||
<div>스케줄</div>
|
||||
<div>재질</div>
|
||||
<div>추가요구</div>
|
||||
<div>사용자요구</div>
|
||||
<div>수량</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredMaterials.map((material) => {
|
||||
const info = parseMaterialInfo(material);
|
||||
|
||||
// 피팅인 경우 10개 컬럼
|
||||
if (info.isFitting) {
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`detailed-material-row fitting-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
|
||||
>
|
||||
{/* 선택 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={() => toggleMaterialSelection(material.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종류 */}
|
||||
<div className="material-cell">
|
||||
<span className={`type-badge ${info.type.toLowerCase()}`}>
|
||||
{info.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 타입/상세 */}
|
||||
<div className="material-cell">
|
||||
<span className="subtype-text">{info.subtype}</span>
|
||||
</div>
|
||||
|
||||
{/* 크기 */}
|
||||
<div className="material-cell">
|
||||
<span className="size-text">{info.size}</span>
|
||||
</div>
|
||||
|
||||
{/* 압력 */}
|
||||
<div className="material-cell">
|
||||
<span className="pressure-info">{info.pressure}</span>
|
||||
</div>
|
||||
|
||||
{/* 스케줄 */}
|
||||
<div className="material-cell">
|
||||
<span>{info.schedule}</span>
|
||||
</div>
|
||||
|
||||
{/* 재질 */}
|
||||
<div className="material-cell">
|
||||
<span className="material-grade">{info.grade}</span>
|
||||
</div>
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 */}
|
||||
<div className="material-cell">
|
||||
<div className="quantity-info">
|
||||
<span className="quantity-value">
|
||||
{info.quantity} {info.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 밸브인 경우 10개 컬럼
|
||||
if (info.isValve) {
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`detailed-material-row valve-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
|
||||
>
|
||||
{/* 선택 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={() => toggleMaterialSelection(material.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 타입 */}
|
||||
<div className="material-cell">
|
||||
<span className="valve-type">{info.valveType}</span>
|
||||
</div>
|
||||
|
||||
{/* 연결방식 */}
|
||||
<div className="material-cell">
|
||||
<span className="connection-type">{info.connectionType}</span>
|
||||
</div>
|
||||
|
||||
{/* 크기 */}
|
||||
<div className="material-cell">
|
||||
<span className="size-text">{info.size}</span>
|
||||
</div>
|
||||
|
||||
{/* 압력 */}
|
||||
<div className="material-cell">
|
||||
<span className="pressure-info">{info.pressure}</span>
|
||||
</div>
|
||||
|
||||
{/* 재질 */}
|
||||
<div className="material-cell">
|
||||
<span className="material-grade">{info.grade}</span>
|
||||
</div>
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 */}
|
||||
<div className="material-cell">
|
||||
<div className="quantity-info">
|
||||
<span className="quantity-value">
|
||||
{info.quantity} {info.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 플랜지인 경우 10개 컬럼
|
||||
if (info.isFlange) {
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`detailed-material-row flange-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
|
||||
>
|
||||
{/* 선택 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={() => toggleMaterialSelection(material.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종류 */}
|
||||
<div className="material-cell">
|
||||
<span className={`type-badge ${info.type.toLowerCase()}`}>
|
||||
{info.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 타입 */}
|
||||
<div className="material-cell">
|
||||
<span className="subtype-text">{info.subtype}</span>
|
||||
</div>
|
||||
|
||||
{/* 크기 */}
|
||||
<div className="material-cell">
|
||||
<span className="size-text">{info.size}</span>
|
||||
</div>
|
||||
|
||||
{/* 압력(파운드) */}
|
||||
<div className="material-cell">
|
||||
<span className="pressure-info">{info.pressure}</span>
|
||||
</div>
|
||||
|
||||
{/* 스케줄 */}
|
||||
<div className="material-cell">
|
||||
<span>{info.schedule}</span>
|
||||
</div>
|
||||
|
||||
{/* 재질 */}
|
||||
<div className="material-cell">
|
||||
<span className="material-grade">{info.grade}</span>
|
||||
</div>
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 */}
|
||||
<div className="material-cell">
|
||||
<div className="quantity-info">
|
||||
<span className="quantity-value">
|
||||
{info.quantity} {info.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// UNKNOWN인 경우 5개 컬럼
|
||||
if (info.isUnknown) {
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`detailed-material-row unknown-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
|
||||
>
|
||||
{/* 선택 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={() => toggleMaterialSelection(material.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종류 */}
|
||||
<div className="material-cell">
|
||||
<span className={`type-badge unknown`}>
|
||||
{info.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="material-cell description-cell">
|
||||
<span className="description-text" title={info.description}>
|
||||
{info.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 */}
|
||||
<div className="material-cell">
|
||||
<div className="quantity-info">
|
||||
<span className="quantity-value">
|
||||
{info.quantity} {info.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 가스켓인 경우 11개 컬럼
|
||||
if (info.isGasket) {
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`detailed-material-row gasket-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
|
||||
>
|
||||
{/* 선택 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={() => toggleMaterialSelection(material.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종류 */}
|
||||
<div className="material-cell">
|
||||
<span className={`type-badge ${info.type.toLowerCase()}`}>
|
||||
{info.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 타입 */}
|
||||
<div className="material-cell">
|
||||
<span className="subtype-text">{info.subtype}</span>
|
||||
</div>
|
||||
|
||||
{/* 크기 */}
|
||||
<div className="material-cell">
|
||||
<span className="size-text">{info.size}</span>
|
||||
</div>
|
||||
|
||||
{/* 압력 */}
|
||||
<div className="material-cell">
|
||||
<span className="pressure-info">{info.pressure}</span>
|
||||
</div>
|
||||
|
||||
{/* 재질 */}
|
||||
<div className="material-cell">
|
||||
<span className="material-structure">{info.materialStructure}</span>
|
||||
</div>
|
||||
|
||||
{/* 상세내역 */}
|
||||
<div className="material-cell">
|
||||
<span className="material-detail">{info.materialDetail}</span>
|
||||
</div>
|
||||
|
||||
{/* 두께 */}
|
||||
<div className="material-cell">
|
||||
<span className="thickness-info">{info.thickness}</span>
|
||||
</div>
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 */}
|
||||
<div className="material-cell">
|
||||
<div className="quantity-info">
|
||||
<span className="quantity-value">
|
||||
{info.quantity} {info.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 플랜지가 아닌 경우 9개 컬럼
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`detailed-material-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
|
||||
>
|
||||
{/* 선택 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={() => toggleMaterialSelection(material.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종류 */}
|
||||
<div className="material-cell">
|
||||
<span className={`type-badge ${info.type.toLowerCase()}`}>
|
||||
{info.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 타입 */}
|
||||
<div className="material-cell">
|
||||
<span className="subtype-text">{info.subtype}</span>
|
||||
</div>
|
||||
|
||||
{/* 크기 */}
|
||||
<div className="material-cell">
|
||||
<span className="size-text">{info.size}</span>
|
||||
</div>
|
||||
|
||||
{/* 스케줄 */}
|
||||
<div className="material-cell">
|
||||
<span>{info.schedule}</span>
|
||||
</div>
|
||||
|
||||
{/* 재질 */}
|
||||
<div className="material-cell">
|
||||
<span className="material-grade">{info.grade}</span>
|
||||
</div>
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 */}
|
||||
<div className="material-cell">
|
||||
<div className="quantity-info">
|
||||
<span className="quantity-value">
|
||||
{info.quantity} {info.unit}
|
||||
</span>
|
||||
{info.type === 'PIPE' && info.details && (
|
||||
<div className="quantity-details">
|
||||
<small>
|
||||
단관 {info.details.pipeCount}개 → {Math.round(info.details.totalLength)}mm
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewMaterialsPage;
|
||||
358
frontend/src/pages/ProjectWorkspacePage.jsx
Normal file
358
frontend/src/pages/ProjectWorkspacePage.jsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
const ProjectWorkspacePage = ({ project, user, onNavigate, onBackToDashboard }) => {
|
||||
const [projectStats, setProjectStats] = useState(null);
|
||||
const [recentFiles, setRecentFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
loadProjectData();
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
const loadProjectData = async () => {
|
||||
try {
|
||||
// 실제 파일 데이터만 로드
|
||||
const filesResponse = await api.get(`/files?job_no=${project.job_no}&limit=5`);
|
||||
|
||||
if (filesResponse.data && Array.isArray(filesResponse.data)) {
|
||||
setRecentFiles(filesResponse.data);
|
||||
|
||||
// 파일 데이터를 기반으로 통계 계산
|
||||
const stats = {
|
||||
totalFiles: filesResponse.data.length,
|
||||
totalMaterials: filesResponse.data.reduce((sum, file) => sum + (file.parsed_count || 0), 0),
|
||||
classifiedMaterials: 0, // API에서 분류 정보를 가져와야 함
|
||||
pendingVerification: 0, // API에서 검증 정보를 가져와야 함
|
||||
};
|
||||
setProjectStats(stats);
|
||||
} else {
|
||||
setRecentFiles([]);
|
||||
setProjectStats({
|
||||
totalFiles: 0,
|
||||
totalMaterials: 0,
|
||||
classifiedMaterials: 0,
|
||||
pendingVerification: 0
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 데이터 로딩 실패:', error);
|
||||
setRecentFiles([]);
|
||||
setProjectStats({
|
||||
totalFiles: 0,
|
||||
totalMaterials: 0,
|
||||
classifiedMaterials: 0,
|
||||
pendingVerification: 0
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailableActions = () => {
|
||||
const userRole = user?.role || 'user';
|
||||
|
||||
const allActions = {
|
||||
// BOM 관리 (통합)
|
||||
'bom-management': {
|
||||
title: 'BOM 관리',
|
||||
description: 'BOM 파일 업로드, 관리 및 리비전 추적을 수행합니다',
|
||||
icon: '📋',
|
||||
color: '#667eea',
|
||||
roles: ['designer', 'manager', 'admin'],
|
||||
path: 'bom-status'
|
||||
},
|
||||
// 자재 관리
|
||||
'material-management': {
|
||||
title: '자재 관리',
|
||||
description: '자재 분류, 검증 및 구매 관리를 수행합니다',
|
||||
icon: '🔧',
|
||||
color: '#48bb78',
|
||||
roles: ['designer', 'purchaser', 'manager', 'admin'],
|
||||
path: 'materials'
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 권한에 따라 필터링
|
||||
return Object.entries(allActions).filter(([key, action]) =>
|
||||
action.roles.includes(userRole)
|
||||
);
|
||||
};
|
||||
|
||||
const handleActionClick = (actionPath) => {
|
||||
switch (actionPath) {
|
||||
case 'bom-management':
|
||||
onNavigate('bom-status', {
|
||||
job_no: project.job_no,
|
||||
job_name: project.project_name
|
||||
});
|
||||
break;
|
||||
case 'material-management':
|
||||
onNavigate('materials', {
|
||||
job_no: project.job_no,
|
||||
job_name: project.project_name
|
||||
});
|
||||
break;
|
||||
default:
|
||||
alert(`${actionPath} 기능은 곧 구현될 예정입니다.`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '200px'
|
||||
}}>
|
||||
<div>프로젝트 데이터를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const availableActions = getAvailableActions();
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<button
|
||||
onClick={onBackToDashboard}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
marginRight: '16px',
|
||||
padding: '8px'
|
||||
}}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div>
|
||||
<h1 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
{project.project_name}
|
||||
</h1>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{project.job_no} • 진행률: {project.progress || 0}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 통계 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
{[
|
||||
{ label: 'BOM 파일', value: projectStats.totalFiles, icon: '📄', color: '#667eea' },
|
||||
{ label: '전체 자재', value: projectStats.totalMaterials, icon: '📦', color: '#48bb78' },
|
||||
{ label: '분류 완료', value: projectStats.classifiedMaterials, icon: '✅', color: '#38b2ac' },
|
||||
{ label: '검증 대기', value: projectStats.pendingVerification, icon: '⏳', color: '#ed8936' }
|
||||
].map((stat, index) => (
|
||||
<div key={index} style={{
|
||||
background: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{stat.label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: stat.color
|
||||
}}>
|
||||
{stat.value}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '24px' }}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 업무 메뉴 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<h2 style={{
|
||||
margin: '0 0 24px 0',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
🚀 사용 가능한 업무
|
||||
</h2>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: '20px'
|
||||
}}>
|
||||
{availableActions.map(([key, action]) => (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => handleActionClick(key)}
|
||||
style={{
|
||||
padding: '20px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'white'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.borderColor = action.color;
|
||||
e.target.style.boxShadow = `0 4px 12px ${action.color}20`;
|
||||
e.target.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.borderColor = '#e2e8f0';
|
||||
e.target.style.boxShadow = 'none';
|
||||
e.target.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '32px',
|
||||
lineHeight: 1
|
||||
}}>
|
||||
{action.icon}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: action.color
|
||||
}}>
|
||||
{action.title}
|
||||
</h3>
|
||||
<p style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
color: '#718096',
|
||||
lineHeight: 1.5
|
||||
}}>
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 활동 (옵션) */}
|
||||
{recentFiles.length > 0 && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
|
||||
}}>
|
||||
<h2 style={{
|
||||
margin: '0 0 24px 0',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
📁 최근 BOM 파일
|
||||
</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{recentFiles.map((file, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '16px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
color: '#2d3748',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{file.original_filename || file.filename}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{file.revision} • {file.uploaded_by || '시스템'} • {file.parsed_count || 0}개 자재
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleActionClick('materials')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
자재 보기
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectWorkspacePage;
|
||||
@@ -386,3 +386,19 @@ const ProjectsPage = ({ user }) => {
|
||||
};
|
||||
|
||||
export default ProjectsPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Button,
|
||||
TextField,
|
||||
Chip,
|
||||
Alert,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Edit,
|
||||
Check,
|
||||
Close,
|
||||
ShoppingCart,
|
||||
CompareArrows,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
import { api } from '../api';
|
||||
|
||||
const PurchaseConfirmationPage = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [purchaseItems, setPurchaseItems] = useState([]);
|
||||
const [revisionComparison, setRevisionComparison] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState(false);
|
||||
|
||||
// URL에서 job_no, revision 정보 가져오기
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const jobNo = searchParams.get('job_no');
|
||||
const revision = searchParams.get('revision');
|
||||
const filename = searchParams.get('filename');
|
||||
const previousRevision = searchParams.get('prev_revision');
|
||||
|
||||
useEffect(() => {
|
||||
if (jobNo && revision) {
|
||||
loadPurchaseItems();
|
||||
if (previousRevision) {
|
||||
loadRevisionComparison();
|
||||
}
|
||||
}
|
||||
}, [jobNo, revision, previousRevision]);
|
||||
|
||||
const loadPurchaseItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/purchase/items/calculate', {
|
||||
params: { job_no: jobNo, revision: revision }
|
||||
});
|
||||
setPurchaseItems(response.data.items || []);
|
||||
} catch (error) {
|
||||
console.error('구매 품목 로딩 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRevisionComparison = async () => {
|
||||
try {
|
||||
const response = await api.get('/purchase/revision-diff', {
|
||||
params: {
|
||||
job_no: jobNo,
|
||||
current_revision: revision,
|
||||
previous_revision: previousRevision
|
||||
}
|
||||
});
|
||||
setRevisionComparison(response.data.comparison);
|
||||
} catch (error) {
|
||||
console.error('리비전 비교 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateItemQuantity = async (itemId, field, value) => {
|
||||
try {
|
||||
await api.patch(`/purchase/items/${itemId}`, {
|
||||
[field]: parseFloat(value)
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
setPurchaseItems(prev =>
|
||||
prev.map(item =>
|
||||
item.id === itemId
|
||||
? { ...item, [field]: parseFloat(value) }
|
||||
: item
|
||||
)
|
||||
);
|
||||
|
||||
setEditingItem(null);
|
||||
} catch (error) {
|
||||
console.error('수량 업데이트 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPurchase = async () => {
|
||||
try {
|
||||
const response = await api.post('/purchase/orders/create', {
|
||||
job_no: jobNo,
|
||||
revision: revision,
|
||||
items: purchaseItems.map(item => ({
|
||||
purchase_item_id: item.id,
|
||||
ordered_quantity: item.calculated_qty,
|
||||
required_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30일 후
|
||||
}))
|
||||
});
|
||||
|
||||
alert('구매 주문이 생성되었습니다!');
|
||||
navigate('/materials', {
|
||||
state: { message: '구매 주문 생성 완료' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('구매 주문 생성 실패:', error);
|
||||
alert('구매 주문 생성에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category) => {
|
||||
const colors = {
|
||||
'PIPE': 'primary',
|
||||
'FITTING': 'secondary',
|
||||
'VALVE': 'success',
|
||||
'FLANGE': 'warning',
|
||||
'BOLT': 'info',
|
||||
'GASKET': 'error',
|
||||
'INSTRUMENT': 'purple'
|
||||
};
|
||||
return colors[category] || 'default';
|
||||
};
|
||||
|
||||
const formatPipeInfo = (item) => {
|
||||
if (item.category !== 'PIPE') return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
절단손실: {item.cutting_loss || 0}mm |
|
||||
구매: {item.pipes_count || 0}본 |
|
||||
여유분: {item.waste_length || 0}mm
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const formatBoltInfo = (item) => {
|
||||
if (item.category !== 'BOLT') return null;
|
||||
|
||||
// 특수 용도 볼트 정보 (백엔드에서 제공되어야 함)
|
||||
const specialApplications = item.special_applications || {};
|
||||
const psvCount = specialApplications.PSV || 0;
|
||||
const ltCount = specialApplications.LT || 0;
|
||||
const ckCount = specialApplications.CK || 0;
|
||||
const oriCount = specialApplications.ORI || 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
분수 사이즈: {(item.size_fraction || item.size_spec || '').replace(/"/g, '')} |
|
||||
표면처리: {item.surface_treatment || '없음'}
|
||||
</Typography>
|
||||
|
||||
{/* 특수 용도 볼트 정보 */}
|
||||
<Box sx={{ mt: 1, p: 1, bgcolor: 'info.50', borderRadius: 1 }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="info.main">
|
||||
특수 용도 볼트 현황:
|
||||
</Typography>
|
||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="caption" color={psvCount > 0 ? "error.main" : "textSecondary"}>
|
||||
PSV용: {psvCount}개
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="caption" color={ltCount > 0 ? "warning.main" : "textSecondary"}>
|
||||
저온용: {ltCount}개
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="caption" color={ckCount > 0 ? "info.main" : "textSecondary"}>
|
||||
체크밸브용: {ckCount}개
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="caption" color={oriCount > 0 ? "secondary.main" : "textSecondary"}>
|
||||
오리피스용: {oriCount}개
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{(psvCount + ltCount + ckCount + oriCount) === 0 && (
|
||||
<Typography variant="caption" color="success.main" sx={{ fontStyle: 'italic' }}>
|
||||
특수 용도 볼트 없음 (일반 볼트만 포함)
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* 헤더 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<IconButton onClick={() => navigate(-1)} sx={{ mr: 2 }}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
🛒 구매 확정
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
Job: {jobNo} | {filename} | {revision}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<ShoppingCart />}
|
||||
onClick={() => setConfirmDialog(true)}
|
||||
size="large"
|
||||
disabled={purchaseItems.length === 0}
|
||||
>
|
||||
구매 주문 생성
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 리비전 비교 알림 */}
|
||||
{revisionComparison && (
|
||||
<Alert
|
||||
severity={revisionComparison.has_changes ? "warning" : "info"}
|
||||
sx={{ mb: 3 }}
|
||||
icon={<CompareArrows />}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
<strong>리비전 변경사항:</strong> {revisionComparison.summary}
|
||||
</Typography>
|
||||
{revisionComparison.additional_items && (
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
추가 구매 필요: {revisionComparison.additional_items}개 품목
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 구매 품목 테이블 */}
|
||||
{purchaseItems.map(item => (
|
||||
<Card key={item.id} sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Chip
|
||||
label={item.category}
|
||||
color={getCategoryColor(item.category)}
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ flex: 1 }}>
|
||||
{item.specification}
|
||||
</Typography>
|
||||
{item.is_additional && (
|
||||
<Chip
|
||||
label="추가 구매"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* BOM 수량 */}
|
||||
<Grid item xs={12} md={3}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
BOM 필요량
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{item.bom_quantity} {item.unit}
|
||||
</Typography>
|
||||
{formatPipeInfo(item)}
|
||||
{formatBoltInfo(item)}
|
||||
</Grid>
|
||||
|
||||
{/* 구매 수량 */}
|
||||
<Grid item xs={12} md={3}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
구매 수량
|
||||
</Typography>
|
||||
{editingItem === item.id ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TextField
|
||||
value={item.calculated_qty}
|
||||
onChange={(e) =>
|
||||
setPurchaseItems(prev =>
|
||||
prev.map(i =>
|
||||
i.id === item.id
|
||||
? { ...i, calculated_qty: parseFloat(e.target.value) || 0 }
|
||||
: i
|
||||
)
|
||||
)
|
||||
}
|
||||
size="small"
|
||||
type="number"
|
||||
sx={{ width: 100 }}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => updateItemQuantity(item.id, 'calculated_qty', item.calculated_qty)}
|
||||
>
|
||||
<Check />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setEditingItem(null)}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6" color="primary">
|
||||
{item.calculated_qty} {item.unit}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setEditingItem(item.id)}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* 이미 구매한 수량 */}
|
||||
{previousRevision && (
|
||||
<Grid item xs={12} md={3}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
기구매 수량
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{item.purchased_quantity || 0} {item.unit}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 추가 구매 필요량 */}
|
||||
{previousRevision && (
|
||||
<Grid item xs={12} md={3}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
추가 구매 필요
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color={item.additional_needed > 0 ? "error" : "success"}
|
||||
>
|
||||
{Math.max(item.additional_needed || 0, 0)} {item.unit}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* 여유율 및 최소 주문 정보 */}
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
여유율
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{((item.safety_factor || 1) - 1) * 100}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
최소 주문
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{item.min_order_qty || 0} {item.unit}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
예상 여유분
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{(item.calculated_qty - item.bom_quantity).toFixed(1)} {item.unit}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
활용률
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{((item.bom_quantity / item.calculated_qty) * 100).toFixed(1)}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* 구매 주문 확인 다이얼로그 */}
|
||||
<Dialog open={confirmDialog} onClose={() => setConfirmDialog(false)}>
|
||||
<DialogTitle>구매 주문 생성 확인</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
총 {purchaseItems.length}개 품목에 대한 구매 주문을 생성하시겠습니까?
|
||||
</Typography>
|
||||
|
||||
{revisionComparison && revisionComparison.has_changes && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
리비전 변경으로 인한 추가 구매가 포함되어 있습니다.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
구매 주문 생성 후에는 수량 변경이 제한됩니다.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDialog(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={confirmPurchase} variant="contained">
|
||||
주문 생성
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseConfirmationPage;
|
||||
@@ -1,742 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
const SimpleMaterialsPage = ({ fileId, jobNo: propJobNo, bomName: propBomName, revision: propRevision, filename: propFilename, onNavigate }) => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [jobNo, setJobNo] = useState('');
|
||||
const [bomName, setBomName] = useState('');
|
||||
const [currentRevision, setCurrentRevision] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('all');
|
||||
const [filterConfidence, setFilterConfidence] = useState('all');
|
||||
const [showPurchaseCalculation, setShowPurchaseCalculation] = useState(false);
|
||||
const [purchaseData, setPurchaseData] = useState(null);
|
||||
const [calculatingPurchase, setCalculatingPurchase] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Props로 받은 값들을 초기화
|
||||
if (propJobNo) setJobNo(propJobNo);
|
||||
if (propBomName) setBomName(propBomName);
|
||||
if (propRevision) setCurrentRevision(propRevision);
|
||||
if (propFilename) setFileName(propFilename);
|
||||
|
||||
if (fileId) {
|
||||
loadMaterials(fileId);
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError('파일 ID가 지정되지 않았습니다.');
|
||||
}
|
||||
}, [fileId, propJobNo, propBomName, propRevision, propFilename]);
|
||||
|
||||
const loadMaterials = async (id) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/files/materials', {
|
||||
params: { file_id: parseInt(id), limit: 10000 }
|
||||
});
|
||||
|
||||
if (response.data && response.data.materials) {
|
||||
setMaterials(response.data.materials);
|
||||
|
||||
// 파일 정보 설정
|
||||
if (response.data.materials.length > 0) {
|
||||
const firstMaterial = response.data.materials[0];
|
||||
setFileName(firstMaterial.filename || '');
|
||||
setJobNo(firstMaterial.project_code || '');
|
||||
setBomName(firstMaterial.filename || '');
|
||||
setCurrentRevision('Rev.0'); // API에서 revision 정보가 없으므로 기본값
|
||||
}
|
||||
} else {
|
||||
setMaterials([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('자재 목록 로드 실패:', err);
|
||||
setError('자재 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 구매 수량 계산 함수 (기존 BOM 규칙 적용)
|
||||
const calculatePurchaseQuantities = async () => {
|
||||
if (!jobNo || !currentRevision) {
|
||||
alert('프로젝트 정보가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCalculatingPurchase(true);
|
||||
try {
|
||||
const response = await api.get(`/purchase/calculate`, {
|
||||
params: {
|
||||
job_no: jobNo,
|
||||
revision: currentRevision,
|
||||
file_id: fileId
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
setPurchaseData(response.data.purchase_items);
|
||||
setShowPurchaseCalculation(true);
|
||||
} else {
|
||||
throw new Error('구매 수량 계산 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('구매 수량 계산 오류:', error);
|
||||
alert('구매 수량 계산에 실패했습니다.');
|
||||
} finally {
|
||||
setCalculatingPurchase(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터링된 자재 목록 (기존 BOM 규칙 적용)
|
||||
const filteredMaterials = materials.filter(material => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
material.original_description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.size_spec?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.material_grade?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesCategory = filterCategory === 'all' ||
|
||||
material.classified_category === filterCategory;
|
||||
|
||||
// 신뢰도 필터링 (기존 BOM 규칙)
|
||||
const matchesConfidence = filterConfidence === 'all' ||
|
||||
(filterConfidence === 'high' && material.classification_confidence >= 0.9) ||
|
||||
(filterConfidence === 'medium' && material.classification_confidence >= 0.7 && material.classification_confidence < 0.9) ||
|
||||
(filterConfidence === 'low' && material.classification_confidence < 0.7);
|
||||
|
||||
return matchesSearch && matchesCategory && matchesConfidence;
|
||||
});
|
||||
|
||||
// 카테고리별 통계
|
||||
const categoryStats = materials.reduce((acc, material) => {
|
||||
const category = material.classified_category || 'unknown';
|
||||
acc[category] = (acc[category] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const categories = Object.keys(categoryStats).sort();
|
||||
|
||||
// 카테고리별 색상 함수
|
||||
const getCategoryColor = (category) => {
|
||||
const colors = {
|
||||
'pipe': '#4299e1',
|
||||
'fitting': '#48bb78',
|
||||
'valve': '#ed8936',
|
||||
'flange': '#9f7aea',
|
||||
'bolt': '#38b2ac',
|
||||
'gasket': '#f56565',
|
||||
'instrument': '#d69e2e',
|
||||
'material': '#718096',
|
||||
'integrated': '#319795',
|
||||
'unknown': '#a0aec0'
|
||||
};
|
||||
return colors[category?.toLowerCase()] || colors.unknown;
|
||||
};
|
||||
|
||||
// 신뢰도 배지 함수 (기존 BOM 규칙 적용)
|
||||
const getConfidenceBadge = (confidence) => {
|
||||
if (!confidence) return '-';
|
||||
|
||||
const conf = parseFloat(confidence);
|
||||
let color, text;
|
||||
|
||||
if (conf >= 0.9) {
|
||||
color = '#48bb78'; // 녹색
|
||||
text = '높음';
|
||||
} else if (conf >= 0.7) {
|
||||
color = '#ed8936'; // 주황색
|
||||
text = '보통';
|
||||
} else {
|
||||
color = '#f56565'; // 빨간색
|
||||
text = '낮음';
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{
|
||||
background: color,
|
||||
color: 'white',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{text}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: '#718096' }}>
|
||||
{Math.round(conf * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 상세정보 표시 함수 (기존 BOM 규칙 적용)
|
||||
const getDetailInfo = (material) => {
|
||||
const details = [];
|
||||
|
||||
// PIPE 상세정보
|
||||
if (material.pipe_details) {
|
||||
const pipe = material.pipe_details;
|
||||
if (pipe.schedule) details.push(`SCH ${pipe.schedule}`);
|
||||
if (pipe.manufacturing_method) details.push(pipe.manufacturing_method);
|
||||
if (pipe.end_preparation) details.push(pipe.end_preparation);
|
||||
}
|
||||
|
||||
// FITTING 상세정보
|
||||
if (material.fitting_details) {
|
||||
const fitting = material.fitting_details;
|
||||
if (fitting.fitting_type) details.push(fitting.fitting_type);
|
||||
if (fitting.connection_method && fitting.connection_method !== 'UNKNOWN') {
|
||||
details.push(fitting.connection_method);
|
||||
}
|
||||
if (fitting.pressure_rating && fitting.pressure_rating !== 'UNKNOWN') {
|
||||
details.push(fitting.pressure_rating);
|
||||
}
|
||||
}
|
||||
|
||||
// VALVE 상세정보
|
||||
if (material.valve_details) {
|
||||
const valve = material.valve_details;
|
||||
if (valve.valve_type) details.push(valve.valve_type);
|
||||
if (valve.connection_type) details.push(valve.connection_type);
|
||||
if (valve.pressure_rating) details.push(valve.pressure_rating);
|
||||
}
|
||||
|
||||
// BOLT 상세정보
|
||||
if (material.bolt_details) {
|
||||
const bolt = material.bolt_details;
|
||||
if (bolt.fastener_type) details.push(bolt.fastener_type);
|
||||
if (bolt.thread_specification) details.push(bolt.thread_specification);
|
||||
if (bolt.length_mm) details.push(`L${bolt.length_mm}mm`);
|
||||
}
|
||||
|
||||
// FLANGE 상세정보
|
||||
if (material.flange_details) {
|
||||
const flange = material.flange_details;
|
||||
if (flange.flange_type) details.push(flange.flange_type);
|
||||
if (flange.pressure_rating) details.push(flange.pressure_rating);
|
||||
if (flange.facing_type) details.push(flange.facing_type);
|
||||
}
|
||||
|
||||
return details.length > 0 ? (
|
||||
<div style={{ fontSize: '11px', color: '#4a5568' }}>
|
||||
{details.slice(0, 2).map((detail, idx) => (
|
||||
<div key={idx} style={{
|
||||
background: '#f7fafc',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '3px',
|
||||
marginBottom: '2px',
|
||||
display: 'inline-block',
|
||||
marginRight: '4px'
|
||||
}}>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
{details.length > 2 && (
|
||||
<span style={{ color: '#718096' }}>+{details.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
) : '-';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ padding: '40px' }}>
|
||||
로딩 중...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
color: '#c53030'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate && onNavigate('bom-status', { job_no: jobNo, job_name: bomName })}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
← 뒤로가기
|
||||
</button>
|
||||
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
📦 자재 목록
|
||||
</h1>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
margin: '0 0 24px 0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
<div><strong>프로젝트:</strong> {jobNo}</div>
|
||||
<div><strong>BOM:</strong> {bomName}</div>
|
||||
<div><strong>리비전:</strong> {currentRevision}</div>
|
||||
<div><strong>총 자재 수:</strong> {materials.length}개</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={calculatePurchaseQuantities}
|
||||
disabled={calculatingPurchase}
|
||||
style={{
|
||||
background: calculatingPurchase ? '#a0aec0' : '#48bb78',
|
||||
color: 'white',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: calculatingPurchase ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
{calculatingPurchase ? '계산중...' : '🧮 구매수량 계산'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 200px 200px',
|
||||
gap: '16px',
|
||||
alignItems: 'end'
|
||||
}}>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
자재 검색
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="자재명, 규격, 설명으로 검색..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
카테고리 필터
|
||||
</label>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: 'white'
|
||||
}}
|
||||
>
|
||||
<option value="all">전체 ({materials.length})</option>
|
||||
{categories.map(category => (
|
||||
<option key={category} value={category}>
|
||||
{category} ({categoryStats[category]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
신뢰도 필터
|
||||
</label>
|
||||
<select
|
||||
value={filterConfidence}
|
||||
onChange={(e) => setFilterConfidence(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: 'white'
|
||||
}}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="high">높음 (90%+)</option>
|
||||
<option value="medium">보통 (70-89%)</option>
|
||||
<option value="low">낮음 (70% 미만)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
{categories.slice(0, 6).map(category => (
|
||||
<div key={category} style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#4299e1' }}>
|
||||
{categoryStats[category]}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#718096', marginTop: '4px' }}>
|
||||
{category}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 자재 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{ padding: '20px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<h3 style={{ margin: '0', fontSize: '18px', fontWeight: '600' }}>
|
||||
자재 목록 ({filteredMaterials.length}개)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>No.</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>자재명</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>규격</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>신뢰도</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>상세정보</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredMaterials.map((material, index) => (
|
||||
<tr key={material.id || index} style={{
|
||||
borderBottom: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{material.line_number || index + 1}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', fontWeight: '500' }}>
|
||||
{material.original_description || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{material.size_spec || material.main_nom || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
|
||||
{material.quantity || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{material.unit || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
<span style={{
|
||||
background: getCategoryColor(material.classified_category),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{material.classified_category || 'unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{material.material_grade || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{getConfidenceBadge(material.classification_confidence)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{getDetailInfo(material)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: '#718096'
|
||||
}}>
|
||||
검색 조건에 맞는 자재가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 구매 수량 계산 결과 모달 */}
|
||||
{showPurchaseCalculation && purchaseData && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '1000px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
margin: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: 0
|
||||
}}>
|
||||
🧮 구매 수량 계산 결과
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowPurchaseCalculation(false)}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
✕ 닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사양</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사이즈</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>BOM 수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>구매 수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{purchaseData.map((item, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
<span style={{
|
||||
background: getCategoryColor(item.category),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{item.category}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{item.specification}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{/* PIPE는 사양에 모든 정보가 포함되므로 사이즈 컬럼 비움 */}
|
||||
{item.category !== 'PIPE' && (
|
||||
<span style={{
|
||||
background: '#e6fffa',
|
||||
color: '#065f46',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{item.size_spec || '-'}
|
||||
</span>
|
||||
)}
|
||||
{item.category === 'PIPE' && (
|
||||
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
사양에 포함
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{/* PIPE는 사양에 모든 정보가 포함되므로 재질 컬럼 비움 */}
|
||||
{item.category !== 'PIPE' && (
|
||||
<span style={{
|
||||
background: '#fef7e0',
|
||||
color: '#92400e',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{item.material_spec || '-'}
|
||||
</span>
|
||||
)}
|
||||
{item.category === 'PIPE' && (
|
||||
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
사양에 포함
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
|
||||
{item.category === 'PIPE' ?
|
||||
`${Math.round(item.bom_quantity)}mm` :
|
||||
item.bom_quantity
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right', fontWeight: '600' }}>
|
||||
{item.category === 'PIPE' ?
|
||||
`${item.pipes_count}본 (${Math.round(item.calculated_qty)}mm)` :
|
||||
item.calculated_qty
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{item.unit}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '12px', color: '#718096' }}>
|
||||
{item.category === 'PIPE' && (
|
||||
<div>
|
||||
<div>절단수: {item.cutting_count}회</div>
|
||||
<div>절단손실: {item.cutting_loss}mm</div>
|
||||
<div>활용률: {Math.round(item.utilization_rate)}%</div>
|
||||
</div>
|
||||
)}
|
||||
{item.category !== 'PIPE' && item.safety_factor && (
|
||||
<div>여유율: {Math.round((item.safety_factor - 1) * 100)}%</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '8px' }}>📋 계산 규칙 (올바른 규칙):</div>
|
||||
<div>• <strong>PIPE:</strong> 6M 단위 올림, 절단당 2mm 손실</div>
|
||||
<div>• <strong>FITTING:</strong> BOM 수량 그대로</div>
|
||||
<div>• <strong>VALVE:</strong> BOM 수량 그대로</div>
|
||||
<div>• <strong>BOLT:</strong> 5% 여유율 후 4의 배수 올림</div>
|
||||
<div>• <strong>GASKET:</strong> 5의 배수 올림</div>
|
||||
<div>• <strong>INSTRUMENT:</strong> BOM 수량 그대로</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleMaterialsPage;
|
||||
455
frontend/src/pages/SystemSettingsPage.jsx
Normal file
455
frontend/src/pages/SystemSettingsPage.jsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
const SystemSettingsPage = ({ onNavigate, user }) => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newUser, setNewUser] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'user'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/auth/users');
|
||||
if (response.data.success) {
|
||||
setUsers(response.data.users);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('사용자 목록 로딩 실패:', err);
|
||||
setError('사용자 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!newUser.username || !newUser.email || !newUser.password) {
|
||||
setError('모든 필수 필드를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.post('/auth/register', newUser);
|
||||
|
||||
if (response.data.success) {
|
||||
alert('사용자가 성공적으로 생성되었습니다.');
|
||||
setNewUser({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'user'
|
||||
});
|
||||
setShowCreateForm(false);
|
||||
loadUsers();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('사용자 생성 실패:', err);
|
||||
setError(err.response?.data?.detail || '사용자 생성에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId) => {
|
||||
if (!confirm('정말로 이 사용자를 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.delete(`/auth/users/${userId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
alert('사용자가 삭제되었습니다.');
|
||||
loadUsers();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('사용자 삭제 실패:', err);
|
||||
setError('사용자 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleDisplay = (role) => {
|
||||
switch (role) {
|
||||
case 'admin': return '관리자';
|
||||
case 'manager': return '매니저';
|
||||
case 'user': return '사용자';
|
||||
default: return role;
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeColor = (role) => {
|
||||
switch (role) {
|
||||
case 'admin': return '#dc2626';
|
||||
case 'manager': return '#ea580c';
|
||||
case 'user': return '#059669';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
// 관리자 권한 확인
|
||||
if (user?.role !== 'admin') {
|
||||
return (
|
||||
<div style={{ padding: '32px', textAlign: 'center' }}>
|
||||
<h2 style={{ color: '#dc2626', marginBottom: '16px' }}>접근 권한이 없습니다</h2>
|
||||
<p style={{ color: '#6b7280', marginBottom: '24px' }}>
|
||||
시스템 설정은 관리자만 접근할 수 있습니다.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: '#4299e1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '12px 24px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
대시보드로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: '700', color: '#2d3748', marginBottom: '8px' }}>
|
||||
⚙️ 시스템 설정
|
||||
</h1>
|
||||
<p style={{ color: '#718096', fontSize: '16px' }}>
|
||||
사용자 계정 관리 및 시스템 설정
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
color: '#4a5568',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
← 대시보드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
color: '#c53030',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사용자 관리 섹션 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
|
||||
border: '1px solid #e2e8f0',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748' }}>
|
||||
👥 사용자 관리
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
style={{
|
||||
background: '#38a169',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
+ 새 사용자 생성
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 사용자 생성 폼 */}
|
||||
{showCreateForm && (
|
||||
<div style={{
|
||||
background: '#f7fafc',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', marginBottom: '16px' }}>
|
||||
새 사용자 생성
|
||||
</h3>
|
||||
<form onSubmit={handleCreateUser}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
사용자명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUser.username}
|
||||
onChange={(e) => setNewUser({...newUser, username: e.target.value})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
이메일 *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={newUser.email}
|
||||
onChange={(e) => setNewUser({...newUser, email: e.target.value})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
비밀번호 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newUser.password}
|
||||
onChange={(e) => setNewUser({...newUser, password: e.target.value})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
전체 이름
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUser.full_name}
|
||||
onChange={(e) => setNewUser({...newUser, full_name: e.target.value})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
권한
|
||||
</label>
|
||||
<select
|
||||
value={newUser.role}
|
||||
onChange={(e) => setNewUser({...newUser, role: e.target.value})}
|
||||
style={{
|
||||
width: '200px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="user">사용자</option>
|
||||
<option value="manager">매니저</option>
|
||||
<option value="admin">관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: '#38a169',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 16px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
{loading ? '생성 중...' : '사용자 생성'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
color: '#4a5568',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사용자 목록 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div style={{ fontSize: '16px', color: '#718096' }}>로딩 중...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #e2e8f0' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||
사용자명
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||
이메일
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||
전체 이름
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
|
||||
권한
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
|
||||
상태
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
|
||||
작업
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((userItem) => (
|
||||
<tr key={userItem.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '12px', fontWeight: '500' }}>
|
||||
{userItem.username}
|
||||
</td>
|
||||
<td style={{ padding: '12px', color: '#4a5568' }}>
|
||||
{userItem.email}
|
||||
</td>
|
||||
<td style={{ padding: '12px', color: '#4a5568' }}>
|
||||
{userItem.full_name || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
background: getRoleBadgeColor(userItem.role),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{getRoleDisplay(userItem.role)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
background: userItem.is_active ? '#d1fae5' : '#fee2e2',
|
||||
color: userItem.is_active ? '#065f46' : '#dc2626',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{userItem.is_active ? '활성' : '비활성'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', textAlign: 'center' }}>
|
||||
{userItem.id !== user?.id && (
|
||||
<button
|
||||
onClick={() => handleDeleteUser(userItem.id)}
|
||||
style={{
|
||||
background: '#dc2626',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '6px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSettingsPage;
|
||||
@@ -428,3 +428,19 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import SimpleFileUpload from '../components/SimpleFileUpload';
|
||||
import MaterialList from '../components/MaterialList';
|
||||
import { fetchMaterials, fetchFiles } from '../api';
|
||||
import { fetchMaterials, fetchFiles, fetchJobs } from '../api';
|
||||
|
||||
const BOMManagementPage = ({ user }) => {
|
||||
const [activeTab, setActiveTab] = useState('upload');
|
||||
@@ -32,10 +32,10 @@ const BOMManagementPage = ({ user }) => {
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/jobs/');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setProjects(data.jobs);
|
||||
// ✅ API 함수 사용 (권장)
|
||||
const response = await fetchJobs();
|
||||
if (response.data.success) {
|
||||
setProjects(response.data.jobs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로딩 실패:', error);
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import MaterialComparisonResult from '../components/MaterialComparisonResult';
|
||||
import { compareMaterialRevisions, confirmMaterialPurchase, api } from '../api';
|
||||
import { compareMaterialRevisions, confirmMaterialPurchase, fetchMaterials, api } from '../api';
|
||||
import { exportComparisonToExcel } from '../utils/excelExport';
|
||||
|
||||
const MaterialComparisonPage = () => {
|
||||
@@ -74,8 +74,11 @@ const MaterialComparisonPage = () => {
|
||||
|
||||
// 🚨 테스트: MaterialsPage API 직접 호출해서 길이 정보 확인
|
||||
try {
|
||||
const testResult = await api.get('/files/materials', {
|
||||
params: { job_no: jobNo, revision: currentRevision, limit: 10 }
|
||||
// ✅ API 함수 사용 - 테스트용 자재 조회
|
||||
const testResult = await fetchMaterials({
|
||||
job_no: jobNo,
|
||||
revision: currentRevision,
|
||||
limit: 10
|
||||
});
|
||||
const pipeData = testResult.data.materials?.filter(m => m.classified_category === 'PIPE');
|
||||
console.log('🧪 MaterialsPage API 테스트 (길이 있는지 확인):', pipeData);
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import MaterialList from '../components/MaterialList';
|
||||
import { fetchMaterials } from '../api';
|
||||
import { fetchMaterials, fetchJobs } from '../api';
|
||||
|
||||
const MaterialsManagementPage = ({ user }) => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
@@ -31,10 +31,10 @@ const MaterialsManagementPage = ({ user }) => {
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/jobs/');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setProjects(data.jobs);
|
||||
// ✅ API 함수 사용 (권장)
|
||||
const response = await fetchJobs();
|
||||
if (response.data.success) {
|
||||
setProjects(response.data.jobs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로딩 실패:', error);
|
||||
736
frontend/src/pages/_backup/PurchaseConfirmationPage.jsx
Normal file
736
frontend/src/pages/_backup/PurchaseConfirmationPage.jsx
Normal file
@@ -0,0 +1,736 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
|
||||
const PurchaseConfirmationPage = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [purchaseItems, setPurchaseItems] = useState([]);
|
||||
const [revisionComparison, setRevisionComparison] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState(false);
|
||||
|
||||
// URL에서 job_no, revision 정보 가져오기
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const jobNo = searchParams.get('job_no');
|
||||
const revision = searchParams.get('revision');
|
||||
const filename = searchParams.get('filename');
|
||||
const previousRevision = searchParams.get('prev_revision');
|
||||
|
||||
useEffect(() => {
|
||||
if (jobNo && revision) {
|
||||
loadPurchaseItems();
|
||||
if (previousRevision) {
|
||||
loadRevisionComparison();
|
||||
}
|
||||
}
|
||||
}, [jobNo, revision, previousRevision]);
|
||||
|
||||
const loadPurchaseItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/purchase/items/calculate', {
|
||||
params: { job_no: jobNo, revision: revision }
|
||||
});
|
||||
setPurchaseItems(response.data.items || []);
|
||||
} catch (error) {
|
||||
console.error('구매 품목 로딩 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRevisionComparison = async () => {
|
||||
try {
|
||||
const response = await api.get('/purchase/revision-diff', {
|
||||
params: {
|
||||
job_no: jobNo,
|
||||
current_revision: revision,
|
||||
previous_revision: previousRevision
|
||||
}
|
||||
});
|
||||
setRevisionComparison(response.data.comparison);
|
||||
} catch (error) {
|
||||
console.error('리비전 비교 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateItemQuantity = async (itemId, field, value) => {
|
||||
try {
|
||||
await api.patch(`/purchase/items/${itemId}`, {
|
||||
[field]: parseFloat(value)
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
setPurchaseItems(prev =>
|
||||
prev.map(item =>
|
||||
item.id === itemId
|
||||
? { ...item, [field]: parseFloat(value) }
|
||||
: item
|
||||
)
|
||||
);
|
||||
|
||||
setEditingItem(null);
|
||||
} catch (error) {
|
||||
console.error('수량 업데이트 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPurchase = async () => {
|
||||
try {
|
||||
// 입력 데이터 검증
|
||||
if (!jobNo || !revision) {
|
||||
alert('Job 번호와 리비전 정보가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (purchaseItems.length === 0) {
|
||||
alert('구매할 품목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 각 품목의 수량 검증
|
||||
const invalidItems = purchaseItems.filter(item =>
|
||||
!item.calculated_qty || item.calculated_qty <= 0
|
||||
);
|
||||
|
||||
if (invalidItems.length > 0) {
|
||||
alert(`다음 품목들의 구매 수량이 유효하지 않습니다:\n${invalidItems.map(item => `- ${item.specification}`).join('\n')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirmDialog(false);
|
||||
|
||||
const response = await api.post('/purchase/orders/create', {
|
||||
job_no: jobNo,
|
||||
revision: revision,
|
||||
items: purchaseItems.map(item => ({
|
||||
purchase_item_id: item.id,
|
||||
ordered_quantity: item.calculated_qty,
|
||||
required_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30일 후
|
||||
}))
|
||||
});
|
||||
|
||||
const successMessage = `구매 주문이 성공적으로 생성되었습니다!\n\n` +
|
||||
`- Job: ${jobNo}\n` +
|
||||
`- Revision: ${revision}\n` +
|
||||
`- 품목 수: ${purchaseItems.length}개\n` +
|
||||
`- 생성 시간: ${new Date().toLocaleString('ko-KR')}`;
|
||||
|
||||
alert(successMessage);
|
||||
|
||||
// 자재 목록 페이지로 이동 (상태 기반 라우팅 사용)
|
||||
// App.jsx의 상태 기반 라우팅을 위해 window 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent('navigateToMaterials', {
|
||||
detail: {
|
||||
jobNo: jobNo,
|
||||
revision: revision,
|
||||
bomName: `${jobNo} ${revision}`,
|
||||
message: '구매 주문 생성 완료'
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('구매 주문 생성 실패:', error);
|
||||
|
||||
let errorMessage = '구매 주문 생성에 실패했습니다.';
|
||||
|
||||
if (error.response?.data?.detail) {
|
||||
errorMessage += `\n\n오류 내용: ${error.response.data.detail}`;
|
||||
} else if (error.message) {
|
||||
errorMessage += `\n\n오류 내용: ${error.message}`;
|
||||
}
|
||||
|
||||
if (error.response?.status === 400) {
|
||||
errorMessage += '\n\n입력 데이터를 확인해주세요.';
|
||||
} else if (error.response?.status === 404) {
|
||||
errorMessage += '\n\n해당 Job이나 리비전을 찾을 수 없습니다.';
|
||||
} else if (error.response?.status >= 500) {
|
||||
errorMessage += '\n\n서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||
}
|
||||
|
||||
alert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category) => {
|
||||
const colors = {
|
||||
'PIPE': '#1976d2',
|
||||
'FITTING': '#9c27b0',
|
||||
'VALVE': '#2e7d32',
|
||||
'FLANGE': '#ed6c02',
|
||||
'BOLT': '#0288d1',
|
||||
'GASKET': '#d32f2f',
|
||||
'INSTRUMENT': '#7b1fa2'
|
||||
};
|
||||
return colors[category] || '#757575';
|
||||
};
|
||||
|
||||
const formatPipeInfo = (item) => {
|
||||
if (item.category !== 'PIPE') return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '8px', fontSize: '12px', color: '#666' }}>
|
||||
절단손실: {item.cutting_loss || 0}mm |
|
||||
구매: {item.pipes_count || 0}본 |
|
||||
여유분: {item.waste_length || 0}mm
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatBoltInfo = (item) => {
|
||||
if (item.category !== 'BOLT') return null;
|
||||
|
||||
// 특수 용도 볼트 정보 (백엔드에서 제공되어야 함)
|
||||
const specialApplications = item.special_applications || {};
|
||||
const psvCount = specialApplications.PSV || 0;
|
||||
const ltCount = specialApplications.LT || 0;
|
||||
const ckCount = specialApplications.CK || 0;
|
||||
const oriCount = specialApplications.ORI || 0;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
분수 사이즈: {(item.size_fraction || item.size_spec || '').replace(/"/g, '')} |
|
||||
표면처리: {item.surface_treatment || '없음'}
|
||||
</div>
|
||||
|
||||
{/* 특수 용도 볼트 정보 */}
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 'bold', color: '#0288d1' }}>
|
||||
특수 용도 볼트 현황:
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '8px',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', color: psvCount > 0 ? '#d32f2f' : '#666' }}>
|
||||
PSV용: {psvCount}개
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: ltCount > 0 ? '#ed6c02' : '#666' }}>
|
||||
저온용: {ltCount}개
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: ckCount > 0 ? '#0288d1' : '#666' }}>
|
||||
체크밸브용: {ckCount}개
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: oriCount > 0 ? '#9c27b0' : '#666' }}>
|
||||
오리피스용: {oriCount}개
|
||||
</div>
|
||||
</div>
|
||||
{(psvCount + ltCount + ckCount + oriCount) === 0 && (
|
||||
<div style={{ fontSize: '12px', color: '#2e7d32', fontStyle: 'italic' }}>
|
||||
특수 용도 볼트 없음 (일반 볼트만 포함)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const exportToExcel = () => {
|
||||
if (purchaseItems.length === 0) {
|
||||
alert('내보낼 구매 품목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 상세한 구매 확정 데이터 생성
|
||||
const data = purchaseItems.map((item, index) => {
|
||||
const baseData = {
|
||||
'순번': index + 1,
|
||||
'품목코드': item.item_code || '',
|
||||
'카테고리': item.category || '',
|
||||
'사양': item.specification || '',
|
||||
'재질': item.material_spec || '',
|
||||
'사이즈': item.size_spec || '',
|
||||
'단위': item.unit || '',
|
||||
'BOM수량': item.bom_quantity || 0,
|
||||
'구매수량': item.calculated_qty || 0,
|
||||
'여유율': ((item.safety_factor || 1) - 1) * 100 + '%',
|
||||
'최소주문': item.min_order_qty || 0,
|
||||
'예상여유분': ((item.calculated_qty || 0) - (item.bom_quantity || 0)).toFixed(1),
|
||||
'활용률': (((item.bom_quantity || 0) / (item.calculated_qty || 1)) * 100).toFixed(1) + '%'
|
||||
};
|
||||
|
||||
// 파이프 특수 정보 추가
|
||||
if (item.category === 'PIPE') {
|
||||
baseData['절단손실'] = item.cutting_loss || 0;
|
||||
baseData['구매본수'] = item.pipes_count || 0;
|
||||
baseData['여유길이'] = item.waste_length || 0;
|
||||
}
|
||||
|
||||
// 볼트 특수 정보 추가
|
||||
if (item.category === 'BOLT') {
|
||||
const specialApps = item.special_applications || {};
|
||||
baseData['PSV용'] = specialApps.PSV || 0;
|
||||
baseData['저온용'] = specialApps.LT || 0;
|
||||
baseData['체크밸브용'] = specialApps.CK || 0;
|
||||
baseData['오리피스용'] = specialApps.ORI || 0;
|
||||
baseData['분수사이즈'] = item.size_fraction || '';
|
||||
baseData['표면처리'] = item.surface_treatment || '';
|
||||
}
|
||||
|
||||
// 리비전 비교 정보 추가 (있는 경우)
|
||||
if (previousRevision) {
|
||||
baseData['기구매수량'] = item.purchased_quantity || 0;
|
||||
baseData['추가구매필요'] = Math.max(item.additional_needed || 0, 0);
|
||||
}
|
||||
|
||||
return baseData;
|
||||
});
|
||||
|
||||
// 헤더 정보 추가
|
||||
const headerInfo = [
|
||||
`구매 확정서`,
|
||||
`Job No: ${jobNo}`,
|
||||
`Revision: ${revision}`,
|
||||
`파일명: ${filename || ''}`,
|
||||
`생성일: ${new Date().toLocaleString('ko-KR')}`,
|
||||
`총 품목수: ${purchaseItems.length}개`,
|
||||
''
|
||||
];
|
||||
|
||||
// 요약 정보 계산
|
||||
const totalBomQty = purchaseItems.reduce((sum, item) => sum + (item.bom_quantity || 0), 0);
|
||||
const totalPurchaseQty = purchaseItems.reduce((sum, item) => sum + (item.calculated_qty || 0), 0);
|
||||
const categoryCount = purchaseItems.reduce((acc, item) => {
|
||||
acc[item.category] = (acc[item.category] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const summaryInfo = [
|
||||
'=== 요약 정보 ===',
|
||||
`전체 BOM 수량: ${totalBomQty.toFixed(1)}`,
|
||||
`전체 구매 수량: ${totalPurchaseQty.toFixed(1)}`,
|
||||
`카테고리별 품목수: ${Object.entries(categoryCount).map(([cat, count]) => `${cat}(${count})`).join(', ')}`,
|
||||
''
|
||||
];
|
||||
|
||||
// CSV 형태로 데이터 구성
|
||||
const csvContent = [
|
||||
...headerInfo,
|
||||
...summaryInfo,
|
||||
'=== 상세 품목 목록 ===',
|
||||
Object.keys(data[0]).join(','),
|
||||
...data.map(row => Object.values(row).map(val =>
|
||||
typeof val === 'string' && val.includes(',') ? `"${val}"` : val
|
||||
).join(','))
|
||||
].join('\n');
|
||||
|
||||
// 파일 다운로드
|
||||
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const fileName = `구매확정서_${jobNo}_${revision}_${timestamp}.csv`;
|
||||
link.setAttribute('download', fileName);
|
||||
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// 성공 메시지
|
||||
alert(`구매 확정서가 다운로드되었습니다.\n파일명: ${fileName}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '200px'
|
||||
}}>
|
||||
<div>로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
marginRight: '16px',
|
||||
padding: '8px'
|
||||
}}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1 style={{ margin: '0 0 8px 0', fontSize: '28px', fontWeight: 'bold' }}>
|
||||
🛒 구매 확정
|
||||
</h1>
|
||||
<h2 style={{ margin: 0, fontSize: '18px', color: '#1976d2' }}>
|
||||
Job: {jobNo} | {filename} | {revision}
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={exportToExcel}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#2e7d32',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
📊 엑셀 내보내기
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDialog(true)}
|
||||
disabled={purchaseItems.length === 0}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: purchaseItems.length === 0 ? '#ccc' : '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: purchaseItems.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
🛒 구매 주문 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리비전 비교 알림 */}
|
||||
{revisionComparison && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
backgroundColor: revisionComparison.has_changes ? '#fff3e0' : '#e3f2fd',
|
||||
border: `1px solid ${revisionComparison.has_changes ? '#ed6c02' : '#0288d1'}`,
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{ marginRight: '8px', fontSize: '20px' }}>🔄</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
|
||||
리비전 변경사항: {revisionComparison.summary}
|
||||
</div>
|
||||
{revisionComparison.additional_items && (
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
추가 구매 필요: {revisionComparison.additional_items}개 품목
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구매 품목 목록 */}
|
||||
{purchaseItems.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '48px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '8px'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📦</div>
|
||||
<div style={{ fontSize: '18px', color: '#666' }}>
|
||||
구매할 품목이 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{purchaseItems.map(item => (
|
||||
<div key={item.id} style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '4px 12px',
|
||||
backgroundColor: getCategoryColor(item.category),
|
||||
color: 'white',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
marginRight: '16px'
|
||||
}}>
|
||||
{item.category}
|
||||
</span>
|
||||
<h3 style={{ margin: 0, flex: 1, fontSize: '18px' }}>
|
||||
{item.specification}
|
||||
</h3>
|
||||
{item.is_additional && (
|
||||
<span style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#fff3e0',
|
||||
color: '#ed6c02',
|
||||
border: '1px solid #ed6c02',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
추가 구매
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '24px'
|
||||
}}>
|
||||
{/* BOM 수량 */}
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
BOM 필요량
|
||||
</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 'bold' }}>
|
||||
{item.bom_quantity} {item.unit}
|
||||
</div>
|
||||
{formatPipeInfo(item)}
|
||||
{formatBoltInfo(item)}
|
||||
</div>
|
||||
|
||||
{/* 구매 수량 */}
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
구매 수량
|
||||
</div>
|
||||
{editingItem === item.id ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={item.calculated_qty}
|
||||
onChange={(e) =>
|
||||
setPurchaseItems(prev =>
|
||||
prev.map(i =>
|
||||
i.id === item.id
|
||||
? { ...i, calculated_qty: parseFloat(e.target.value) || 0 }
|
||||
: i
|
||||
)
|
||||
)
|
||||
}
|
||||
style={{
|
||||
width: '100px',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateItemQuantity(item.id, 'calculated_qty', item.calculated_qty)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#1976d2',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingItem(null)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#1976d2' }}>
|
||||
{item.calculated_qty} {item.unit}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingItem(item.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이미 구매한 수량 */}
|
||||
{previousRevision && (
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
기구매 수량
|
||||
</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 'bold' }}>
|
||||
{item.purchased_quantity || 0} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 구매 필요량 */}
|
||||
{previousRevision && (
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
추가 구매 필요
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: item.additional_needed > 0 ? '#d32f2f' : '#2e7d32'
|
||||
}}>
|
||||
{Math.max(item.additional_needed || 0, 0)} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 여유율 및 최소 주문 정보 */}
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>여유율</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{((item.safety_factor || 1) - 1) * 100}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>최소 주문</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{item.min_order_qty || 0} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>예상 여유분</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{(item.calculated_qty - item.bom_quantity).toFixed(1)} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>활용률</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{((item.bom_quantity / item.calculated_qty) * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구매 주문 확인 다이얼로그 */}
|
||||
{confirmDialog && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
minWidth: '400px',
|
||||
maxWidth: '500px'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0' }}>구매 주문 생성 확인</h3>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
총 {purchaseItems.length}개 품목에 대한 구매 주문을 생성하시겠습니까?
|
||||
</div>
|
||||
|
||||
{revisionComparison && revisionComparison.has_changes && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
backgroundColor: '#fff3e0',
|
||||
border: '1px solid #ed6c02',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
⚠️ 리비전 변경으로 인한 추가 구매가 포함되어 있습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '24px' }}>
|
||||
구매 주문 생성 후에는 수량 변경이 제한됩니다.
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<button
|
||||
onClick={() => setConfirmDialog(false)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'white',
|
||||
color: '#666',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmPurchase}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
주문 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseConfirmationPage;
|
||||
Reference in New Issue
Block a user