feat: 자재 리비전 비교 및 구매 목록 시스템 구현
- 자재 리비전간 비교 기능 추가 (MaterialComparisonPage) - 버그 해결 필요 - 리비전간 추가 구매 필요 자재 분석 페이지 추가 (RevisionPurchasePage) - 자재 비교 결과 컴포넌트 구현 (MaterialComparisonResult) - 자재 비교 API 라우터 추가 (material_comparison.py) - 로직 개선 필요 - 자재 비교 시스템 데이터베이스 스키마 추가 - FileManager, FileUpload 컴포넌트 개선 - BOMManagerPage 제거 및 새로운 구조로 리팩토링 - 자재 분류기 및 스키마 개선 TODO: 자재 비교 알고리즘 정확도 향상 및 예외 처리 강화 필요
This commit is contained in:
@@ -1,219 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, TextField, CircularProgress, Alert, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { fetchFiles, uploadFile, deleteFile } from '../api';
|
||||
|
||||
const BOMManagerPage = () => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [file, setFile] = useState(null);
|
||||
const [filename, setFilename] = useState('');
|
||||
const [revisionDialogOpen, setRevisionDialogOpen] = useState(false);
|
||||
const [revisionTarget, setRevisionTarget] = useState(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 loadFiles = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await fetchFiles({ job_no: jobNo });
|
||||
if (Array.isArray(response.data)) {
|
||||
setFiles(response.data);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('파일 목록을 불러오지 못했습니다.');
|
||||
console.error('파일 목록 로드 에러:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (jobNo) loadFiles();
|
||||
// eslint-disable-next-line
|
||||
}, [jobNo]);
|
||||
|
||||
// 파일 업로드 핸들러
|
||||
const handleUpload = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!file || !filename) return;
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('bom_name', filename);
|
||||
|
||||
const response = await uploadFile(formData);
|
||||
if (response.data.success) {
|
||||
setFile(null);
|
||||
setFilename('');
|
||||
loadFiles(); // 파일 목록 새로고침
|
||||
alert(`업로드 성공: ${response.data.materials_count}개 자재가 분류되었습니다.`);
|
||||
} else {
|
||||
throw new Error(response.data.error || '업로드 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`파일 업로드에 실패했습니다: ${e.message}`);
|
||||
console.error('업로드 에러:', e);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 리비전 업로드 핸들러
|
||||
const handleRevisionUpload = async () => {
|
||||
if (!revisionFile || !revisionTarget) return;
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', revisionFile);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('bom_name', revisionTarget.original_filename);
|
||||
formData.append('parent_bom_id', revisionTarget.id);
|
||||
|
||||
const response = await uploadFile(formData);
|
||||
if (response.data.success) {
|
||||
setRevisionDialogOpen(false);
|
||||
setRevisionFile(null);
|
||||
setRevisionTarget(null);
|
||||
loadFiles();
|
||||
alert(`리비전 업로드 성공: ${response.data.revision}`);
|
||||
} else {
|
||||
throw new Error(response.data.error || '리비전 업로드 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`리비전 업로드에 실패했습니다: ${e.message}`);
|
||||
console.error('리비전 업로드 에러:', e);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 삭제 핸들러
|
||||
const handleDelete = async (fileId, filename) => {
|
||||
if (!confirm(`정말로 "${filename}" 파일을 삭제하시겠습니까?`)) return;
|
||||
|
||||
try {
|
||||
const response = await deleteFile(fileId);
|
||||
if (response.data.success) {
|
||||
loadFiles();
|
||||
alert('파일이 삭제되었습니다.');
|
||||
} else {
|
||||
throw new Error(response.data.error || '삭제 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`파일 삭제에 실패했습니다: ${e.message}`);
|
||||
console.error('삭제 에러:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 자재확인 페이지로 이동
|
||||
const handleViewMaterials = (file) => {
|
||||
navigate(`/materials?file_id=${file.id}&job_no=${jobNo}&filename=${encodeURIComponent(file.original_filename)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1000, mx: 'auto', mt: 4 }}>
|
||||
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
|
||||
← 프로젝트 선택
|
||||
</Button>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||
{jobNo && jobName && `${jobNo} (${jobName})`}
|
||||
</Typography>
|
||||
{/* BOM 업로드 폼 */}
|
||||
<form onSubmit={handleUpload} style={{ marginBottom: 24, display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="도면명(파일명)"
|
||||
value={filename}
|
||||
onChange={e => setFilename(e.target.value)}
|
||||
size="small"
|
||||
required
|
||||
sx={{ minWidth: 220 }}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={e => setFile(e.target.files[0])}
|
||||
disabled={uploading}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Button type="submit" variant="contained" disabled={!file || !filename || uploading}>
|
||||
업로드
|
||||
</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>
|
||||
<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}</TableCell>
|
||||
<TableCell>{file.revision}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="small" variant="outlined" onClick={() => handleViewMaterials(file)}>
|
||||
자재확인
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button size="small" variant="outlined" color="info" onClick={() => { setRevisionTarget(file); setRevisionDialogOpen(true); }}>
|
||||
리비전
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button size="small" variant="outlined" color="error" onClick={() => handleDelete(file.id, file.original_filename)}>
|
||||
삭제
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{/* 리비전 업로드 다이얼로그 */}
|
||||
<Dialog open={revisionDialogOpen} onClose={() => setRevisionDialogOpen(false)}>
|
||||
<DialogTitle>리비전 업로드</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ mb: 2 }}>
|
||||
도면명: <b>{revisionTarget?.original_filename}</b>
|
||||
</Typography>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={e => setRevisionFile(e.target.files[0])}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRevisionDialogOpen(false)}>취소</Button>
|
||||
<Button variant="contained" onClick={handleRevisionUpload} disabled={!revisionFile || uploading}>
|
||||
업로드
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMManagerPage;
|
||||
@@ -137,7 +137,7 @@ const BOMStatusPage = () => {
|
||||
formData.append('bom_name', revisionDialog.bomName);
|
||||
formData.append('bom_type', 'excel');
|
||||
formData.append('description', '');
|
||||
formData.append('parent_bom_id', revisionDialog.parentId);
|
||||
formData.append('parent_file_id', revisionDialog.parentId);
|
||||
|
||||
const response = await uploadFileApi(formData);
|
||||
|
||||
@@ -189,7 +189,7 @@ const BOMStatusPage = () => {
|
||||
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
|
||||
← 뒤로가기
|
||||
</Button>
|
||||
<Typography variant="h4" gutterBottom>BOM 업로드 및 현황</Typography>
|
||||
<Typography variant="h4" gutterBottom>📊 BOM 관리 시스템</Typography>
|
||||
{jobNo && jobName && (
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 3 }}>
|
||||
{jobNo} - {jobName}
|
||||
@@ -257,30 +257,46 @@ const BOMStatusPage = () => {
|
||||
{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'
|
||||
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="textSecondary">
|
||||
<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>{file.filename || file.original_filename}</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={index === 0 ? 'primary' : 'textSecondary'}
|
||||
fontWeight={index === 0 ? 'bold' : 'normal'}
|
||||
>
|
||||
{file.revision || 'Rev.0'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{file.parsed_count || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
|
||||
<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
|
||||
@@ -306,6 +322,28 @@ const BOMStatusPage = () => {
|
||||
리비전
|
||||
</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"
|
||||
|
||||
234
frontend/src/pages/MaterialComparisonPage.jsx
Normal file
234
frontend/src/pages/MaterialComparisonPage.jsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Breadcrumbs,
|
||||
Link,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Refresh,
|
||||
History
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import MaterialComparisonResult from '../components/MaterialComparisonResult';
|
||||
import { compareMaterialRevisions, confirmMaterialPurchase } from '../api';
|
||||
|
||||
const MaterialComparisonPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [comparisonResult, setComparisonResult] = useState(null);
|
||||
|
||||
// URL 파라미터에서 정보 추출
|
||||
const jobNo = searchParams.get('job_no');
|
||||
const currentRevision = searchParams.get('revision');
|
||||
const previousRevision = searchParams.get('prev_revision');
|
||||
const filename = searchParams.get('filename');
|
||||
|
||||
useEffect(() => {
|
||||
if (jobNo && currentRevision) {
|
||||
loadComparison();
|
||||
} else {
|
||||
setError('필수 파라미터가 누락되었습니다 (job_no, revision)');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobNo, currentRevision, previousRevision]);
|
||||
|
||||
const loadComparison = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('자재 비교 실행:', { jobNo, currentRevision, previousRevision });
|
||||
|
||||
const result = await compareMaterialRevisions(
|
||||
jobNo,
|
||||
currentRevision,
|
||||
previousRevision,
|
||||
true // 결과 저장
|
||||
);
|
||||
|
||||
console.log('비교 결과:', result);
|
||||
setComparisonResult(result);
|
||||
|
||||
} catch (err) {
|
||||
console.error('자재 비교 실패:', err);
|
||||
setError(err.response?.data?.detail || err.message || '자재 비교 중 오류가 발생했습니다');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmPurchase = async (confirmations) => {
|
||||
try {
|
||||
setConfirmLoading(true);
|
||||
|
||||
console.log('발주 확정 실행:', { jobNo, currentRevision, confirmations });
|
||||
|
||||
const result = await confirmMaterialPurchase(
|
||||
jobNo,
|
||||
currentRevision,
|
||||
confirmations,
|
||||
'user'
|
||||
);
|
||||
|
||||
console.log('발주 확정 결과:', result);
|
||||
|
||||
// 성공 메시지 표시 후 비교 결과 새로고침
|
||||
alert(`${result.confirmed_items?.length || confirmations.length}개 항목의 발주가 확정되었습니다!`);
|
||||
|
||||
// 비교 결과 새로고침 (재고 상태가 변경되었을 수 있음)
|
||||
await loadComparison();
|
||||
|
||||
} catch (err) {
|
||||
console.error('발주 확정 실패:', err);
|
||||
alert('발주 확정 중 오류가 발생했습니다: ' + (err.response?.data?.detail || err.message));
|
||||
} finally {
|
||||
setConfirmLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadComparison();
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
// 이전 페이지로 이동 (대부분 파일 업로드 완료 페이지)
|
||||
if (jobNo) {
|
||||
navigate(`/materials?job_no=${jobNo}`);
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Breadcrumbs sx={{ mb: 2 }}>
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={() => navigate('/jobs')}
|
||||
sx={{ textDecoration: 'none' }}
|
||||
>
|
||||
프로젝트 목록
|
||||
</Link>
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={() => navigate(`/materials?job_no=${jobNo}`)}
|
||||
sx={{ textDecoration: 'none' }}
|
||||
>
|
||||
{jobNo}
|
||||
</Link>
|
||||
<Typography variant="body2" color="textPrimary">
|
||||
자재 비교
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
자재 리비전 비교
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
{filename && `파일: ${filename}`}
|
||||
<br />
|
||||
{previousRevision ?
|
||||
`${previousRevision} → ${currentRevision} 비교` :
|
||||
`${currentRevision} (이전 리비전 없음)`
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
돌아가기
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
{renderHeader()}
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<Stack alignItems="center" spacing={2}>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
자재 비교 중...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
리비전간 차이점을 분석하고 있습니다
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
{renderHeader()}
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
자재 비교 실패
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{error}
|
||||
</Typography>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Refresh />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
{renderHeader()}
|
||||
|
||||
{comparisonResult ? (
|
||||
<MaterialComparisonResult
|
||||
comparison={comparisonResult}
|
||||
onConfirmPurchase={handleConfirmPurchase}
|
||||
loading={confirmLoading}
|
||||
/>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
비교 결과가 없습니다.
|
||||
</Alert>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialComparisonPage;
|
||||
@@ -16,11 +16,17 @@ import {
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Divider
|
||||
Divider,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Grid
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ShoppingCart from '@mui/icons-material/ShoppingCart';
|
||||
import { api } from '../api';
|
||||
import { Compare as CompareIcon } from '@mui/icons-material';
|
||||
import { api, fetchFiles } from '../api';
|
||||
|
||||
const MaterialsPage = () => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
@@ -28,23 +34,63 @@ const MaterialsPage = () => {
|
||||
const [error, setError] = useState(null);
|
||||
const [fileId, setFileId] = useState(null);
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [jobNo, setJobNo] = useState('');
|
||||
const [bomName, setBomName] = useState('');
|
||||
const [currentRevision, setCurrentRevision] = useState('');
|
||||
const [availableRevisions, setAvailableRevisions] = useState([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const id = urlParams.get('file_id');
|
||||
const name = urlParams.get('filename') || '';
|
||||
const job_no = urlParams.get('job_no') || '';
|
||||
|
||||
if (id) {
|
||||
if (id && job_no) {
|
||||
setFileId(id);
|
||||
setFileName(decodeURIComponent(name));
|
||||
setJobNo(job_no);
|
||||
loadMaterials(id);
|
||||
loadAvailableRevisions(job_no, name);
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError('파일 ID가 지정되지 않았습니다.');
|
||||
setError('파일 ID 또는 Job No가 지정되지 않았습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 같은 BOM의 다른 리비전들 로드
|
||||
const loadAvailableRevisions = async (job_no, filename) => {
|
||||
try {
|
||||
const response = await fetchFiles({ job_no });
|
||||
if (Array.isArray(response.data)) {
|
||||
// 같은 BOM 이름의 파일들만 필터링
|
||||
const sameNameFiles = response.data.filter(file =>
|
||||
file.original_filename === filename ||
|
||||
file.bom_name === filename ||
|
||||
file.filename === filename
|
||||
);
|
||||
|
||||
// 리비전 순으로 정렬 (최신부터)
|
||||
const sortedFiles = sameNameFiles.sort((a, b) => {
|
||||
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
|
||||
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
|
||||
return revB - revA;
|
||||
});
|
||||
|
||||
setAvailableRevisions(sortedFiles);
|
||||
|
||||
// 현재 파일 정보 설정
|
||||
const currentFile = sortedFiles.find(file => file.id === parseInt(fileId));
|
||||
if (currentFile) {
|
||||
setCurrentRevision(currentFile.revision || 'Rev.0');
|
||||
setBomName(currentFile.bom_name || currentFile.original_filename);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('리비전 목록 로드 실패:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMaterials = async (id) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -586,21 +632,75 @@ const MaterialsPage = () => {
|
||||
뒤로가기
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
size="large"
|
||||
startIcon={<ShoppingCart />}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
navigate(`/purchase-confirmation?${params.toString()}`);
|
||||
}}
|
||||
disabled={materialSpecs.length === 0}
|
||||
sx={{ minWidth: 150 }}
|
||||
>
|
||||
구매 확정
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
{/* 리비전 비교 버튼 */}
|
||||
{availableRevisions.length > 1 && currentRevision !== 'Rev.0' && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon={<CompareIcon />}
|
||||
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${currentRevision}&filename=${encodeURIComponent(fileName)}`)}
|
||||
>
|
||||
리비전 비교
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
size="large"
|
||||
startIcon={<ShoppingCart />}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
navigate(`/purchase-confirmation?${params.toString()}`);
|
||||
}}
|
||||
disabled={materialSpecs.length === 0}
|
||||
sx={{ minWidth: 150 }}
|
||||
>
|
||||
구매 확정
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 리비전 선택 */}
|
||||
{availableRevisions.length > 1 && (
|
||||
<Card sx={{ mb: 3, p: 2 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
📋 {bomName}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Job No: {jobNo} | 현재 리비전: <strong>{currentRevision}</strong>
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>리비전 선택</InputLabel>
|
||||
<Select
|
||||
value={fileId || ''}
|
||||
label="리비전 선택"
|
||||
onChange={(e) => {
|
||||
const selectedFileId = e.target.value;
|
||||
const selectedFile = availableRevisions.find(file => file.id === selectedFileId);
|
||||
if (selectedFile) {
|
||||
// 새로운 리비전 페이지로 이동
|
||||
navigate(`/materials?file_id=${selectedFileId}&job_no=${jobNo}&filename=${encodeURIComponent(selectedFile.original_filename || selectedFile.filename)}`);
|
||||
window.location.reload(); // 페이지 새로고침으로 데이터 갱신
|
||||
}
|
||||
}}
|
||||
>
|
||||
{availableRevisions.map((file) => (
|
||||
<MenuItem key={file.id} value={file.id}>
|
||||
{file.revision || 'Rev.0'} ({file.parsed_count || 0}개 자재) - {new Date(file.upload_date).toLocaleDateString('ko-KR')}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
📋 자재 사양서
|
||||
|
||||
437
frontend/src/pages/RevisionPurchasePage.jsx
Normal file
437
frontend/src/pages/RevisionPurchasePage.jsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Grid,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Tabs,
|
||||
Tab,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
ShoppingCart,
|
||||
Compare,
|
||||
Add as AddIcon,
|
||||
Remove as RemoveIcon,
|
||||
TrendingUp,
|
||||
Assessment
|
||||
} from '@mui/icons-material';
|
||||
import { compareMaterialRevisions, fetchFiles } from '../api';
|
||||
|
||||
const RevisionPurchasePage = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [comparisonResult, setComparisonResult] = useState(null);
|
||||
const [availableRevisions, setAvailableRevisions] = useState([]);
|
||||
const [selectedTab, setSelectedTab] = useState(0);
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// URL 파라미터에서 정보 추출
|
||||
const jobNo = searchParams.get('job_no');
|
||||
const currentRevision = searchParams.get('current_revision') || searchParams.get('revision');
|
||||
const previousRevision = searchParams.get('previous_revision');
|
||||
const bomName = searchParams.get('bom_name');
|
||||
|
||||
useEffect(() => {
|
||||
if (jobNo && currentRevision) {
|
||||
loadAvailableRevisions();
|
||||
loadComparisonData();
|
||||
} else {
|
||||
setError('필수 파라미터가 누락되었습니다. (job_no, current_revision)');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobNo, currentRevision, previousRevision]);
|
||||
|
||||
const loadAvailableRevisions = async () => {
|
||||
try {
|
||||
const response = await fetchFiles({ job_no: jobNo });
|
||||
if (Array.isArray(response.data)) {
|
||||
// BOM별로 그룹화
|
||||
const bomGroups = response.data.reduce((acc, file) => {
|
||||
const key = file.bom_name || file.original_filename;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(file);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 현재 BOM과 관련된 리비전들만 필터링
|
||||
let relevantFiles = [];
|
||||
if (bomName) {
|
||||
relevantFiles = bomGroups[bomName] || [];
|
||||
} else {
|
||||
// bomName이 없으면 현재 리비전과 같은 원본파일명을 가진 것들
|
||||
const currentFile = response.data.find(file => file.revision === currentRevision);
|
||||
if (currentFile) {
|
||||
const key = currentFile.bom_name || currentFile.original_filename;
|
||||
relevantFiles = bomGroups[key] || [];
|
||||
}
|
||||
}
|
||||
|
||||
// 리비전 순으로 정렬
|
||||
relevantFiles.sort((a, b) => {
|
||||
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
|
||||
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
|
||||
return revB - revA;
|
||||
});
|
||||
|
||||
setAvailableRevisions(relevantFiles);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('리비전 목록 로드 실패:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadComparisonData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await compareMaterialRevisions(
|
||||
jobNo,
|
||||
currentRevision,
|
||||
previousRevision,
|
||||
true
|
||||
);
|
||||
setComparisonResult(result);
|
||||
} catch (err) {
|
||||
setError(`리비전 비교 실패: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevisionChange = (type, newRevision) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (type === 'current') {
|
||||
params.set('current_revision', newRevision);
|
||||
} else {
|
||||
params.set('previous_revision', newRevision);
|
||||
}
|
||||
navigate(`?${params.toString()}`, { replace: true });
|
||||
};
|
||||
|
||||
const calculatePurchaseNeeds = () => {
|
||||
if (!comparisonResult) return { newItems: [], increasedItems: [] };
|
||||
|
||||
const newItems = comparisonResult.new_items || [];
|
||||
const modifiedItems = comparisonResult.modified_items || [];
|
||||
|
||||
// 수량이 증가한 항목들만 필터링
|
||||
const increasedItems = modifiedItems.filter(item =>
|
||||
item.quantity_change > 0
|
||||
);
|
||||
|
||||
return { newItems, increasedItems };
|
||||
};
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: 'KRW'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<CircularProgress />
|
||||
<Typography variant="h6" sx={{ ml: 2 }}>
|
||||
리비전 비교 분석 중...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto', mt: 4, px: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
뒤로가기
|
||||
</Button>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { newItems, increasedItems } = calculatePurchaseNeeds();
|
||||
const totalNewItems = newItems.length;
|
||||
const totalIncreasedItems = increasedItems.length;
|
||||
const totalPurchaseItems = totalNewItems + totalIncreasedItems;
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1400, mx: 'auto', mt: 4, px: 2 }}>
|
||||
{/* 헤더 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
뒤로가기
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Assessment />}
|
||||
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${currentRevision}&prev_revision=${previousRevision}`)}
|
||||
>
|
||||
상세 비교 보기
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<ShoppingCart />}
|
||||
disabled={totalPurchaseItems === 0}
|
||||
>
|
||||
구매 목록 생성
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h4" gutterBottom>
|
||||
🛒 리비전간 추가 구매 필요 자재
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
Job No: {jobNo} | {currentRevision} vs {previousRevision || '이전 리비전'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 리비전 선택 카드 */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
리비전 비교 설정
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>현재 리비전</InputLabel>
|
||||
<Select
|
||||
value={currentRevision}
|
||||
label="현재 리비전"
|
||||
onChange={(e) => handleRevisionChange('current', e.target.value)}
|
||||
>
|
||||
{availableRevisions.map((file) => (
|
||||
<MenuItem key={file.id} value={file.revision}>
|
||||
{file.revision} ({file.parsed_count || 0}개 자재)
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>이전 리비전</InputLabel>
|
||||
<Select
|
||||
value={previousRevision || ''}
|
||||
label="이전 리비전"
|
||||
onChange={(e) => handleRevisionChange('previous', e.target.value)}
|
||||
>
|
||||
<MenuItem value="">자동 선택 (직전 리비전)</MenuItem>
|
||||
{availableRevisions
|
||||
.filter(file => file.revision !== currentRevision)
|
||||
.map((file) => (
|
||||
<MenuItem key={file.id} value={file.revision}>
|
||||
{file.revision} ({file.parsed_count || 0}개 자재)
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 구매 요약 */}
|
||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<AddIcon color="primary" sx={{ fontSize: 48, mb: 1 }} />
|
||||
<Typography variant="h4" color="primary">
|
||||
{totalNewItems}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
신규 자재
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<TrendingUp color="warning" sx={{ fontSize: 48, mb: 1 }} />
|
||||
<Typography variant="h4" color="warning.main">
|
||||
{totalIncreasedItems}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
수량 증가
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<ShoppingCart color="success" sx={{ fontSize: 48, mb: 1 }} />
|
||||
<Typography variant="h4" color="success.main">
|
||||
{totalPurchaseItems}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
총 구매 항목
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* 탭으로 구분된 자재 목록 */}
|
||||
<Card>
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onChange={(e, newValue) => setSelectedTab(newValue)}
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab label={`신규 자재 (${totalNewItems})`} />
|
||||
<Tab label={`수량 증가 (${totalIncreasedItems})`} />
|
||||
</Tabs>
|
||||
|
||||
<CardContent>
|
||||
{selectedTab === 0 && (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom color="primary">
|
||||
🆕 신규 추가 자재
|
||||
</Typography>
|
||||
{newItems.length === 0 ? (
|
||||
<Alert severity="info">새로 추가된 자재가 없습니다.</Alert>
|
||||
) : (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>카테고리</TableCell>
|
||||
<TableCell>자재 설명</TableCell>
|
||||
<TableCell>사이즈</TableCell>
|
||||
<TableCell>재질</TableCell>
|
||||
<TableCell>수량</TableCell>
|
||||
<TableCell>단위</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{newItems.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={item.category}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.description}</TableCell>
|
||||
<TableCell>{item.size_spec || '-'}</TableCell>
|
||||
<TableCell>{item.material_grade || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight="bold" color="primary">
|
||||
+{item.quantity}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{item.unit || 'EA'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{selectedTab === 1 && (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom color="warning.main">
|
||||
📈 수량 증가 자재
|
||||
</Typography>
|
||||
{increasedItems.length === 0 ? (
|
||||
<Alert severity="info">수량이 증가한 자재가 없습니다.</Alert>
|
||||
) : (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>카테고리</TableCell>
|
||||
<TableCell>자재 설명</TableCell>
|
||||
<TableCell>사이즈</TableCell>
|
||||
<TableCell>재질</TableCell>
|
||||
<TableCell>이전 수량</TableCell>
|
||||
<TableCell>현재 수량</TableCell>
|
||||
<TableCell>증가량</TableCell>
|
||||
<TableCell>단위</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{increasedItems.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={item.category}
|
||||
size="small"
|
||||
color="warning"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.description}</TableCell>
|
||||
<TableCell>{item.size_spec || '-'}</TableCell>
|
||||
<TableCell>{item.material_grade || '-'}</TableCell>
|
||||
<TableCell>{item.previous_quantity}</TableCell>
|
||||
<TableCell>{item.current_quantity}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight="bold" color="warning.main">
|
||||
+{item.quantity_change}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{item.unit || 'EA'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{totalPurchaseItems === 0 && (
|
||||
<Alert severity="success" sx={{ mt: 3 }}>
|
||||
🎉 추가로 구매가 필요한 자재가 없습니다!
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionPurchasePage;
|
||||
Reference in New Issue
Block a user