feat: PIPE 분석 기능 개선 및 자재 확인 페이지 UX 향상

- 자재 확인 페이지에 뒤로가기 버튼 추가
- 상세 목록 탭에 PIPE 분석 섹션 추가
  - 재질-외경-스케줄-제작방식별로 그룹화
  - 동일 속성 파이프들의 길이 합산 표시
  - 총 파이프 길이 및 규격 종류 수 요약
- 파일 삭제 기능 수정 (외래키 제약 조건 해결)
- MaterialsPage에서 전체 자재 목록 표시 (limit 10000)
- 길이 단위 변환 로직 수정 (mm 단위 유지)
- 파싱 로직에 디버그 출력 추가

TODO: MAIN_NOM/RED_NOM 별도 저장을 위한 스키마 개선 필요
This commit is contained in:
Hyungi Ahn
2025-07-17 15:55:40 +09:00
parent 5f7a6f0b3a
commit 82f057a0c9
8 changed files with 1433 additions and 156 deletions

View File

@@ -1,15 +1,20 @@
import React, { useState, useEffect } from 'react';
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert } from '@mui/material';
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 } from '../api';
const BOMStatusPage = () => {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [file, setFile] = useState(null);
const [selectedFile, setSelectedFile] = useState(null);
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();
// 파일 목록 불러오기
@@ -39,98 +44,325 @@ const BOMStatusPage = () => {
// 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 (e) => {
e.preventDefault();
if (!file) return;
const handleUpload = async () => {
if (!selectedFile) {
setError('파일을 선택해주세요.');
return;
}
if (!bomName.trim()) {
setError('BOM 이름을 입력해주세요.');
return;
}
setUploading(true);
setError('');
try {
const formData = new FormData();
formData.append('file', file);
formData.append('project_id', 1); // 예시: 실제 프로젝트 ID로 대체 필요
const res = await fetch('http://localhost:8000/upload', {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error('업로드 실패');
setFile(null);
fetchFiles();
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', '');
const response = await uploadFileApi(formData);
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) {
setError('파일 업로드에 실패했습니다.');
console.error('업로드 에러:', e);
if (e.response?.data?.detail) {
setError(e.response.data.detail);
} else {
setError('파일 업로드에 실패했습니다.');
}
} finally {
setUploading(false);
}
};
// 리비전 업로드 핸들러
const handleRevisionUpload = async () => {
if (!revisionFile) {
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('bom_type', 'excel');
formData.append('description', '');
formData.append('parent_bom_id', revisionDialog.parentId);
const response = await uploadFileApi(formData);
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('리비전 업로드에 실패했습니다.');
}
} 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 (
<Box sx={{ maxWidth: 900, mx: 'auto', mt: 4 }}>
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
뒤로가기
</Button>
<Typography variant="h4" gutterBottom>BOM 업로드 현황</Typography>
<form onSubmit={handleUpload} style={{ marginBottom: 24 }}>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={e => setFile(e.target.files[0])}
disabled={uploading}
{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 type="submit" variant="contained" disabled={!file || uploading} sx={{ ml: 2 }}>
업로드
<Button
variant="contained"
onClick={handleUpload}
disabled={!selectedFile || !bomName.trim() || uploading}
>
{uploading ? '업로드 중...' : '업로드'}
</Button>
</form>
</Box>
{selectedFile && (
<Typography variant="body2" color="textSecondary">
선택된 파일: {selectedFile.name}
</Typography>
)}
</Box>
</Paper>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading && <CircularProgress sx={{ mt: 4 }} />}
<TableContainer component={Paper} sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>파일명</TableCell>
<TableCell>리비전</TableCell>
<TableCell>세부내역</TableCell>
<TableCell>리비전</TableCell>
<TableCell>삭제</TableCell>
</TableRow>
</TableHead>
<TableBody>
{files.map(file => (
<TableRow key={file.id}>
<TableCell>{file.original_filename || file.filename}</TableCell>
<TableCell>{file.revision}</TableCell>
<TableCell>
<Button size="small" variant="outlined" onClick={() => navigate(`/materials?fileId=${file.id}`)}>
자재확인
</Button>
</TableCell>
<TableCell>
<Button size="small" variant="outlined" color="info" onClick={() => alert(`리비전 관리: ${file.original_filename}`)}>
리비전
</Button>
</TableCell>
<TableCell>
<Button size="small" variant="outlined" color="error" onClick={async () => {
if (window.confirm(`정말로 ${file.original_filename}을 삭제하시겠습니까?`)) {
try {
const res = await fetch(`http://localhost:8000/files/${file.id}`, { method: 'DELETE' });
if (res.ok) {
fetchFiles();
} else {
alert('삭제에 실패했습니다.');
}
} catch (e) {
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)' : 'inherit'
}}>
삭제
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TableCell>
<Typography variant="body2" fontWeight={index === 0 ? 'bold' : 'normal'}>
{file.bom_name || bomKey}
</Typography>
{index === 0 && bomFiles.length > 1 && (
<Typography variant="caption" color="textSecondary">
(최신 리비전)
</Typography>
)}
</TableCell>
<TableCell>{file.filename || file.original_filename}</TableCell>
<TableCell>
<Typography
variant="body2"
color={index === 0 ? 'primary' : 'textSecondary'}
>
{file.revision || 'Rev.0'}
</Typography>
</TableCell>
<TableCell>{file.parsed_count || '-'}</TableCell>
<TableCell>
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
</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>
)}
<Button
size="small"
color="error"
onClick={async () => {
if (confirm(`정말 "${file.revision || 'Rev.0'}"을 삭제하시겠습니까?`)) {
try {
const res = await fetch(`http://localhost:8000/files/${file.id}`, { method: 'DELETE' });
if (res.ok) {
fetchFiles();
} else {
alert('삭제 실패');
}
} catch (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}
>
{uploading ? '업로드 중...' : '업로드'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};