feat: PIPE 분석 기능 개선 및 자재 확인 페이지 UX 향상
- 자재 확인 페이지에 뒤로가기 버튼 추가 - 상세 목록 탭에 PIPE 분석 섹션 추가 - 재질-외경-스케줄-제작방식별로 그룹화 - 동일 속성 파이프들의 길이 합산 표시 - 총 파이프 길이 및 규격 종류 수 요약 - 파일 삭제 기능 수정 (외래키 제약 조건 해결) - MaterialsPage에서 전체 자재 목록 표시 (limit 10000) - 길이 단위 변환 로직 수정 (mm 단위 유지) - 파싱 로직에 디버그 출력 추가 TODO: MAIN_NOM/RED_NOM 별도 저장을 위한 스키마 개선 필요
This commit is contained in:
@@ -132,11 +132,16 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('revision', 'Rev.0');
|
||||
formData.append('bom_name', file.name); // BOM 이름으로 파일명 사용
|
||||
formData.append('bom_type', 'excel'); // 파일 타입
|
||||
formData.append('description', ''); // 설명 (빈 문자열)
|
||||
|
||||
console.log('FormData 내용:', {
|
||||
fileName: file.name,
|
||||
jobNo: selectedProject.job_no,
|
||||
revision: 'Rev.0'
|
||||
revision: 'Rev.0',
|
||||
bomName: file.name,
|
||||
bomType: 'excel'
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
99
frontend/src/components/FittingDetailsCard.jsx
Normal file
99
frontend/src/components/FittingDetailsCard.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box, Grid, Chip, Divider } from '@mui/material';
|
||||
|
||||
const FittingDetailsCard = ({ material }) => {
|
||||
const fittingDetails = material.fitting_details || {};
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 2, backgroundColor: '#f5f5f5' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
🔗 FITTING 상세 정보
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`신뢰도: ${Math.round((material.classification_confidence || 0) * 100)}%`}
|
||||
color={material.classification_confidence >= 0.8 ? 'success' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="text.secondary">자재명</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 'medium' }}>
|
||||
{material.original_description}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">피팅 타입</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.fitting_type || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">세부 타입</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.fitting_subtype || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">연결 방식</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.connection_method || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">압력 등급</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.pressure_rating || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">재질 규격</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.material_standard || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">재질 등급</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.material_grade || material.material_grade || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">주 사이즈</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.main_size || material.size_spec || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">축소 사이즈</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.reduced_size || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="text.secondary">수량</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
{material.quantity} {material.unit}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FittingDetailsCard;
|
||||
@@ -1,28 +1,96 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box } from '@mui/material';
|
||||
import { Card, CardContent, Typography, Box, Grid, Chip, Divider } from '@mui/material';
|
||||
|
||||
const PipeDetailsCard = ({ material, fileId }) => {
|
||||
// 간단한 테스트 버전
|
||||
const PipeDetailsCard = ({ material }) => {
|
||||
const pipeDetails = material.pipe_details || {};
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 2, backgroundColor: '#f5f5f5' }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
PIPE 상세 정보 (테스트)
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography variant="body2">
|
||||
자재명: {material.original_description}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
분류: {material.classified_category}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
사이즈: {material.size_spec || '정보 없음'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
수량: {material.quantity} {material.unit}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
🔧 PIPE 상세 정보
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`신뢰도: ${Math.round((material.classification_confidence || 0) * 100)}%`}
|
||||
color={material.classification_confidence >= 0.8 ? 'success' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="text.secondary">자재명</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 'medium' }}>
|
||||
{material.original_description}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">크기</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.size_inches || material.size_spec || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">스케줄</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.schedule_type || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">재질</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.material_spec || material.material_grade || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">제작방식</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.manufacturing_method || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">길이</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.length_mm ? `${pipeDetails.length_mm}mm` : '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">외경</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.outer_diameter_mm ? `${pipeDetails.outer_diameter_mm}mm` : '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">두께</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.wall_thickness_mm ? `${pipeDetails.wall_thickness_mm}mm` : '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">중량</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.weight_per_meter_kg ? `${pipeDetails.weight_per_meter_kg}kg/m` : '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="text.secondary">수량</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
{material.quantity} {material.unit}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert } from '@mui/material';
|
||||
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert, TextField, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { uploadFile as uploadFileApi } from '../api';
|
||||
|
||||
const BOMStatusPage = () => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [file, setFile] = useState(null);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [bomName, setBomName] = useState('');
|
||||
const [revisionDialog, setRevisionDialog] = useState({ open: false, bomName: '', parentId: null });
|
||||
const [revisionFile, setRevisionFile] = useState(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
const jobNo = searchParams.get('job_no');
|
||||
const jobName = searchParams.get('job_name');
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 파일 목록 불러오기
|
||||
@@ -39,98 +44,325 @@ const BOMStatusPage = () => {
|
||||
// eslint-disable-next-line
|
||||
}, [jobNo]);
|
||||
|
||||
// BOM 이름 중복 체크
|
||||
const checkDuplicateBOM = () => {
|
||||
return files.some(file =>
|
||||
file.bom_name === bomName ||
|
||||
file.original_filename === bomName ||
|
||||
file.filename === bomName
|
||||
);
|
||||
};
|
||||
|
||||
// 파일 업로드 핸들러
|
||||
const handleUpload = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
setError('파일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bomName.trim()) {
|
||||
setError('BOM 이름을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('project_id', 1); // 예시: 실제 프로젝트 ID로 대체 필요
|
||||
const res = await fetch('http://localhost:8000/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (!res.ok) throw new Error('업로드 실패');
|
||||
setFile(null);
|
||||
fetchFiles();
|
||||
const isDuplicate = checkDuplicateBOM();
|
||||
if (isDuplicate && !confirm(`"${bomName}"은(는) 이미 존재하는 BOM입니다. 새로운 리비전으로 업로드하시겠습니까?`)) {
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('revision', 'Rev.0');
|
||||
formData.append('bom_name', bomName);
|
||||
formData.append('bom_type', 'excel');
|
||||
formData.append('description', '');
|
||||
|
||||
const response = await uploadFileApi(formData);
|
||||
|
||||
if (response.data.success) {
|
||||
setSelectedFile(null);
|
||||
setBomName('');
|
||||
// 파일 input 초기화
|
||||
const fileInput = document.getElementById('file-input');
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
fetchFiles();
|
||||
alert(`업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision || 'Rev.0'}`);
|
||||
} else {
|
||||
setError(response.data.message || '업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('파일 업로드에 실패했습니다.');
|
||||
console.error('업로드 에러:', e);
|
||||
if (e.response?.data?.detail) {
|
||||
setError(e.response.data.detail);
|
||||
} else {
|
||||
setError('파일 업로드에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 리비전 업로드 핸들러
|
||||
const handleRevisionUpload = async () => {
|
||||
if (!revisionFile) {
|
||||
setError('파일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', revisionFile);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('revision', 'Rev.0'); // 백엔드에서 자동 증가
|
||||
formData.append('bom_name', revisionDialog.bomName);
|
||||
formData.append('bom_type', 'excel');
|
||||
formData.append('description', '');
|
||||
formData.append('parent_bom_id', revisionDialog.parentId);
|
||||
|
||||
const response = await uploadFileApi(formData);
|
||||
|
||||
if (response.data.success) {
|
||||
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||
setRevisionFile(null);
|
||||
fetchFiles();
|
||||
alert(`리비전 업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision}`);
|
||||
} else {
|
||||
setError(response.data.message || '리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('리비전 업로드 에러:', e);
|
||||
if (e.response?.data?.detail) {
|
||||
setError(e.response.data.detail);
|
||||
} else {
|
||||
setError('리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// BOM별로 그룹화
|
||||
const groupFilesByBOM = () => {
|
||||
const grouped = {};
|
||||
files.forEach(file => {
|
||||
const bomKey = file.bom_name || file.original_filename || file.filename;
|
||||
if (!grouped[bomKey]) {
|
||||
grouped[bomKey] = [];
|
||||
}
|
||||
grouped[bomKey].push(file);
|
||||
});
|
||||
|
||||
// 각 그룹을 리비전 순으로 정렬
|
||||
Object.keys(grouped).forEach(key => {
|
||||
grouped[key].sort((a, b) => {
|
||||
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
|
||||
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
|
||||
return revB - revA; // 최신 리비전이 먼저 오도록
|
||||
});
|
||||
});
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', mt: 4 }}>
|
||||
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
|
||||
← 뒤로가기
|
||||
</Button>
|
||||
<Typography variant="h4" gutterBottom>BOM 업로드 및 현황</Typography>
|
||||
<form onSubmit={handleUpload} style={{ marginBottom: 24 }}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={e => setFile(e.target.files[0])}
|
||||
disabled={uploading}
|
||||
{jobNo && jobName && (
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 3 }}>
|
||||
{jobNo} - {jobName}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 파일 업로드 폼 */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>새 BOM 업로드</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="BOM 이름"
|
||||
value={bomName}
|
||||
onChange={(e) => setBomName(e.target.value)}
|
||||
placeholder="예: PIPING_BOM_A구역"
|
||||
required
|
||||
size="small"
|
||||
helperText="동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setSelectedFile(e.target.files[0])}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button type="submit" variant="contained" disabled={!file || uploading} sx={{ ml: 2 }}>
|
||||
업로드
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || !bomName.trim() || uploading}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
{selectedFile && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
선택된 파일: {selectedFile.name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
{loading && <CircularProgress sx={{ mt: 4 }} />}
|
||||
<TableContainer component={Paper} sx={{ mt: 2 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>파일명</TableCell>
|
||||
<TableCell>리비전</TableCell>
|
||||
<TableCell>세부내역</TableCell>
|
||||
<TableCell>리비전</TableCell>
|
||||
<TableCell>삭제</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{files.map(file => (
|
||||
<TableRow key={file.id}>
|
||||
<TableCell>{file.original_filename || file.filename}</TableCell>
|
||||
<TableCell>{file.revision}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="small" variant="outlined" onClick={() => navigate(`/materials?fileId=${file.id}`)}>
|
||||
자재확인
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button size="small" variant="outlined" color="info" onClick={() => alert(`리비전 관리: ${file.original_filename}`)}>
|
||||
리비전
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button size="small" variant="outlined" color="error" onClick={async () => {
|
||||
if (window.confirm(`정말로 ${file.original_filename}을 삭제하시겠습니까?`)) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/files/${file.id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
fetchFiles();
|
||||
} else {
|
||||
alert('삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 4, mb: 2 }}>업로드된 BOM 목록</Typography>
|
||||
{loading && <CircularProgress />}
|
||||
{!loading && files.length === 0 && (
|
||||
<Alert severity="info">업로드된 BOM이 없습니다.</Alert>
|
||||
)}
|
||||
{!loading && files.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>BOM 이름</TableCell>
|
||||
<TableCell>파일명</TableCell>
|
||||
<TableCell>리비전</TableCell>
|
||||
<TableCell>자재 수</TableCell>
|
||||
<TableCell>업로드 일시</TableCell>
|
||||
<TableCell>작업</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
|
||||
bomFiles.map((file, index) => (
|
||||
<TableRow key={file.id} sx={{
|
||||
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'inherit'
|
||||
}}>
|
||||
삭제
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={index === 0 ? 'bold' : 'normal'}>
|
||||
{file.bom_name || bomKey}
|
||||
</Typography>
|
||||
{index === 0 && bomFiles.length > 1 && (
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
(최신 리비전)
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{file.filename || file.original_filename}</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={index === 0 ? 'primary' : 'textSecondary'}
|
||||
>
|
||||
{file.revision || 'Rev.0'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{file.parsed_count || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="small"
|
||||
variant={index === 0 ? "contained" : "outlined"}
|
||||
onClick={() => navigate(`/materials?file_id=${file.id}&job_no=${jobNo}&filename=${encodeURIComponent(file.filename || file.original_filename)}`)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
자재확인
|
||||
</Button>
|
||||
{index === 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => setRevisionDialog({
|
||||
open: true,
|
||||
bomName: file.bom_name || bomKey,
|
||||
parentId: file.id
|
||||
})}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
리비전
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={async () => {
|
||||
if (confirm(`정말 "${file.revision || 'Rev.0'}"을 삭제하시겠습니까?`)) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/files/${file.id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
fetchFiles();
|
||||
} else {
|
||||
alert('삭제 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* 리비전 업로드 다이얼로그 */}
|
||||
<Dialog open={revisionDialog.open} onClose={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}>
|
||||
<DialogTitle>리비전 업로드</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
BOM 이름: <strong>{revisionDialog.bomName}</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
새로운 리비전 파일을 선택하세요. 리비전 번호는 자동으로 증가합니다.
|
||||
</Typography>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setRevisionFile(e.target.files[0])}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
{revisionFile && (
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
선택된 파일: {revisionFile.name}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => {
|
||||
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||
setRevisionFile(null);
|
||||
}}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleRevisionUpload}
|
||||
disabled={!revisionFile || uploading}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
@@ -21,7 +22,9 @@ import {
|
||||
FormControlLabel,
|
||||
Switch
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import PipeDetailsCard from '../components/PipeDetailsCard';
|
||||
import FittingDetailsCard from '../components/FittingDetailsCard';
|
||||
import { Pie, Bar } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
@@ -55,9 +58,14 @@ const MaterialsPage = () => {
|
||||
const [revisionComparison, setRevisionComparison] = useState(null);
|
||||
const [showOnlyPurchaseRequired, setShowOnlyPurchaseRequired] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 컴포넌트 마운트 확인
|
||||
console.log('MaterialsPage 컴포넌트 마운트됨');
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const id = urlParams.get('fileId');
|
||||
const id = urlParams.get('file_id'); // fileId -> file_id로 변경
|
||||
if (id) {
|
||||
setFileId(id);
|
||||
loadMaterials(id);
|
||||
@@ -71,9 +79,20 @@ const MaterialsPage = () => {
|
||||
console.log('자재 로딩 시작, file_id:', id);
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/files/materials', { params: { file_id: parseInt(id) } });
|
||||
// limit을 충분히 크게 설정하여 모든 자재를 가져옴
|
||||
const response = await api.get('/files/materials', { params: { file_id: parseInt(id), limit: 10000 } });
|
||||
console.log('자재 데이터 로딩 성공:', response.data);
|
||||
setMaterials(response.data);
|
||||
|
||||
// API 응답이 객체로 오는 경우 materials 배열 추출
|
||||
if (response.data && response.data.materials) {
|
||||
setMaterials(response.data.materials);
|
||||
} else if (Array.isArray(response.data)) {
|
||||
setMaterials(response.data);
|
||||
} else {
|
||||
console.error('예상치 못한 응답 형식:', response.data);
|
||||
setMaterials([]);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('자재 정보를 불러오는데 실패했습니다.');
|
||||
@@ -107,6 +126,12 @@ const MaterialsPage = () => {
|
||||
|
||||
const calculateCategoryStats = () => {
|
||||
const stats = {};
|
||||
// materials가 배열인지 확인
|
||||
if (!Array.isArray(materials)) {
|
||||
console.error('materials is not an array:', materials);
|
||||
return stats;
|
||||
}
|
||||
|
||||
materials.forEach(material => {
|
||||
const category = material.classified_category || 'UNKNOWN';
|
||||
if (!stats[category]) {
|
||||
@@ -119,11 +144,24 @@ const MaterialsPage = () => {
|
||||
};
|
||||
|
||||
const getAvailableCategories = () => {
|
||||
if (!Array.isArray(materials)) return [];
|
||||
const categories = [...new Set(materials.map(m => m.classified_category).filter(Boolean))];
|
||||
return categories.sort();
|
||||
};
|
||||
|
||||
const calculateClassificationStats = () => {
|
||||
if (!Array.isArray(materials)) {
|
||||
return {
|
||||
totalItems: 0,
|
||||
classifiedItems: 0,
|
||||
unclassifiedItems: 0,
|
||||
highConfidence: 0,
|
||||
mediumConfidence: 0,
|
||||
lowConfidence: 0,
|
||||
categoryBreakdown: {}
|
||||
};
|
||||
}
|
||||
|
||||
const totalItems = materials.length;
|
||||
const classifiedItems = materials.filter(m => m.classified_category && m.classified_category !== 'UNKNOWN').length;
|
||||
const unclassifiedItems = totalItems - classifiedItems;
|
||||
@@ -226,25 +264,103 @@ const MaterialsPage = () => {
|
||||
};
|
||||
};
|
||||
|
||||
// PIPE 분석용 헬퍼 함수들
|
||||
const groupPipesBySpecs = (pipeItems) => {
|
||||
const groups = {};
|
||||
|
||||
pipeItems.forEach(item => {
|
||||
const details = item.pipe_details || {};
|
||||
|
||||
// 재질-크기-스케줄-제작방식으로 키 생성
|
||||
const material = details.material_standard || item.material_grade || 'Unknown';
|
||||
let size = details.nominal_size || item.size_spec || 'Unknown';
|
||||
|
||||
// 크기 정리 (인치 표시)
|
||||
if (size && size !== 'Unknown') {
|
||||
size = size.replace(/["']/g, '').trim();
|
||||
if (!size.includes('"') && !size.includes('inch')) {
|
||||
size += '"';
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = details.schedule || 'Unknown';
|
||||
const manufacturing = details.manufacturing_method || 'Unknown';
|
||||
|
||||
const key = `${material}|${size}|${schedule}|${manufacturing}`;
|
||||
|
||||
if (!groups[key]) {
|
||||
groups[key] = {
|
||||
material,
|
||||
size,
|
||||
schedule,
|
||||
manufacturing,
|
||||
items: [],
|
||||
totalLength: 0,
|
||||
count: 0
|
||||
};
|
||||
}
|
||||
|
||||
groups[key].items.push(item);
|
||||
groups[key].count += 1;
|
||||
|
||||
// 길이 합산
|
||||
if (item.pipe_details?.length_mm) {
|
||||
groups[key].totalLength += item.pipe_details.length_mm;
|
||||
}
|
||||
});
|
||||
|
||||
// 배열로 변환하고 총 길이순으로 정렬
|
||||
return Object.values(groups).sort((a, b) => b.totalLength - a.totalLength);
|
||||
};
|
||||
|
||||
const generatePipeChartData = (pipeItems, property) => {
|
||||
const groups = groupPipesByProperty(pipeItems, property);
|
||||
|
||||
const chartData = Object.entries(groups).map(([key, items]) => {
|
||||
const totalLength = items.reduce((sum, item) => {
|
||||
let lengthMm = 0;
|
||||
if (item.pipe_details?.length_mm) {
|
||||
lengthMm = item.pipe_details.length_mm;
|
||||
}
|
||||
return sum + lengthMm;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
label: key,
|
||||
value: totalLength,
|
||||
count: items.length,
|
||||
items: items
|
||||
};
|
||||
}).sort((a, b) => b.value - a.value);
|
||||
|
||||
return {
|
||||
labels: chartData.map(d => d.label),
|
||||
datasets: [{
|
||||
data: chartData.map(d => d.value),
|
||||
backgroundColor: [
|
||||
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
|
||||
'#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384'
|
||||
],
|
||||
borderWidth: 1
|
||||
}],
|
||||
chartData: chartData
|
||||
};
|
||||
};
|
||||
|
||||
const generateCategoryChartData = (category, items) => {
|
||||
switch (category) {
|
||||
case 'PIPE':
|
||||
const totalLength = items.reduce((sum, item) => {
|
||||
const details = item.classification_details || {};
|
||||
const cuttingDimensions = details?.cutting_dimensions || {};
|
||||
let lengthMm = cuttingDimensions?.length_mm;
|
||||
|
||||
// 백엔드에서 전달된 length 필드도 확인
|
||||
if (!lengthMm && item.length) {
|
||||
lengthMm = item.length;
|
||||
let lengthMm = 0;
|
||||
if (item.pipe_details?.length_mm) {
|
||||
lengthMm = item.pipe_details.length_mm;
|
||||
}
|
||||
|
||||
return sum + (lengthMm || 0);
|
||||
return sum + lengthMm;
|
||||
}, 0);
|
||||
return {
|
||||
value: totalLength,
|
||||
unit: 'mm',
|
||||
displayText: `${totalLength}mm`,
|
||||
displayText: `${(totalLength / 1000).toFixed(1)}m`,
|
||||
isLength: true
|
||||
};
|
||||
case 'BOLT':
|
||||
@@ -382,8 +498,22 @@ const MaterialsPage = () => {
|
||||
total_quantity: materials.reduce((sum, m) => sum + (m.quantity || 0), 0)
|
||||
};
|
||||
|
||||
// 에러 디버깅을 위한 로그
|
||||
console.log('Rendering MaterialsPage, materials:', materials.length);
|
||||
console.log('Loading:', loading, 'Error:', error);
|
||||
console.log('FileId:', fileId);
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1400, mx: 'auto', mt: 4, p: 2 }}>
|
||||
{/* 뒤로가기 버튼 */}
|
||||
<Button
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
뒤로가기
|
||||
</Button>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📋 자재 분류 결과
|
||||
@@ -669,15 +799,79 @@ const MaterialsPage = () => {
|
||||
)}
|
||||
|
||||
{/* 상세 목록 탭 */}
|
||||
{!loading && materials.length > 0 && activeTab === 1 && (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
||||
📋 상세 자재 목록 (테스트)
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||||
총 {materials.length}개 자재가 로드되었습니다.
|
||||
</Typography>
|
||||
|
||||
{!loading && materials.length > 0 && activeTab === 1 && (() => {
|
||||
const pipeItems = materials.filter(m => m.classified_category === 'PIPE');
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
||||
📋 상세 자재 목록 (테스트)
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||||
총 {materials.length}개 자재가 로드되었습니다.
|
||||
</Typography>
|
||||
|
||||
{/* PIPE 분석 섹션 */}
|
||||
{pipeItems.length > 0 && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
🔧 PIPE 분석 ({pipeItems.length}개)
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
동일한 재질-크기-스케줄-제작방식을 가진 파이프들을 그룹화하여 표시합니다.
|
||||
</Typography>
|
||||
|
||||
<TableContainer component={Paper} sx={{ mt: 2 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell><strong>재질</strong></TableCell>
|
||||
<TableCell><strong>외경</strong></TableCell>
|
||||
<TableCell><strong>스케줄</strong></TableCell>
|
||||
<TableCell><strong>제작방식</strong></TableCell>
|
||||
<TableCell align="right"><strong>총 길이</strong></TableCell>
|
||||
<TableCell align="center"><strong>개수</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{groupPipesBySpecs(pipeItems).map((group, index) => (
|
||||
<TableRow key={index} hover>
|
||||
<TableCell>{group.material}</TableCell>
|
||||
<TableCell>{group.size}</TableCell>
|
||||
<TableCell>{group.schedule}</TableCell>
|
||||
<TableCell>{group.manufacturing}</TableCell>
|
||||
<TableCell align="right">
|
||||
<strong>{(group.totalLength / 1000).toFixed(2)}m</strong>
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
({group.totalLength.toFixed(0)}mm)
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={group.count}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* 총계 */}
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>총 파이프 길이: {(pipeItems.reduce((sum, item) => sum + (item.pipe_details?.length_mm || 0), 0) / 1000).toFixed(2)}m</strong>
|
||||
{' '}({groupPipesBySpecs(pipeItems).length}가지 규격)
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 필터 */}
|
||||
<Box sx={{ mb: 2, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{getAvailableCategories().map(category => (
|
||||
@@ -740,11 +934,45 @@ const MaterialsPage = () => {
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/* PIPE 상세 정보 */}
|
||||
{/* 자재별 상세 정보 카드 */}
|
||||
{material.classified_category === 'PIPE' && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ p: 0 }}>
|
||||
<PipeDetailsCard material={material} fileId={fileId} />
|
||||
<PipeDetailsCard material={material} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{material.classified_category === 'FITTING' && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ p: 0 }}>
|
||||
<FittingDetailsCard material={material} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{material.classified_category === 'VALVE' && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ p: 0 }}>
|
||||
<Box sx={{ m: 2, p: 2, backgroundColor: '#f5f5f5', borderRadius: 1 }}>
|
||||
<Typography variant="h6" gutterBottom>🚰 VALVE 상세 정보</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">밸브 타입</Typography>
|
||||
<Typography>{material.valve_details?.valve_type || '-'}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">작동 방식</Typography>
|
||||
<Typography>{material.valve_details?.actuator_type || '-'}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">압력 등급</Typography>
|
||||
<Typography>{material.valve_details?.pressure_rating || '-'}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">크기</Typography>
|
||||
<Typography>{material.valve_details?.size_inches || '-'}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -755,7 +983,8 @@ const MaterialsPage = () => {
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 리비전 비교 탭 */}
|
||||
{!loading && materials.length > 0 && activeTab === 2 && (
|
||||
|
||||
Reference in New Issue
Block a user