feat: 자재 분류 시스템 개선 및 상세 테이블 추가

- 모든 자재 카테고리별 상세 테이블 생성 (fitting, valve, flange, bolt, gasket, instrument)
- PIPE, FITTING, VALVE 분류 결과를 각 상세 테이블에 저장하는 로직 구현
- 프론트엔드 라우팅 정리 및 BOM 현황 페이지 기능 개선
- 자재확인 페이지 에러 처리 개선

TODO: FLANGE, BOLT, GASKET, INSTRUMENT 저장 로직 추가 필요
This commit is contained in:
Hyungi Ahn
2025-07-17 10:44:19 +09:00
parent ea111433e4
commit 5f7a6f0b3a
30 changed files with 3963 additions and 923 deletions

View File

@@ -17,7 +17,7 @@ const BOMStatusPage = () => {
setLoading(true);
setError('');
try {
let url = '/files';
let url = 'http://localhost:8000/files';
if (jobNo) {
url += `?job_no=${jobNo}`;
}
@@ -28,6 +28,7 @@ const BOMStatusPage = () => {
else setFiles([]);
} catch (e) {
setError('파일 목록을 불러오지 못했습니다.');
console.error('파일 목록 로드 에러:', e);
} finally {
setLoading(false);
}
@@ -45,10 +46,10 @@ const BOMStatusPage = () => {
setUploading(true);
setError('');
try {
const formData = new FormData();
const formData = new FormData();
formData.append('file', file);
formData.append('project_id', 1); // 예시: 실제 프로젝트 ID로 대체 필요
const res = await fetch('/files/upload', {
const res = await fetch('http://localhost:8000/upload', {
method: 'POST',
body: formData
});
@@ -74,49 +75,62 @@ const BOMStatusPage = () => {
accept=".csv,.xlsx,.xls"
onChange={e => setFile(e.target.files[0])}
disabled={uploading}
/>
/>
<Button type="submit" variant="contained" disabled={!file || uploading} sx={{ ml: 2 }}>
업로드
</Button>
</Button>
</form>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading && <CircularProgress sx={{ mt: 4 }} />}
<TableContainer component={Paper} sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableHead>
<TableRow>
<TableCell>파일명</TableCell>
<TableCell>리비전</TableCell>
<TableCell>세부내역</TableCell>
<TableCell>리비전</TableCell>
<TableCell>삭제</TableCell>
</TableRow>
</TableHead>
<TableBody>
</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={() => alert(`자재확인: ${file.original_filename}`)}>
<TableCell>
<Button size="small" variant="outlined" onClick={() => navigate(`/materials?fileId=${file.id}`)}>
자재확인
</Button>
</TableCell>
<TableCell>
</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={() => alert(`삭제: ${file.original_filename}`)}>
</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('삭제 중 오류가 발생했습니다.');
}
}
}}>
삭제
</Button>
</TableCell>
</TableRow>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</TableBody>
</Table>
</TableContainer>
</Box>
);
};

View File

