feat: 자재 리비전 비교 및 구매 목록 시스템 구현
- 자재 리비전간 비교 기능 추가 (MaterialComparisonPage) - 버그 해결 필요 - 리비전간 추가 구매 필요 자재 분석 페이지 추가 (RevisionPurchasePage) - 자재 비교 결과 컴포넌트 구현 (MaterialComparisonResult) - 자재 비교 API 라우터 추가 (material_comparison.py) - 로직 개선 필요 - 자재 비교 시스템 데이터베이스 스키마 추가 - FileManager, FileUpload 컴포넌트 개선 - BOMManagerPage 제거 및 새로운 구조로 리팩토링 - 자재 분류기 및 스키마 개선 TODO: 자재 비교 알고리즘 정확도 향상 및 예외 처리 강화 필요
This commit is contained in:
@@ -34,15 +34,19 @@ import {
|
||||
FileUpload,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Error
|
||||
Error,
|
||||
Update
|
||||
} from '@mui/icons-material';
|
||||
import { fetchFiles, deleteFile } from '../api';
|
||||
import { fetchFiles, deleteFile, uploadFile } from '../api';
|
||||
import Toast from './Toast';
|
||||
|
||||
function FileManager({ selectedProject }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteDialog, setDeleteDialog] = useState({ open: false, file: null });
|
||||
const [revisionDialog, setRevisionDialog] = useState({ open: false, file: null });
|
||||
const [revisionFile, setRevisionFile] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
const [filter, setFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
@@ -131,6 +135,57 @@ function FileManager({ selectedProject }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevisionUpload = async () => {
|
||||
if (!revisionFile || !revisionDialog.file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', revisionFile);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('parent_file_id', revisionDialog.file.id);
|
||||
formData.append('bom_name', revisionDialog.file.bom_name || revisionDialog.file.original_filename);
|
||||
|
||||
console.log('🔄 리비전 업로드 FormData:', {
|
||||
fileName: revisionFile.name,
|
||||
jobNo: selectedProject.job_no,
|
||||
parentFileId: revisionDialog.file.id,
|
||||
parentFileIdType: typeof revisionDialog.file.id,
|
||||
baseFileName: revisionDialog.file.original_filename,
|
||||
bomName: revisionDialog.file.bom_name || revisionDialog.file.original_filename,
|
||||
fullFileObject: revisionDialog.file
|
||||
});
|
||||
|
||||
const response = await uploadFile(formData);
|
||||
|
||||
if (response.data.success) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: `리비전 업로드 성공! ${response.data.revision}`,
|
||||
type: 'success'
|
||||
});
|
||||
setRevisionDialog({ open: false, file: null });
|
||||
setRevisionFile(null);
|
||||
fetchFilesList(); // 목록 새로고침
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: response.data.message || '리비전 업로드에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 업로드 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '리비전 업로드에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
@@ -396,6 +451,17 @@ function FileManager({ selectedProject }) {
|
||||
>
|
||||
<Visibility />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="warning"
|
||||
title="리비전 업로드"
|
||||
onClick={() => {
|
||||
console.log('🔄 리비전 버튼 클릭, 파일 정보:', file);
|
||||
setRevisionDialog({ open: true, file });
|
||||
}}
|
||||
>
|
||||
<Update />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
@@ -461,6 +527,62 @@ function FileManager({ selectedProject }) {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 리비전 업로드 다이얼로그 */}
|
||||
<Dialog
|
||||
open={revisionDialog.open}
|
||||
onClose={() => {
|
||||
setRevisionDialog({ open: false, file: null });
|
||||
setRevisionFile(null);
|
||||
}}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>리비전 업로드</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<strong>기준 파일:</strong> {revisionDialog.file?.original_filename}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
현재 리비전: {revisionDialog.file?.revision || 'Rev.0'}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
새 리비전 파일을 선택하세요:
|
||||
</Typography>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={(e) => setRevisionFile(e.target.files[0])}
|
||||
style={{ width: '100%', padding: '8px' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{revisionFile && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
선택된 파일: {revisionFile.name}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setRevisionDialog({ open: false, file: null });
|
||||
setRevisionFile(null);
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRevisionUpload}
|
||||
variant="contained"
|
||||
disabled={!revisionFile || uploading}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '리비전 업로드'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,16 +29,19 @@ import {
|
||||
Description,
|
||||
AutoAwesome,
|
||||
Category,
|
||||
Science
|
||||
Science,
|
||||
Compare
|
||||
} from '@mui/icons-material';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { uploadFile as uploadFileApi, fetchMaterialsSummary } from '../api';
|
||||
import Toast from './Toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
console.log('=== FileUpload 컴포넌트 렌더링 ===');
|
||||
console.log('selectedProject:', selectedProject);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadResult, setUploadResult] = useState(null);
|
||||
@@ -131,7 +134,7 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('revision', 'Rev.0');
|
||||
formData.append('revision', 'Rev.0'); // 새 BOM은 항상 Rev.0
|
||||
formData.append('bom_name', file.name); // BOM 이름으로 파일명 사용
|
||||
formData.append('bom_type', 'excel'); // 파일 타입
|
||||
formData.append('description', ''); // 설명 (빈 문자열)
|
||||
@@ -139,7 +142,7 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
console.log('FormData 내용:', {
|
||||
fileName: file.name,
|
||||
jobNo: selectedProject.job_no,
|
||||
revision: 'Rev.0',
|
||||
revision: 'Rev.0', // 새 BOM은 항상 Rev.0
|
||||
bomName: file.name,
|
||||
bomType: 'excel'
|
||||
});
|
||||
@@ -458,7 +461,7 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 2 }}>
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => window.location.href = '/materials'}
|
||||
@@ -466,6 +469,18 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
>
|
||||
자재 목록 보기
|
||||
</Button>
|
||||
|
||||
{uploadResult?.revision && uploadResult.revision !== 'Rev.0' && uploadResult.revision !== undefined && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => navigate(`/material-comparison?job_no=${selectedProject.job_no}&revision=${uploadResult.revision}&filename=${encodeURIComponent(uploadResult.original_filename || uploadResult.filename)}`)}
|
||||
startIcon={<Compare />}
|
||||
>
|
||||
이전 리비전과 비교 ({uploadResult.revision})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={resetUpload}
|
||||
|
||||
516
frontend/src/components/MaterialComparisonResult.jsx
Normal file
516
frontend/src/components/MaterialComparisonResult.jsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
Alert,
|
||||
Tabs,
|
||||
Tab,
|
||||
Button,
|
||||
Checkbox,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Stack,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
ShoppingCart as ShoppingCartIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Remove as RemoveIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
const MaterialComparisonResult = ({
|
||||
comparison,
|
||||
onConfirmPurchase,
|
||||
loading = false
|
||||
}) => {
|
||||
const [selectedTab, setSelectedTab] = useState(0);
|
||||
const [selectedItems, setSelectedItems] = useState(new Set());
|
||||
const [confirmDialog, setConfirmDialog] = useState(false);
|
||||
const [purchaseConfirmations, setPurchaseConfirmations] = useState({});
|
||||
|
||||
if (!comparison || !comparison.success) {
|
||||
return (
|
||||
<Alert severity="info">
|
||||
비교할 이전 리비전이 없거나 비교 데이터를 불러올 수 없습니다.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, new_items, modified_items, removed_items, purchase_summary } = comparison;
|
||||
|
||||
// 탭 변경 핸들러
|
||||
const handleTabChange = (event, newValue) => {
|
||||
setSelectedTab(newValue);
|
||||
setSelectedItems(new Set()); // 탭 변경시 선택 초기화
|
||||
};
|
||||
|
||||
// 아이템 선택 핸들러
|
||||
const handleItemSelect = (materialHash, checked) => {
|
||||
const newSelected = new Set(selectedItems);
|
||||
if (checked) {
|
||||
newSelected.add(materialHash);
|
||||
} else {
|
||||
newSelected.delete(materialHash);
|
||||
}
|
||||
setSelectedItems(newSelected);
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = (items, checked) => {
|
||||
const newSelected = new Set(selectedItems);
|
||||
items.forEach(item => {
|
||||
if (checked) {
|
||||
newSelected.add(item.material_hash);
|
||||
} else {
|
||||
newSelected.delete(item.material_hash);
|
||||
}
|
||||
});
|
||||
setSelectedItems(newSelected);
|
||||
};
|
||||
|
||||
// 발주 확정 다이얼로그 열기
|
||||
const handleOpenConfirmDialog = () => {
|
||||
const confirmations = {};
|
||||
|
||||
// 선택된 신규 항목
|
||||
new_items.forEach(item => {
|
||||
if (selectedItems.has(item.material_hash)) {
|
||||
confirmations[item.material_hash] = {
|
||||
material_hash: item.material_hash,
|
||||
description: item.description,
|
||||
confirmed_quantity: item.additional_needed,
|
||||
supplier_name: '',
|
||||
unit_price: 0
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 선택된 변경 항목 (추가 필요량만)
|
||||
modified_items.forEach(item => {
|
||||
if (selectedItems.has(item.material_hash) && item.additional_needed > 0) {
|
||||
confirmations[item.material_hash] = {
|
||||
material_hash: item.material_hash,
|
||||
description: item.description,
|
||||
confirmed_quantity: item.additional_needed,
|
||||
supplier_name: '',
|
||||
unit_price: 0
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
setPurchaseConfirmations(confirmations);
|
||||
setConfirmDialog(true);
|
||||
};
|
||||
|
||||
// 발주 확정 실행
|
||||
const handleConfirmPurchase = () => {
|
||||
const confirmationList = Object.values(purchaseConfirmations).filter(
|
||||
conf => conf.confirmed_quantity > 0
|
||||
);
|
||||
|
||||
if (confirmationList.length > 0) {
|
||||
onConfirmPurchase?.(confirmationList);
|
||||
}
|
||||
|
||||
setConfirmDialog(false);
|
||||
setSelectedItems(new Set());
|
||||
};
|
||||
|
||||
// 수량 변경 핸들러
|
||||
const handleQuantityChange = (materialHash, quantity) => {
|
||||
setPurchaseConfirmations(prev => ({
|
||||
...prev,
|
||||
[materialHash]: {
|
||||
...prev[materialHash],
|
||||
confirmed_quantity: parseFloat(quantity) || 0
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// 공급업체 변경 핸들러
|
||||
const handleSupplierChange = (materialHash, supplier) => {
|
||||
setPurchaseConfirmations(prev => ({
|
||||
...prev,
|
||||
[materialHash]: {
|
||||
...prev[materialHash],
|
||||
supplier_name: supplier
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// 단가 변경 핸들러
|
||||
const handlePriceChange = (materialHash, price) => {
|
||||
setPurchaseConfirmations(prev => ({
|
||||
...prev,
|
||||
[materialHash]: {
|
||||
...prev[materialHash],
|
||||
unit_price: parseFloat(price) || 0
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const renderSummaryCard = () => (
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
리비전 비교 요약
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap">
|
||||
<Chip
|
||||
icon={<AddIcon />}
|
||||
label={`신규: ${summary.new_items_count}개`}
|
||||
color="success"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
icon={<EditIcon />}
|
||||
label={`변경: ${summary.modified_items_count}개`}
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
icon={<RemoveIcon />}
|
||||
label={`삭제: ${summary.removed_items_count}개`}
|
||||
color="error"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{purchase_summary.additional_purchase_needed > 0 && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>추가 발주 필요:</strong> {purchase_summary.additional_purchase_needed}개 항목
|
||||
(신규 {purchase_summary.total_new_items}개 + 증량 {purchase_summary.total_increased_items}개)
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderNewItemsTable = () => (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
indeterminate={selectedItems.size > 0 && selectedItems.size < new_items.length}
|
||||
checked={new_items.length > 0 && selectedItems.size === new_items.length}
|
||||
onChange={(e) => handleSelectAll(new_items, e.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>품명</TableCell>
|
||||
<TableCell>사이즈</TableCell>
|
||||
<TableCell align="right">필요수량</TableCell>
|
||||
<TableCell align="right">기존재고</TableCell>
|
||||
<TableCell align="right">추가필요</TableCell>
|
||||
<TableCell>재질</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{new_items.map((item, index) => (
|
||||
<TableRow
|
||||
key={item.material_hash}
|
||||
selected={selectedItems.has(item.material_hash)}
|
||||
hover
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.material_hash)}
|
||||
onChange={(e) => handleItemSelect(item.material_hash, e.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{item.size_spec}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={item.quantity}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">{item.available_stock}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={item.additional_needed}
|
||||
size="small"
|
||||
color={item.additional_needed > 0 ? "error" : "success"}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{item.material_grade}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
const renderModifiedItemsTable = () => (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
indeterminate={selectedItems.size > 0 && selectedItems.size < modified_items.length}
|
||||
checked={modified_items.length > 0 && selectedItems.size === modified_items.length}
|
||||
onChange={(e) => handleSelectAll(modified_items, e.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>품명</TableCell>
|
||||
<TableCell>사이즈</TableCell>
|
||||
<TableCell align="right">이전수량</TableCell>
|
||||
<TableCell align="right">현재수량</TableCell>
|
||||
<TableCell align="right">증감</TableCell>
|
||||
<TableCell align="right">기존재고</TableCell>
|
||||
<TableCell align="right">추가필요</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{modified_items.map((item, index) => (
|
||||
<TableRow
|
||||
key={item.material_hash}
|
||||
selected={selectedItems.has(item.material_hash)}
|
||||
hover
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.material_hash)}
|
||||
onChange={(e) => handleItemSelect(item.material_hash, e.target.checked)}
|
||||
disabled={item.additional_needed <= 0} // 추가 필요량이 없으면 선택 불가
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{item.size_spec}</TableCell>
|
||||
<TableCell align="right">{item.previous_quantity}</TableCell>
|
||||
<TableCell align="right">{item.current_quantity}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={item.quantity_diff > 0 ? `+${item.quantity_diff}` : item.quantity_diff}
|
||||
size="small"
|
||||
color={item.quantity_diff > 0 ? "error" : "success"}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">{item.available_stock}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={item.additional_needed}
|
||||
size="small"
|
||||
color={item.additional_needed > 0 ? "error" : "success"}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
const renderRemovedItemsTable = () => (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>품명</TableCell>
|
||||
<TableCell>사이즈</TableCell>
|
||||
<TableCell align="right">삭제된 수량</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{removed_items.map((item, index) => (
|
||||
<TableRow key={item.material_hash}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{item.size_spec}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={item.quantity}
|
||||
size="small"
|
||||
color="default"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
const renderConfirmDialog = () => (
|
||||
<Dialog
|
||||
open={confirmDialog}
|
||||
onClose={() => setConfirmDialog(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<ShoppingCartIcon />
|
||||
<Typography variant="h6">발주 확정</Typography>
|
||||
</Stack>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
선택한 {Object.keys(purchaseConfirmations).length}개 항목의 발주를 확정합니다.
|
||||
수량과 공급업체 정보를 확인해주세요.
|
||||
</Typography>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>품명</TableCell>
|
||||
<TableCell align="right">확정수량</TableCell>
|
||||
<TableCell>공급업체</TableCell>
|
||||
<TableCell align="right">단가</TableCell>
|
||||
<TableCell align="right">총액</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.values(purchaseConfirmations).map((conf) => (
|
||||
<TableRow key={conf.material_hash}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{conf.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={conf.confirmed_quantity}
|
||||
onChange={(e) => handleQuantityChange(conf.material_hash, e.target.value)}
|
||||
sx={{ width: 80 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="공급업체"
|
||||
value={conf.supplier_name}
|
||||
onChange={(e) => handleSupplierChange(conf.material_hash, e.target.value)}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={conf.unit_price}
|
||||
onChange={(e) => handlePriceChange(conf.material_hash, e.target.value)}
|
||||
sx={{ width: 80 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography variant="body2">
|
||||
{(conf.confirmed_quantity * conf.unit_price).toLocaleString()}원
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDialog(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmPurchase}
|
||||
variant="contained"
|
||||
startIcon={<CheckCircleIcon />}
|
||||
disabled={loading}
|
||||
>
|
||||
발주 확정
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{renderSummaryCard()}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="자재 비교 상세"
|
||||
action={
|
||||
selectedItems.size > 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
onClick={handleOpenConfirmDialog}
|
||||
disabled={loading}
|
||||
>
|
||||
선택 항목 발주 확정 ({selectedItems.size}개)
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<CardContent>
|
||||
<Tabs value={selectedTab} onChange={handleTabChange}>
|
||||
<Tab
|
||||
label={`신규 항목 (${new_items.length})`}
|
||||
icon={<AddIcon />}
|
||||
/>
|
||||
<Tab
|
||||
label={`수량 변경 (${modified_items.length})`}
|
||||
icon={<EditIcon />}
|
||||
/>
|
||||
<Tab
|
||||
label={`삭제 항목 (${removed_items.length})`}
|
||||
icon={<RemoveIcon />}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{selectedTab === 0 && renderNewItemsTable()}
|
||||
{selectedTab === 1 && renderModifiedItemsTable()}
|
||||
{selectedTab === 2 && renderRemovedItemsTable()}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{renderConfirmDialog()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialComparisonResult;
|
||||
Reference in New Issue
Block a user