프론트엔드 작성중
This commit is contained in:
219
frontend/src/pages/BOMManagerPage.jsx
Normal file
219
frontend/src/pages/BOMManagerPage.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
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;
|
||||
124
frontend/src/pages/BOMStatusPage.jsx
Normal file
124
frontend/src/pages/BOMStatusPage.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert } from '@mui/material';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
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 [searchParams] = useSearchParams();
|
||||
const jobNo = searchParams.get('job_no');
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 파일 목록 불러오기
|
||||
const fetchFiles = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
let url = '/files';
|
||||
if (jobNo) {
|
||||
url += `?job_no=${jobNo}`;
|
||||
}
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) setFiles(data);
|
||||
else if (data && Array.isArray(data.files)) setFiles(data.files);
|
||||
else setFiles([]);
|
||||
} catch (e) {
|
||||
setError('파일 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles();
|
||||
// eslint-disable-next-line
|
||||
}, [jobNo]);
|
||||
|
||||
// 파일 업로드 핸들러
|
||||
const handleUpload = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('project_id', 1); // 예시: 실제 프로젝트 ID로 대체 필요
|
||||
const res = await fetch('/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (!res.ok) throw new Error('업로드 실패');
|
||||
setFile(null);
|
||||
fetchFiles();
|
||||
} catch (e) {
|
||||
setError('파일 업로드에 실패했습니다.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
<Button type="submit" variant="contained" disabled={!file || uploading} sx={{ ml: 2 }}>
|
||||
업로드
|
||||
</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 || file.filename}</TableCell>
|
||||
<TableCell>{file.revision}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="small" variant="outlined" onClick={() => alert(`자재확인: ${file.original_filename}`)}>
|
||||
자재확인
|
||||
</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={() => alert(`삭제: ${file.original_filename}`)}>
|
||||
삭제
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMStatusPage;
|
||||
@@ -1,455 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Upload, FileText, AlertCircle, CheckCircle, Loader2, Database, TrendingUp, Settings, Eye, BarChart3, Filter } from 'lucide-react';
|
||||
|
||||
const FileUploadPage = () => {
|
||||
const [uploadStatus, setUploadStatus] = useState('idle'); // idle, uploading, analyzing, classifying, success, error
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadResult, setUploadResult] = useState(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState('');
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [analysisStep, setAnalysisStep] = useState('');
|
||||
const [classificationPreview, setClassificationPreview] = useState(null);
|
||||
|
||||
// 프로젝트 목록 로드
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/projects');
|
||||
const data = await response.json();
|
||||
setProjects(data);
|
||||
if (data.length > 0) {
|
||||
setSelectedProject(data[0].id.toString());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 핸들러
|
||||
const handleDrag = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFileUpload(e.dataTransfer.files[0]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 파일 업로드 및 4단계 자동 분류 처리
|
||||
const handleFileUpload = async (file) => {
|
||||
if (!file) return;
|
||||
if (!selectedProject) {
|
||||
alert('프로젝트를 먼저 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 타입 체크 (다양한 형식 지원)
|
||||
const allowedTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
||||
'application/vnd.ms-excel', // .xls
|
||||
'application/vnd.ms-excel.sheet.macroEnabled.12', // .xlsm
|
||||
'text/csv',
|
||||
'text/plain'
|
||||
];
|
||||
|
||||
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx|xls|xlsm|csv|txt)$/i)) {
|
||||
alert('지원 형식: 엑셀(.xlsx, .xls, .xlsm), CSV, 텍스트 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadStatus('uploading');
|
||||
setUploadProgress(0);
|
||||
setAnalysisStep('파일 업로드 중...');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('project_id', selectedProject);
|
||||
formData.append('revision', 'Rev.0');
|
||||
formData.append('description', `${file.name} - 자재 목록`);
|
||||
|
||||
// 단계별 진행률 시뮬레이션
|
||||
const steps = [
|
||||
{ progress: 20, status: 'uploading', step: '파일 업로드 중...' },
|
||||
{ progress: 40, status: 'analyzing', step: '자동 구조 인식 중... (컬럼 분석)' },
|
||||
{ progress: 60, status: 'classifying', step: '4단계 자동 분류 진행 중...' },
|
||||
{ progress: 80, status: 'classifying', step: '재질 코드 및 사이즈 표준화...' },
|
||||
{ progress: 90, status: 'classifying', step: 'DB 저장 중...' }
|
||||
];
|
||||
|
||||
let stepIndex = 0;
|
||||
const progressInterval = setInterval(() => {
|
||||
if (stepIndex < steps.length) {
|
||||
const currentStep = steps[stepIndex];
|
||||
setUploadProgress(currentStep.progress);
|
||||
setUploadStatus(currentStep.status);
|
||||
setAnalysisStep(currentStep.step);
|
||||
stepIndex++;
|
||||
} else {
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
}, 800);
|
||||
|
||||
const response = await fetch('http://localhost:8000/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setUploadResult(result);
|
||||
setUploadStatus('success');
|
||||
setAnalysisStep('분류 완료! 결과를 확인하세요.');
|
||||
|
||||
// 분류 결과 미리보기 생성
|
||||
setClassificationPreview({
|
||||
totalItems: result.parsed_count || 0,
|
||||
categories: {
|
||||
'파이프': Math.floor((result.parsed_count || 0) * 0.4),
|
||||
'피팅류': Math.floor((result.parsed_count || 0) * 0.3),
|
||||
'볼트(너트)': Math.floor((result.parsed_count || 0) * 0.15),
|
||||
'밸브': Math.floor((result.parsed_count || 0) * 0.1),
|
||||
'계기류': Math.floor((result.parsed_count || 0) * 0.05)
|
||||
},
|
||||
materials: ['A333-6 (저온용 배관)', 'A105 (단조 탄소강)', 'S355', 'SM490'],
|
||||
sizes: ['1"', '2"', '3"', '4"', '6"', '8"']
|
||||
});
|
||||
|
||||
} else {
|
||||
throw new Error('업로드 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
setUploadStatus('error');
|
||||
setAnalysisStep('처리 중 오류가 발생했습니다.');
|
||||
setTimeout(() => {
|
||||
setUploadStatus('idle');
|
||||
setUploadProgress(0);
|
||||
setAnalysisStep('');
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInput = (e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleFileUpload(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const resetUpload = () => {
|
||||
setUploadStatus('idle');
|
||||
setUploadProgress(0);
|
||||
setUploadResult(null);
|
||||
setAnalysisStep('');
|
||||
setClassificationPreview(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||
🏗️ 도면 자재 분석 시스템
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Phase 1: 파일 분석 → 4단계 자동 분류 → 체계적 DB 저장
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Phase 1 핵심 프로세스 플로우 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-800">🎯 Phase 1: 핵심 기능 처리 과정</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="flex flex-col items-center text-center p-4 bg-blue-50 rounded-lg">
|
||||
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center text-white mb-2">
|
||||
<Upload size={20} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">다양한 형식</span>
|
||||
<span className="text-xs text-gray-600">xlsx,xls,xlsm,csv</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center p-4 bg-green-50 rounded-lg">
|
||||
<div className="w-12 h-12 bg-green-500 rounded-full flex items-center justify-center text-white mb-2">
|
||||
<Settings size={20} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">자동 구조 인식</span>
|
||||
<span className="text-xs text-gray-600">컬럼 자동 판별</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center p-4 bg-purple-50 rounded-lg">
|
||||
<div className="w-12 h-12 bg-purple-500 rounded-full flex items-center justify-center text-white mb-2">
|
||||
<Filter size={20} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">4단계 자동 분류</span>
|
||||
<span className="text-xs text-gray-600">대분류→세부→재질→사이즈</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center p-4 bg-orange-50 rounded-lg">
|
||||
<div className="w-12 h-12 bg-orange-500 rounded-full flex items-center justify-center text-white mb-2">
|
||||
<Database size={20} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">체계적 저장</span>
|
||||
<span className="text-xs text-gray-600">버전관리+이력추적</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 왼쪽: 업로드 영역 */}
|
||||
<div className="space-y-6">
|
||||
{/* 프로젝트 선택 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold mb-3 text-gray-800">📋 프로젝트 선택</h3>
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => setSelectedProject(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
{projects.map(project => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.official_project_code} - {project.project_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 업로드 영역 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div
|
||||
className={`relative border-2 border-dashed rounded-xl p-8 text-center transition-all duration-300 ${
|
||||
dragActive
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: uploadStatus === 'idle'
|
||||
? 'border-gray-300 hover:border-blue-400 hover:bg-blue-50'
|
||||
: 'border-green-400 bg-green-50'
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
accept=".xlsx,.xls,.xlsm,.csv,.txt"
|
||||
onChange={handleFileInput}
|
||||
disabled={uploadStatus !== 'idle'}
|
||||
/>
|
||||
|
||||
{uploadStatus === 'idle' && (
|
||||
<>
|
||||
<Upload className="mx-auto mb-4 text-gray-400" size={48} />
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-2">
|
||||
자재 목록 파일을 업로드하세요
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-4">
|
||||
드래그 앤 드롭하거나 클릭하여 파일을 선택하세요
|
||||
</p>
|
||||
<div className="text-sm text-gray-400 mb-4">
|
||||
지원 형식: Excel (.xlsx, .xls, .xlsm), CSV, 텍스트
|
||||
</div>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer transition-colors"
|
||||
>
|
||||
<Upload className="mr-2" size={20} />
|
||||
파일 선택
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(uploadStatus === 'uploading' || uploadStatus === 'analyzing' || uploadStatus === 'classifying') && (
|
||||
<div className="space-y-4">
|
||||
<Loader2 className="mx-auto text-blue-500 animate-spin" size={48} />
|
||||
<h3 className="text-xl font-semibold text-gray-700">
|
||||
{analysisStep}
|
||||
</h3>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-blue-500 h-3 rounded-full transition-all duration-500"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{uploadProgress}% 완료</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadStatus === 'success' && (
|
||||
<div className="space-y-4">
|
||||
<CheckCircle className="mx-auto text-green-500" size={48} />
|
||||
<h3 className="text-xl font-semibold text-green-700">
|
||||
분석 및 분류 완료!
|
||||
</h3>
|
||||
<p className="text-gray-600">{analysisStep}</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => window.location.href = '/materials'}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center"
|
||||
>
|
||||
<Eye className="mr-2" size={16} />
|
||||
결과 보기
|
||||
</button>
|
||||
<button
|
||||
onClick={resetUpload}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700"
|
||||
>
|
||||
새 파일 업로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadStatus === 'error' && (
|
||||
<div className="space-y-4">
|
||||
<AlertCircle className="mx-auto text-red-500" size={48} />
|
||||
<h3 className="text-xl font-semibold text-red-700">
|
||||
업로드 실패
|
||||
</h3>
|
||||
<p className="text-gray-600">{analysisStep}</p>
|
||||
<button
|
||||
onClick={resetUpload}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 4단계 분류 시스템 설명 & 미리보기 */}
|
||||
<div className="space-y-6">
|
||||
{/* 4단계 분류 시스템 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-800 flex items-center">
|
||||
<Filter className="mr-2" size={20} />
|
||||
4단계 자동 분류 시스템
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<h4 className="font-semibold text-blue-700">1단계: 대분류</h4>
|
||||
<p className="text-sm text-gray-600">파이프 / 피팅류 / 볼트(너트) / 밸브 / 계기류</p>
|
||||
</div>
|
||||
<div className="border-l-4 border-green-500 pl-4">
|
||||
<h4 className="font-semibold text-green-700">2단계: 세부분류</h4>
|
||||
<p className="text-sm text-gray-600">90도 엘보우 / 용접목 플랜지 / SEAMLESS 파이프</p>
|
||||
</div>
|
||||
<div className="border-l-4 border-purple-500 pl-4">
|
||||
<h4 className="font-semibold text-purple-700">3단계: 재질 인식</h4>
|
||||
<p className="text-sm text-gray-600">A333-6 (저온용 배관) / A105 (단조 탄소강) / S355 / SM490</p>
|
||||
</div>
|
||||
<div className="border-l-4 border-orange-500 pl-4">
|
||||
<h4 className="font-semibold text-orange-700">4단계: 사이즈 표준화</h4>
|
||||
<p className="text-sm text-gray-600">6.0" → 6인치, 규격 통일 및 단위 자동 결정</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 분류 결과 미리보기 */}
|
||||
{classificationPreview && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-800 flex items-center">
|
||||
<BarChart3 className="mr-2" size={20} />
|
||||
분류 결과 미리보기
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">{classificationPreview.totalItems}</div>
|
||||
<div className="text-sm text-gray-600">총 자재 수</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">대분류별 분포</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(classificationPreview.categories).map(([category, count]) => (
|
||||
<div key={category} className="flex justify-between items-center">
|
||||
<span className="text-sm">{category}</span>
|
||||
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm font-medium">
|
||||
{count}개
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">인식된 재질</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{classificationPreview.materials.map((material, index) => (
|
||||
<span key={index} className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">
|
||||
{material}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">표준화된 사이즈</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{classificationPreview.sizes.map((size, index) => (
|
||||
<span key={index} className="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs">
|
||||
{size}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터베이스 저장 정보 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-800 flex items-center">
|
||||
<Database className="mr-2" size={20} />
|
||||
체계적 DB 저장
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full mr-3"></div>
|
||||
<span>프로젝트 단위 관리 (코드 체계)</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mr-3"></div>
|
||||
<span>버전 관리 (Rev.0, Rev.1, Rev.2)</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 bg-purple-500 rounded-full mr-3"></div>
|
||||
<span>파일 업로드 이력 추적</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 bg-orange-500 rounded-full mr-3"></div>
|
||||
<span>분류 결과 + 원본 정보 보존</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full mr-3"></div>
|
||||
<span>수량 정보 세분화 저장</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploadPage;
|
||||
85
frontend/src/pages/JobSelectionPage.jsx
Normal file
85
frontend/src/pages/JobSelectionPage.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Button, CircularProgress, Alert } from '@mui/material';
|
||||
import { fetchJobs } from '../api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const JobSelectionPage = () => {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [selectedJobNo, setSelectedJobNo] = useState('');
|
||||
const [selectedJobName, setSelectedJobName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadJobs() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetchJobs({});
|
||||
if (res.data && Array.isArray(res.data.jobs)) {
|
||||
setJobs(res.data.jobs);
|
||||
} else {
|
||||
setJobs([]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('프로젝트 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadJobs();
|
||||
}, []);
|
||||
|
||||
const handleSelect = (e) => {
|
||||
const jobNo = e.target.value;
|
||||
setSelectedJobNo(jobNo);
|
||||
const job = jobs.find(j => j.job_no === jobNo);
|
||||
setSelectedJobName(job ? job.job_name : '');
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedJobNo && selectedJobName) {
|
||||
navigate(`/bom-manager?job_no=${selectedJobNo}&job_name=${encodeURIComponent(selectedJobName)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 8 }}>
|
||||
<Typography variant="h4" gutterBottom>프로젝트 선택</Typography>
|
||||
{loading && <CircularProgress sx={{ mt: 4 }} />}
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<FormControl size="small" fullWidth sx={{ mt: 3 }}>
|
||||
<InputLabel>프로젝트</InputLabel>
|
||||
<Select
|
||||
value={selectedJobNo}
|
||||
label="프로젝트"
|
||||
onChange={handleSelect}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">선택</MenuItem>
|
||||
{jobs.map(job => (
|
||||
<MenuItem key={job.job_no} value={job.job_no}>
|
||||
{job.job_no} ({job.job_name})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedJobNo && (
|
||||
<Alert severity="info" sx={{ mt: 3 }}>
|
||||
선택된 프로젝트: <b>{selectedJobNo} ({selectedJobName})</b>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ mt: 4, minWidth: 120 }}
|
||||
disabled={!selectedJobNo}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobSelectionPage;
|
||||
221
frontend/src/pages/MaterialLookupPage.jsx
Normal file
221
frontend/src/pages/MaterialLookupPage.jsx
Normal file
@@ -0,0 +1,221 @@
|
||||
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;
|
||||
256
frontend/src/pages/MaterialsPage.jsx
Normal file
256
frontend/src/pages/MaterialsPage.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Chip,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid
|
||||
} from '@mui/material';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { fetchMaterialsSummary } from '../api';
|
||||
|
||||
const MaterialsPage = () => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fileId = searchParams.get('file_id');
|
||||
const jobNo = searchParams.get('job_no');
|
||||
const filename = searchParams.get('filename');
|
||||
|
||||
// 자재 목록 불러오기
|
||||
const loadMaterials = async () => {
|
||||
if (!fileId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
// 자재 목록 조회
|
||||
const response = await fetch(`http://localhost:8000/files/materials?file_id=${fileId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.materials && Array.isArray(data.materials)) {
|
||||
// 동일 항목 그룹화 (품명 + 사이즈 + 재질이 같은 것들)
|
||||
const groupedMaterials = groupMaterialsByItem(data.materials);
|
||||
setMaterials(groupedMaterials);
|
||||
} else {
|
||||
setMaterials([]);
|
||||
}
|
||||
|
||||
// 요약 정보 조회
|
||||
const summaryResponse = await fetchMaterialsSummary({ file_id: fileId });
|
||||
if (summaryResponse.data.success) {
|
||||
setSummary(summaryResponse.data.summary);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('자재 목록을 불러오지 못했습니다.');
|
||||
console.error('자재 로드 에러:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 동일 항목 그룹화 함수
|
||||
const groupMaterialsByItem = (materials) => {
|
||||
const grouped = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
// 그룹화 키: 품명 + 사이즈 + 재질 + 분류
|
||||
const key = `${material.original_description}_${material.size_spec || ''}_${material.material_grade || ''}_${material.classified_category || ''}`;
|
||||
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = {
|
||||
...material,
|
||||
totalQuantity: 0,
|
||||
items: []
|
||||
};
|
||||
}
|
||||
|
||||
grouped[key].totalQuantity += material.quantity || 0;
|
||||
grouped[key].items.push(material);
|
||||
});
|
||||
|
||||
return Object.values(grouped).sort((a, b) => b.totalQuantity - a.totalQuantity);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadMaterials();
|
||||
}, [fileId]);
|
||||
|
||||
// 분류별 색상 지정
|
||||
const getCategoryColor = (category) => {
|
||||
const colors = {
|
||||
'PIPE': 'primary',
|
||||
'FITTING': 'secondary',
|
||||
'VALVE': 'error',
|
||||
'FLANGE': 'warning',
|
||||
'BOLT': 'info',
|
||||
'GASKET': 'success',
|
||||
'INSTRUMENT': 'default'
|
||||
};
|
||||
return colors[category] || 'default';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto', mt: 4, p: 2 }}>
|
||||
{/* 헤더 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate(`/bom-manager?job_no=${jobNo}`)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
← BOM 관리로 돌아가기
|
||||
</Button>
|
||||
|
||||
<Typography variant="h5" gutterBottom>
|
||||
자재 목록 - {filename}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
Job No: {jobNo}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 요약 정보 */}
|
||||
{summary && (
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
총 항목 수
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{summary.total_items}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
고유 품명 수
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{summary.unique_descriptions}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
총 수량
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{summary.total_quantity}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
고유 재질 수
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{summary.unique_materials}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
{loading && <CircularProgress sx={{ mt: 4 }} />}
|
||||
|
||||
{/* 자재 목록 테이블 */}
|
||||
{!loading && materials.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>분류</TableCell>
|
||||
<TableCell>품명</TableCell>
|
||||
<TableCell>사이즈</TableCell>
|
||||
<TableCell>재질</TableCell>
|
||||
<TableCell align="right">총 수량</TableCell>
|
||||
<TableCell>단위</TableCell>
|
||||
<TableCell align="center">항목 수</TableCell>
|
||||
<TableCell>신뢰도</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{materials.map((material, index) => (
|
||||
<TableRow key={index} hover>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={material.classified_category || 'OTHER'}
|
||||
color={getCategoryColor(material.classified_category)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
|
||||
{material.original_description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{material.size_spec || '-'}</TableCell>
|
||||
<TableCell>{material.material_grade || '-'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>
|
||||
{material.totalQuantity}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{material.unit || 'EA'}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={material.items.length}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={material.classification_confidence > 0.7 ? 'success.main' : 'warning.main'}
|
||||
>
|
||||
{Math.round((material.classification_confidence || 0) * 100)}%
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{!loading && materials.length === 0 && fileId && (
|
||||
<Alert severity="info" sx={{ mt: 4 }}>
|
||||
해당 파일에 자재 정보가 없습니다.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialsPage;
|
||||
71
frontend/src/pages/ProjectSelectionPage.jsx
Normal file
71
frontend/src/pages/ProjectSelectionPage.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, CircularProgress, Alert, Button } from '@mui/material';
|
||||
import { fetchJobs } from '../api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const ProjectSelectionPage = () => {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [selectedJob, setSelectedJob] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadJobs() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetchJobs({});
|
||||
if (res.data && Array.isArray(res.data.jobs)) {
|
||||
setJobs(res.data.jobs);
|
||||
} else {
|
||||
setJobs([]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('프로젝트 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadJobs();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 8 }}>
|
||||
<Typography variant="h4" gutterBottom>프로젝트(Job No) 선택</Typography>
|
||||
{loading && <CircularProgress sx={{ mt: 4 }} />}
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<FormControl size="small" fullWidth sx={{ mt: 3 }}>
|
||||
<InputLabel>Job No</InputLabel>
|
||||
<Select
|
||||
value={selectedJob}
|
||||
label="Job No"
|
||||
onChange={e => setSelectedJob(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">선택</MenuItem>
|
||||
{jobs.map(job => (
|
||||
<MenuItem key={job.job_no} value={job.job_no}>
|
||||
{job.job_no} ({job.job_name})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedJob && (
|
||||
<Alert severity="info" sx={{ mt: 3 }}>
|
||||
선택된 Job No: <b>{selectedJob}</b>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ mt: 4, minWidth: 120 }}
|
||||
disabled={!selectedJob}
|
||||
onClick={() => navigate(`/bom?job_no=${selectedJob}`)}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSelectionPage;
|
||||
Reference in New Issue
Block a user