Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산) - UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인) - 자재별 그룹핑 로직 개선: * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시 * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등) * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시 * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계) * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시 * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑 - 전체 카테고리 버튼 제거 (표시 복잡도 감소) - 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
518 lines
18 KiB
JavaScript
518 lines
18 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
|
import {
|
|
Box,
|
|
Container,
|
|
Typography,
|
|
Button,
|
|
CircularProgress,
|
|
Alert,
|
|
Breadcrumbs,
|
|
Link,
|
|
Stack,
|
|
Card,
|
|
CardContent,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
Paper,
|
|
Chip,
|
|
Grid,
|
|
Divider,
|
|
Tabs,
|
|
Tab
|
|
} from '@mui/material';
|
|
import {
|
|
ArrowBack,
|
|
Refresh,
|
|
History,
|
|
Download
|
|
} from '@mui/icons-material';
|
|
|
|
import MaterialComparisonResult from '../components/MaterialComparisonResult';
|
|
import { compareMaterialRevisions, confirmMaterialPurchase, fetchMaterials, api } from '../api';
|
|
import { exportComparisonToExcel } from '../utils/excelExport';
|
|
|
|
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);
|
|
const [selectedTab, setSelectedTab] = useState(0);
|
|
|
|
// 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,
|
|
filename
|
|
});
|
|
|
|
// 🚨 테스트: MaterialsPage API 직접 호출해서 길이 정보 확인
|
|
try {
|
|
// ✅ API 함수 사용 - 테스트용 자재 조회
|
|
const testResult = await fetchMaterials({
|
|
job_no: jobNo,
|
|
revision: currentRevision,
|
|
limit: 10
|
|
});
|
|
const pipeData = testResult.data.materials?.filter(m => m.classified_category === 'PIPE');
|
|
console.log('🧪 MaterialsPage API 테스트 (길이 있는지 확인):', pipeData);
|
|
if (pipeData && pipeData.length > 0) {
|
|
console.log('🧪 첫 번째 파이프 상세:', JSON.stringify(pipeData[0], null, 2));
|
|
}
|
|
} catch (e) {
|
|
console.log('🧪 MaterialsPage API 테스트 실패:', e);
|
|
}
|
|
|
|
const result = await compareMaterialRevisions(
|
|
jobNo,
|
|
currentRevision,
|
|
previousRevision,
|
|
true // 결과 저장
|
|
);
|
|
|
|
console.log('✅ 비교 결과 성공:', result);
|
|
console.log('🔍 전체 데이터 구조:', JSON.stringify(result.data || result, null, 2));
|
|
setComparisonResult(result.data || result);
|
|
|
|
} catch (err) {
|
|
console.error('❌ 자재 비교 실패:', {
|
|
message: err.message,
|
|
response: err.response?.data,
|
|
status: err.response?.status,
|
|
params: { jobNo, currentRevision, previousRevision }
|
|
});
|
|
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 = () => {
|
|
// BOM 상태 페이지로 이동
|
|
if (jobNo) {
|
|
navigate(`/bom-status?job_no=${jobNo}`);
|
|
} else {
|
|
navigate(-1);
|
|
}
|
|
};
|
|
|
|
const handleExportToExcel = () => {
|
|
if (!comparisonResult) {
|
|
alert('내보낼 비교 데이터가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
const additionalInfo = {
|
|
jobNo: jobNo,
|
|
currentRevision: currentRevision,
|
|
previousRevision: previousRevision,
|
|
filename: filename
|
|
};
|
|
|
|
const baseFilename = `리비전비교_${jobNo}_${currentRevision}_vs_${previousRevision}`;
|
|
|
|
exportComparisonToExcel(comparisonResult, baseFilename, additionalInfo);
|
|
};
|
|
|
|
const renderComparisonResults = () => {
|
|
const { summary, new_items = [], modified_items = [], removed_items = [] } = comparisonResult;
|
|
|
|
return (
|
|
<Box>
|
|
{/* 요약 통계 카드 */}
|
|
<Grid container spacing={3} sx={{ mb: 3 }}>
|
|
<Grid item xs={12} md={3}>
|
|
<Card>
|
|
<CardContent sx={{ textAlign: 'center' }}>
|
|
<Typography variant="h4" color="primary" gutterBottom>
|
|
{summary?.new_items_count || 0}
|
|
</Typography>
|
|
<Typography variant="h6">신규 자재</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
새로 추가된 자재
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
<Grid item xs={12} md={3}>
|
|
<Card>
|
|
<CardContent sx={{ textAlign: 'center' }}>
|
|
<Typography variant="h4" color="warning.main" gutterBottom>
|
|
{summary?.modified_items_count || 0}
|
|
</Typography>
|
|
<Typography variant="h6">변경 자재</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
수량이 변경된 자재
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
<Grid item xs={12} md={3}>
|
|
<Card>
|
|
<CardContent sx={{ textAlign: 'center' }}>
|
|
<Typography variant="h4" color="error.main" gutterBottom>
|
|
{summary?.removed_items_count || 0}
|
|
</Typography>
|
|
<Typography variant="h6">삭제 자재</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
제거된 자재
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
<Grid item xs={12} md={3}>
|
|
<Card>
|
|
<CardContent sx={{ textAlign: 'center' }}>
|
|
<Typography variant="h4" color="success.main" gutterBottom>
|
|
{summary?.total_current_items || 0}
|
|
</Typography>
|
|
<Typography variant="h6">총 자재</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
현재 리비전 전체
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
{/* 탭으로 구분된 자재 목록 */}
|
|
<Card>
|
|
<Tabs
|
|
value={selectedTab}
|
|
onChange={(e, newValue) => setSelectedTab(newValue)}
|
|
variant="fullWidth"
|
|
>
|
|
<Tab label={`신규 자재 (${new_items.length})`} />
|
|
<Tab label={`변경 자재 (${modified_items.length})`} />
|
|
<Tab label={`삭제 자재 (${removed_items.length})`} />
|
|
</Tabs>
|
|
|
|
<CardContent>
|
|
{selectedTab === 0 && renderMaterialTable(new_items, 'new')}
|
|
{selectedTab === 1 && renderMaterialTable(modified_items, 'modified')}
|
|
{selectedTab === 2 && renderMaterialTable(removed_items, 'removed')}
|
|
</CardContent>
|
|
</Card>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const renderMaterialTable = (items, type) => {
|
|
if (items.length === 0) {
|
|
return (
|
|
<Alert severity="info">
|
|
{type === 'new' && '새로 추가된 자재가 없습니다.'}
|
|
{type === 'modified' && '수량이 변경된 자재가 없습니다.'}
|
|
{type === 'removed' && '삭제된 자재가 없습니다.'}
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
console.log(`🔍 ${type} 테이블 렌더링:`, items); // 디버깅용
|
|
|
|
return (
|
|
<TableContainer component={Paper} variant="outlined">
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>카테고리</TableCell>
|
|
<TableCell>자재 설명</TableCell>
|
|
<TableCell>사이즈</TableCell>
|
|
<TableCell>재질</TableCell>
|
|
{type === 'modified' && (
|
|
<>
|
|
<TableCell align="center">이전 수량</TableCell>
|
|
<TableCell align="center">현재 수량</TableCell>
|
|
<TableCell align="center">변경량</TableCell>
|
|
</>
|
|
)}
|
|
{type !== 'modified' && <TableCell align="center">수량</TableCell>}
|
|
<TableCell>단위</TableCell>
|
|
<TableCell>길이(mm)</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{items.map((item, index) => {
|
|
console.log(`🔍 항목 ${index}:`, item); // 각 항목 확인
|
|
|
|
// 파이프인 경우 길이 정보 표시
|
|
console.log(`🔧 길이 확인 - ${item.category}:`, item.pipe_details); // 디버깅
|
|
console.log(`🔧 전체 아이템:`, item); // 전체 구조 확인
|
|
|
|
let lengthInfo = '-';
|
|
if (item.category === 'PIPE' && item.pipe_details?.length_mm && item.pipe_details.length_mm > 0) {
|
|
const avgUnitLength = item.pipe_details.length_mm;
|
|
const currentTotalLength = item.pipe_details.total_length_mm || (item.quantity || 0) * avgUnitLength;
|
|
|
|
if (type === 'modified') {
|
|
// 변경된 파이프: 백엔드에서 계산된 실제 길이 사용
|
|
let prevTotalLength, lengthChange;
|
|
|
|
if (item.previous_pipe_details && item.previous_pipe_details.total_length_mm) {
|
|
// 백엔드에서 실제 이전 총길이를 제공한 경우
|
|
prevTotalLength = item.previous_pipe_details.total_length_mm;
|
|
lengthChange = currentTotalLength - prevTotalLength;
|
|
} else {
|
|
// 백업: 비율 계산
|
|
const prevRatio = (item.previous_quantity || 0) / (item.current_quantity || item.quantity || 1);
|
|
prevTotalLength = currentTotalLength * prevRatio;
|
|
lengthChange = currentTotalLength - prevTotalLength;
|
|
}
|
|
|
|
lengthInfo = (
|
|
<Box>
|
|
<Typography variant="body2">
|
|
이전: {Math.round(prevTotalLength).toLocaleString()}mm → 현재: {Math.round(currentTotalLength).toLocaleString()}mm
|
|
</Typography>
|
|
<Typography
|
|
variant="body2"
|
|
fontWeight="bold"
|
|
color={lengthChange > 0 ? 'success.main' : lengthChange < 0 ? 'error.main' : 'text.primary'}
|
|
>
|
|
변화: {lengthChange > 0 ? '+' : ''}{Math.round(lengthChange).toLocaleString()}mm
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
} else {
|
|
// 신규/삭제된 파이프: 실제 총길이 사용
|
|
lengthInfo = (
|
|
<Box>
|
|
<Typography variant="body2" fontWeight="bold">
|
|
총 길이: {Math.round(currentTotalLength).toLocaleString()}mm
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
}
|
|
} else if (item.category === 'PIPE') {
|
|
lengthInfo = '길이 정보 없음';
|
|
}
|
|
|
|
return (
|
|
<TableRow key={index}>
|
|
<TableCell>
|
|
<Chip
|
|
label={item.category}
|
|
size="small"
|
|
color={type === 'new' ? 'primary' : type === 'modified' ? 'warning' : 'error'}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>{item.description}</TableCell>
|
|
<TableCell>{item.size_spec || '-'}</TableCell>
|
|
<TableCell>{item.material_grade || '-'}</TableCell>
|
|
{type === 'modified' && (
|
|
<>
|
|
<TableCell align="center">{item.previous_quantity}</TableCell>
|
|
<TableCell align="center">{item.current_quantity}</TableCell>
|
|
<TableCell align="center">
|
|
<Typography
|
|
variant="body2"
|
|
fontWeight="bold"
|
|
color={item.quantity_change > 0 ? 'success.main' : 'error.main'}
|
|
>
|
|
{item.quantity_change > 0 ? '+' : ''}{item.quantity_change}
|
|
</Typography>
|
|
</TableCell>
|
|
</>
|
|
)}
|
|
{type !== 'modified' && (
|
|
<TableCell align="center">
|
|
<Typography variant="body2" fontWeight="bold">
|
|
{item.quantity}
|
|
</Typography>
|
|
</TableCell>
|
|
)}
|
|
<TableCell>{item.unit || 'EA'}</TableCell>
|
|
<TableCell align="center">
|
|
<Typography variant="body2" color={lengthInfo !== '-' ? 'primary.main' : 'text.secondary'}>
|
|
{lengthInfo}
|
|
</Typography>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
);
|
|
};
|
|
|
|
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"
|
|
color="primary"
|
|
startIcon={<Download />}
|
|
onClick={handleExportToExcel}
|
|
disabled={!comparisonResult}
|
|
>
|
|
엑셀 내보내기
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={<ArrowBack />}
|
|
onClick={handleGoBack}
|
|
>
|
|
BOM 목록으로
|
|
</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 && renderComparisonResults()}
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
export default MaterialComparisonPage;
|