feat: 자재 리비전 비교 및 구매 목록 시스템 구현

- 자재 리비전간 비교 기능 추가 (MaterialComparisonPage) - 버그 해결 필요
- 리비전간 추가 구매 필요 자재 분석 페이지 추가 (RevisionPurchasePage)
- 자재 비교 결과 컴포넌트 구현 (MaterialComparisonResult)
- 자재 비교 API 라우터 추가 (material_comparison.py) - 로직 개선 필요
- 자재 비교 시스템 데이터베이스 스키마 추가
- FileManager, FileUpload 컴포넌트 개선
- BOMManagerPage 제거 및 새로운 구조로 리팩토링
- 자재 분류기 및 스키마 개선

TODO: 자재 비교 알고리즘 정확도 향상 및 예외 처리 강화 필요
This commit is contained in:
Hyungi Ahn
2025-07-22 15:56:40 +09:00
parent 6ca1cd17e2
commit 534015cc7c
16 changed files with 2577 additions and 267 deletions

View File

@@ -1,20 +1,24 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import ProjectSelectionPage from './pages/ProjectSelectionPage';
import BOMManagerPage from './pages/BOMManagerPage';
import MaterialsPage from './pages/MaterialsPage';
import BOMStatusPage from './pages/BOMStatusPage';
import PurchaseConfirmationPage from './pages/PurchaseConfirmationPage';
import MaterialComparisonPage from './pages/MaterialComparisonPage';
import RevisionPurchasePage from './pages/RevisionPurchasePage';
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<ProjectSelectionPage />} />
<Route path="/bom-manager" element={<BOMManagerPage />} />
<Route path="/materials" element={<MaterialsPage />} />
{/* BOM 관리는 /bom-status로 통일 */}
<Route path="/bom-manager" element={<Navigate to="/bom-status" replace />} />
<Route path="/bom-status" element={<BOMStatusPage />} />
<Route path="/materials" element={<MaterialsPage />} />
<Route path="/purchase-confirmation" element={<PurchaseConfirmationPage />} />
<Route path="/material-comparison" element={<MaterialComparisonPage />} />
<Route path="/revision-purchase" element={<RevisionPurchasePage />} />
</Routes>
</Router>
);

View File

@@ -131,4 +131,45 @@ export function generateSpoolIdentifier(dwgName, areaNumber, spoolNumber) {
area_number: areaNumber,
spool_number: spoolNumber
});
}
// 자재 비교 관련 API
export function compareMaterialRevisions(jobNo, currentRevision, previousRevision = null, saveResult = true) {
return api.post('/materials/compare-revisions', null, {
params: {
job_no: jobNo,
current_revision: currentRevision,
previous_revision: previousRevision,
save_result: saveResult
}
});
}
export function getMaterialComparisonHistory(jobNo, limit = 10) {
return api.get('/materials/comparison-history', {
params: { job_no: jobNo, limit }
});
}
export function getMaterialInventoryStatus(jobNo, materialHash = null) {
return api.get('/materials/inventory-status', {
params: { job_no: jobNo, material_hash: materialHash }
});
}
export function confirmMaterialPurchase(jobNo, revision, confirmations, confirmedBy = 'user') {
return api.post('/materials/confirm-purchase', null, {
params: {
job_no: jobNo,
revision: revision,
confirmed_by: confirmedBy
},
data: confirmations
});
}
export function getMaterialPurchaseStatus(jobNo, revision = null, status = null) {
return api.get('/materials/purchase-status', {
params: { job_no: jobNo, revision, status }
});
}

View File

@@ -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>
);
}

View File

@@ -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}

View 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;

View File