@@ -40,7 +40,7 @@ const JobSelectionPage = () => {
const handleConfirm = () => {
if (selectedJobNo && selectedJobName) {
navigate(`/bom-manager?job_no=${selectedJobNo}&job_name=${encodeURIComponent(selectedJobName)}`);
navigate(`/bom-status?job_no=${selectedJobNo}&job_name=${encodeURIComponent(selectedJobName)}`);
}
};

View File

@@ -1,221 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Typography,
FormControl,
InputLabel,
Select,
MenuItem,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
CircularProgress,
Alert
} from '@mui/material';
// API 함수는 기존 api.js의 fetchJobs, fetchFiles, fetchMaterials를 활용한다고 가정
import { fetchJobs, fetchMaterials } from '../api';
const MaterialLookupPage = () => {
const [jobs, setJobs] = useState([]);
const [files, setFiles] = useState([]);
const [revisions, setRevisions] = useState([]);
const [selectedJobNo, setSelectedJobNo] = useState('');
const [selectedFilename, setSelectedFilename] = useState('');
const [selectedRevision, setSelectedRevision] = useState('');
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 1. Job 목록 불러오기 (최초 1회)
useEffect(() => {
async function loadJobs() {
try {
const res = await fetchJobs({});
if (res.data && res.data.jobs) setJobs(res.data.jobs);
} catch (e) {
setError('Job 목록을 불러오지 못했습니다.');
}
}
loadJobs();
}, []);
// 2. Job 선택 시 해당 도면(파일) 목록 불러오기
useEffect(() => {
async function loadFiles() {
if (!selectedJobNo) {
setFiles([]);
setRevisions([]);
setSelectedFilename('');
setSelectedRevision('');
return;
}
try {
const res = await fetch(`/files?job_no=${selectedJobNo}`);
const data = await res.json();
if (Array.isArray(data)) setFiles(data);
else if (data && Array.isArray(data.files)) setFiles(data.files);
else setFiles([]);
setSelectedFilename('');
setSelectedRevision('');
setRevisions([]);
} catch (e) {
setFiles([]);
setRevisions([]);
setError('도면 목록을 불러오지 못했습니다.');
}
}
loadFiles();
}, [selectedJobNo]);
// 3. 도면 선택 시 해당 리비전 목록 추출
useEffect(() => {
if (!selectedFilename) {
setRevisions([]);
setSelectedRevision('');
return;
}
const filtered = files.filter(f => f.original_filename === selectedFilename);
setRevisions(filtered.map(f => f.revision));
setSelectedRevision('');
}, [selectedFilename, files]);
// 4. 조회 버튼 클릭 시 자재 목록 불러오기
const handleLookup = async () => {
setLoading(true);
setError('');
setMaterials([]);
try {
const params = {
job_no: selectedJobNo,
filename: selectedFilename,
revision: selectedRevision
};
const res = await fetchMaterials(params);
if (res.data && Array.isArray(res.data.materials)) {
setMaterials(res.data.materials);
} else {
setMaterials([]);
}
} catch (e) {
setError('자재 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
};
// 5. 3개 모두 선택 시 자동 조회 (원하면 주석 해제)
// useEffect(() => {
// if (selectedJobNo && selectedFilename && selectedRevision) {
// handleLookup();
// }
// }, [selectedJobNo, selectedFilename, selectedRevision]);
return (
<Box sx={{ maxWidth: 900, mx: 'auto', mt: 4 }}>
<Typography variant="h4" gutterBottom>
자재 상세 조회 (Job No + 도면명 + 리비전)
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
{/* Job No 드롭다운 */}
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Job No</InputLabel>
<Select
value={selectedJobNo}
label="Job No"
onChange={e => setSelectedJobNo(e.target.value)}
displayEmpty
>
<MenuItem value="">선택</MenuItem>
{jobs.map(job => (
<MenuItem key={job.job_number} value={job.job_number}>
{job.job_number}
</MenuItem>
))}
</Select>
</FormControl>
{/* 도면명 드롭다운 */}
<FormControl size="small" sx={{ minWidth: 200 }} disabled={!selectedJobNo}>
<InputLabel>도면명(파일명)</InputLabel>
<Select
value={selectedFilename}
label="도면명(파일명)"
onChange={e => setSelectedFilename(e.target.value)}
displayEmpty
>
<MenuItem value="">선택</MenuItem>
{files.map(file => (
<MenuItem key={file.id} value={file.original_filename}>
{file.bom_name || file.original_filename || file.filename}
</MenuItem>
))}
</Select>
</FormControl>
{/* 리비전 드롭다운 */}
<FormControl size="small" sx={{ minWidth: 120 }} disabled={!selectedFilename}>
<InputLabel>리비전</InputLabel>
<Select
value={selectedRevision}
label="리비전"
onChange={e => setSelectedRevision(e.target.value)}
displayEmpty
>
<MenuItem value="">선택</MenuItem>
{revisions.map(rev => (
<MenuItem key={rev} value={rev}>{rev}</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
onClick={handleLookup}
disabled={!(selectedJobNo && selectedFilename && selectedRevision) || loading}
>
조회
</Button>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading && <CircularProgress sx={{ mt: 4 }} />}
{!loading && materials.length > 0 && (
<TableContainer component={Paper} sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>품명</TableCell>
<TableCell>수량</TableCell>
<TableCell>단위</TableCell>
<TableCell>사이즈</TableCell>
<TableCell>재질</TableCell>
<TableCell>라인번호</TableCell>
</TableRow>
</TableHead>
<TableBody>
{materials.map(mat => (
<TableRow key={mat.id}>
<TableCell>{mat.original_description}</TableCell>
<TableCell>{mat.quantity}</TableCell>
<TableCell>{mat.unit}</TableCell>
<TableCell>{mat.size_spec}</TableCell>
<TableCell>{mat.material_grade}</TableCell>
<TableCell>{mat.line_number}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{!loading && materials.length === 0 && (selectedJobNo && selectedFilename && selectedRevision) && (
<Alert severity="info" sx={{ mt: 4 }}>
해당 조건에 맞는 자재가 없습니다.
</Alert>
)}
</Box>
);
};
export default MaterialLookupPage;

File diff suppressed because it is too large Load Diff

View File

@@ -14,13 +14,13 @@ const ProjectSelectionPage = () => {
async function loadJobs() {
setLoading(true);
setError('');
try {
try {
const res = await fetchJobs({});
if (res.data && Array.isArray(res.data.jobs)) {
setJobs(res.data.jobs);
} else {
setJobs([]);
}
}
} catch (e) {
setError('프로젝트 목록을 불러오지 못했습니다.');
} finally {
@@ -37,8 +37,8 @@ const ProjectSelectionPage = () => {
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<FormControl size="small" fullWidth sx={{ mt: 3 }}>
<InputLabel>Job No</InputLabel>
<Select
value={selectedJob}
<Select
value={selectedJob}
label="Job No"
onChange={e => setSelectedJob(e.target.value)}
displayEmpty
@@ -47,23 +47,23 @@ const ProjectSelectionPage = () => {
{jobs.map(job => (
<MenuItem key={job.job_no} value={job.job_no}>
{job.job_no} ({job.job_name})
</MenuItem>
</MenuItem>
))}
</Select>
</FormControl>
{selectedJob && (
</Select>
</FormControl>
{selectedJob && (
<Alert severity="info" sx={{ mt: 3 }}>
선택된 Job No: <b>{selectedJob}</b>
</Alert>
)}
<Button
variant="contained"
<Button
variant="contained"
sx={{ mt: 4, minWidth: 120 }}
disabled={!selectedJob}
onClick={() => navigate(`/bom?job_no=${selectedJob}`)}
>
확인
</Button>
>
확인
</Button>
</Box>
);
};