feat: SWG 가스켓 전체 구성 정보 표시 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시 - 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시 - 외부링/필러/내부링/추가구성 모든 정보 포함 - 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert, TextField, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi } from '../api';
|
||||
import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi, api } from '../api';
|
||||
import BOMFileUpload from '../components/BOMFileUpload';
|
||||
import BOMFileTable from '../components/BOMFileTable';
|
||||
import RevisionUploadDialog from '../components/RevisionUploadDialog';
|
||||
|
||||
const BOMStatusPage = () => {
|
||||
const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -12,10 +13,24 @@ const BOMStatusPage = () => {
|
||||
const [bomName, setBomName] = useState('');
|
||||
const [revisionDialog, setRevisionDialog] = useState({ open: false, bomName: '', parentId: null });
|
||||
const [revisionFile, setRevisionFile] = useState(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
const jobNo = searchParams.get('job_no');
|
||||
const jobName = searchParams.get('job_name');
|
||||
const navigate = useNavigate();
|
||||
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;
|
||||
};
|
||||
|
||||
// 파일 목록 불러오기
|
||||
const fetchFiles = async () => {
|
||||
@@ -26,134 +41,167 @@ const BOMStatusPage = () => {
|
||||
const response = await fetchFilesApi({ job_no: jobNo });
|
||||
console.log('API 응답:', response);
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
console.log('데이터 배열 형태:', response.data.length, '개');
|
||||
if (response.data && response.data.data && Array.isArray(response.data.data)) {
|
||||
setFiles(response.data.data);
|
||||
} else if (response.data && Array.isArray(response.data)) {
|
||||
setFiles(response.data);
|
||||
} else if (response.data && Array.isArray(response.data.files)) {
|
||||
console.log('데이터.files 배열 형태:', response.data.files.length, '개');
|
||||
setFiles(response.data.files);
|
||||
} else {
|
||||
console.log('빈 배열로 설정');
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('파일 목록을 불러오지 못했습니다.');
|
||||
console.error('파일 목록 로드 에러:', e);
|
||||
} catch (err) {
|
||||
console.error('파일 목록 불러오기 실패:', err);
|
||||
setError('파일 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('useEffect 실행 - jobNo:', jobNo);
|
||||
if (jobNo) {
|
||||
fetchFiles();
|
||||
} else {
|
||||
console.log('jobNo가 없어서 fetchFiles 실행하지 않음');
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [jobNo]);
|
||||
|
||||
// BOM 이름 중복 체크
|
||||
const checkDuplicateBOM = () => {
|
||||
return files.some(file =>
|
||||
file.bom_name === bomName ||
|
||||
file.original_filename === bomName ||
|
||||
file.filename === bomName
|
||||
);
|
||||
};
|
||||
|
||||
// 파일 업로드 핸들러
|
||||
// 파일 업로드
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
setError('파일을 선택해주세요.');
|
||||
if (!selectedFile || !bomName.trim()) {
|
||||
setError('파일과 BOM 이름을 모두 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bomName.trim()) {
|
||||
setError('BOM 이름을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
|
||||
|
||||
try {
|
||||
const isDuplicate = checkDuplicateBOM();
|
||||
if (isDuplicate && !confirm(`"${bomName}"은(는) 이미 존재하는 BOM입니다. 새로운 리비전으로 업로드하시겠습니까?`)) {
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('revision', 'Rev.0');
|
||||
formData.append('bom_name', bomName);
|
||||
formData.append('bom_type', 'excel');
|
||||
formData.append('description', '');
|
||||
formData.append('bom_name', bomName.trim());
|
||||
|
||||
const uploadResult = await uploadFileApi(formData);
|
||||
|
||||
const response = await uploadFileApi(formData);
|
||||
// 업로드 성공 후 파일 목록 새로고침
|
||||
await fetchFiles();
|
||||
|
||||
if (response.data.success) {
|
||||
setSelectedFile(null);
|
||||
setBomName('');
|
||||
// 파일 input 초기화
|
||||
const fileInput = document.getElementById('file-input');
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
fetchFiles();
|
||||
alert(`업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision || 'Rev.0'}`);
|
||||
} else {
|
||||
setError(response.data.message || '업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('업로드 에러:', e);
|
||||
if (e.response?.data?.detail) {
|
||||
setError(e.response.data.detail);
|
||||
} else {
|
||||
setError('파일 업로드에 실패했습니다.');
|
||||
// 업로드 완료 후 자동으로 구매 수량 계산 실행
|
||||
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초 후 실행 (분류 완료 대기)
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
setSelectedFile(null);
|
||||
setBomName('');
|
||||
document.getElementById('file-input').value = '';
|
||||
|
||||
} catch (err) {
|
||||
console.error('파일 업로드 실패:', err);
|
||||
setError('파일 업로드에 실패했습니다.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 리비전 업로드 핸들러
|
||||
// 파일 삭제
|
||||
const handleDelete = async (fileId) => {
|
||||
if (!window.confirm('정말로 이 파일을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteFileApi(fileId);
|
||||
await fetchFiles(); // 목록 새로고침
|
||||
} 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 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) {
|
||||
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('revision', 'Rev.0'); // 백엔드에서 자동 증가
|
||||
formData.append('bom_name', revisionDialog.bomName);
|
||||
formData.append('parent_file_id', revisionDialog.parentId);
|
||||
formData.append('parent_id', revisionDialog.parentId);
|
||||
|
||||
await uploadFileApi(formData);
|
||||
|
||||
const response = await uploadFileApi(formData);
|
||||
// 업로드 성공 후 파일 목록 새로고침
|
||||
await fetchFiles();
|
||||
|
||||
if (response.data.success) {
|
||||
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||
setRevisionFile(null);
|
||||
fetchFiles();
|
||||
alert(`리비전 업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision}`);
|
||||
} else {
|
||||
setError(response.data.message || '리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('리비전 업로드 에러:', e);
|
||||
if (e.response?.data?.detail) {
|
||||
setError(e.response.data.detail);
|
||||
} else {
|
||||
setError('리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
// 다이얼로그 닫기
|
||||
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||
setRevisionFile(null);
|
||||
|
||||
} catch (err) {
|
||||
console.error('리비전 업로드 실패:', err);
|
||||
setError('리비전 업로드에 실패했습니다.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -183,236 +231,274 @@ const BOMStatusPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', mt: 4 }}>
|
||||
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
|
||||
← 뒤로가기
|
||||
</Button>
|
||||
<Typography variant="h4" gutterBottom>📊 BOM 관리 시스템</Typography>
|
||||
{jobNo && jobName && (
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 3 }}>
|
||||
{jobNo} - {jobName}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 파일 업로드 폼 */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>새 BOM 업로드</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="BOM 이름"
|
||||
value={bomName}
|
||||
onChange={(e) => setBomName(e.target.value)}
|
||||
placeholder="예: PIPING_BOM_A구역"
|
||||
required
|
||||
size="small"
|
||||
helperText="동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setSelectedFile(e.target.files[0])}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || !bomName.trim() || uploading}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</Button>
|
||||
</Box>
|
||||
{selectedFile && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
선택된 파일: {selectedFile.name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 4, mb: 2 }}>업로드된 BOM 목록</Typography>
|
||||
{loading && <CircularProgress />}
|
||||
{!loading && files.length === 0 && (
|
||||
<Alert severity="info">업로드된 BOM이 없습니다.</Alert>
|
||||
)}
|
||||
{!loading && files.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>BOM 이름</TableCell>
|
||||
<TableCell>파일명</TableCell>
|
||||
<TableCell>리비전</TableCell>
|
||||
<TableCell>자재 수</TableCell>
|
||||
<TableCell>업로드 일시</TableCell>
|
||||
<TableCell>작업</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
|
||||
bomFiles.map((file, index) => (
|
||||
<TableRow key={file.id} sx={{
|
||||
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'rgba(0, 0, 0, 0.02)'
|
||||
}}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={index === 0 ? 'bold' : 'normal'}>
|
||||
{file.bom_name || bomKey}
|
||||
</Typography>
|
||||
{index === 0 && bomFiles.length > 1 && (
|
||||
<Typography variant="caption" color="primary">
|
||||
(최신 리비전)
|
||||
</Typography>
|
||||
)}
|
||||
{index > 0 && (
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
(이전 버전)
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
|
||||
{file.filename || file.original_filename}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={index === 0 ? 'primary' : 'textSecondary'}
|
||||
fontWeight={index === 0 ? 'bold' : 'normal'}
|
||||
>
|
||||
{file.revision || 'Rev.0'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
|
||||
{file.parsed_count || 0}개
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
|
||||
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="small"
|
||||
variant={index === 0 ? "contained" : "outlined"}
|
||||
onClick={() => navigate(`/materials?file_id=${file.id}&job_no=${jobNo}&filename=${encodeURIComponent(file.filename || file.original_filename)}`)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
자재확인
|
||||
</Button>
|
||||
{index === 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => setRevisionDialog({
|
||||
open: true,
|
||||
bomName: file.bom_name || bomKey,
|
||||
parentId: file.id
|
||||
})}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
리비전
|
||||
</Button>
|
||||
)}
|
||||
{file.revision !== 'Rev.0' && index < 3 && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${file.revision}&filename=${encodeURIComponent(file.original_filename)}`)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
비교
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="success"
|
||||
onClick={() => navigate(`/revision-purchase?job_no=${jobNo}¤t_revision=${file.revision}&bom_name=${encodeURIComponent(file.bom_name || bomKey)}`)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
구매 필요
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={async () => {
|
||||
if (confirm(`정말 "${file.revision || 'Rev.0'}"을 삭제하시겠습니까?`)) {
|
||||
try {
|
||||
const response = await deleteFileApi(file.id);
|
||||
if (response.data.success) {
|
||||
fetchFiles();
|
||||
alert('삭제되었습니다.');
|
||||
} else {
|
||||
alert('삭제 실패: ' + (response.data.message || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('삭제 오류:', e);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* 리비전 업로드 다이얼로그 */}
|
||||
<Dialog open={revisionDialog.open} onClose={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}>
|
||||
<DialogTitle>리비전 업로드</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
BOM 이름: <strong>{revisionDialog.bomName}</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
새로운 리비전 파일을 선택하세요. 리비전 번호는 자동으로 증가합니다.
|
||||
</Typography>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setRevisionFile(e.target.files[0])}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
{revisionFile && (
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
선택된 파일: {revisionFile.name}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => {
|
||||
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||
setRevisionFile(null);
|
||||
}}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleRevisionUpload}
|
||||
disabled={!revisionFile || uploading}
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate && onNavigate('bom')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
← 뒤로가기
|
||||
</button>
|
||||
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
📊 BOM 관리 시스템
|
||||
</h1>
|
||||
|
||||
{jobNo && jobName && (
|
||||
<h2 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: '#4299e1',
|
||||
margin: '0 0 24px 0'
|
||||
}}>
|
||||
{jobNo} - {jobName}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 파일 업로드 컴포넌트 */}
|
||||
<BOMFileUpload
|
||||
bomName={bomName}
|
||||
setBomName={setBomName}
|
||||
selectedFile={selectedFile}
|
||||
setSelectedFile={setSelectedFile}
|
||||
uploading={uploading}
|
||||
handleUpload={handleUpload}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{/* BOM 목록 */}
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '32px 0 16px 0'
|
||||
}}>
|
||||
업로드된 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 && (
|
||||
<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={() => 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),
|
||||
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 BOMStatusPage;
|
||||
export default BOMStatusPage;
|
||||
Reference in New Issue
Block a user