@@ -1,219 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, TextField, CircularProgress, Alert, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { fetchFiles, uploadFile, deleteFile } from '../api';
const BOMManagerPage = () => {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [file, setFile] = useState(null);
const [filename, setFilename] = useState('');
const [revisionDialogOpen, setRevisionDialogOpen] = useState(false);
const [revisionTarget, setRevisionTarget] = useState(null);
const [revisionFile, setRevisionFile] = useState(null);
const [searchParams] = useSearchParams();
const jobNo = searchParams.get('job_no');
const jobName = searchParams.get('job_name');
const navigate = useNavigate();
// 파일 목록 불러오기
const loadFiles = async () => {
setLoading(true);
setError('');
try {
const response = await fetchFiles({ job_no: jobNo });
if (Array.isArray(response.data)) {
setFiles(response.data);
} else {
setFiles([]);
}
} catch (e) {
setError('파일 목록을 불러오지 못했습니다.');
console.error('파일 목록 로드 에러:', e);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (jobNo) loadFiles();
// eslint-disable-next-line
}, [jobNo]);
// 파일 업로드 핸들러
const handleUpload = async (e) => {
e.preventDefault();
if (!file || !filename) return;
setUploading(true);
setError('');
try {
const formData = new FormData();
formData.append('file', file);
formData.append('job_no', jobNo);
formData.append('bom_name', filename);
const response = await uploadFile(formData);
if (response.data.success) {
setFile(null);
setFilename('');
loadFiles(); // 파일 목록 새로고침
alert(`업로드 성공: ${response.data.materials_count}개 자재가 분류되었습니다.`);
} else {
throw new Error(response.data.error || '업로드 실패');
}
} catch (e) {
setError(`파일 업로드에 실패했습니다: ${e.message}`);
console.error('업로드 에러:', e);
} finally {
setUploading(false);
}
};
// 리비전 업로드 핸들러
const handleRevisionUpload = async () => {
if (!revisionFile || !revisionTarget) return;
setUploading(true);
setError('');
try {
const formData = new FormData();
formData.append('file', revisionFile);
formData.append('job_no', jobNo);
formData.append('bom_name', revisionTarget.original_filename);
formData.append('parent_bom_id', revisionTarget.id);
const response = await uploadFile(formData);
if (response.data.success) {
setRevisionDialogOpen(false);
setRevisionFile(null);
setRevisionTarget(null);
loadFiles();
alert(`리비전 업로드 성공: ${response.data.revision}`);
} else {
throw new Error(response.data.error || '리비전 업로드 실패');
}
} catch (e) {
setError(`리비전 업로드에 실패했습니다: ${e.message}`);
console.error('리비전 업로드 에러:', e);
} finally {
setUploading(false);
}
};
// 파일 삭제 핸들러
const handleDelete = async (fileId, filename) => {
if (!confirm(`정말로 "${filename}" 파일을 삭제하시겠습니까?`)) return;
try {
const response = await deleteFile(fileId);
if (response.data.success) {
loadFiles();
alert('파일이 삭제되었습니다.');
} else {
throw new Error(response.data.error || '삭제 실패');
}
} catch (e) {
setError(`파일 삭제에 실패했습니다: ${e.message}`);
console.error('삭제 에러:', e);
}
};
// 자재확인 페이지로 이동
const handleViewMaterials = (file) => {
navigate(`/materials?file_id=${file.id}&job_no=${jobNo}&filename=${encodeURIComponent(file.original_filename)}`);
};
return (
<Box sx={{ maxWidth: 1000, mx: 'auto', mt: 4 }}>
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
프로젝트 선택
</Button>
<Typography variant="h5" sx={{ mb: 2 }}>
{jobNo && jobName && `${jobNo} (${jobName})`}
</Typography>
{/* BOM 업로드 폼 */}
<form onSubmit={handleUpload} style={{ marginBottom: 24, display: 'flex', gap: 16, alignItems: 'center' }}>
<TextField
label="도면명(파일명)"
value={filename}
onChange={e => setFilename(e.target.value)}
size="small"
required
sx={{ minWidth: 220 }}
/>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={e => setFile(e.target.files[0])}
disabled={uploading}
style={{ marginRight: 8 }}
/>
<Button type="submit" variant="contained" disabled={!file || !filename || uploading}>
업로드
</Button>
</form>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading && <CircularProgress sx={{ mt: 4 }} />}
{/* 파일 목록 리스트 */}
<TableContainer component={Paper} sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>도면명</TableCell>
<TableCell>리비전</TableCell>
<TableCell>세부내역</TableCell>
<TableCell>리비전</TableCell>
<TableCell>삭제</TableCell>
</TableRow>
</TableHead>
<TableBody>
{files.map(file => (
<TableRow key={file.id}>
<TableCell>{file.original_filename}</TableCell>
<TableCell>{file.revision}</TableCell>
<TableCell>
<Button size="small" variant="outlined" onClick={() => handleViewMaterials(file)}>
자재확인
</Button>
</TableCell>
<TableCell>
<Button size="small" variant="outlined" color="info" onClick={() => { setRevisionTarget(file); setRevisionDialogOpen(true); }}>
리비전
</Button>
</TableCell>
<TableCell>
<Button size="small" variant="outlined" color="error" onClick={() => handleDelete(file.id, file.original_filename)}>
삭제
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* 리비전 업로드 다이얼로그 */}
<Dialog open={revisionDialogOpen} onClose={() => setRevisionDialogOpen(false)}>
<DialogTitle>리비전 업로드</DialogTitle>
<DialogContent>
<Typography sx={{ mb: 2 }}>
도면명: <b>{revisionTarget?.original_filename}</b>
</Typography>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={e => setRevisionFile(e.target.files[0])}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setRevisionDialogOpen(false)}>취소</Button>
<Button variant="contained" onClick={handleRevisionUpload} disabled={!revisionFile || uploading}>
업로드
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default BOMManagerPage;

View File

@@ -137,7 +137,7 @@ const BOMStatusPage = () => {
formData.append('bom_name', revisionDialog.bomName);
formData.append('bom_type', 'excel');
formData.append('description', '');
formData.append('parent_bom_id', revisionDialog.parentId);
formData.append('parent_file_id', revisionDialog.parentId);
const response = await uploadFileApi(formData);
@@ -189,7 +189,7 @@ const BOMStatusPage = () => {
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
뒤로가기
</Button>
<Typography variant="h4" gutterBottom>BOM 업로드 현황</Typography>
<Typography variant="h4" gutterBottom>📊 BOM 관리 시스템</Typography>
{jobNo && jobName && (
<Typography variant="h6" color="primary" sx={{ mb: 3 }}>
{jobNo} - {jobName}
@@ -257,30 +257,46 @@ const BOMStatusPage = () => {
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
bomFiles.map((file, index) => (
<TableRow key={file.id} sx={{
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'inherit'
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'rgba(0, 0, 0, 0.02)'
}}>
<TableCell>
<Typography variant="body2" fontWeight={index === 0 ? 'bold' : 'normal'}>
{file.bom_name || bomKey}
</Typography>
{index === 0 && bomFiles.length > 1 && (
<Typography variant="caption" color="textSecondary">
<Typography variant="caption" color="primary">
(최신 리비전)
</Typography>
)}
{index > 0 && (
<Typography variant="caption" color="textSecondary">
(이전 버전)
</Typography>
)}
</TableCell>
<TableCell>
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
{file.filename || file.original_filename}
</Typography>
</TableCell>
<TableCell>{file.filename || file.original_filename}</TableCell>
<TableCell>
<Typography
variant="body2"
color={index === 0 ? 'primary' : 'textSecondary'}
fontWeight={index === 0 ? 'bold' : 'normal'}
>
{file.revision || 'Rev.0'}
</Typography>
</TableCell>
<TableCell>{file.parsed_count || '-'}</TableCell>
<TableCell>
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
{file.parsed_count || 0}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
</Typography>
</TableCell>
<TableCell>
<Button
@@ -306,6 +322,28 @@ const BOMStatusPage = () => {
리비전
</Button>
)}
{file.revision !== 'Rev.0' && index < 3 && (
<>
<Button
size="small"
variant="outlined"
color="secondary"
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${file.revision}&filename=${encodeURIComponent(file.original_filename)}`)}
sx={{ mr: 1 }}
>
비교
</Button>
<Button
size="small"
variant="outlined"
color="success"
onClick={() => navigate(`/revision-purchase?job_no=${jobNo}&current_revision=${file.revision}&bom_name=${encodeURIComponent(file.bom_name || bomKey)}`)}
sx={{ mr: 1 }}
>
구매 필요
</Button>
</>
)}
<Button
size="small"
color="error"

View File

@@ -0,0 +1,234 @@
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import {
Box,
Container,
Typography,
Button,
CircularProgress,
Alert,
Breadcrumbs,
Link,
Stack
} from '@mui/material';
import {
ArrowBack,
Refresh,
History
} from '@mui/icons-material';
import MaterialComparisonResult from '../components/MaterialComparisonResult';
import { compareMaterialRevisions, confirmMaterialPurchase } from '../api';
const MaterialComparisonPage = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [loading, setLoading] = useState(true);
const [confirmLoading, setConfirmLoading] = useState(false);
const [error, setError] = useState(null);
const [comparisonResult, setComparisonResult] = useState(null);
// URL 파라미터에서 정보 추출
const jobNo = searchParams.get('job_no');
const currentRevision = searchParams.get('revision');
const previousRevision = searchParams.get('prev_revision');
const filename = searchParams.get('filename');
useEffect(() => {
if (jobNo && currentRevision) {
loadComparison();
} else {
setError('필수 파라미터가 누락되었습니다 (job_no, revision)');
setLoading(false);
}
}, [jobNo, currentRevision, previousRevision]);
const loadComparison = async () => {
try {
setLoading(true);
setError(null);
console.log('자재 비교 실행:', { jobNo, currentRevision, previousRevision });
const result = await compareMaterialRevisions(
jobNo,
currentRevision,
previousRevision,
true // 결과 저장
);
console.log('비교 결과:', result);
setComparisonResult(result);
} catch (err) {
console.error('자재 비교 실패:', err);
setError(err.response?.data?.detail || err.message || '자재 비교 중 오류가 발생했습니다');
} finally {
setLoading(false);
}
};
const handleConfirmPurchase = async (confirmations) => {
try {
setConfirmLoading(true);
console.log('발주 확정 실행:', { jobNo, currentRevision, confirmations });
const result = await confirmMaterialPurchase(
jobNo,
currentRevision,
confirmations,
'user'
);
console.log('발주 확정 결과:', result);
// 성공 메시지 표시 후 비교 결과 새로고침
alert(`${result.confirmed_items?.length || confirmations.length}개 항목의 발주가 확정되었습니다!`);
// 비교 결과 새로고침 (재고 상태가 변경되었을 수 있음)
await loadComparison();
} catch (err) {
console.error('발주 확정 실패:', err);
alert('발주 확정 중 오류가 발생했습니다: ' + (err.response?.data?.detail || err.message));
} finally {
setConfirmLoading(false);
}
};
const handleRefresh = () => {
loadComparison();
};
const handleGoBack = () => {
// 이전 페이지로 이동 (대부분 파일 업로드 완료 페이지)
if (jobNo) {
navigate(`/materials?job_no=${jobNo}`);
} else {
navigate(-1);
}
};
const renderHeader = () => (
<Box sx={{ mb: 3 }}>
<Breadcrumbs sx={{ mb: 2 }}>
<Link
component="button"
variant="body2"
onClick={() => navigate('/jobs')}
sx={{ textDecoration: 'none' }}
>
프로젝트 목록
</Link>
<Link
component="button"
variant="body2"
onClick={() => navigate(`/materials?job_no=${jobNo}`)}
sx={{ textDecoration: 'none' }}
>
{jobNo}
</Link>
<Typography variant="body2" color="textPrimary">
자재 비교
</Typography>
</Breadcrumbs>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="h4" gutterBottom>
자재 리비전 비교
</Typography>
<Typography variant="body1" color="textSecondary">
{filename && `파일: ${filename}`}
<br />
{previousRevision ?
`${previousRevision}${currentRevision} 비교` :
`${currentRevision} (이전 리비전 없음)`
}
</Typography>
</Box>
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={handleRefresh}
disabled={loading}
>
새로고침
</Button>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={handleGoBack}
>
돌아가기
</Button>
</Stack>
</Stack>
</Box>
);
if (loading) {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
{renderHeader()}
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<Stack alignItems="center" spacing={2}>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
자재 비교 ...
</Typography>
<Typography variant="body2" color="textSecondary">
리비전간 차이점을 분석하고 있습니다
</Typography>
</Stack>
</Box>
</Container>
);
}
if (error) {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
{renderHeader()}
<Alert severity="error" sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
자재 비교 실패
</Typography>
<Typography variant="body2">
{error}
</Typography>
</Alert>
<Button
variant="contained"
startIcon={<Refresh />}
onClick={handleRefresh}
>
다시 시도
</Button>
</Container>
);
}
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
{renderHeader()}
{comparisonResult ? (
<MaterialComparisonResult
comparison={comparisonResult}
onConfirmPurchase={handleConfirmPurchase}
loading={confirmLoading}
/>
) : (
<Alert severity="info">
비교 결과가 없습니다.
</Alert>
)}
</Container>
);
};
export default MaterialComparisonPage;

View File

@@ -16,11 +16,17 @@ import {
Alert,
CircularProgress,
Chip,
Divider
Divider,
FormControl,
InputLabel,
Select,
MenuItem,
Grid
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ShoppingCart from '@mui/icons-material/ShoppingCart';
import { api } from '../api';
import { Compare as CompareIcon } from '@mui/icons-material';
import { api, fetchFiles } from '../api';
const MaterialsPage = () => {
const [materials, setMaterials] = useState([]);
@@ -28,23 +34,63 @@ const MaterialsPage = () => {
const [error, setError] = useState(null);
const [fileId, setFileId] = useState(null);
const [fileName, setFileName] = useState('');
const [jobNo, setJobNo] = useState('');
const [bomName, setBomName] = useState('');
const [currentRevision, setCurrentRevision] = useState('');
const [availableRevisions, setAvailableRevisions] = useState([]);
const navigate = useNavigate();
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const id = urlParams.get('file_id');
const name = urlParams.get('filename') || '';
const job_no = urlParams.get('job_no') || '';
if (id) {
if (id && job_no) {
setFileId(id);
setFileName(decodeURIComponent(name));
setJobNo(job_no);
loadMaterials(id);
loadAvailableRevisions(job_no, name);
} else {
setLoading(false);
setError('파일 ID가 지정되지 않았습니다.');
setError('파일 ID 또는 Job No가 지정되지 않았습니다.');
}
}, []);
// 같은 BOM의 다른 리비전들 로드
const loadAvailableRevisions = async (job_no, filename) => {
try {
const response = await fetchFiles({ job_no });
if (Array.isArray(response.data)) {
// 같은 BOM 이름의 파일들만 필터링
const sameNameFiles = response.data.filter(file =>
file.original_filename === filename ||
file.bom_name === filename ||
file.filename === filename
);
// 리비전 순으로 정렬 (최신부터)
const sortedFiles = sameNameFiles.sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA;
});
setAvailableRevisions(sortedFiles);
// 현재 파일 정보 설정
const currentFile = sortedFiles.find(file => file.id === parseInt(fileId));
if (currentFile) {
setCurrentRevision(currentFile.revision || 'Rev.0');
setBomName(currentFile.bom_name || currentFile.original_filename);
}
}
} catch (err) {
console.error('리비전 목록 로드 실패:', err);
}
};
const loadMaterials = async (id) => {
try {
setLoading(true);
@@ -586,21 +632,75 @@ const MaterialsPage = () => {
뒤로가기
</Button>
<Button
variant="contained"
color="success"
size="large"
startIcon={<ShoppingCart />}
onClick={() => {
const params = new URLSearchParams(window.location.search);
navigate(`/purchase-confirmation?${params.toString()}`);
}}
disabled={materialSpecs.length === 0}
sx={{ minWidth: 150 }}
>
구매 확정
</Button>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
{/* 리비전 비교 버튼 */}
{availableRevisions.length > 1 && currentRevision !== 'Rev.0' && (
<Button
variant="outlined"
color="secondary"
startIcon={<CompareIcon />}
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${currentRevision}&filename=${encodeURIComponent(fileName)}`)}
>
리비전 비교
</Button>
)}
<Button
variant="contained"
color="success"
size="large"
startIcon={<ShoppingCart />}
onClick={() => {
const params = new URLSearchParams(window.location.search);
navigate(`/purchase-confirmation?${params.toString()}`);
}}
disabled={materialSpecs.length === 0}
sx={{ minWidth: 150 }}
>
구매 확정
</Button>
</Box>
</Box>
{/* 리비전 선택 */}
{availableRevisions.length > 1 && (
<Card sx={{ mb: 3, p: 2 }}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={6}>
<Typography variant="h6" gutterBottom>
📋 {bomName}
</Typography>
<Typography variant="body2" color="textSecondary">
Job No: {jobNo} | 현재 리비전: <strong>{currentRevision}</strong>
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth size="small">
<InputLabel>리비전 선택</InputLabel>
<Select
value={fileId || ''}
label="리비전 선택"
onChange={(e) => {
const selectedFileId = e.target.value;
const selectedFile = availableRevisions.find(file => file.id === selectedFileId);
if (selectedFile) {
// 새로운 리비전 페이지로 이동
navigate(`/materials?file_id=${selectedFileId}&job_no=${jobNo}&filename=${encodeURIComponent(selectedFile.original_filename || selectedFile.filename)}`);
window.location.reload(); // 페이지 새로고침으로 데이터 갱신
}
}}
>
{availableRevisions.map((file) => (
<MenuItem key={file.id} value={file.id}>
{file.revision || 'Rev.0'} ({file.parsed_count || 0} 자재) - {new Date(file.upload_date).toLocaleDateString('ko-KR')}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</Card>
)}
<Typography variant="h4" component="h1" gutterBottom>
📋 자재 사양서

View File

@@ -0,0 +1,437 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Box,
Card,
CardContent,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Button,
Alert,
CircularProgress,
Chip,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
Tabs,
Tab,
Divider
} from '@mui/material';
import {
ArrowBack,
ShoppingCart,
Compare,
Add as AddIcon,
Remove as RemoveIcon,
TrendingUp,
Assessment
} from '@mui/icons-material';
import { compareMaterialRevisions, fetchFiles } from '../api';
const RevisionPurchasePage = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [comparisonResult, setComparisonResult] = useState(null);
const [availableRevisions, setAvailableRevisions] = useState([]);
const [selectedTab, setSelectedTab] = useState(0);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
// URL 파라미터에서 정보 추출
const jobNo = searchParams.get('job_no');
const currentRevision = searchParams.get('current_revision') || searchParams.get('revision');
const previousRevision = searchParams.get('previous_revision');
const bomName = searchParams.get('bom_name');
useEffect(() => {
if (jobNo && currentRevision) {
loadAvailableRevisions();
loadComparisonData();
} else {
setError('필수 파라미터가 누락되었습니다. (job_no, current_revision)');
setLoading(false);
}
}, [jobNo, currentRevision, previousRevision]);
const loadAvailableRevisions = async () => {
try {
const response = await fetchFiles({ job_no: jobNo });
if (Array.isArray(response.data)) {
// BOM별로 그룹화
const bomGroups = response.data.reduce((acc, file) => {
const key = file.bom_name || file.original_filename;
if (!acc[key]) acc[key] = [];
acc[key].push(file);
return acc;
}, {});
// 현재 BOM과 관련된 리비전들만 필터링
let relevantFiles = [];
if (bomName) {
relevantFiles = bomGroups[bomName] || [];
} else {
// bomName이 없으면 현재 리비전과 같은 원본파일명을 가진 것들
const currentFile = response.data.find(file => file.revision === currentRevision);
if (currentFile) {
const key = currentFile.bom_name || currentFile.original_filename;
relevantFiles = bomGroups[key] || [];
}
}
// 리비전 순으로 정렬
relevantFiles.sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA;
});
setAvailableRevisions(relevantFiles);
}
} catch (err) {
console.error('리비전 목록 로드 실패:', err);
}
};
const loadComparisonData = async () => {
try {
setLoading(true);
const result = await compareMaterialRevisions(
jobNo,
currentRevision,
previousRevision,
true
);
setComparisonResult(result);
} catch (err) {
setError(`리비전 비교 실패: ${err.message}`);
} finally {
setLoading(false);
}
};
const handleRevisionChange = (type, newRevision) => {
const params = new URLSearchParams(searchParams);
if (type === 'current') {
params.set('current_revision', newRevision);
} else {
params.set('previous_revision', newRevision);
}
navigate(`?${params.toString()}`, { replace: true });
};
const calculatePurchaseNeeds = () => {
if (!comparisonResult) return { newItems: [], increasedItems: [] };
const newItems = comparisonResult.new_items || [];
const modifiedItems = comparisonResult.modified_items || [];
// 수량이 증가한 항목들만 필터링
const increasedItems = modifiedItems.filter(item =>
item.quantity_change > 0
);
return { newItems, increasedItems };
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW'
}).format(amount);
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
<Typography variant="h6" sx={{ ml: 2 }}>
리비전 비교 분석 ...
</Typography>
</Box>
);
}
if (error) {
return (
<Box sx={{ maxWidth: 1200, mx: 'auto', mt: 4, px: 2 }}>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={() => navigate(-1)}
sx={{ mb: 2 }}
>
뒤로가기
</Button>
<Alert severity="error">{error}</Alert>
</Box>
);
}
const { newItems, increasedItems } = calculatePurchaseNeeds();
const totalNewItems = newItems.length;
const totalIncreasedItems = increasedItems.length;
const totalPurchaseItems = totalNewItems + totalIncreasedItems;
return (
<Box sx={{ maxWidth: 1400, mx: 'auto', mt: 4, px: 2 }}>
{/* 헤더 */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={() => navigate(-1)}
>
뒤로가기
</Button>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="outlined"
startIcon={<Assessment />}
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${currentRevision}&prev_revision=${previousRevision}`)}
>
상세 비교 보기
</Button>
<Button
variant="contained"
color="success"
startIcon={<ShoppingCart />}
disabled={totalPurchaseItems === 0}
>
구매 목록 생성
</Button>
</Box>
</Box>
<Typography variant="h4" gutterBottom>
🛒 리비전간 추가 구매 필요 자재
</Typography>
<Typography variant="h6" color="textSecondary">
Job No: {jobNo} | {currentRevision} vs {previousRevision || '이전 리비전'}
</Typography>
</Box>
{/* 리비전 선택 카드 */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
리비전 비교 설정
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>현재 리비전</InputLabel>
<Select
value={currentRevision}
label="현재 리비전"
onChange={(e) => handleRevisionChange('current', e.target.value)}
>
{availableRevisions.map((file) => (
<MenuItem key={file.id} value={file.revision}>
{file.revision} ({file.parsed_count || 0} 자재)
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>이전 리비전</InputLabel>
<Select
value={previousRevision || ''}
label="이전 리비전"
onChange={(e) => handleRevisionChange('previous', e.target.value)}
>
<MenuItem value="">자동 선택 (직전 리비전)</MenuItem>
{availableRevisions
.filter(file => file.revision !== currentRevision)
.map((file) => (
<MenuItem key={file.id} value={file.revision}>
{file.revision} ({file.parsed_count || 0} 자재)
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</CardContent>
</Card>
{/* 구매 요약 */}
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} md={4}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<AddIcon color="primary" sx={{ fontSize: 48, mb: 1 }} />
<Typography variant="h4" color="primary">
{totalNewItems}
</Typography>
<Typography variant="h6">
신규 자재
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<TrendingUp color="warning" sx={{ fontSize: 48, mb: 1 }} />
<Typography variant="h4" color="warning.main">
{totalIncreasedItems}
</Typography>
<Typography variant="h6">
수량 증가
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<ShoppingCart color="success" sx={{ fontSize: 48, mb: 1 }} />
<Typography variant="h4" color="success.main">
{totalPurchaseItems}
</Typography>
<Typography variant="h6">
구매 항목
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* 탭으로 구분된 자재 목록 */}
<Card>
<Tabs
value={selectedTab}
onChange={(e, newValue) => setSelectedTab(newValue)}
variant="fullWidth"
>
<Tab label={`신규 자재 (${totalNewItems})`} />
<Tab label={`수량 증가 (${totalIncreasedItems})`} />
</Tabs>
<CardContent>
{selectedTab === 0 && (
<Box>
<Typography variant="h6" gutterBottom color="primary">
🆕 신규 추가 자재
</Typography>
{newItems.length === 0 ? (
<Alert severity="info">새로 추가된 자재가 없습니다.</Alert>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>카테고리</TableCell>
<TableCell>자재 설명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell>재질</TableCell>
<TableCell>수량</TableCell>
<TableCell>단위</TableCell>
</TableRow>
</TableHead>
<TableBody>
{newItems.map((item, index) => (
<TableRow key={index}>
<TableCell>
<Chip
label={item.category}
size="small"
color="primary"
/>
</TableCell>
<TableCell>{item.description}</TableCell>
<TableCell>{item.size_spec || '-'}</TableCell>
<TableCell>{item.material_grade || '-'}</TableCell>
<TableCell>
<Typography variant="body2" fontWeight="bold" color="primary">
+{item.quantity}
</Typography>
</TableCell>
<TableCell>{item.unit || 'EA'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
)}
{selectedTab === 1 && (
<Box>
<Typography variant="h6" gutterBottom color="warning.main">
📈 수량 증가 자재
</Typography>
{increasedItems.length === 0 ? (
<Alert severity="info">수량이 증가한 자재가 없습니다.</Alert>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>카테고리</TableCell>
<TableCell>자재 설명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell>재질</TableCell>
<TableCell>이전 수량</TableCell>
<TableCell>현재 수량</TableCell>
<TableCell>증가량</TableCell>
<TableCell>단위</TableCell>
</TableRow>
</TableHead>
<TableBody>
{increasedItems.map((item, index) => (
<TableRow key={index}>
<TableCell>
<Chip
label={item.category}
size="small"
color="warning"
/>
</TableCell>
<TableCell>{item.description}</TableCell>
<TableCell>{item.size_spec || '-'}</TableCell>
<TableCell>{item.material_grade || '-'}</TableCell>
<TableCell>{item.previous_quantity}</TableCell>
<TableCell>{item.current_quantity}</TableCell>
<TableCell>
<Typography variant="body2" fontWeight="bold" color="warning.main">
+{item.quantity_change}
</Typography>
</TableCell>
<TableCell>{item.unit || 'EA'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
)}
</CardContent>
</Card>
{totalPurchaseItems === 0 && (
<Alert severity="success" sx={{ mt: 3 }}>
🎉 추가로 구매가 필요한 자재가 없습니다!
</Alert>
)}
</Box>
);
};
export default RevisionPurchasePage;