feat(tkeg): tkeg BOM 자재관리 서비스 초기 세팅 (api + web + docker-compose)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
136
tkeg/web/src/components/BOMFileUpload.jsx
Normal file
136
tkeg/web/src/components/BOMFileUpload.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
|
||||
const BOMFileUpload = ({
|
||||
bomName,
|
||||
setBomName,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
uploading,
|
||||
handleUpload,
|
||||
error
|
||||
}) => {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 16px 0'
|
||||
}}>
|
||||
새 BOM 업로드
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
BOM 이름 <span style={{ color: '#e53e3e' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bomName}
|
||||
onChange={(e) => setBomName(e.target.value)}
|
||||
placeholder="예: PIPING_BOM_A구역, 배관자재_1차, VALVE_LIST_Rev0"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: bomName ? '#f0fff4' : 'white'
|
||||
}}
|
||||
/>
|
||||
<p style={{
|
||||
fontSize: '12px',
|
||||
color: '#718096',
|
||||
margin: '4px 0 0 0'
|
||||
}}>
|
||||
💡 이 이름은 엑셀 내보내기 파일명과 자재 관리에 사용됩니다.
|
||||
동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setSelectedFile(e.target.files[0])}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || !bomName.trim() || uploading}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: (!selectedFile || !bomName.trim() || uploading) ? '#e2e8f0' : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: (!selectedFile || !bomName.trim() || uploading) ? '#a0aec0' : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: (!selectedFile || !bomName.trim() || uploading) ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedFile && (
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096',
|
||||
margin: '0'
|
||||
}}>
|
||||
선택된 파일: {selectedFile.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginTop: '16px',
|
||||
color: '#c53030'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMFileUpload;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
440
tkeg/web/src/components/Dashboard.jsx
Normal file
440
tkeg/web/src/components/Dashboard.jsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import { fetchMaterials } from '../api';
|
||||
import { Bar, Pie, Line } from 'react-chartjs-2';
|
||||
import 'chart.js/auto';
|
||||
import Toast from './Toast';
|
||||
|
||||
function Dashboard({ selectedProject, projects }) {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [barData, setBarData] = useState(null);
|
||||
const [pieData, setPieData] = useState(null);
|
||||
const [materialGradeData, setMaterialGradeData] = useState(null);
|
||||
const [sizeData, setSizeData] = useState(null);
|
||||
const [topMaterials, setTopMaterials] = useState([]);
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchMaterialStats();
|
||||
fetchMaterialList();
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
const fetchMaterialStats = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/files/materials/summary?project_id=${selectedProject.id}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data.summary);
|
||||
}
|
||||
} catch (error) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '자재 통계 로드 실패',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMaterialList = async () => {
|
||||
try {
|
||||
// 최대 1000개까지 조회(실무에서는 서버 페이징/집계 API 권장)
|
||||
const params = { project_id: selectedProject.id, skip: 0, limit: 1000 };
|
||||
const response = await fetchMaterials(params);
|
||||
setMaterials(response.data.materials || []);
|
||||
} catch (error) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '자재 목록 로드 실패',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (materials.length > 0) {
|
||||
// 분류별 집계
|
||||
const typeCounts = {};
|
||||
const typeQuantities = {};
|
||||
const materialGrades = {};
|
||||
const sizes = {};
|
||||
const materialQuantities = {};
|
||||
|
||||
materials.forEach(mat => {
|
||||
const type = mat.item_type || 'OTHER';
|
||||
const grade = mat.material_grade || '미분류';
|
||||
const size = mat.size_spec || '미분류';
|
||||
const desc = mat.original_description;
|
||||
|
||||
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
||||
typeQuantities[type] = (typeQuantities[type] || 0) + (mat.quantity || 0);
|
||||
materialGrades[grade] = (materialGrades[grade] || 0) + 1;
|
||||
sizes[size] = (sizes[size] || 0) + 1;
|
||||
materialQuantities[desc] = (materialQuantities[desc] || 0) + (mat.quantity || 0);
|
||||
});
|
||||
|
||||
// Bar 차트 데이터
|
||||
setBarData({
|
||||
labels: Object.keys(typeCounts),
|
||||
datasets: [
|
||||
{
|
||||
label: '자재 수',
|
||||
data: Object.values(typeCounts),
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.6)',
|
||||
borderColor: 'rgba(25, 118, 210, 1)',
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: '총 수량',
|
||||
data: Object.values(typeQuantities),
|
||||
backgroundColor: 'rgba(220, 0, 78, 0.4)',
|
||||
borderColor: 'rgba(220, 0, 78, 1)',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 재질별 Pie 차트
|
||||
setMaterialGradeData({
|
||||
labels: Object.keys(materialGrades),
|
||||
datasets: [{
|
||||
data: Object.values(materialGrades),
|
||||
backgroundColor: [
|
||||
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
|
||||
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
|
||||
],
|
||||
borderWidth: 2
|
||||
}]
|
||||
});
|
||||
|
||||
// 사이즈별 Pie 차트
|
||||
setSizeData({
|
||||
labels: Object.keys(sizes),
|
||||
datasets: [{
|
||||
data: Object.values(sizes),
|
||||
backgroundColor: [
|
||||
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
|
||||
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
|
||||
],
|
||||
borderWidth: 2
|
||||
}]
|
||||
});
|
||||
|
||||
// 상위 자재 (수량 기준)
|
||||
const sortedMaterials = Object.entries(materialQuantities)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.slice(0, 10)
|
||||
.map(([desc, qty]) => ({ description: desc, quantity: qty }));
|
||||
|
||||
setTopMaterials(sortedMaterials);
|
||||
} else {
|
||||
setBarData(null);
|
||||
setMaterialGradeData(null);
|
||||
setSizeData(null);
|
||||
setTopMaterials([]);
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📊 대시보드
|
||||
</Typography>
|
||||
|
||||
{/* 선택된 프로젝트 정보 */}
|
||||
{selectedProject && (
|
||||
<Box sx={{ mb: 3, p: 2, bgcolor: 'primary.50', borderRadius: 2, border: '1px solid', borderColor: 'primary.200' }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Box>
|
||||
<Typography variant="h6" color="primary">
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
상태: {selectedProject.status} | 생성일: {new Date(selectedProject.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Chip
|
||||
label={selectedProject.status}
|
||||
color={selectedProject.status === 'active' ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={selectedProject.is_code_matched ? '코드 매칭됨' : '코드 미매칭'}
|
||||
color={selectedProject.is_code_matched ? 'success' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* 프로젝트 현황 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
프로젝트 현황
|
||||
</Typography>
|
||||
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
|
||||
{projects.length}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
총 프로젝트 수
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 2 }}>
|
||||
선택된 프로젝트: {selectedProject.project_name}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 자재 현황 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="secondary" gutterBottom>
|
||||
자재 현황
|
||||
</Typography>
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" py={3}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : stats ? (
|
||||
<Box>
|
||||
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
|
||||
{stats.total_items.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
총 자재 수
|
||||
</Typography>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={6}>
|
||||
<Chip label={`고유 품목: ${stats.unique_descriptions}개`} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Chip label={`고유 사이즈: ${stats.unique_sizes}개`} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Chip label={`총 수량: ${stats.total_quantity.toLocaleString()}`} size="small" color="success" />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Chip label={`평균 수량: ${stats.avg_quantity}`} size="small" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Typography variant="body2" sx={{ mt: 2, fontSize: '0.8rem' }}>
|
||||
최초 업로드: {stats.earliest_upload ? new Date(stats.earliest_upload).toLocaleString() : '-'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
최신 업로드: {stats.latest_upload ? new Date(stats.latest_upload).toLocaleString() : '-'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
프로젝트를 선택하면 자재 현황을 확인할 수 있습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 분류별 자재 통계 */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
분류별 자재 통계
|
||||
</Typography>
|
||||
{barData ? (
|
||||
<Bar data={barData} options={{
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
title: { display: true, text: '분류별 자재 수/총 수량' }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}} />
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
자재 데이터가 없습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 재질별 분포 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
재질별 분포
|
||||
</Typography>
|
||||
{materialGradeData ? (
|
||||
<Pie data={materialGradeData} options={{
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
title: { display: true, text: '재질별 자재 분포' }
|
||||
}
|
||||
}} />
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
재질 데이터가 없습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 사이즈별 분포 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
사이즈별 분포
|
||||
</Typography>
|
||||
{sizeData ? (
|
||||
<Pie data={sizeData} options={{
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
title: { display: true, text: '사이즈별 자재 분포' }
|
||||
}
|
||||
}} />
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
사이즈 데이터가 없습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 상위 자재 */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
상위 자재 (수량 기준)
|
||||
</Typography>
|
||||
{topMaterials.length > 0 ? (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell><strong>순위</strong></TableCell>
|
||||
<TableCell><strong>자재명</strong></TableCell>
|
||||
<TableCell align="right"><strong>총 수량</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{topMaterials.map((material, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ maxWidth: 300 }}>
|
||||
{material.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={material.quantity.toLocaleString()}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
자재 데이터가 없습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 프로젝트 상세 정보 */}
|
||||
{selectedProject && (
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
📋 프로젝트 상세 정보
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">프로젝트 코드</Typography>
|
||||
<Typography variant="body1">{selectedProject.official_project_code}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">프로젝트명</Typography>
|
||||
<Typography variant="body1">{selectedProject.project_name}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">상태</Typography>
|
||||
<Chip label={selectedProject.status} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">생성일</Typography>
|
||||
<Typography variant="body1">
|
||||
{new Date(selectedProject.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
590
tkeg/web/src/components/FileManager.jsx
Normal file
590
tkeg/web/src/components/FileManager.jsx
Normal file
@@ -0,0 +1,590 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
IconButton,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Chip,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Delete,
|
||||
Download,
|
||||
Visibility,
|
||||
FileUpload,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Error,
|
||||
Update
|
||||
} from '@mui/icons-material';
|
||||
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('');
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchFilesList();
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
// 파일 업로드 이벤트 리스너 추가
|
||||
useEffect(() => {
|
||||
console.log('FileManager: 이벤트 리스너 등록 시작');
|
||||
|
||||
const handleFileUploaded = (event) => {
|
||||
const { jobNo } = event.detail;
|
||||
console.log('FileManager: 파일 업로드 이벤트 수신:', event.detail);
|
||||
console.log('FileManager: 현재 선택된 프로젝트:', selectedProject);
|
||||
|
||||
if (selectedProject && selectedProject.job_no === jobNo) {
|
||||
console.log('FileManager: 파일 업로드 감지됨, 목록 갱신 중...');
|
||||
fetchFilesList();
|
||||
} else {
|
||||
console.log('FileManager: job_no 불일치 또는 프로젝트 미선택');
|
||||
console.log('이벤트 jobNo:', jobNo);
|
||||
console.log('선택된 프로젝트 jobNo:', selectedProject?.job_no);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('fileUploaded', handleFileUploaded);
|
||||
console.log('FileManager: fileUploaded 이벤트 리스너 등록 완료');
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('fileUploaded', handleFileUploaded);
|
||||
console.log('FileManager: fileUploaded 이벤트 리스너 제거');
|
||||
};
|
||||
}, [selectedProject]);
|
||||
|
||||
const fetchFilesList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log('FileManager: 파일 목록 조회 시작, job_no:', selectedProject.job_no);
|
||||
const response = await fetchFiles({ job_no: selectedProject.job_no });
|
||||
console.log('FileManager: API 응답:', response.data);
|
||||
|
||||
if (response.data && response.data.files) {
|
||||
setFiles(response.data.files);
|
||||
console.log('FileManager: 파일 목록 업데이트 완료, 파일 수:', response.data.files.length);
|
||||
} else {
|
||||
console.log('FileManager: 파일 목록이 비어있음');
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('FileManager: 파일 목록 조회 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '파일 목록을 불러오는데 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async () => {
|
||||
if (!deleteDialog.file) return;
|
||||
|
||||
try {
|
||||
await deleteFile(deleteDialog.file.id);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '파일이 성공적으로 삭제되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
setDeleteDialog({ open: false, file: null });
|
||||
fetchFilesList(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error('파일 삭제 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '파일 삭제에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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':
|
||||
return 'success';
|
||||
case 'processing':
|
||||
return 'warning';
|
||||
case 'failed':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle />;
|
||||
case 'processing':
|
||||
return <CircularProgress size={16} />;
|
||||
case 'failed':
|
||||
return <Error />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleString('ko-KR');
|
||||
};
|
||||
|
||||
const filteredFiles = files.filter(file => {
|
||||
const matchesFilter = !filter ||
|
||||
file.original_filename.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
file.project_name?.toLowerCase().includes(filter.toLowerCase());
|
||||
|
||||
const matchesStatus = !statusFilter || file.status === statusFilter;
|
||||
|
||||
return matchesFilter && matchesStatus;
|
||||
});
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📁 도면 관리
|
||||
</Typography>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<FileUpload sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
프로젝트를 선택해주세요
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
프로젝트 관리 탭에서 프로젝트를 선택하면 도면을 관리할 수 있습니다.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📁 도면 관리
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
|
||||
{/* 필터 UI */}
|
||||
<Box sx={{ mb: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<TextField
|
||||
label="파일명/프로젝트명 검색"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="파일명 또는 프로젝트명으로 검색"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>상태</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
label="상태"
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
<MenuItem value="completed">완료</MenuItem>
|
||||
<MenuItem value="processing">처리 중</MenuItem>
|
||||
<MenuItem value="failed">실패</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} md={4}>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setFilter('');
|
||||
setStatusFilter('');
|
||||
}}
|
||||
>
|
||||
필터 초기화
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={fetchFilesList}
|
||||
>
|
||||
새로고침
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||
파일 목록 로딩 중...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<FileUpload sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{filter || statusFilter ? '검색 결과가 없습니다' : '업로드된 파일이 없습니다'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
{filter || statusFilter
|
||||
? '다른 검색 조건을 시도해보세요.'
|
||||
: '파일 업로드 탭에서 BOM 파일을 업로드해주세요.'
|
||||
}
|
||||
</Typography>
|
||||
{(filter || statusFilter) && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setFilter('');
|
||||
setStatusFilter('');
|
||||
}}
|
||||
>
|
||||
필터 초기화
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">
|
||||
총 {filteredFiles.length}개 파일
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${files.length}개 전체`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell><strong>번호</strong></TableCell>
|
||||
<TableCell><strong>파일명</strong></TableCell>
|
||||
<TableCell align="center"><strong>프로젝트</strong></TableCell>
|
||||
<TableCell align="center"><strong>상태</strong></TableCell>
|
||||
<TableCell align="center"><strong>파일 크기</strong></TableCell>
|
||||
<TableCell align="center"><strong>업로드 일시</strong></TableCell>
|
||||
<TableCell align="center"><strong>처리 완료</strong></TableCell>
|
||||
<TableCell align="center"><strong>작업</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredFiles.map((file, index) => (
|
||||
<TableRow
|
||||
key={file.id}
|
||||
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
|
||||
>
|
||||
<TableCell>
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ maxWidth: 300 }}>
|
||||
{file.original_filename}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={file.project_name || '-'}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={file.status === 'completed' ? '완료' :
|
||||
file.status === 'processing' ? '처리 중' :
|
||||
file.status === 'failed' ? '실패' : '대기'}
|
||||
size="small"
|
||||
color={getStatusColor(file.status)}
|
||||
icon={getStatusIcon(file.status)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="body2">
|
||||
{formatFileSize(file.file_size || 0)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="body2">
|
||||
{formatDate(file.created_at)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="body2">
|
||||
{file.processed_at ? formatDate(file.processed_at) : '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
title="다운로드"
|
||||
disabled={file.status !== 'completed'}
|
||||
>
|
||||
<Download />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="info"
|
||||
title="상세 보기"
|
||||
>
|
||||
<Visibility />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="warning"
|
||||
title="리비전 업로드"
|
||||
onClick={() => {
|
||||
console.log('🔄 리비전 버튼 클릭, 파일 정보:', file);
|
||||
setRevisionDialog({ open: true, file });
|
||||
}}
|
||||
>
|
||||
<Update />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
title="삭제"
|
||||
onClick={() => setDeleteDialog({ open: true, file })}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<Dialog
|
||||
open={deleteDialog.open}
|
||||
onClose={() => setDeleteDialog({ open: false, file: null })}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Warning color="error" />
|
||||
파일 삭제 확인
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
다음 파일을 삭제하시겠습니까?
|
||||
</Typography>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>파일명:</strong> {deleteDialog.file?.original_filename}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>프로젝트:</strong> {deleteDialog.file?.project_name}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>업로드 일시:</strong> {deleteDialog.file?.created_at ? formatDate(deleteDialog.file.created_at) : '-'}
|
||||
</Typography>
|
||||
</Alert>
|
||||
<Typography variant="body2" color="error">
|
||||
⚠️ 이 작업은 되돌릴 수 없습니다. 파일과 관련된 모든 자재 데이터가 함께 삭제됩니다.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => setDeleteDialog({ open: false, file: null })}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteFile}
|
||||
color="error"
|
||||
variant="contained"
|
||||
startIcon={<Delete />}
|
||||
>
|
||||
삭제
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileManager;
|
||||
579
tkeg/web/src/components/FileUpload.jsx
Normal file
579
tkeg/web/src/components/FileUpload.jsx
Normal file
@@ -0,0 +1,579 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
LinearProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Chip,
|
||||
Paper,
|
||||
Divider,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
StepContent,
|
||||
Alert,
|
||||
Grid
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CloudUpload,
|
||||
AttachFile,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Description,
|
||||
AutoAwesome,
|
||||
Category,
|
||||
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);
|
||||
const [error, setError] = useState('');
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [materialsSummary, setMaterialsSummary] = useState(null);
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
const [uploadSteps, setUploadSteps] = useState([
|
||||
{ label: '파일 업로드', completed: false, active: false },
|
||||
{ label: '데이터 파싱', completed: false, active: false },
|
||||
{ label: '자재 분류', completed: false, active: false },
|
||||
{ label: '분류기 실행', completed: false, active: false },
|
||||
{ label: '데이터베이스 저장', completed: false, active: false }
|
||||
]);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles) => {
|
||||
console.log('=== FileUpload: onDrop 함수 호출됨 ===');
|
||||
console.log('받은 파일들:', acceptedFiles);
|
||||
console.log('선택된 프로젝트:', selectedProject);
|
||||
|
||||
if (!selectedProject) {
|
||||
console.log('프로젝트가 선택되지 않음');
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트를 먼저 선택해주세요.',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (acceptedFiles.length > 0) {
|
||||
console.log('파일 업로드 시작');
|
||||
uploadFile(acceptedFiles[0]);
|
||||
} else {
|
||||
console.log('선택된 파일이 없음');
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, acceptedFiles } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'text/csv': ['.csv']
|
||||
},
|
||||
multiple: false,
|
||||
maxSize: 10 * 1024 * 1024 // 10MB
|
||||
});
|
||||
|
||||
const updateUploadStep = (stepIndex, completed = false, active = false) => {
|
||||
setUploadSteps(prev => prev.map((step, index) => ({
|
||||
...step,
|
||||
completed: index < stepIndex ? true : (index === stepIndex ? completed : false),
|
||||
active: index === stepIndex ? active : false
|
||||
})));
|
||||
};
|
||||
|
||||
const uploadFile = async (file) => {
|
||||
console.log('=== FileUpload: uploadFile 함수 시작 ===');
|
||||
console.log('파일 정보:', {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
});
|
||||
console.log('선택된 프로젝트:', selectedProject);
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
setError('');
|
||||
setUploadResult(null);
|
||||
setMaterialsSummary(null);
|
||||
|
||||
console.log('업로드 시작:', {
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
jobNo: selectedProject?.job_no,
|
||||
projectName: selectedProject?.project_name
|
||||
});
|
||||
|
||||
// 업로드 단계 초기화
|
||||
setUploadSteps([
|
||||
{ label: '파일 업로드', completed: false, active: true },
|
||||
{ label: '데이터 파싱', completed: false, active: false },
|
||||
{ label: '자재 분류', completed: false, active: false },
|
||||
{ label: '분류기 실행', completed: false, active: false },
|
||||
{ label: '데이터베이스 저장', completed: false, active: false }
|
||||
]);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('revision', 'Rev.0'); // 새 BOM은 항상 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', // 새 BOM은 항상 Rev.0
|
||||
bomName: file.name,
|
||||
bomType: 'excel'
|
||||
});
|
||||
|
||||
try {
|
||||
// 1단계: 파일 업로드
|
||||
updateUploadStep(0, true, false);
|
||||
updateUploadStep(1, false, true);
|
||||
|
||||
console.log('API 호출 시작: /upload');
|
||||
const response = await uploadFileApi(formData, {
|
||||
onUploadProgress: (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
setUploadProgress(progress);
|
||||
console.log('업로드 진행률:', progress + '%');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('API 응답:', response.data);
|
||||
const result = response.data;
|
||||
|
||||
console.log('응답 데이터 구조:', {
|
||||
success: result.success,
|
||||
file_id: result.file_id,
|
||||
message: result.message,
|
||||
hasFileId: 'file_id' in result
|
||||
});
|
||||
|
||||
// 2단계: 데이터 파싱 완료
|
||||
updateUploadStep(1, true, false);
|
||||
updateUploadStep(2, false, true);
|
||||
|
||||
if (result.success) {
|
||||
// 3단계: 자재 분류 완료
|
||||
updateUploadStep(2, true, false);
|
||||
updateUploadStep(3, false, true);
|
||||
|
||||
// 4단계: 분류기 실행 완료
|
||||
updateUploadStep(3, true, false);
|
||||
updateUploadStep(4, false, true);
|
||||
|
||||
// 5단계: 데이터베이스 저장 완료
|
||||
updateUploadStep(4, true, false);
|
||||
|
||||
setUploadResult(result);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '파일 업로드 및 분류가 성공했습니다!',
|
||||
type: 'success'
|
||||
});
|
||||
|
||||
console.log('업로드 성공 결과:', result);
|
||||
console.log('파일 ID:', result.file_id);
|
||||
console.log('선택된 프로젝트:', selectedProject);
|
||||
|
||||
// 업로드 성공 후 자재 통계 미리보기 호출
|
||||
try {
|
||||
const summaryRes = await fetchMaterialsSummary({ file_id: result.file_id });
|
||||
if (summaryRes.data && summaryRes.data.success) {
|
||||
setMaterialsSummary(summaryRes.data.summary);
|
||||
}
|
||||
} catch (e) {
|
||||
// 통계 조회 실패는 무시(UX만)
|
||||
}
|
||||
|
||||
if (onUploadSuccess) {
|
||||
console.log('onUploadSuccess 콜백 호출');
|
||||
onUploadSuccess(result);
|
||||
}
|
||||
|
||||
// 파일 목록 갱신을 위한 이벤트 발생
|
||||
console.log('파일 업로드 이벤트 발생:', {
|
||||
fileId: result.file_id,
|
||||
jobNo: selectedProject.job_no
|
||||
});
|
||||
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('fileUploaded', {
|
||||
detail: { fileId: result.file_id, jobNo: selectedProject.job_no }
|
||||
}));
|
||||
console.log('CustomEvent dispatch 성공');
|
||||
} catch (error) {
|
||||
console.error('CustomEvent dispatch 실패:', error);
|
||||
}
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: result.message || '업로드에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업로드 실패:', error);
|
||||
|
||||
// 에러 타입별 상세 메시지
|
||||
let errorMessage = '업로드에 실패했습니다.';
|
||||
|
||||
if (error.response) {
|
||||
// 서버 응답이 있는 경우
|
||||
const status = error.response.status;
|
||||
const data = error.response.data;
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
errorMessage = `잘못된 요청: ${data?.detail || '파일 형식이나 데이터를 확인해주세요.'}`;
|
||||
break;
|
||||
case 413:
|
||||
errorMessage = '파일 크기가 너무 큽니다. (최대 10MB)';
|
||||
break;
|
||||
case 422:
|
||||
errorMessage = `데이터 검증 실패: ${data?.detail || '파일 내용을 확인해주세요.'}`;
|
||||
break;
|
||||
case 500:
|
||||
errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||
break;
|
||||
default:
|
||||
errorMessage = `서버 오류 (${status}): ${data?.detail || error.message}`;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 네트워크 오류
|
||||
errorMessage = '서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.';
|
||||
} else {
|
||||
// 기타 오류
|
||||
errorMessage = `오류 발생: ${error.message}`;
|
||||
}
|
||||
|
||||
setToast({
|
||||
open: true,
|
||||
message: errorMessage,
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
uploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const resetUpload = () => {
|
||||
setUploadResult(null);
|
||||
setUploadProgress(0);
|
||||
setUploadSteps([
|
||||
{ label: '파일 업로드', completed: false, active: false },
|
||||
{ label: '데이터 파싱', completed: false, active: false },
|
||||
{ label: '자재 분류', completed: false, active: false },
|
||||
{ label: '분류기 실행', completed: false, active: false },
|
||||
{ label: '데이터베이스 저장', completed: false, active: false }
|
||||
]);
|
||||
setToast({ open: false, message: '', type: 'info' });
|
||||
};
|
||||
|
||||
const getClassificationStats = () => {
|
||||
if (!uploadResult?.classification_stats) return null;
|
||||
|
||||
const stats = uploadResult.classification_stats;
|
||||
const total = Object.values(stats).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
return Object.entries(stats)
|
||||
.filter(([category, count]) => count > 0)
|
||||
.map(([category, count]) => ({
|
||||
category,
|
||||
count,
|
||||
percentage: total > 0 ? Math.round((count / total) * 100) : 0
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📁 파일 업로드
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{uploading && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<AutoAwesome sx={{ mr: 1, color: 'primary.main' }} />
|
||||
업로드 및 분류 진행 중...
|
||||
</Typography>
|
||||
|
||||
<Stepper orientation="vertical" sx={{ mt: 2 }}>
|
||||
{uploadSteps.map((step, index) => (
|
||||
<Step key={index} active={step.active} completed={step.completed}>
|
||||
<StepLabel>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{step.completed ? (
|
||||
<CheckCircle color="success" sx={{ mr: 1 }} />
|
||||
) : step.active ? (
|
||||
<Science color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<Category color="disabled" sx={{ mr: 1 }} />
|
||||
)}
|
||||
{step.label}
|
||||
</Box>
|
||||
</StepLabel>
|
||||
{step.active && (
|
||||
<StepContent>
|
||||
<LinearProgress sx={{ mt: 1 }} />
|
||||
</StepContent>
|
||||
)}
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{uploadProgress > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
파일 업로드 진행률: {uploadProgress}%
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{uploadResult ? (
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<CheckCircle color="success" sx={{ mr: 1 }} />
|
||||
<Typography variant="h6" color="success.main">
|
||||
업로드 및 분류 성공!
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
📊 업로드 결과
|
||||
</Typography>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Description />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="파일명"
|
||||
secondary={uploadResult.original_filename}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircle />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="파싱된 자재 수"
|
||||
secondary={`${uploadResult.parsed_materials_count}개`}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircle />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="저장된 자재 수"
|
||||
secondary={`${uploadResult.saved_materials_count}개`}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
🏷️ 분류 결과
|
||||
</Typography>
|
||||
{getClassificationStats() && (
|
||||
<Box>
|
||||
{getClassificationStats().map((stat, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Chip
|
||||
label={stat.category}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Typography variant="body2">
|
||||
{stat.count}개 ({stat.percentage}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{materialsSummary && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
💡 <strong>자재 통계 미리보기:</strong><br/>
|
||||
• 총 자재 수: {materialsSummary.total_items || 0}개<br/>
|
||||
• 고유 자재: {materialsSummary.unique_descriptions || 0}종류<br/>
|
||||
• 총 수량: {materialsSummary.total_quantity || 0}개
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
// 상태 기반 라우팅을 위한 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent('navigateToMaterials', {
|
||||
detail: {
|
||||
jobNo: selectedProject?.job_no,
|
||||
revision: uploadResult?.revision || 'Rev.0',
|
||||
bomName: uploadResult?.original_filename || uploadResult?.filename,
|
||||
message: '파일 업로드 완료',
|
||||
file_id: uploadResult?.file_id // file_id 추가
|
||||
}
|
||||
}));
|
||||
}}
|
||||
startIcon={<Description />}
|
||||
>
|
||||
자재 목록 보기
|
||||
</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}
|
||||
>
|
||||
새로 업로드
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Paper
|
||||
{...getRootProps()}
|
||||
onClick={() => console.log('드래그 앤 드롭 영역 클릭됨')}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: 2,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: isDragActive ? 'primary.main' : 'grey.300',
|
||||
bgcolor: isDragActive ? 'primary.50' : 'grey.50',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: 'primary.50'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<CloudUpload sx={{
|
||||
fontSize: 64,
|
||||
color: isDragActive ? 'primary.main' : 'grey.400',
|
||||
mb: 2
|
||||
}} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{isDragActive
|
||||
? "파일을 여기에 놓으세요!"
|
||||
: "Excel 파일을 드래그하거나 클릭하여 선택"
|
||||
}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
지원 형식: .xlsx, .xls, .csv (최대 10MB)
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AttachFile />}
|
||||
component="span"
|
||||
disabled={uploading}
|
||||
onClick={() => console.log('파일 선택 버튼 클릭됨')}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '파일 선택'}
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
💡 <strong>업로드 및 분류 프로세스:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
• BOM(Bill of Materials) 파일을 업로드하면 자동으로 자재 정보를 추출합니다
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
• 각 자재는 8가지 분류기(볼트, 플랜지, 피팅, 가스켓, 계기, 파이프, 밸브, 재질)로 자동 분류됩니다
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
• 분류 결과는 신뢰도 점수와 함께 저장되며, 필요시 수동 검증이 가능합니다
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
FileUpload.propTypes = {
|
||||
selectedProject: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
project_name: PropTypes.string.isRequired,
|
||||
official_project_code: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
onUploadSuccess: PropTypes.func,
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
99
tkeg/web/src/components/FittingDetailsCard.jsx
Normal file
99
tkeg/web/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;
|
||||
516
tkeg/web/src/components/MaterialComparisonResult.jsx
Normal file
516
tkeg/web/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;
|
||||
855
tkeg/web/src/components/MaterialList.jsx
Normal file
855
tkeg/web/src/components/MaterialList.jsx
Normal file
@@ -0,0 +1,855 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
TablePagination,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Select,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
Grid,
|
||||
IconButton,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Inventory,
|
||||
Clear,
|
||||
ExpandMore,
|
||||
CompareArrows,
|
||||
Add,
|
||||
Remove,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { fetchMaterials as fetchMaterialsApi, fetchJobs } from '../api';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import Toast from './Toast';
|
||||
|
||||
function MaterialList({ selectedProject }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(25);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get('searchValue') || '');
|
||||
const [itemType, setItemType] = useState(searchParams.get('itemType') || '');
|
||||
const [materialGrade, setMaterialGrade] = useState(searchParams.get('materialGrade') || '');
|
||||
const [sizeSpec, setSizeSpec] = useState(searchParams.get('sizeSpec') || '');
|
||||
const [fileFilter, setFileFilter] = useState(searchParams.get('fileFilter') || '');
|
||||
const [selectedJob, setSelectedJob] = useState(searchParams.get('jobId') || '');
|
||||
const [selectedRevision, setSelectedRevision] = useState(searchParams.get('revision') || '');
|
||||
const [groupingType, setGroupingType] = useState(searchParams.get('grouping') || 'item');
|
||||
const [sortBy, setSortBy] = useState(searchParams.get('sortBy') || '');
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
const [revisionComparison, setRevisionComparison] = useState(null);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [fileId, setFileId] = useState(searchParams.get('file_id') || '');
|
||||
const [selectedJobNo, setSelectedJobNo] = useState(searchParams.get('job_no') || '');
|
||||
const [selectedFilename, setSelectedFilename] = useState(searchParams.get('filename') || '');
|
||||
|
||||
// URL 쿼리 파라미터 동기화
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (searchValue) params.set('searchValue', searchValue);
|
||||
if (itemType) params.set('itemType', itemType);
|
||||
if (materialGrade) params.set('materialGrade', materialGrade);
|
||||
if (sizeSpec) params.set('sizeSpec', sizeSpec);
|
||||
if (fileFilter) params.set('fileFilter', fileFilter);
|
||||
if (selectedJob) params.set('jobId', selectedJob);
|
||||
if (selectedRevision) params.set('revision', selectedRevision);
|
||||
if (groupingType) params.set('grouping', groupingType);
|
||||
if (sortBy) params.set('sortBy', sortBy);
|
||||
if (page > 0) params.set('page', page.toString());
|
||||
if (rowsPerPage !== 25) params.set('rowsPerPage', rowsPerPage.toString());
|
||||
if (fileId) params.set('file_id', fileId);
|
||||
if (selectedJobNo) params.set('job_no', selectedJobNo);
|
||||
if (selectedFilename) params.set('filename', selectedFilename);
|
||||
if (selectedRevision) params.set('revision', selectedRevision);
|
||||
|
||||
setSearchParams(params);
|
||||
}, [search, searchValue, itemType, materialGrade, sizeSpec, fileFilter, selectedJob, selectedRevision, groupingType, sortBy, page, rowsPerPage, fileId, setSearchParams, selectedJobNo, selectedFilename, selectedRevision]);
|
||||
|
||||
// URL 파라미터로 진입 시 자동 필터 적용
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlJobNo = urlParams.get('job_no');
|
||||
const urlFilename = urlParams.get('filename');
|
||||
const urlRevision = urlParams.get('revision');
|
||||
if (urlJobNo) setSelectedJobNo(urlJobNo);
|
||||
if (urlFilename) setSelectedFilename(urlFilename);
|
||||
if (urlRevision) setSelectedRevision(urlRevision);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchJobsData();
|
||||
fetchMaterials();
|
||||
} else {
|
||||
setMaterials([]);
|
||||
setTotalCount(0);
|
||||
setJobs([]);
|
||||
}
|
||||
}, [selectedProject, page, rowsPerPage, search, searchValue, itemType, materialGrade, sizeSpec, fileFilter, selectedJob, selectedRevision, groupingType, sortBy, fileId, selectedJobNo, selectedFilename, selectedRevision]);
|
||||
|
||||
// 파일 업로드 이벤트 리스너 추가
|
||||
useEffect(() => {
|
||||
const handleFileUploaded = (event) => {
|
||||
const { jobNo } = event.detail;
|
||||
if (selectedProject && selectedProject.job_no === jobNo) {
|
||||
console.log('파일 업로드 감지됨, 자재 목록 갱신 중...');
|
||||
fetchMaterials();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('fileUploaded', handleFileUploaded);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('fileUploaded', handleFileUploaded);
|
||||
};
|
||||
}, [selectedProject]);
|
||||
|
||||
// 파일 목록 불러오기 (선택된 프로젝트가 바뀔 때마다)
|
||||
useEffect(() => {
|
||||
async function fetchFilesForProject() {
|
||||
if (!selectedProject?.job_no) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/files?job_no=${selectedProject.job_no}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data)) setFiles(data);
|
||||
else if (data && Array.isArray(data.files)) setFiles(data.files);
|
||||
else setFiles([]);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
} catch {
|
||||
setFiles([]);
|
||||
}
|
||||
}
|
||||
fetchFilesForProject();
|
||||
}, [selectedProject]);
|
||||
|
||||
const fetchJobsData = async () => {
|
||||
try {
|
||||
const response = await fetchJobs({ project_id: selectedProject.id });
|
||||
if (response.data && response.data.jobs) {
|
||||
setJobs(response.data.jobs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Job 조회 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMaterials = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const skip = page * rowsPerPage;
|
||||
const params = {
|
||||
job_no: selectedJobNo || undefined,
|
||||
filename: selectedFilename || undefined,
|
||||
revision: selectedRevision || undefined,
|
||||
skip,
|
||||
limit: rowsPerPage,
|
||||
search: search || undefined,
|
||||
search_value: searchValue || undefined,
|
||||
item_type: itemType || undefined,
|
||||
material_grade: materialGrade || undefined,
|
||||
size_spec: sizeSpec || undefined,
|
||||
// file_id, fileFilter 등은 사용하지 않음
|
||||
grouping: groupingType || undefined,
|
||||
sort_by: sortBy || undefined
|
||||
};
|
||||
|
||||
// selectedProject가 없으면 API 호출하지 않음
|
||||
if (!selectedProject?.job_no) {
|
||||
setMaterials([]);
|
||||
setTotalCount(0);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('API 요청 파라미터:', params); // 디버깅용
|
||||
|
||||
const response = await fetchMaterialsApi(params);
|
||||
const data = response.data;
|
||||
|
||||
console.log('API 응답:', data); // 디버깅용
|
||||
|
||||
setMaterials(data.materials || []);
|
||||
setTotalCount(data.total_count || 0);
|
||||
|
||||
// 리비전 비교 데이터가 있으면 설정
|
||||
if (data.revision_comparison) {
|
||||
setRevisionComparison(data.revision_comparison);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('자재 조회 실패:', error);
|
||||
console.error('에러 상세:', error.response?.data); // 디버깅용
|
||||
setToast({
|
||||
open: true,
|
||||
message: `자재 데이터를 불러오는데 실패했습니다: ${error.response?.data?.detail || error.message}`,
|
||||
type: 'error'
|
||||
});
|
||||
setMaterials([]);
|
||||
setTotalCount(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('');
|
||||
setSearchValue('');
|
||||
setItemType('');
|
||||
setMaterialGrade('');
|
||||
setSizeSpec('');
|
||||
setFileFilter('');
|
||||
setSelectedJob('');
|
||||
setSelectedRevision('');
|
||||
setGroupingType('item');
|
||||
setSortBy('');
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const getItemTypeColor = (itemType) => {
|
||||
const colors = {
|
||||
'PIPE': 'primary',
|
||||
'FITTING': 'secondary',
|
||||
'VALVE': 'success',
|
||||
'FLANGE': 'warning',
|
||||
'BOLT': 'info',
|
||||
'GASKET': 'error',
|
||||
'INSTRUMENT': 'purple',
|
||||
'OTHER': 'default'
|
||||
};
|
||||
return colors[itemType] || 'default';
|
||||
};
|
||||
|
||||
const getRevisionChangeColor = (change) => {
|
||||
if (change > 0) return 'success';
|
||||
if (change < 0) return 'error';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getRevisionChangeIcon = (change) => {
|
||||
if (change > 0) return <Add />;
|
||||
if (change < 0) return <Remove />;
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📋 자재 목록
|
||||
</Typography>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
프로젝트를 선택해주세요
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
프로젝트 관리 탭에서 프로젝트를 선택하면 자재 목록을 확인할 수 있습니다.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📋 자재 목록 (그룹핑)
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
|
||||
{/* 필터/검색/정렬 UI */}
|
||||
<Box sx={{ mb: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
{/* 검색 유형 */}
|
||||
<Grid item xs={12} sm={3} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>검색 유형</InputLabel>
|
||||
<Select
|
||||
value={search}
|
||||
label="검색 유형"
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem key="all-search" value="">전체</MenuItem>
|
||||
<MenuItem key="project-search" value="project">프로젝트명</MenuItem>
|
||||
<MenuItem key="job-search" value="job">Job No.</MenuItem>
|
||||
<MenuItem key="material-search" value="material">자재명</MenuItem>
|
||||
<MenuItem key="description-search" value="description">설명</MenuItem>
|
||||
<MenuItem key="grade-search" value="grade">재질</MenuItem>
|
||||
<MenuItem key="size-search" value="size">사이즈</MenuItem>
|
||||
<MenuItem key="filename-search" value="filename">파일명</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 검색어 입력/선택 */}
|
||||
<Grid item xs={12} sm={3} md={2}>
|
||||
{search === 'project' ? (
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>프로젝트명 선택</InputLabel>
|
||||
<Select
|
||||
value={searchValue}
|
||||
label="프로젝트명 선택"
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem key="all-projects" value="">전체 프로젝트</MenuItem>
|
||||
<MenuItem key="mp7-rev2" value="MP7 PIPING PROJECT Rev.2">MP7 PIPING PROJECT Rev.2</MenuItem>
|
||||
<MenuItem key="pp5-5701" value="PP5 5701">PP5 5701</MenuItem>
|
||||
<MenuItem key="mp7" value="MP7">MP7</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : search === 'job' ? (
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Job No. 선택</InputLabel>
|
||||
<Select
|
||||
value={searchValue}
|
||||
label="Job No. 선택"
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem key="all-jobs-search" value="">전체 Job</MenuItem>
|
||||
{jobs.map((job) => (
|
||||
<MenuItem key={`job-search-${job.id}`} value={job.job_number}>
|
||||
{job.job_number}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : search === 'material' || search === 'description' ? (
|
||||
<TextField
|
||||
label="자재명/설명 검색"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="자재명 또는 설명 입력"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : search === 'grade' ? (
|
||||
<TextField
|
||||
label="재질 검색"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="재질 입력 (예: SS316)"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : search === 'size' ? (
|
||||
<TextField
|
||||
label="사이즈 검색"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="사이즈 입력 (예: 6인치)"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : search === 'filename' ? (
|
||||
<TextField
|
||||
label="파일명 검색"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="파일명 입력"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
label="검색어"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="검색어 입력"
|
||||
disabled={!search}
|
||||
InputProps={{
|
||||
endAdornment: search && (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Job No. 선택 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Job No.</InputLabel>
|
||||
<Select
|
||||
value={selectedJobNo}
|
||||
label="Job No."
|
||||
onChange={e => setSelectedJobNo(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
{jobs.map((job) => (
|
||||
<MenuItem key={job.job_number} value={job.job_number}>
|
||||
{job.job_number}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 도면명(파일명) 선택 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>도면명(파일명)</InputLabel>
|
||||
<Select
|
||||
value={selectedFilename}
|
||||
label="도면명(파일명)"
|
||||
onChange={e => setSelectedFilename(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
{files.map((file) => (
|
||||
<MenuItem key={file.id} value={file.original_filename}>
|
||||
{file.bom_name || file.original_filename || file.filename}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 리비전 선택 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>리비전</InputLabel>
|
||||
<Select
|
||||
value={selectedRevision}
|
||||
label="리비전"
|
||||
onChange={e => setSelectedRevision(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
{files
|
||||
.filter(file => file.original_filename === selectedFilename)
|
||||
.map((file) => (
|
||||
<MenuItem key={file.id} value={file.revision}>
|
||||
{file.revision}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 그룹핑 타입 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>그룹핑</InputLabel>
|
||||
<Select
|
||||
value={groupingType}
|
||||
label="그룹핑"
|
||||
onChange={e => setGroupingType(e.target.value)}
|
||||
>
|
||||
<MenuItem key="item" value="item">품목별</MenuItem>
|
||||
<MenuItem key="material" value="material">재질별</MenuItem>
|
||||
<MenuItem key="size" value="size">사이즈별</MenuItem>
|
||||
<MenuItem key="job" value="job">Job별</MenuItem>
|
||||
<MenuItem key="revision" value="revision">리비전별</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 품목 필터 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>품목</InputLabel>
|
||||
<Select
|
||||
value={itemType}
|
||||
label="품목"
|
||||
onChange={e => setItemType(e.target.value)}
|
||||
>
|
||||
<MenuItem key="all" value="">전체</MenuItem>
|
||||
<MenuItem key="PIPE" value="PIPE">PIPE</MenuItem>
|
||||
<MenuItem key="FITTING" value="FITTING">FITTING</MenuItem>
|
||||
<MenuItem key="VALVE" value="VALVE">VALVE</MenuItem>
|
||||
<MenuItem key="FLANGE" value="FLANGE">FLANGE</MenuItem>
|
||||
<MenuItem key="BOLT" value="BOLT">BOLT</MenuItem>
|
||||
<MenuItem key="GASKET" value="GASKET">GASKET</MenuItem>
|
||||
<MenuItem key="INSTRUMENT" value="INSTRUMENT">INSTRUMENT</MenuItem>
|
||||
<MenuItem key="OTHER" value="OTHER">기타</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 재질 필터 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<TextField
|
||||
label="재질"
|
||||
value={materialGrade}
|
||||
onChange={e => setMaterialGrade(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="예: SS316"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 사이즈 필터 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<TextField
|
||||
label="사이즈"
|
||||
value={sizeSpec}
|
||||
onChange={e => setSizeSpec(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder={'예: 6"'}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 파일명 필터 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<TextField
|
||||
label="파일명"
|
||||
value={fileFilter}
|
||||
onChange={e => setFileFilter(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="파일명 검색"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 파일(도면) 선택 드롭다운 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>도면(파일)</InputLabel>
|
||||
<Select
|
||||
value={fileId}
|
||||
label="도면(파일)"
|
||||
onChange={e => setFileId(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
{files.map((file) => (
|
||||
<MenuItem key={file.id} value={file.id}>
|
||||
{file.bom_name || file.original_filename || file.filename}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>정렬</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="정렬"
|
||||
onChange={e => setSortBy(e.target.value)}
|
||||
>
|
||||
<MenuItem key="default" value="">기본</MenuItem>
|
||||
<MenuItem key="quantity_desc" value="quantity_desc">수량 내림차순</MenuItem>
|
||||
<MenuItem key="quantity_asc" value="quantity_asc">수량 오름차순</MenuItem>
|
||||
<MenuItem key="name_asc" value="name_asc">이름 오름차순</MenuItem>
|
||||
<MenuItem key="name_desc" value="name_desc">이름 내림차순</MenuItem>
|
||||
<MenuItem key="created_desc" value="created_desc">최신 업로드</MenuItem>
|
||||
<MenuItem key="created_asc" value="created_asc">오래된 업로드</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 필터 초기화 */}
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<Clear />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
필터 초기화
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{/* 리비전 비교 알림 */}
|
||||
{revisionComparison && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>리비전 비교:</strong> {revisionComparison.summary}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 필터 상태 표시 */}
|
||||
{(search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision || groupingType !== 'item' || sortBy) && (
|
||||
<Box sx={{ mb: 2, p: 1, bgcolor: 'info.50', borderRadius: 1, border: '1px solid', borderColor: 'info.200' }}>
|
||||
<Typography variant="body2" color="info.main">
|
||||
필터 적용 중:
|
||||
{search && ` 검색 유형: ${search}`}
|
||||
{searchValue && ` 검색어: "${searchValue}"`}
|
||||
{selectedJobNo && ` Job No: ${selectedJobNo}`}
|
||||
{selectedFilename && ` 파일: ${selectedFilename}`}
|
||||
{selectedRevision && ` 리비전: ${selectedRevision}`}
|
||||
{groupingType !== 'item' && ` 그룹핑: ${groupingType}`}
|
||||
{itemType && ` 품목: ${itemType}`}
|
||||
{materialGrade && ` 재질: ${materialGrade}`}
|
||||
{sizeSpec && ` 사이즈: ${sizeSpec}`}
|
||||
{fileFilter && ` 파일: ${fileFilter}`}
|
||||
{sortBy && ` 정렬: ${sortBy}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||
자재 데이터 로딩 중...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : materials.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision ? '검색 결과가 없습니다' : '자재 데이터가 없습니다'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
{search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision
|
||||
? '다른 검색 조건을 시도해보세요.'
|
||||
: '파일 업로드 탭에서 BOM 파일을 업로드해주세요.'
|
||||
}
|
||||
</Typography>
|
||||
{(search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision) && (
|
||||
<Button variant="outlined" onClick={clearFilters}>
|
||||
필터 초기화
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">
|
||||
총 {totalCount.toLocaleString()}개 자재 그룹
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${materials.length}개 표시 중`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell><strong>번호</strong></TableCell>
|
||||
<TableCell><strong>유형</strong></TableCell>
|
||||
<TableCell><strong>자재명</strong></TableCell>
|
||||
<TableCell align="center"><strong>총 수량</strong></TableCell>
|
||||
<TableCell align="center"><strong>단위</strong></TableCell>
|
||||
<TableCell align="center"><strong>사이즈</strong></TableCell>
|
||||
<TableCell><strong>재질</strong></TableCell>
|
||||
<TableCell align="center"><strong>Job No.</strong></TableCell>
|
||||
<TableCell align="center"><strong>리비전</strong></TableCell>
|
||||
<TableCell align="center"><strong>변경</strong></TableCell>
|
||||
<TableCell align="center"><strong>라인 수</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{materials.map((material, index) => (
|
||||
<TableRow
|
||||
key={`${material.id}-${index}`}
|
||||
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
|
||||
>
|
||||
<TableCell>
|
||||
{page * rowsPerPage + index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={material.item_type || 'OTHER'}
|
||||
size="small"
|
||||
color={getItemTypeColor(material.item_type)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ maxWidth: 300 }}>
|
||||
{material.original_description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="h6" color="primary">
|
||||
{material.classified_category === 'PIPE' ? (() => {
|
||||
const bomLength = material.pipe_details?.total_length_mm || 0;
|
||||
const pipeCount = material.pipe_details?.pipe_count || 0;
|
||||
const cuttingLoss = pipeCount * 2;
|
||||
const requiredLength = bomLength + cuttingLoss;
|
||||
const pipesNeeded = Math.ceil(requiredLength / 6000);
|
||||
return pipesNeeded.toLocaleString();
|
||||
})() : material.quantity.toLocaleString()}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip label={material.classified_category === 'PIPE' ? '본' : material.unit} size="small" />
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={material.size_spec || '-'}
|
||||
size="small"
|
||||
color="secondary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="primary">
|
||||
{material.material_grade || '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={material.job_number || '-'}
|
||||
size="small"
|
||||
color="info"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={material.revision || 'Rev.0'}
|
||||
size="small"
|
||||
color="warning"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{material.quantity_change && (
|
||||
<Chip
|
||||
label={`${material.quantity_change > 0 ? '+' : ''}${material.quantity_change}`}
|
||||
size="small"
|
||||
color={getRevisionChangeColor(material.quantity_change)}
|
||||
icon={getRevisionChangeIcon(material.quantity_change)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={`${material.line_count || 1}개 라인`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
title={material.line_numbers_str}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
labelRowsPerPage="페이지당 행 수:"
|
||||
labelDisplayedRows={({ from, to, count }) =>
|
||||
`${from}-${to} / 총 ${count !== -1 ? count : to} 개`
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
MaterialList.propTypes = {
|
||||
selectedProject: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
project_name: PropTypes.string.isRequired,
|
||||
official_project_code: PropTypes.string.isRequired,
|
||||
}),
|
||||
};
|
||||
|
||||
export default MaterialList;
|
||||
559
tkeg/web/src/components/NavigationBar.css
Normal file
559
tkeg/web/src/components/NavigationBar.css
Normal file
@@ -0,0 +1,559 @@
|
||||
.navigation-bar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
/* 브랜드 로고 */
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 32px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-text span {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
display: block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 모바일 메뉴 토글 */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle span {
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 메인 메뉴 */
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.menu-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* 사용자 메뉴 */
|
||||
.user-menu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-trigger:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 11px;
|
||||
opacity: 0.9;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-trigger:hover .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 사용자 드롭다운 */
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 320px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
animation: dropdownSlide 0.3s ease-out;
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
@keyframes dropdownSlide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.user-dropdown-header {
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.user-avatar-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-details .user-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #2d3748;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-username {
|
||||
font-size: 14px;
|
||||
color: #718096;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role-badge,
|
||||
.access-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.role-badge { background: #bee3f8; color: #2b6cb0; }
|
||||
.access-badge { background: #c6f6d5; color: #2f855a; }
|
||||
|
||||
.user-department {
|
||||
font-size: 12px;
|
||||
color: #718096;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.user-dropdown-menu {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.dropdown-item.logout-item {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.dropdown-item.logout-item:hover {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: #e2e8f0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.user-dropdown-footer {
|
||||
padding: 16px 24px;
|
||||
background: #f7fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.permissions-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.permissions-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #718096;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.permissions-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.permission-tag {
|
||||
padding: 2px 6px;
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
font-size: 10px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.permission-more {
|
||||
padding: 2px 6px;
|
||||
background: #cbd5e0;
|
||||
color: #2d3748;
|
||||
font-size: 10px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 모바일 오버레이 */
|
||||
.mobile-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 1024px) {
|
||||
.menu-items {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav-container {
|
||||
padding: 0 16px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.brand-text span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-menu.mobile-open {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mobile-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-items {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 16px;
|
||||
color: #2d3748;
|
||||
border-radius: 12px;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #edf2f7;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.user-menu-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-menu-trigger {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
position: static;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
box-shadow: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.nav-container {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
width: calc(100vw - 24px);
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
292
tkeg/web/src/components/NavigationBar.jsx
Normal file
292
tkeg/web/src/components/NavigationBar.jsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './NavigationBar.css';
|
||||
|
||||
const NavigationBar = ({ currentPage, onNavigate }) => {
|
||||
const { user, logout, hasPermission, isAdmin, isManager } = useAuth();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
// 메뉴 항목 정의 (권한별)
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: '대시보드',
|
||||
icon: '📊',
|
||||
path: '/dashboard',
|
||||
permission: null, // 모든 사용자 접근 가능
|
||||
description: '전체 현황 보기'
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
label: '프로젝트 관리',
|
||||
icon: '📋',
|
||||
path: '/projects',
|
||||
permission: 'project.view',
|
||||
description: '프로젝트 등록 및 관리'
|
||||
},
|
||||
{
|
||||
id: 'bom',
|
||||
label: 'BOM 관리',
|
||||
icon: '📄',
|
||||
path: '/bom',
|
||||
permission: 'bom.view',
|
||||
description: 'BOM 파일 업로드 및 분석'
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
label: '자재 관리',
|
||||
icon: '🔧',
|
||||
path: '/materials',
|
||||
permission: 'bom.view',
|
||||
description: '자재 목록 및 비교'
|
||||
},
|
||||
{
|
||||
id: 'purchase',
|
||||
label: '구매 관리',
|
||||
icon: '💰',
|
||||
path: '/purchase',
|
||||
permission: 'project.view',
|
||||
description: '구매 확인 및 관리'
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: '파일 관리',
|
||||
icon: '📁',
|
||||
path: '/files',
|
||||
permission: 'file.upload',
|
||||
description: '파일 업로드 및 관리'
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: '사용자 관리',
|
||||
icon: '👥',
|
||||
path: '/users',
|
||||
permission: 'user.view',
|
||||
description: '사용자 계정 관리',
|
||||
adminOnly: true
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: '시스템 설정',
|
||||
icon: '⚙️',
|
||||
path: '/system',
|
||||
permission: 'system.admin',
|
||||
description: '시스템 환경 설정',
|
||||
adminOnly: true
|
||||
}
|
||||
];
|
||||
|
||||
// 사용자가 접근 가능한 메뉴만 필터링
|
||||
const accessibleMenuItems = menuItems.filter(item => {
|
||||
// 관리자 전용 메뉴 체크
|
||||
if (item.adminOnly && !isAdmin() && !isManager()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 권한 체크
|
||||
if (item.permission && !hasPermission(item.permission)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
setShowUserMenu(false);
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuClick = (item) => {
|
||||
onNavigate(item.id);
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const getRoleDisplayName = (role) => {
|
||||
const roleMap = {
|
||||
'admin': '관리자',
|
||||
'system': '시스템',
|
||||
'leader': '팀장',
|
||||
'support': '지원',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleMap[role] || role;
|
||||
};
|
||||
|
||||
const getAccessLevelDisplayName = (level) => {
|
||||
const levelMap = {
|
||||
'manager': '관리자',
|
||||
'leader': '팀장',
|
||||
'worker': '작업자',
|
||||
'viewer': '조회자'
|
||||
};
|
||||
return levelMap[level] || level;
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navigation-bar">
|
||||
<div className="nav-container">
|
||||
{/* 로고 및 브랜드 */}
|
||||
<div className="nav-brand">
|
||||
<div className="brand-logo">🚀</div>
|
||||
<div className="brand-text">
|
||||
<h1>TK-MP System</h1>
|
||||
<span>통합 프로젝트 관리</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 메뉴 토글 */}
|
||||
<button
|
||||
className="mobile-menu-toggle"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
{/* 메인 메뉴 */}
|
||||
<div className={`nav-menu ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
|
||||
<div className="menu-items">
|
||||
{accessibleMenuItems.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`menu-item ${currentPage === item.id ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(item)}
|
||||
title={item.description}
|
||||
>
|
||||
<span className="menu-icon">{item.icon}</span>
|
||||
<span className="menu-label">{item.label}</span>
|
||||
{item.adminOnly && (
|
||||
<span className="admin-badge">관리자</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 사용자 메뉴 */}
|
||||
<div className="user-menu-container">
|
||||
<button
|
||||
className="user-menu-trigger"
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
>
|
||||
<div className="user-avatar">
|
||||
{user?.name?.charAt(0) || '👤'}
|
||||
</div>
|
||||
<div className="user-info">
|
||||
<span className="user-name">{user?.name}</span>
|
||||
<span className="user-role">
|
||||
{getRoleDisplayName(user?.role)} · {getAccessLevelDisplayName(user?.access_level)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="dropdown-arrow">▼</span>
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="user-dropdown">
|
||||
<div className="user-dropdown-header">
|
||||
<div className="user-avatar-large">
|
||||
{user?.name?.charAt(0) || '👤'}
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<div className="user-name">{user?.name}</div>
|
||||
<div className="user-username">@{user?.username}</div>
|
||||
<div className="user-email">{user?.email}</div>
|
||||
<div className="user-meta">
|
||||
<span className="role-badge role-{user?.role}">
|
||||
{getRoleDisplayName(user?.role)}
|
||||
</span>
|
||||
<span className="access-badge access-{user?.access_level}">
|
||||
{getAccessLevelDisplayName(user?.access_level)}
|
||||
</span>
|
||||
</div>
|
||||
{user?.department && (
|
||||
<div className="user-department">{user.department}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-dropdown-menu">
|
||||
<button className="dropdown-item">
|
||||
<span className="item-icon">👤</span>
|
||||
프로필 설정
|
||||
</button>
|
||||
<button className="dropdown-item">
|
||||
<span className="item-icon">🔐</span>
|
||||
비밀번호 변경
|
||||
</button>
|
||||
<button className="dropdown-item">
|
||||
<span className="item-icon">🔔</span>
|
||||
알림 설정
|
||||
</button>
|
||||
<div className="dropdown-divider"></div>
|
||||
<button
|
||||
className="dropdown-item logout-item"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<span className="item-icon">🚪</span>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="user-dropdown-footer">
|
||||
<div className="permissions-info">
|
||||
<span className="permissions-label">권한:</span>
|
||||
<div className="permissions-list">
|
||||
{user?.permissions?.slice(0, 3).map(permission => (
|
||||
<span key={permission} className="permission-tag">
|
||||
{permission}
|
||||
</span>
|
||||
))}
|
||||
{user?.permissions?.length > 3 && (
|
||||
<span className="permission-more">
|
||||
+{user.permissions.length - 3}개 더
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 오버레이 */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="mobile-overlay"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationBar;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
272
tkeg/web/src/components/NavigationMenu.css
Normal file
272
tkeg/web/src/components/NavigationMenu.css
Normal file
@@ -0,0 +1,272 @@
|
||||
/* 네비게이션 메뉴 스타일 */
|
||||
.navigation-menu {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 모바일 햄버거 버튼 */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle span {
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
background: #4a5568;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 메뉴 오버레이 (모바일) */
|
||||
.menu-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* 사이드바 */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* 사이드바 헤더 */
|
||||
.sidebar-header {
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.logo-text h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logo-text span {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 메뉴 섹션 */
|
||||
.menu-section {
|
||||
flex: 1;
|
||||
padding: 20px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.menu-section-title {
|
||||
padding: 0 20px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #718096;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.menu-button:hover {
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.menu-button.active {
|
||||
background: #edf2f7;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-button.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 18px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #667eea;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 사이드바 푸터 */
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.menu-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 데스크톱에서 사이드바가 있을 때 메인 콘텐츠 여백 */
|
||||
@media (min-width: 769px) {
|
||||
.main-content-with-sidebar {
|
||||
margin-left: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일링 */
|
||||
.menu-section::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.menu-section::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.menu-section::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.menu-section::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
196
tkeg/web/src/components/NavigationMenu.jsx
Normal file
196
tkeg/web/src/components/NavigationMenu.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useState } from 'react';
|
||||
import './NavigationMenu.css';
|
||||
|
||||
const NavigationMenu = ({ user, currentPage, onPageChange }) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// 권한별 메뉴 정의
|
||||
const getMenuItems = () => {
|
||||
const baseMenus = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
title: '대시보드',
|
||||
icon: '🏠',
|
||||
description: '시스템 현황 및 개요',
|
||||
requiredPermission: null // 모든 사용자
|
||||
}
|
||||
];
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'projects',
|
||||
title: '프로젝트 관리',
|
||||
icon: '📋',
|
||||
description: '프로젝트 등록 및 관리',
|
||||
requiredPermission: 'project_management'
|
||||
},
|
||||
{
|
||||
id: 'bom',
|
||||
title: 'BOM 관리',
|
||||
icon: '🔧',
|
||||
description: 'Bill of Materials 관리',
|
||||
requiredPermission: 'bom_management'
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
title: '자재 관리',
|
||||
icon: '📦',
|
||||
description: '자재 정보 및 재고 관리',
|
||||
requiredPermission: 'material_management'
|
||||
},
|
||||
{
|
||||
id: 'quotes',
|
||||
title: '견적 관리',
|
||||
icon: '💰',
|
||||
description: '견적서 작성 및 관리',
|
||||
requiredPermission: 'quote_management'
|
||||
},
|
||||
{
|
||||
id: 'procurement',
|
||||
title: '구매 관리',
|
||||
icon: '🛒',
|
||||
description: '구매 요청 및 발주 관리',
|
||||
requiredPermission: 'procurement_management'
|
||||
},
|
||||
{
|
||||
id: 'production',
|
||||
title: '생산 관리',
|
||||
icon: '🏭',
|
||||
description: '생산 계획 및 진행 관리',
|
||||
requiredPermission: 'production_management'
|
||||
},
|
||||
{
|
||||
id: 'shipment',
|
||||
title: '출하 관리',
|
||||
icon: '🚚',
|
||||
description: '출하 계획 및 배송 관리',
|
||||
requiredPermission: 'shipment_management'
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
title: '사용자 관리',
|
||||
icon: '👥',
|
||||
description: '사용자 계정 및 권한 관리',
|
||||
requiredPermission: 'user_management'
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
title: '시스템 설정',
|
||||
icon: '⚙️',
|
||||
description: '시스템 환경 설정',
|
||||
requiredPermission: 'system_admin'
|
||||
}
|
||||
];
|
||||
|
||||
// 사용자 권한에 따라 메뉴 필터링
|
||||
const userPermissions = user?.permissions || [];
|
||||
const filteredMenus = menuItems.filter(menu =>
|
||||
!menu.requiredPermission ||
|
||||
userPermissions.includes(menu.requiredPermission) ||
|
||||
user?.role === 'admin' // 관리자는 모든 메뉴 접근 가능
|
||||
);
|
||||
|
||||
return [...baseMenus, ...filteredMenus];
|
||||
};
|
||||
|
||||
const menuItems = getMenuItems();
|
||||
|
||||
const handleMenuClick = (menuId) => {
|
||||
onPageChange(menuId);
|
||||
setIsMenuOpen(false); // 모바일에서 메뉴 닫기
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="navigation-menu">
|
||||
{/* 모바일 햄버거 버튼 */}
|
||||
<button
|
||||
className="mobile-menu-toggle"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
aria-label="메뉴 토글"
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
{/* 메뉴 오버레이 (모바일) */}
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
className="menu-overlay"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 사이드바 메뉴 */}
|
||||
<nav className={`sidebar ${isMenuOpen ? 'open' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
<span className="logo-icon">🚀</span>
|
||||
<div className="logo-text">
|
||||
<h2>TK-MP</h2>
|
||||
<span>통합 관리 시스템</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="menu-section">
|
||||
<div className="menu-section-title">메인 메뉴</div>
|
||||
<ul className="menu-list">
|
||||
{menuItems.map(item => (
|
||||
<li key={item.id} className="menu-item">
|
||||
<button
|
||||
className={`menu-button ${currentPage === item.id ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(item.id)}
|
||||
title={item.description}
|
||||
>
|
||||
<span className="menu-icon">{item.icon}</span>
|
||||
<span className="menu-title">{item.title}</span>
|
||||
{currentPage === item.id && (
|
||||
<span className="active-indicator"></span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-info">
|
||||
<div className="user-avatar">
|
||||
{user?.name?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<div className="user-name">{user?.name}</div>
|
||||
<div className="user-role">{user?.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationMenu;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
482
tkeg/web/src/components/PersonalizedDashboard.jsx
Normal file
482
tkeg/web/src/components/PersonalizedDashboard.jsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
const PersonalizedDashboard = () => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [dashboardData, setDashboardData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [recentActivities, setRecentActivities] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUserData();
|
||||
loadDashboardData();
|
||||
loadRecentActivities();
|
||||
}, []);
|
||||
|
||||
const loadUserData = () => {
|
||||
const userData = localStorage.getItem('user_data');
|
||||
if (userData) {
|
||||
setUser(JSON.parse(userData));
|
||||
}
|
||||
};
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
// 실제 API에서 대시보드 데이터 로드
|
||||
const response = await api.get('/dashboard/stats');
|
||||
if (response.data && response.data.success) {
|
||||
// API 데이터와 목 데이터를 병합 (quickActions 등 누락된 필드 보완)
|
||||
const mockData = generateMockDataByRole();
|
||||
const mergedData = {
|
||||
...mockData,
|
||||
...response.data.stats,
|
||||
// quickActions가 없으면 목 데이터의 것을 사용
|
||||
quickActions: response.data.stats.quickActions || mockData?.quickActions || []
|
||||
};
|
||||
setDashboardData(mergedData);
|
||||
} else {
|
||||
// API 실패 시 목 데이터 사용
|
||||
console.log('대시보드 API 응답이 없어 목 데이터를 사용합니다.');
|
||||
const mockData = generateMockDataByRole();
|
||||
setDashboardData(mockData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('대시보드 API가 구현되지 않아 목 데이터를 사용합니다:', error.response?.status);
|
||||
// 에러 시 목 데이터 사용
|
||||
const mockData = generateMockDataByRole();
|
||||
setDashboardData(mockData);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecentActivities = async () => {
|
||||
try {
|
||||
// 실제 API에서 활동 이력 로드
|
||||
const response = await api.get('/dashboard/activities?limit=5');
|
||||
if (response.data.success && response.data.activities.length > 0) {
|
||||
setRecentActivities(response.data.activities);
|
||||
} else {
|
||||
// API 실패 시 목 데이터 사용
|
||||
const mockActivities = generateMockActivitiesByRole();
|
||||
setRecentActivities(mockActivities);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('활동 이력 API가 구현되지 않아 목 데이터를 사용합니다:', error.response?.status);
|
||||
// 에러 시 목 데이터 사용
|
||||
const mockActivities = generateMockActivitiesByRole();
|
||||
setRecentActivities(mockActivities);
|
||||
}
|
||||
};
|
||||
|
||||
const generateMockDataByRole = () => {
|
||||
if (!user) return null;
|
||||
|
||||
const baseData = {
|
||||
admin: {
|
||||
title: "시스템 관리자",
|
||||
subtitle: "전체 시스템을 관리하고 모니터링합니다",
|
||||
metrics: [
|
||||
{ label: "전체 프로젝트 수", value: 45, icon: "📋", color: "#667eea" },
|
||||
{ label: "활성 사용자 수", value: 12, icon: "👥", color: "#48bb78" },
|
||||
{ label: "시스템 상태", value: "정상", icon: "🟢", color: "#38b2ac" },
|
||||
{ label: "오늘 업로드", value: 8, icon: "📤", color: "#ed8936" }
|
||||
],
|
||||
quickActions: [
|
||||
{ title: "사용자 관리", icon: "👤", path: "/admin/users", color: "#667eea" },
|
||||
{ title: "시스템 설정", icon: "⚙️", path: "/admin/settings", color: "#48bb78" },
|
||||
{ title: "백업 관리", icon: "💾", path: "/admin/backup", color: "#ed8936" },
|
||||
{ title: "활동 로그", icon: "📊", path: "/admin/logs", color: "#9f7aea" }
|
||||
]
|
||||
},
|
||||
manager: {
|
||||
title: "프로젝트 매니저",
|
||||
subtitle: "팀 프로젝트를 관리하고 진행상황을 모니터링합니다",
|
||||
metrics: [
|
||||
{ label: "담당 프로젝트", value: 8, icon: "📋", color: "#667eea" },
|
||||
{ label: "팀 진행률", value: "87%", icon: "📈", color: "#48bb78" },
|
||||
{ label: "승인 대기", value: 3, icon: "⏳", color: "#ed8936" },
|
||||
{ label: "이번 주 완료", value: 5, icon: "✅", color: "#38b2ac" }
|
||||
],
|
||||
quickActions: [
|
||||
{ title: "프로젝트 생성", icon: "➕", path: "/projects/new", color: "#667eea" },
|
||||
{ title: "팀 관리", icon: "👥", path: "/team", color: "#48bb78" },
|
||||
{ title: "진행 상황", icon: "📊", path: "/progress", color: "#38b2ac" },
|
||||
{ title: "승인 처리", icon: "✅", path: "/approvals", color: "#ed8936" }
|
||||
]
|
||||
},
|
||||
designer: {
|
||||
title: "설계 담당자",
|
||||
subtitle: "BOM 파일을 관리하고 자재를 분류합니다",
|
||||
metrics: [
|
||||
{ label: "내 BOM 파일", value: 15, icon: "📄", color: "#667eea" },
|
||||
{ label: "분류 완료율", value: "92%", icon: "🎯", color: "#48bb78" },
|
||||
{ label: "검증 대기", value: 7, icon: "⏳", color: "#ed8936" },
|
||||
{ label: "이번 주 업로드", value: 12, icon: "📤", color: "#9f7aea" }
|
||||
],
|
||||
quickActions: [
|
||||
{ title: "BOM 업로드", icon: "📤", path: "/upload", color: "#667eea" },
|
||||
{ title: "자재 분류", icon: "🔧", path: "/materials", color: "#48bb78" },
|
||||
{ title: "리비전 관리", icon: "🔄", path: "/revisions", color: "#38b2ac" },
|
||||
{ title: "분류 검증", icon: "✅", path: "/verify", color: "#ed8936" }
|
||||
]
|
||||
},
|
||||
purchaser: {
|
||||
title: "구매 담당자",
|
||||
subtitle: "구매 요청을 처리하고 발주를 관리합니다",
|
||||
metrics: [
|
||||
{ label: "구매 요청", value: 23, icon: "🛒", color: "#667eea" },
|
||||
{ label: "발주 완료", value: 18, icon: "✅", color: "#48bb78" },
|
||||
{ label: "입고 대기", value: 5, icon: "📦", color: "#ed8936" },
|
||||
{ label: "이번 달 금액", value: "₩2.3M", icon: "💰", color: "#9f7aea" }
|
||||
],
|
||||
quickActions: [
|
||||
{ title: "구매 확정", icon: "🛒", path: "/purchase", color: "#667eea" },
|
||||
{ title: "발주 관리", icon: "📋", path: "/orders", color: "#48bb78" },
|
||||
{ title: "공급업체", icon: "🏢", path: "/suppliers", color: "#38b2ac" },
|
||||
{ title: "입고 처리", icon: "📦", path: "/receiving", color: "#ed8936" }
|
||||
]
|
||||
},
|
||||
user: {
|
||||
title: "일반 사용자",
|
||||
subtitle: "할당된 업무를 수행하고 프로젝트에 참여합니다",
|
||||
metrics: [
|
||||
{ label: "내 업무", value: 6, icon: "📋", color: "#667eea" },
|
||||
{ label: "완료율", value: "75%", icon: "📈", color: "#48bb78" },
|
||||
{ label: "대기 중", value: 2, icon: "⏳", color: "#ed8936" },
|
||||
{ label: "이번 주 활동", value: 12, icon: "🎯", color: "#9f7aea" }
|
||||
],
|
||||
quickActions: [
|
||||
{ title: "내 업무", icon: "📋", path: "/my-tasks", color: "#667eea" },
|
||||
{ title: "프로젝트 보기", icon: "👁️", path: "/projects", color: "#48bb78" },
|
||||
{ title: "리포트 다운로드", icon: "📊", path: "/reports", color: "#38b2ac" },
|
||||
{ title: "도움말", icon: "❓", path: "/help", color: "#9f7aea" }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return baseData[user.role] || baseData.user;
|
||||
};
|
||||
|
||||
const generateMockActivitiesByRole = () => {
|
||||
if (!user) return [];
|
||||
|
||||
const activities = {
|
||||
admin: [
|
||||
{ type: "system", message: "새 사용자 3명이 등록되었습니다", time: "30분 전", icon: "👥" },
|
||||
{ type: "backup", message: "일일 백업이 완료되었습니다", time: "2시간 전", icon: "💾" },
|
||||
{ type: "alert", message: "시스템 리소스 사용률 85%", time: "4시간 전", icon: "⚠️" },
|
||||
{ type: "update", message: "데이터베이스 인덱스가 최적화되었습니다", time: "6시간 전", icon: "🔧" }
|
||||
],
|
||||
manager: [
|
||||
{ type: "approval", message: "냉동기 프로젝트 구매 승인 완료", time: "1시간 전", icon: "✅" },
|
||||
{ type: "meeting", message: "주간 팀 미팅 일정이 등록되었습니다", time: "3시간 전", icon: "📅" },
|
||||
{ type: "progress", message: "BOG 시스템 프로젝트 90% 진행", time: "5시간 전", icon: "📈" },
|
||||
{ type: "task", message: "김설계님에게 새 업무가 할당되었습니다", time: "1일 전", icon: "👤" }
|
||||
],
|
||||
designer: [
|
||||
{ type: "upload", message: "다이아프램 펌프 BOM 파일을 업로드했습니다", time: "45분 전", icon: "📤" },
|
||||
{ type: "classify", message: "스테인리스 파이프 127개 자재 분류 완료", time: "2시간 전", icon: "🔧" },
|
||||
{ type: "revision", message: "드라이어 시스템 Rev.2 업데이트", time: "4시간 전", icon: "🔄" },
|
||||
{ type: "verify", message: "볼트 분류 검증 5건 완료", time: "1일 전", icon: "✅" }
|
||||
],
|
||||
purchaser: [
|
||||
{ type: "purchase", message: "스테인리스 파이프 구매 확정", time: "20분 전", icon: "🛒" },
|
||||
{ type: "order", message: "ABC 공급업체에 발주서 전송", time: "1시간 전", icon: "📋" },
|
||||
{ type: "receive", message: "밸브 15개 입고 처리 완료", time: "3시간 전", icon: "📦" },
|
||||
{ type: "quote", message: "새 견적서 3건 접수", time: "5시간 전", icon: "💰" }
|
||||
],
|
||||
user: [
|
||||
{ type: "task", message: "자재 검증 업무 2건 완료", time: "1시간 전", icon: "✅" },
|
||||
{ type: "view", message: "냉동기 프로젝트 진행상황 확인", time: "3시간 전", icon: "👁️" },
|
||||
{ type: "download", message: "월간 리포트 다운로드", time: "6시간 전", icon: "📊" },
|
||||
{ type: "help", message: "도움말 페이지 방문", time: "1일 전", icon: "❓" }
|
||||
]
|
||||
};
|
||||
|
||||
return activities[user.role] || activities.user;
|
||||
};
|
||||
|
||||
const handleQuickAction = (action) => {
|
||||
// 실제 네비게이션 구현 (향후)
|
||||
console.log(`네비게이션: ${action.path}`);
|
||||
alert(`${action.title} 기능은 곧 구현될 예정입니다.`);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('user_data');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (loading || !user || !dashboardData) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f7fafc'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⏳</div>
|
||||
<div>대시보드를 불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: '#f7fafc',
|
||||
fontFamily: 'Arial, sans-serif'
|
||||
}}>
|
||||
{/* 네비게이션 바 */}
|
||||
<nav style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: '16px 24px',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '24px' }}>🚀</span>
|
||||
<div>
|
||||
<h1 style={{ margin: '0', fontSize: '20px', fontWeight: '700' }}>TK-MP System</h1>
|
||||
<span style={{ fontSize: '12px', opacity: '0.9' }}>통합 프로젝트 관리</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600' }}>{user.name || user.username}</div>
|
||||
<div style={{ fontSize: '12px', opacity: '0.9' }}>
|
||||
{dashboardData.title}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<main style={{ padding: '32px 24px' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 개인별 맞춤 배너 */}
|
||||
<div style={{
|
||||
background: `linear-gradient(135deg, ${dashboardData.metrics[0].color}20 0%, ${dashboardData.metrics[1].color}20 100%)`,
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
marginBottom: '32px',
|
||||
border: `1px solid ${dashboardData.metrics[0].color}40`
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '48px' }}>
|
||||
{user.role === 'admin' ? '👑' :
|
||||
user.role === 'manager' ? '👨💼' :
|
||||
user.role === 'designer' ? '🎨' :
|
||||
user.role === 'purchaser' ? '🛒' : '👤'}
|
||||
</div>
|
||||
<div>
|
||||
<h2 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
안녕하세요, {user.name || user.username}님! 👋
|
||||
</h2>
|
||||
<p style={{
|
||||
margin: '0',
|
||||
fontSize: '16px',
|
||||
color: '#4a5568',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{dashboardData.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 핵심 지표 카드들 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||
gap: '24px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
{(dashboardData.metrics || []).map((metric, index) => (
|
||||
<div key={index} style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096',
|
||||
marginBottom: '8px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{metric.label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: '700',
|
||||
color: metric.color
|
||||
}}>
|
||||
{metric.value}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '32px',
|
||||
opacity: 0.8
|
||||
}}>
|
||||
{metric.icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
|
||||
gap: '24px'
|
||||
}}>
|
||||
{/* 빠른 작업 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
⚡ 빠른 작업
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{(dashboardData.quickActions || []).map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleQuickAction(action)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 16px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568',
|
||||
textAlign: 'left'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = `${action.color}10`;
|
||||
e.target.style.borderColor = action.color;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'transparent';
|
||||
e.target.style.borderColor = '#e2e8f0';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{action.icon}</span>
|
||||
<span>{action.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 활동 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
📈 최근 활동
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{recentActivities.map((activity, index) => (
|
||||
<div key={index} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: '#f7fafc',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<span style={{ fontSize: '16px' }}>
|
||||
{activity.icon}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#2d3748',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{activity.message}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{activity.time}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonalizedDashboard;
|
||||
99
tkeg/web/src/components/PipeDetailsCard.jsx
Normal file
99
tkeg/web/src/components/PipeDetailsCard.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box, Grid, Chip, Divider } from '@mui/material';
|
||||
|
||||
const PipeDetailsCard = ({ material }) => {
|
||||
const pipeDetails = material.pipe_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' }}>
|
||||
🔧 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipeDetailsCard;
|
||||
518
tkeg/web/src/components/ProjectManager.jsx
Normal file
518
tkeg/web/src/components/ProjectManager.jsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Snackbar,
|
||||
IconButton,
|
||||
Chip,
|
||||
Grid,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
Divider,
|
||||
Menu,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Assignment,
|
||||
Edit,
|
||||
Delete,
|
||||
MoreVert,
|
||||
Visibility,
|
||||
CheckCircle,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
import { createJob, updateProject, deleteProject } from '../api';
|
||||
import Toast from './Toast';
|
||||
|
||||
function ProjectManager({ projects, selectedProject, setSelectedProject, onProjectsChange }) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState(null);
|
||||
const [projectCode, setProjectCode] = useState('');
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||
const [selectedProjectForMenu, setSelectedProjectForMenu] = useState(null);
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!projectCode.trim() || !projectName.trim()) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트 코드와 이름을 모두 입력해주세요.',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const data = {
|
||||
official_project_code: projectCode.trim(),
|
||||
project_name: projectName.trim(),
|
||||
design_project_code: projectCode.trim(),
|
||||
is_code_matched: true,
|
||||
status: 'active'
|
||||
};
|
||||
const response = await createJob(data);
|
||||
const result = response.data;
|
||||
if (result && result.job) {
|
||||
onProjectsChange();
|
||||
setSelectedProject(result.job);
|
||||
setDialogOpen(false);
|
||||
setProjectCode('');
|
||||
setProjectName('');
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트가 성공적으로 생성되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: result.message || '프로젝트 생성에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 생성 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '네트워크 오류가 발생했습니다. 백엔드 서버가 실행 중인지 확인해주세요.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditProject = async () => {
|
||||
if (!editingProject || !editingProject.project_name.trim()) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트명을 입력해주세요.',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await updateProject(editingProject.id, {
|
||||
project_name: editingProject.project_name.trim(),
|
||||
status: editingProject.status
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
onProjectsChange();
|
||||
setEditDialogOpen(false);
|
||||
setEditingProject(null);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트가 성공적으로 수정되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: response.data?.message || '프로젝트 수정에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 수정 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트 수정 중 오류가 발생했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async (project) => {
|
||||
if (!window.confirm(`정말로 프로젝트 "${project.project_name}"을 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await deleteProject(project.id);
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
onProjectsChange();
|
||||
if (selectedProject?.id === project.id) {
|
||||
setSelectedProject(null);
|
||||
}
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트가 성공적으로 삭제되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: response.data?.message || '프로젝트 삭제에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 삭제 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트 삭제 중 오류가 발생했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMenu = (event, project) => {
|
||||
setMenuAnchor(event.currentTarget);
|
||||
setSelectedProjectForMenu(project);
|
||||
};
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setMenuAnchor(null);
|
||||
setSelectedProjectForMenu(null);
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
setEditingProject({ ...selectedProjectForMenu });
|
||||
setEditDialogOpen(true);
|
||||
handleCloseMenu();
|
||||
};
|
||||
|
||||
const handleDetailClick = () => {
|
||||
setDetailDialogOpen(true);
|
||||
handleCloseMenu();
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setProjectCode('');
|
||||
setProjectName('');
|
||||
};
|
||||
|
||||
const handleCloseEditDialog = () => {
|
||||
setEditDialogOpen(false);
|
||||
setEditingProject(null);
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'inactive': return 'warning';
|
||||
case 'completed': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'active': return <CheckCircle />;
|
||||
case 'inactive': return <Warning />;
|
||||
default: return <Assignment />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h4">
|
||||
🗂️ 프로젝트 관리
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
새 프로젝트
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Assignment sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
프로젝트가 없습니다
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
|
||||
새 프로젝트를 생성하여 시작하세요!
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
첫 번째 프로젝트 생성
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{projects.map((project) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={project.id}>
|
||||
<Card
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: selectedProject?.id === project.id ? 2 : 1,
|
||||
borderColor: selectedProject?.id === project.id ? 'primary.main' : 'divider',
|
||||
'&:hover': {
|
||||
boxShadow: 3,
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
onClick={() => setSelectedProject(project)}
|
||||
>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{project.project_name || project.official_project_code}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="primary" sx={{ mb: 1 }}>
|
||||
코드: {project.official_project_code}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={project.status}
|
||||
size="small"
|
||||
color={getStatusColor(project.status)}
|
||||
icon={getStatusIcon(project.status)}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
생성일: {new Date(project.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenMenu(e, project);
|
||||
}}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 프로젝트 메뉴 */}
|
||||
<Menu
|
||||
anchorEl={menuAnchor}
|
||||
open={Boolean(menuAnchor)}
|
||||
onClose={handleCloseMenu}
|
||||
>
|
||||
<MenuItem onClick={handleDetailClick}>
|
||||
<Visibility sx={{ mr: 1 }} />
|
||||
상세 보기
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleEditClick}>
|
||||
<Edit sx={{ mr: 1 }} />
|
||||
수정
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleDeleteProject(selectedProjectForMenu);
|
||||
handleCloseMenu();
|
||||
}}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<Delete sx={{ mr: 1 }} />
|
||||
삭제
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* 새 프로젝트 생성 다이얼로그 */}
|
||||
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>새 프로젝트 생성</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="프로젝트 코드"
|
||||
placeholder="예: MP7-PIPING-R3"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={projectCode}
|
||||
onChange={(e) => setProjectCode(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="프로젝트명"
|
||||
placeholder="예: MP7 PIPING PROJECT Rev.3"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateProject}
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{loading ? '생성 중...' : '생성'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 프로젝트 수정 다이얼로그 */}
|
||||
<Dialog open={editDialogOpen} onClose={handleCloseEditDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>프로젝트 수정</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="프로젝트 코드"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={editingProject?.official_project_code || ''}
|
||||
disabled
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="프로젝트명"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={editingProject?.project_name || ''}
|
||||
onChange={(e) => setEditingProject({
|
||||
...editingProject,
|
||||
project_name: e.target.value
|
||||
})}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
margin="dense"
|
||||
label="상태"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={editingProject?.status || 'active'}
|
||||
onChange={(e) => setEditingProject({
|
||||
...editingProject,
|
||||
status: e.target.value
|
||||
})}
|
||||
>
|
||||
<MenuItem key="active" value="active">활성</MenuItem>
|
||||
<MenuItem key="inactive" value="inactive">비활성</MenuItem>
|
||||
<MenuItem key="completed" value="completed">완료</MenuItem>
|
||||
</TextField>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseEditDialog} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEditProject}
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{loading ? '수정 중...' : '수정'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 프로젝트 상세 보기 다이얼로그 */}
|
||||
<Dialog open={detailDialogOpen} onClose={() => setDetailDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>프로젝트 상세 정보</DialogTitle>
|
||||
<DialogContent>
|
||||
{selectedProjectForMenu && (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">프로젝트 코드</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{selectedProjectForMenu.official_project_code}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">프로젝트명</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{selectedProjectForMenu.project_name}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">상태</Typography>
|
||||
<Chip
|
||||
label={selectedProjectForMenu.status}
|
||||
color={getStatusColor(selectedProjectForMenu.status)}
|
||||
icon={getStatusIcon(selectedProjectForMenu.status)}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">생성일</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{new Date(selectedProjectForMenu.created_at).toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">설계 프로젝트 코드</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{selectedProjectForMenu.design_project_code || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">코드 매칭</Typography>
|
||||
<Chip
|
||||
label={selectedProjectForMenu.is_code_matched ? '매칭됨' : '매칭 안됨'}
|
||||
color={selectedProjectForMenu.is_code_matched ? 'success' : 'warning'}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDetailDialogOpen(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectManager;
|
||||
319
tkeg/web/src/components/ProjectSelector.jsx
Normal file
319
tkeg/web/src/components/ProjectSelector.jsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
const ProjectSelector = ({ onProjectSelect, selectedProject }) => {
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await api.get('/jobs/');
|
||||
console.log('프로젝트 API 응답:', response.data);
|
||||
|
||||
// API 응답 구조에 맞게 처리
|
||||
let projectsData = [];
|
||||
if (response.data && response.data.success && Array.isArray(response.data.jobs)) {
|
||||
// 실제 API 데이터를 프론트엔드 형식에 맞게 변환
|
||||
projectsData = response.data.jobs.map(job => ({
|
||||
job_no: job.job_no,
|
||||
project_name: job.project_name || job.job_name,
|
||||
status: job.status === '진행중' ? 'active' : 'completed',
|
||||
progress: job.status === '진행중' ? 75 : 100, // 임시 진행률
|
||||
client_name: job.client_name,
|
||||
project_site: job.project_site,
|
||||
delivery_date: job.delivery_date
|
||||
}));
|
||||
}
|
||||
|
||||
// 데이터가 없으면 목 데이터 사용
|
||||
if (projectsData.length === 0) {
|
||||
projectsData = [
|
||||
{ job_no: 'TK-2024-001', project_name: '냉동기 시스템', status: 'active', progress: 75 },
|
||||
{ job_no: 'TK-2024-002', project_name: 'BOG 처리 시스템', status: 'active', progress: 45 },
|
||||
{ job_no: 'TK-2024-003', project_name: '다이아프램 펌프', status: 'active', progress: 90 },
|
||||
{ job_no: 'TK-2024-004', project_name: '드라이어 시스템', status: 'completed', progress: 100 },
|
||||
{ job_no: 'TK-2024-005', project_name: '열교환기 시스템', status: 'active', progress: 30 }
|
||||
];
|
||||
}
|
||||
|
||||
setProjects(projectsData);
|
||||
} catch (error) {
|
||||
console.error('프로젝트 목록 로딩 실패:', error);
|
||||
// 목 데이터 사용
|
||||
const mockProjects = [
|
||||
{ job_no: 'TK-2024-001', project_name: '냉동기 시스템', status: 'active', progress: 75 },
|
||||
{ job_no: 'TK-2024-002', project_name: 'BOG 처리 시스템', status: 'active', progress: 45 },
|
||||
{ job_no: 'TK-2024-003', project_name: '다이아프램 펌프', status: 'active', progress: 90 },
|
||||
{ job_no: 'TK-2024-004', project_name: '드라이어 시스템', status: 'completed', progress: 100 },
|
||||
{ job_no: 'TK-2024-005', project_name: '열교환기 시스템', status: 'active', progress: 30 }
|
||||
];
|
||||
setProjects(mockProjects);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProjects = projects.filter(project =>
|
||||
project.project_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
project.job_no.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
'active': '#48bb78',
|
||||
'completed': '#38b2ac',
|
||||
'on_hold': '#ed8936',
|
||||
'cancelled': '#e53e3e'
|
||||
};
|
||||
return colors[status] || '#718096';
|
||||
};
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
'active': '진행중',
|
||||
'completed': '완료',
|
||||
'on_hold': '보류',
|
||||
'cancelled': '취소'
|
||||
};
|
||||
return texts[status] || '알 수 없음';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: '#666'
|
||||
}}>
|
||||
프로젝트 목록을 불러오는 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
{/* 선택된 프로젝트 표시 또는 선택 버튼 */}
|
||||
<div
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
background: selectedProject ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'white',
|
||||
color: selectedProject ? 'white' : '#2d3748',
|
||||
border: selectedProject ? 'none' : '2px dashed #cbd5e0',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
boxShadow: selectedProject ? '0 4px 12px rgba(102, 126, 234, 0.3)' : '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!selectedProject) {
|
||||
e.target.style.borderColor = '#667eea';
|
||||
e.target.style.backgroundColor = '#f7fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!selectedProject) {
|
||||
e.target.style.borderColor = '#cbd5e0';
|
||||
e.target.style.backgroundColor = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{selectedProject ? (
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '4px' }}>
|
||||
{selectedProject.project_name}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', opacity: '0.9' }}>
|
||||
{selectedProject.job_no} • {getStatusText(selectedProject.status)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '4px' }}>
|
||||
🎯 프로젝트를 선택하세요
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#718096' }}>
|
||||
작업할 프로젝트를 선택하면 관련 업무를 시작할 수 있습니다
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '20px' }}>
|
||||
{showDropdown ? '🔼' : '🔽'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 드롭다운 메뉴 */}
|
||||
{showDropdown && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginTop: '8px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.15)',
|
||||
border: '1px solid #e2e8f0',
|
||||
zIndex: 1000,
|
||||
maxHeight: '400px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* 검색 입력 */}
|
||||
<div style={{ padding: '16px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="프로젝트 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #cbd5e0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#667eea'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#cbd5e0'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 목록 */}
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{filteredProjects.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: '#718096'
|
||||
}}>
|
||||
검색 결과가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
filteredProjects.map((project) => (
|
||||
<div
|
||||
key={project.job_no}
|
||||
onClick={() => {
|
||||
onProjectSelect(project);
|
||||
setShowDropdown(false);
|
||||
setSearchTerm('');
|
||||
}}
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid #f7fafc',
|
||||
transition: 'background-color 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.backgroundColor = '#f7fafc';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{project.project_name}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
{project.job_no}
|
||||
</div>
|
||||
|
||||
{/* 진행률 바 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: '4px',
|
||||
backgroundColor: '#e2e8f0',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${project.progress || 0}%`,
|
||||
height: '100%',
|
||||
backgroundColor: getStatusColor(project.status),
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#718096',
|
||||
minWidth: '35px'
|
||||
}}>
|
||||
{project.progress || 0}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginLeft: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end'
|
||||
}}>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: `${getStatusColor(project.status)}20`,
|
||||
color: getStatusColor(project.status),
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{getStatusText(project.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 드롭다운 외부 클릭 시 닫기 */}
|
||||
{showDropdown && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 999
|
||||
}}
|
||||
onClick={() => setShowDropdown(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSelector;
|
||||
159
tkeg/web/src/components/ProtectedRoute.jsx
Normal file
159
tkeg/web/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import LoginPage from '../pages/LoginPage';
|
||||
|
||||
const ProtectedRoute = ({
|
||||
children,
|
||||
requiredPermission = null,
|
||||
requiredRole = null,
|
||||
fallback = null
|
||||
}) => {
|
||||
const { isAuthenticated, isLoading, user, hasPermission, hasRole } = useAuth();
|
||||
|
||||
// 로딩 중일 때
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading-container" style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
background: '#f7fafc',
|
||||
color: '#718096'
|
||||
}}>
|
||||
<div className="loading-spinner-large" style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
border: '4px solid #e2e8f0',
|
||||
borderTop: '4px solid #667eea',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginBottom: '16px'
|
||||
}}></div>
|
||||
<p>인증 정보를 확인하는 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 인증되지 않은 경우
|
||||
if (!isAuthenticated) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
// 특정 권한이 필요한 경우
|
||||
if (requiredPermission && !hasPermission(requiredPermission)) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">🔒</div>
|
||||
<h2>접근 권한이 없습니다</h2>
|
||||
<p>이 페이지에 접근하기 위한 권한이 없습니다.</p>
|
||||
<p className="permission-info">
|
||||
필요한 권한: <code>{requiredPermission}</code>
|
||||
</p>
|
||||
<div className="user-info">
|
||||
<p>현재 사용자: <strong>{user?.name}</strong> ({user?.username})</p>
|
||||
<p>역할: <strong>{user?.role}</strong></p>
|
||||
<p>접근 레벨: <strong>{user?.access_level}</strong></p>
|
||||
</div>
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
이전 페이지로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 특정 역할이 필요한 경우
|
||||
if (requiredRole && !hasRole(requiredRole)) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">👤</div>
|
||||
<h2>역할 권한이 없습니다</h2>
|
||||
<p>이 페이지에 접근하기 위한 역할 권한이 없습니다.</p>
|
||||
<p className="role-info">
|
||||
필요한 역할: <code>{requiredRole}</code>
|
||||
</p>
|
||||
<div className="user-info">
|
||||
<p>현재 사용자: <strong>{user?.name}</strong> ({user?.username})</p>
|
||||
<p>현재 역할: <strong>{user?.role}</strong></p>
|
||||
</div>
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
이전 페이지로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모든 조건을 만족하는 경우 자식 컴포넌트 렌더링
|
||||
return children;
|
||||
};
|
||||
|
||||
// 관리자 전용 라우트
|
||||
export const AdminRoute = ({ children, fallback = null }) => {
|
||||
return (
|
||||
<ProtectedRoute
|
||||
requiredRole="admin"
|
||||
fallback={fallback}
|
||||
>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
// 시스템 관리자 전용 라우트
|
||||
export const SystemRoute = ({ children, fallback = null }) => {
|
||||
const { hasRole } = useAuth();
|
||||
|
||||
if (!hasRole('admin') && !hasRole('system')) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">⚙️</div>
|
||||
<h2>시스템 관리자 권한이 필요합니다</h2>
|
||||
<p>이 페이지는 시스템 관리자만 접근할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
// 매니저 이상 권한 라우트
|
||||
export const ManagerRoute = ({ children, fallback = null }) => {
|
||||
const { isManager } = useAuth();
|
||||
|
||||
if (!isManager()) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">👔</div>
|
||||
<h2>관리자 권한이 필요합니다</h2>
|
||||
<p>이 페이지는 관리자 이상의 권한이 필요합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
104
tkeg/web/src/components/RevisionUploadDialog.jsx
Normal file
104
tkeg/web/src/components/RevisionUploadDialog.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
|
||||
const RevisionUploadDialog = ({
|
||||
revisionDialog,
|
||||
setRevisionDialog,
|
||||
revisionFile,
|
||||
setRevisionFile,
|
||||
handleRevisionUpload,
|
||||
uploading
|
||||
}) => {
|
||||
if (!revisionDialog.open) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '500px',
|
||||
width: '90%'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0' }}>
|
||||
리비전 업로드: {revisionDialog.bomName}
|
||||
</h3>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setRevisionFile(e.target.files[0])}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginBottom: '16px',
|
||||
padding: '8px'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e2e8f0',
|
||||
color: '#4a5568',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRevisionUpload}
|
||||
disabled={!revisionFile || uploading}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: (!revisionFile || uploading) ? '#e2e8f0' : '#4299e1',
|
||||
color: (!revisionFile || uploading) ? '#a0aec0' : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: (!revisionFile || uploading) ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionUploadDialog;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
323
tkeg/web/src/components/SimpleFileUpload.jsx
Normal file
323
tkeg/web/src/components/SimpleFileUpload.jsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useState } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
const SimpleFileUpload = ({ selectedProject, onUploadComplete }) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadResult, setUploadResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const handleDrag = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFileUpload(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleFileUpload(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file) => {
|
||||
if (!selectedProject) {
|
||||
setError('프로젝트를 먼저 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 유효성 검사
|
||||
const allowedTypes = ['.xlsx', '.xls', '.csv'];
|
||||
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (!allowedTypes.includes(fileExtension)) {
|
||||
setError(`지원하지 않는 파일 형식입니다. 허용된 확장자: ${allowedTypes.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('파일 크기는 10MB를 초과할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
setUploadResult(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('revision', 'Rev.0');
|
||||
|
||||
// 업로드 진행률 시뮬레이션
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => {
|
||||
if (prev >= 90) {
|
||||
clearInterval(progressInterval);
|
||||
return 90;
|
||||
}
|
||||
return prev + 10;
|
||||
});
|
||||
}, 200);
|
||||
|
||||
const response = await api.post('/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
|
||||
if (response.data.success) {
|
||||
setUploadResult({
|
||||
success: true,
|
||||
message: response.data.message,
|
||||
file: response.data.file,
|
||||
job: response.data.job,
|
||||
sampleMaterials: response.data.sample_materials || []
|
||||
});
|
||||
|
||||
// 업로드 완료 콜백 호출
|
||||
if (onUploadComplete) {
|
||||
onUploadComplete(response.data);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data.message || '업로드 실패');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('업로드 에러:', err);
|
||||
setError(err.response?.data?.detail || err.message || '파일 업로드에 실패했습니다.');
|
||||
setUploadProgress(0);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
<div
|
||||
style={{
|
||||
border: `2px dashed ${dragActive ? '#667eea' : '#e2e8f0'}`,
|
||||
borderRadius: '12px',
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
background: dragActive ? '#f7fafc' : 'white',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '20px'
|
||||
}}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('file-input').click()}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
|
||||
{uploading ? '⏳' : '📤'}
|
||||
</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '8px' }}>
|
||||
{uploading ? '업로드 중...' : 'BOM 파일을 업로드하세요'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#718096', marginBottom: '16px' }}>
|
||||
파일을 드래그하거나 클릭하여 선택하세요
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#a0aec0' }}>
|
||||
지원 형식: Excel (.xlsx, .xls), CSV (.csv) | 최대 크기: 10MB
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 업로드 진행률 */}
|
||||
{uploading && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
업로드 진행률
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', color: '#667eea' }}>
|
||||
{uploadProgress}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '8px',
|
||||
background: '#e2e8f0',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${uploadProgress}%`,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #667eea, #764ba2)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{ color: '#c53030', fontSize: '16px' }}>⚠️</span>
|
||||
<span style={{ color: '#c53030', fontSize: '14px' }}>{error}</span>
|
||||
<button
|
||||
onClick={() => setError('')}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#c53030',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 성공 결과 */}
|
||||
{uploadResult && uploadResult.success && (
|
||||
<div style={{
|
||||
background: '#c6f6d5',
|
||||
border: '1px solid #68d391',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<span style={{ color: '#2f855a', fontSize: '20px' }}>✅</span>
|
||||
<span style={{ color: '#2f855a', fontSize: '16px', fontWeight: '600' }}>
|
||||
업로드 완료!
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ color: '#2f855a', fontSize: '14px', marginBottom: '16px' }}>
|
||||
{uploadResult.message}
|
||||
</div>
|
||||
|
||||
{/* 파일 정보 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#2d3748', fontSize: '14px' }}>
|
||||
📄 파일 정보
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', fontSize: '12px' }}>
|
||||
<div><strong>파일명:</strong> {uploadResult.file?.original_filename}</div>
|
||||
<div><strong>분석된 자재:</strong> {uploadResult.file?.parsed_count}개</div>
|
||||
<div><strong>저장된 자재:</strong> {uploadResult.file?.saved_count}개</div>
|
||||
<div><strong>프로젝트:</strong> {uploadResult.job?.job_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 샘플 자재 미리보기 */}
|
||||
{uploadResult.sampleMaterials && uploadResult.sampleMaterials.length > 0 && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '16px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#2d3748', fontSize: '14px' }}>
|
||||
🔧 자재 샘플 (처음 3개)
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{uploadResult.sampleMaterials.map((material, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '8px 12px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#4a5568'
|
||||
}}>
|
||||
<strong>{material.description || material.item_code}</strong>
|
||||
{material.category && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{material.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleFileUpload;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
426
tkeg/web/src/components/SpoolManager.jsx
Normal file
426
tkeg/web/src/components/SpoolManager.jsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Build,
|
||||
CheckCircle,
|
||||
Error,
|
||||
Visibility,
|
||||
Edit,
|
||||
Delete,
|
||||
MoreVert
|
||||
} from '@mui/icons-material';
|
||||
import { fetchProjectSpools, validateSpoolIdentifier, generateSpoolIdentifier } from '../api';
|
||||
import Toast from './Toast';
|
||||
|
||||
function SpoolManager({ selectedProject }) {
|
||||
const [spools, setSpools] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
|
||||
// 스풀 생성 폼 상태
|
||||
const [newSpool, setNewSpool] = useState({
|
||||
dwg_name: '',
|
||||
area_number: '',
|
||||
spool_number: ''
|
||||
});
|
||||
|
||||
// 유효성 검증 상태
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [validating, setValidating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchSpools();
|
||||
} else {
|
||||
setSpools([]);
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
const fetchSpools = async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchProjectSpools(selectedProject.id);
|
||||
if (response.data && response.data.spools) {
|
||||
setSpools(response.data.spools);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('스풀 조회 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '스풀 데이터를 불러오는데 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSpool = async () => {
|
||||
if (!newSpool.dwg_name || !newSpool.area_number || !newSpool.spool_number) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '도면명, 에리어 번호, 스풀 번호를 모두 입력해주세요.',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await generateSpoolIdentifier(
|
||||
newSpool.dwg_name,
|
||||
newSpool.area_number,
|
||||
newSpool.spool_number
|
||||
);
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '스풀이 성공적으로 생성되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
setDialogOpen(false);
|
||||
setNewSpool({ dwg_name: '', area_number: '', spool_number: '' });
|
||||
fetchSpools(); // 목록 새로고침
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: response.data?.message || '스풀 생성에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('스풀 생성 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '스풀 생성 중 오류가 발생했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateSpool = async (identifier) => {
|
||||
setValidating(true);
|
||||
try {
|
||||
const response = await validateSpoolIdentifier(identifier);
|
||||
setValidationResult(response.data);
|
||||
setValidationDialogOpen(true);
|
||||
} catch (error) {
|
||||
console.error('스풀 유효성 검증 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '스풀 유효성 검증에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'inactive': return 'warning';
|
||||
case 'completed': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
🔧 스풀 관리
|
||||
</Typography>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Build sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
프로젝트를 선택해주세요
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
프로젝트 관리 탭에서 프로젝트를 선택하면 스풀을 관리할 수 있습니다.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h4">
|
||||
🔧 스풀 관리
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
새 스풀
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||
스풀 데이터 로딩 중...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : spools.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Build sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
스풀이 없습니다
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
|
||||
새 스풀을 생성하여 시작하세요!
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
첫 번째 스풀 생성
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">
|
||||
총 {spools.length}개 스풀
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${spools.length}개 표시 중`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell><strong>스풀 식별자</strong></TableCell>
|
||||
<TableCell><strong>도면명</strong></TableCell>
|
||||
<TableCell><strong>에리어</strong></TableCell>
|
||||
<TableCell><strong>스풀 번호</strong></TableCell>
|
||||
<TableCell align="center"><strong>자재 수</strong></TableCell>
|
||||
<TableCell align="center"><strong>총 수량</strong></TableCell>
|
||||
<TableCell><strong>상태</strong></TableCell>
|
||||
<TableCell align="center"><strong>작업</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{spools.map((spool) => (
|
||||
<TableRow
|
||||
key={spool.id}
|
||||
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{spool.spool_identifier}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{spool.dwg_name}</TableCell>
|
||||
<TableCell>{spool.area_number}</TableCell>
|
||||
<TableCell>{spool.spool_number}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={spool.material_count || 0}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={(spool.total_quantity || 0).toLocaleString()}
|
||||
size="small"
|
||||
color="success"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={spool.status || 'active'}
|
||||
size="small"
|
||||
color={getStatusColor(spool.status)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleValidateSpool(spool.spool_identifier)}
|
||||
disabled={validating}
|
||||
>
|
||||
<Visibility />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 새 스풀 생성 다이얼로그 */}
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>새 스풀 생성</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="도면명"
|
||||
placeholder="예: MP7-PIPING-001"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newSpool.dwg_name}
|
||||
onChange={(e) => setNewSpool({ ...newSpool, dwg_name: e.target.value })}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="에리어 번호"
|
||||
placeholder="예: A1"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newSpool.area_number}
|
||||
onChange={(e) => setNewSpool({ ...newSpool, area_number: e.target.value })}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="스풀 번호"
|
||||
placeholder="예: 001"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newSpool.spool_number}
|
||||
onChange={(e) => setNewSpool({ ...newSpool, spool_number: e.target.value })}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateSpool}
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{loading ? '생성 중...' : '생성'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 스풀 유효성 검증 결과 다이얼로그 */}
|
||||
<Dialog open={validationDialogOpen} onClose={() => setValidationDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>스풀 유효성 검증 결과</DialogTitle>
|
||||
<DialogContent>
|
||||
{validationResult && (
|
||||
<Box>
|
||||
<Alert
|
||||
severity={validationResult.validation.is_valid ? 'success' : 'error'}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{validationResult.validation.is_valid ? '유효한 스풀 식별자입니다.' : '유효하지 않은 스풀 식별자입니다.'}
|
||||
</Alert>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">스풀 식별자</Typography>
|
||||
<Typography variant="body1" sx={{ fontFamily: 'monospace', mb: 2 }}>
|
||||
{validationResult.spool_identifier}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">검증 시간</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{new Date(validationResult.timestamp).toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">검증 세부사항</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{validationResult.validation.details &&
|
||||
Object.entries(validationResult.validation.details).map(([key, value]) => (
|
||||
<Chip
|
||||
key={key}
|
||||
label={`${key}: ${value}`}
|
||||
size="small"
|
||||
color={value ? 'success' : 'error'}
|
||||
sx={{ mr: 1, mb: 1 }}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setValidationDialogOpen(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpoolManager;
|
||||
74
tkeg/web/src/components/Toast.jsx
Normal file
74
tkeg/web/src/components/Toast.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Snackbar, Alert, AlertTitle } from '@mui/material';
|
||||
import {
|
||||
CheckCircle,
|
||||
Error,
|
||||
Warning,
|
||||
Info
|
||||
} from '@mui/icons-material';
|
||||
|
||||
const Toast = React.memo(({
|
||||
open,
|
||||
message,
|
||||
type = 'info',
|
||||
title,
|
||||
autoHideDuration = 4000,
|
||||
onClose,
|
||||
anchorOrigin = { vertical: 'top', horizontal: 'center' }
|
||||
}) => {
|
||||
const getSeverity = () => {
|
||||
switch (type) {
|
||||
case 'success': return 'success';
|
||||
case 'error': return 'error';
|
||||
case 'warning': return 'warning';
|
||||
case 'info':
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'success': return <CheckCircle />;
|
||||
case 'error': return <Error />;
|
||||
case 'warning': return <Warning />;
|
||||
case 'info':
|
||||
default: return <Info />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={autoHideDuration}
|
||||
onClose={onClose}
|
||||
anchorOrigin={anchorOrigin}
|
||||
>
|
||||
<Alert
|
||||
onClose={onClose}
|
||||
severity={getSeverity()}
|
||||
icon={getIcon()}
|
||||
sx={{
|
||||
width: '100%',
|
||||
minWidth: 300,
|
||||
maxWidth: 600
|
||||
}}
|
||||
>
|
||||
{title && <AlertTitle>{title}</AlertTitle>}
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
});
|
||||
|
||||
Toast.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
type: PropTypes.oneOf(['success', 'error', 'warning', 'info']),
|
||||
title: PropTypes.string,
|
||||
autoHideDuration: PropTypes.number,
|
||||
onClose: PropTypes.func,
|
||||
anchorOrigin: PropTypes.object,
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
3
tkeg/web/src/components/bom/index.js
Normal file
3
tkeg/web/src/components/bom/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// BOM Components
|
||||
export * from './materials';
|
||||
export * from './shared';
|
||||
742
tkeg/web/src/components/bom/materials/BoltMaterialsView.jsx
Normal file
742
tkeg/web/src/components/bom/materials/BoltMaterialsView.jsx
Normal file
@@ -0,0 +1,742 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const BoltMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
jobNo,
|
||||
fileId,
|
||||
user
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// 볼트 추가요구사항 추출 함수
|
||||
const extractBoltAdditionalRequirements = (description) => {
|
||||
const additionalReqs = [];
|
||||
|
||||
// 표면처리 패턴 확인
|
||||
const surfacePatterns = {
|
||||
'ELEC.GALV': 'ELEC.GALV',
|
||||
'ELEC GALV': 'ELEC.GALV',
|
||||
'GALVANIZED': 'GALVANIZED',
|
||||
'GALV': 'GALV',
|
||||
'HOT DIP GALV': 'HDG',
|
||||
'HDG': 'HDG',
|
||||
'ZINC PLATED': 'ZINC PLATED',
|
||||
'ZINC': 'ZINC',
|
||||
'PLAIN': 'PLAIN'
|
||||
};
|
||||
|
||||
for (const [pattern, treatment] of Object.entries(surfacePatterns)) {
|
||||
if (description.includes(pattern)) {
|
||||
additionalReqs.push(treatment);
|
||||
break; // 첫 번째 매치만 사용
|
||||
}
|
||||
}
|
||||
|
||||
return additionalReqs.join(', ') || '-';
|
||||
};
|
||||
|
||||
const parseBoltInfo = (material) => {
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
|
||||
// 플랜지당 볼트 세트 수 추출 (예: (8), (4))
|
||||
const boltDetails = material.bolt_details || {};
|
||||
let boltsPerFlange = boltDetails.dimensions?.bolts_per_flange || 1;
|
||||
|
||||
// 백엔드에서 정보가 없으면 원본 설명에서 직접 추출
|
||||
if (boltsPerFlange === 1) {
|
||||
const description = material.original_description || '';
|
||||
const flangePattern = description.match(/\((\d+)\)/);
|
||||
if (flangePattern) {
|
||||
boltsPerFlange = parseInt(flangePattern[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// 실제 필요한 볼트 수 = 플랜지 수 × 플랜지당 볼트 세트 수
|
||||
const totalBoltsNeeded = qty * boltsPerFlange;
|
||||
const safetyQty = Math.ceil(totalBoltsNeeded * 1.05); // 5% 여유율
|
||||
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
|
||||
|
||||
// 길이 정보 (bolt_details 우선, 없으면 원본 설명에서 추출)
|
||||
let boltLength = '-';
|
||||
if (boltDetails.length && boltDetails.length !== '-') {
|
||||
boltLength = boltDetails.length;
|
||||
} else {
|
||||
// 원본 설명에서 길이 추출
|
||||
const description = material.original_description || '';
|
||||
const lengthPatterns = [
|
||||
/(\d+(?:\.\d+)?)\s*LG/i, // 75 LG, 90.0000 LG
|
||||
/(\d+(?:\.\d+)?)\s*mm/i, // 50mm
|
||||
/(\d+(?:\.\d+)?)\s*MM/i, // 50MM
|
||||
/LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태
|
||||
];
|
||||
|
||||
for (const pattern of lengthPatterns) {
|
||||
const match = description.match(pattern);
|
||||
if (match) {
|
||||
let lengthValue = match[1];
|
||||
// 소수점 제거 (145.0000 → 145)
|
||||
if (lengthValue.includes('.') && lengthValue.endsWith('.0000')) {
|
||||
lengthValue = lengthValue.split('.')[0];
|
||||
} else if (lengthValue.includes('.') && /\.0+$/.test(lengthValue)) {
|
||||
lengthValue = lengthValue.split('.')[0];
|
||||
}
|
||||
boltLength = `${lengthValue}mm`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 재질 정보 (bolt_details 우선, 없으면 기본 필드 사용)
|
||||
let boltGrade = '-';
|
||||
if (boltDetails.material_standard && boltDetails.material_grade) {
|
||||
// bolt_details에서 완전한 재질 정보 구성
|
||||
if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== 'UNCLASSIFIED' && boltDetails.material_grade !== boltDetails.material_standard) {
|
||||
boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`;
|
||||
} else {
|
||||
boltGrade = boltDetails.material_standard;
|
||||
}
|
||||
} else if (material.full_material_grade && material.full_material_grade !== '-') {
|
||||
boltGrade = material.full_material_grade;
|
||||
} else if (material.material_grade && material.material_grade !== '-') {
|
||||
boltGrade = material.material_grade;
|
||||
}
|
||||
|
||||
// 볼트 타입 (PSV_BOLT, LT_BOLT 등)
|
||||
let boltSubtype = 'BOLT_GENERAL';
|
||||
if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN' && boltDetails.bolt_type !== 'UNCLASSIFIED') {
|
||||
boltSubtype = boltDetails.bolt_type;
|
||||
} else {
|
||||
// 원본 설명에서 특수 볼트 타입 추출
|
||||
const description = material.original_description || '';
|
||||
const upperDesc = description.toUpperCase();
|
||||
if (upperDesc.includes('PSV')) {
|
||||
boltSubtype = 'PSV_BOLT';
|
||||
} else if (upperDesc.includes('LT')) {
|
||||
boltSubtype = 'LT_BOLT';
|
||||
} else if (upperDesc.includes('CK')) {
|
||||
boltSubtype = 'CK_BOLT';
|
||||
}
|
||||
}
|
||||
|
||||
// 압력 등급 추출 (150LB 등)
|
||||
let boltPressure = '-';
|
||||
const description = material.original_description || '';
|
||||
const pressureMatch = description.match(/(\d+)\s*LB/i);
|
||||
if (pressureMatch) {
|
||||
boltPressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
// User Requirements 추출 (ELEC.GALV 등)
|
||||
const userRequirements = extractBoltAdditionalRequirements(material.original_description || '');
|
||||
|
||||
// Purchase Quantity (Quantity + Unit 통합) - 플랜지당 볼트 세트 수 정보 포함
|
||||
const purchaseQuantity = boltsPerFlange > 1
|
||||
? `${purchaseQty} SETS (${boltsPerFlange}/flange)`
|
||||
: `${purchaseQty} SETS`;
|
||||
|
||||
return {
|
||||
type: 'BOLT',
|
||||
subtype: boltSubtype,
|
||||
size: material.size_spec || material.main_nom || '-',
|
||||
pressure: boltPressure, // 압력 등급 (150LB 등)
|
||||
schedule: boltLength, // 길이 정보
|
||||
grade: boltGrade,
|
||||
userRequirements: userRequirements, // User Requirements (ELEC.GALV 등)
|
||||
additionalReq: '-', // 추가요구사항 (사용자 입력)
|
||||
purchaseQuantity: purchaseQuantity // 구매수량 (통합)
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = parseBoltInfo(material);
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
if (sortConfig && sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseBoltInfo(a);
|
||||
const bInfo = parseBoltInfo(b);
|
||||
|
||||
if (!aInfo || !bInfo) return 0;
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
// 값이 없는 경우 처리
|
||||
if (aValue === undefined && bValue === undefined) return 0;
|
||||
if (aValue === undefined) return 1;
|
||||
if (bValue === undefined) return -1;
|
||||
|
||||
// 숫자인 경우 숫자로 비교
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||
}
|
||||
|
||||
// 문자열로 비교
|
||||
const aStr = String(aValue).toLowerCase();
|
||||
const bStr = String(bValue).toLowerCase();
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aStr.localeCompare(bStr);
|
||||
} else {
|
||||
return bStr.localeCompare(aStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
|
||||
if (selectedMaterials.size === selectableMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
if (purchasedMaterials.has(materialId)) {
|
||||
return; // 구매신청된 자재는 선택 불가
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `BOLT_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 볼트 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'BOLT',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'BOLT',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 엑셀 파일을 서버에 업로드
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'BOLT');
|
||||
|
||||
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||
await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
console.log('✅ 엑셀 파일 서버 업로드 완료');
|
||||
|
||||
// 4. 구매된 자재 목록 업데이트 (비활성화)
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
console.log('✅ 구매된 자재 목록 업데이트 완료');
|
||||
|
||||
// 5. 클라이언트에 파일 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} else {
|
||||
throw new Error(response.data?.message || '구매신청 생성 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'BOLT',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Bolt Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ minWidth: '1500px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 200px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="subtype"
|
||||
filterKey="subtype"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="pressure"
|
||||
filterKey="pressure"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Pressure
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="schedule"
|
||||
filterKey="schedule"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Length
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="grade"
|
||||
filterKey="grade"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Material Grade
|
||||
</FilterableHeader>
|
||||
<div>User Requirements</div>
|
||||
<div>Additional Request</div>
|
||||
<FilterableHeader
|
||||
sortKey="purchaseQuantity"
|
||||
filterKey="purchaseQuantity"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Purchase Quantity
|
||||
</FilterableHeader>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseBoltInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 200px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.subtype}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.pressure}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.schedule}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.grade}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{info.userRequirements}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.purchaseQuantity}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Bolt Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No bolt materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoltMaterialsView;
|
||||
898
tkeg/web/src/components/bom/materials/FittingMaterialsView.jsx
Normal file
898
tkeg/web/src/components/bom/materials/FittingMaterialsView.jsx
Normal file
@@ -0,0 +1,898 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader, MaterialTable } from '../shared';
|
||||
|
||||
const FittingMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
fileId,
|
||||
jobNo,
|
||||
user,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// 니플 끝단 정보 추출 (기존 로직 복원)
|
||||
const extractNippleEndInfo = (description) => {
|
||||
const descUpper = description.toUpperCase();
|
||||
|
||||
// 니플 끝단 패턴들 (기존 NewMaterialsPage와 동일)
|
||||
const endPatterns = {
|
||||
'PBE': 'PBE', // Plain Both End
|
||||
'BBE': 'BBE', // Bevel Both End
|
||||
'POE': 'POE', // Plain One End
|
||||
'BOE': 'BOE', // Bevel One End
|
||||
'TOE': 'TOE', // Thread One End
|
||||
'SW X NPT': 'SW×NPT', // Socket Weld x NPT
|
||||
'SW X SW': 'SW×SW', // Socket Weld x Socket Weld
|
||||
'NPT X NPT': 'NPT×NPT', // NPT x NPT
|
||||
'BOTH END THREADED': 'B.E.T',
|
||||
'B.E.T': 'B.E.T',
|
||||
'ONE END THREADED': 'O.E.T',
|
||||
'O.E.T': 'O.E.T',
|
||||
'THREADED': 'THD'
|
||||
};
|
||||
|
||||
for (const [pattern, display] of Object.entries(endPatterns)) {
|
||||
if (descUpper.includes(pattern)) {
|
||||
return display;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
// 피팅 정보 파싱 (기존 상세 로직 복원)
|
||||
const parseFittingInfo = (material) => {
|
||||
const fittingDetails = material.fitting_details || {};
|
||||
const classificationDetails = material.classification_details || {};
|
||||
|
||||
// 개선된 분류기 결과 우선 사용
|
||||
const fittingTypeInfo = classificationDetails.fitting_type || {};
|
||||
const scheduleInfo = classificationDetails.schedule_info || {};
|
||||
|
||||
// 기존 필드와 새 필드 통합
|
||||
const fittingType = fittingTypeInfo.type || fittingDetails.fitting_type || '';
|
||||
const fittingSubtype = fittingTypeInfo.subtype || fittingDetails.fitting_subtype || '';
|
||||
const mainSchedule = scheduleInfo.main_schedule || fittingDetails.schedule || '';
|
||||
const redSchedule = scheduleInfo.red_schedule || '';
|
||||
const hasDifferentSchedules = scheduleInfo.has_different_schedules || false;
|
||||
|
||||
const description = material.original_description || '';
|
||||
|
||||
// 피팅 타입별 상세 표시
|
||||
let displayType = '';
|
||||
|
||||
// 개선된 분류기 결과 우선 표시
|
||||
if (fittingType === 'TEE' && fittingSubtype === 'REDUCING') {
|
||||
displayType = 'TEE REDUCING';
|
||||
} else if (fittingType === 'REDUCER' && fittingSubtype === 'CONCENTRIC') {
|
||||
displayType = 'REDUCER CONC';
|
||||
} else if (fittingType === 'REDUCER' && fittingSubtype === 'ECCENTRIC') {
|
||||
displayType = 'REDUCER ECC';
|
||||
} else if (description.toUpperCase().includes('TEE RED')) {
|
||||
displayType = 'TEE REDUCING';
|
||||
} else if (description.toUpperCase().includes('RED CONC')) {
|
||||
displayType = 'REDUCER CONC';
|
||||
} else if (description.toUpperCase().includes('RED ECC')) {
|
||||
displayType = 'REDUCER ECC';
|
||||
} else if (description.toUpperCase().includes('CAP')) {
|
||||
if (description.includes('NPT(F)')) {
|
||||
displayType = 'CAP NPT(F)';
|
||||
} else if (description.includes('SW')) {
|
||||
displayType = 'CAP SW';
|
||||
} else if (description.includes('BW')) {
|
||||
displayType = 'CAP BW';
|
||||
} else {
|
||||
displayType = 'CAP';
|
||||
}
|
||||
} else if (description.toUpperCase().includes('PLUG')) {
|
||||
if (description.toUpperCase().includes('HEX')) {
|
||||
if (description.includes('NPT(M)')) {
|
||||
displayType = 'HEX PLUG NPT(M)';
|
||||
} else {
|
||||
displayType = 'HEX PLUG';
|
||||
}
|
||||
} else if (description.includes('NPT(M)')) {
|
||||
displayType = 'PLUG NPT(M)';
|
||||
} else if (description.includes('NPT')) {
|
||||
displayType = 'PLUG NPT';
|
||||
} else {
|
||||
displayType = 'PLUG';
|
||||
}
|
||||
} else if (fittingType === 'NIPPLE') {
|
||||
const length = fittingDetails.length_mm || fittingDetails.avg_length_mm;
|
||||
const endInfo = extractNippleEndInfo(description);
|
||||
|
||||
let nippleType = 'NIPPLE';
|
||||
if (length) nippleType += ` ${length}mm`;
|
||||
if (endInfo) nippleType += ` ${endInfo}`;
|
||||
|
||||
displayType = nippleType;
|
||||
} else if (fittingType === 'ELBOW') {
|
||||
let elbowDetails = [];
|
||||
|
||||
// 각도 정보 추출
|
||||
if (fittingSubtype.includes('90DEG') || description.includes('90') || description.includes('90°')) {
|
||||
elbowDetails.push('90°');
|
||||
} else if (fittingSubtype.includes('45DEG') || description.includes('45') || description.includes('45°')) {
|
||||
elbowDetails.push('45°');
|
||||
}
|
||||
|
||||
// 반경 정보 추출 (Long Radius / Short Radius)
|
||||
if (fittingSubtype.includes('LONG_RADIUS') || description.toUpperCase().includes('LR') || description.toUpperCase().includes('LONG RADIUS')) {
|
||||
elbowDetails.push('LR');
|
||||
} else if (fittingSubtype.includes('SHORT_RADIUS') || description.toUpperCase().includes('SR') || description.toUpperCase().includes('SHORT RADIUS')) {
|
||||
elbowDetails.push('SR');
|
||||
}
|
||||
|
||||
// 연결 방식
|
||||
if (description.includes('SW')) {
|
||||
elbowDetails.push('SW');
|
||||
} else if (description.includes('BW')) {
|
||||
elbowDetails.push('BW');
|
||||
}
|
||||
|
||||
// 기본값 설정 (각도가 없으면 90도로 가정)
|
||||
if (!elbowDetails.some(detail => detail.includes('°'))) {
|
||||
elbowDetails.unshift('90°');
|
||||
}
|
||||
|
||||
displayType = `ELBOW ${elbowDetails.join(' ')}`.trim();
|
||||
} else if (fittingType === 'TEE') {
|
||||
// TEE 타입과 연결 방식 상세 표시
|
||||
let teeDetails = [];
|
||||
|
||||
// 등경/축소 타입
|
||||
if (fittingSubtype === 'EQUAL' || description.toUpperCase().includes('TEE EQ')) {
|
||||
teeDetails.push('EQ');
|
||||
} else if (fittingSubtype === 'REDUCING' || description.toUpperCase().includes('TEE RED')) {
|
||||
teeDetails.push('RED');
|
||||
}
|
||||
|
||||
// 연결 방식
|
||||
if (description.includes('SW')) {
|
||||
teeDetails.push('SW');
|
||||
} else if (description.includes('BW')) {
|
||||
teeDetails.push('BW');
|
||||
}
|
||||
|
||||
displayType = `TEE ${teeDetails.join(' ')}`.trim();
|
||||
} else if (fittingType === 'REDUCER') {
|
||||
const reducerType = fittingSubtype === 'CONCENTRIC' ? 'CONC' : fittingSubtype === 'ECCENTRIC' ? 'ECC' : '';
|
||||
const sizes = fittingDetails.reduced_size ? `${material.size_spec}×${fittingDetails.reduced_size}` : material.size_spec;
|
||||
displayType = `RED ${reducerType} ${sizes}`.trim();
|
||||
} else if (fittingType === 'SWAGE') {
|
||||
const swageType = fittingSubtype || '';
|
||||
displayType = `SWAGE ${swageType}`.trim();
|
||||
} else if (fittingType === 'OLET') {
|
||||
const oletSubtype = fittingSubtype || '';
|
||||
let oletDisplayName = '';
|
||||
|
||||
// 백엔드 분류기 결과 우선 사용
|
||||
switch (oletSubtype) {
|
||||
case 'SOCKOLET':
|
||||
oletDisplayName = 'SOCK-O-LET';
|
||||
break;
|
||||
case 'WELDOLET':
|
||||
oletDisplayName = 'WELD-O-LET';
|
||||
break;
|
||||
case 'ELLOLET':
|
||||
oletDisplayName = 'ELL-O-LET';
|
||||
break;
|
||||
case 'THREADOLET':
|
||||
oletDisplayName = 'THREAD-O-LET';
|
||||
break;
|
||||
case 'ELBOLET':
|
||||
oletDisplayName = 'ELB-O-LET';
|
||||
break;
|
||||
case 'NIPOLET':
|
||||
oletDisplayName = 'NIP-O-LET';
|
||||
break;
|
||||
case 'COUPOLET':
|
||||
oletDisplayName = 'COUP-O-LET';
|
||||
break;
|
||||
default:
|
||||
// 백엔드 분류가 없으면 description에서 직접 추출
|
||||
const upperDesc = description.toUpperCase();
|
||||
if (upperDesc.includes('SOCK-O-LET') || upperDesc.includes('SOCKOLET')) {
|
||||
oletDisplayName = 'SOCK-O-LET';
|
||||
} else if (upperDesc.includes('WELD-O-LET') || upperDesc.includes('WELDOLET')) {
|
||||
oletDisplayName = 'WELD-O-LET';
|
||||
} else if (upperDesc.includes('ELL-O-LET') || upperDesc.includes('ELLOLET')) {
|
||||
oletDisplayName = 'ELL-O-LET';
|
||||
} else if (upperDesc.includes('THREAD-O-LET') || upperDesc.includes('THREADOLET')) {
|
||||
oletDisplayName = 'THREAD-O-LET';
|
||||
} else if (upperDesc.includes('ELB-O-LET') || upperDesc.includes('ELBOLET')) {
|
||||
oletDisplayName = 'ELB-O-LET';
|
||||
} else if (upperDesc.includes('NIP-O-LET') || upperDesc.includes('NIPOLET')) {
|
||||
oletDisplayName = 'NIP-O-LET';
|
||||
} else if (upperDesc.includes('COUP-O-LET') || upperDesc.includes('COUPOLET')) {
|
||||
oletDisplayName = 'COUP-O-LET';
|
||||
} else {
|
||||
oletDisplayName = 'OLET';
|
||||
}
|
||||
}
|
||||
|
||||
displayType = oletDisplayName;
|
||||
} else if (!displayType) {
|
||||
displayType = fittingType || 'FITTING';
|
||||
}
|
||||
|
||||
// 압력 등급과 스케줄 추출 (기존 NewMaterialsPage 로직)
|
||||
let pressure = '-';
|
||||
let schedule = '-';
|
||||
|
||||
// 압력 등급 찾기 (3000LB, 6000LB 등) - 소켓웰드 피팅에 특히 중요
|
||||
const pressureMatch = description.match(/(\d+)LB/i);
|
||||
if (pressureMatch) {
|
||||
pressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
// 소켓웰드 피팅의 경우 압력 등급이 더 중요함
|
||||
if (description.includes('SW') && !pressureMatch) {
|
||||
// SW 피팅인데 압력이 명시되지 않은 경우 기본값 설정
|
||||
if (description.includes('3000') || description.includes('3K')) {
|
||||
pressure = '3000LB';
|
||||
} else if (description.includes('6000') || description.includes('6K')) {
|
||||
pressure = '6000LB';
|
||||
}
|
||||
}
|
||||
|
||||
// 스케줄 표시 (분리 스케줄 지원) - 개선된 로직
|
||||
// 레듀싱 자재인지 확인
|
||||
const isReducingFitting = displayType.includes('REDUCING') || displayType.includes('RED') ||
|
||||
description.toUpperCase().includes('RED') ||
|
||||
description.toUpperCase().includes('REDUCING');
|
||||
|
||||
if (hasDifferentSchedules && mainSchedule && redSchedule) {
|
||||
schedule = `${mainSchedule}×${redSchedule}`;
|
||||
} else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
|
||||
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
|
||||
schedule = `${mainSchedule}×${mainSchedule}`;
|
||||
} else if (mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
|
||||
schedule = mainSchedule;
|
||||
} else {
|
||||
// Description에서 스케줄 추출 - 더 강력한 패턴 매칭
|
||||
const schedulePatterns = [
|
||||
/SCH\s*(\d+S?)/i, // SCH 40, SCH 80S
|
||||
/SCHEDULE\s*(\d+S?)/i, // SCHEDULE 40
|
||||
/스케줄\s*(\d+S?)/i, // 스케줄 40
|
||||
/(\d+S?)\s*SCH/i, // 40 SCH (역순)
|
||||
/SCH\.?\s*(\d+S?)/i, // SCH.40
|
||||
/SCH\s*(\d+S?)\s*[xX×]\s*SCH\s*(\d+S?)/i // SCH 40 x SCH 80
|
||||
];
|
||||
|
||||
for (const pattern of schedulePatterns) {
|
||||
const match = description.match(pattern);
|
||||
if (match) {
|
||||
if (match.length > 2) {
|
||||
// 분리 스케줄 패턴 (SCH 40 x SCH 80)
|
||||
schedule = `SCH ${match[1]}×SCH ${match[2]}`;
|
||||
} else {
|
||||
const scheduleNum = match[1];
|
||||
if (isReducingFitting) {
|
||||
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
|
||||
schedule = `SCH ${scheduleNum}×SCH ${scheduleNum}`;
|
||||
} else {
|
||||
schedule = `SCH ${scheduleNum}`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 여전히 찾지 못했다면 더 넓은 패턴 시도
|
||||
if (schedule === '-') {
|
||||
const broadPatterns = [
|
||||
/\b(\d+)\s*LB/i, // 압력 등급에서 유추
|
||||
/\b(40|80|120|160)\b/i, // 일반적인 스케줄 숫자
|
||||
/\b(10|20|30|40|60|80|100|120|140|160)\b/i // 모든 표준 스케줄
|
||||
];
|
||||
|
||||
for (const pattern of broadPatterns) {
|
||||
const match = description.match(pattern);
|
||||
if (match) {
|
||||
const num = match[1];
|
||||
// 압력 등급이 아닌 경우만 스케줄로 간주
|
||||
if (!description.includes(`${num}LB`)) {
|
||||
if (isReducingFitting) {
|
||||
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
|
||||
schedule = `SCH ${num}×SCH ${num}`;
|
||||
} else {
|
||||
schedule = `SCH ${num}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FITTING',
|
||||
subtype: displayType,
|
||||
size: material.size_spec || '-',
|
||||
pressure: pressure,
|
||||
schedule: schedule,
|
||||
grade: material.full_material_grade || material.material_grade || '-',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개',
|
||||
isFitting: true
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = parseFittingInfo(material);
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
if (sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseFittingInfo(a);
|
||||
const bInfo = parseFittingInfo(b);
|
||||
const aValue = aInfo[sortConfig.key] || '';
|
||||
const bValue = bInfo[sortConfig.key] || '';
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
|
||||
if (selectedMaterials.size === selectableMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
if (purchasedMaterials.has(materialId)) {
|
||||
return; // 구매신청된 자재는 선택 불가
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `FITTING_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 피팅 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'FITTING',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'FITTING',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'FITTING');
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
if (onPurchasedMaterialsUpdate) {
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 클라이언트 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'FITTING',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
// 필터 헤더 컴포넌트
|
||||
const FilterableHeader = ({ sortKey, filterKey, children }) => (
|
||||
<div className="filterable-header" style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
onClick={() => handleSort(sortKey)}
|
||||
style={{ cursor: 'pointer', flex: 1 }}
|
||||
>
|
||||
{children}
|
||||
{sortConfig.key === sortKey && (
|
||||
<span style={{ marginLeft: '4px' }}>
|
||||
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
fontSize: '12px',
|
||||
color: '#6b7280'
|
||||
}}
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</div>
|
||||
{showFilterDropdown === filterKey && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
padding: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 1000,
|
||||
minWidth: '150px'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Filter ${children}...`}
|
||||
value={columnFilters[filterKey] || ''}
|
||||
onChange={(e) => setColumnFilters({
|
||||
...columnFilters,
|
||||
[filterKey]: e.target.value
|
||||
})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Fitting Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ minWidth: '1380px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 250px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<div>Type</div>
|
||||
<div>Size</div>
|
||||
<div>Pressure</div>
|
||||
<div>Schedule</div>
|
||||
<div>Material Grade</div>
|
||||
<div>User Requirements</div>
|
||||
<div>Purchase Quantity</div>
|
||||
<div>Additional Request</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseFittingInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 250px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.subtype}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.pressure}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.schedule}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.grade}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{material.user_requirements?.join(', ') || '-'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
|
||||
{info.quantity} {info.unit}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Fitting Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No fitting materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FittingMaterialsView;
|
||||
722
tkeg/web/src/components/bom/materials/FlangeMaterialsView.jsx
Normal file
722
tkeg/web/src/components/bom/materials/FlangeMaterialsView.jsx
Normal file
@@ -0,0 +1,722 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader, MaterialTable } from '../shared';
|
||||
|
||||
const FlangeMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
fileId,
|
||||
jobNo,
|
||||
user,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// 플랜지 정보 파싱
|
||||
const parseFlangeInfo = (material) => {
|
||||
const description = material.original_description || '';
|
||||
const flangeDetails = material.flange_details || {};
|
||||
|
||||
const flangeTypeMap = {
|
||||
'WN': 'WELD NECK FLANGE',
|
||||
'WELD_NECK': 'WELD NECK FLANGE',
|
||||
'SO': 'SLIP ON FLANGE',
|
||||
'SLIP_ON': 'SLIP ON FLANGE',
|
||||
'SW': 'SOCKET WELD FLANGE',
|
||||
'SOCKET_WELD': 'SOCKET WELD FLANGE',
|
||||
'THREADED': 'THREADED FLANGE',
|
||||
'THD': 'THREADED FLANGE',
|
||||
'BLIND': 'BLIND FLANGE',
|
||||
'LAP_JOINT': 'LAP JOINT FLANGE',
|
||||
'LJ': 'LAP JOINT FLANGE',
|
||||
'REDUCING': 'REDUCING FLANGE',
|
||||
'ORIFICE': 'ORIFICE FLANGE',
|
||||
'SPECTACLE': 'SPECTACLE BLIND',
|
||||
'SPECTACLE_BLIND': 'SPECTACLE BLIND',
|
||||
'PADDLE': 'PADDLE BLIND',
|
||||
'PADDLE_BLIND': 'PADDLE BLIND',
|
||||
'SPACER': 'SPACER',
|
||||
'SWIVEL': 'SWIVEL FLANGE',
|
||||
'DRIP_RING': 'DRIP RING',
|
||||
'NOZZLE': 'NOZZLE FLANGE'
|
||||
};
|
||||
|
||||
const facingTypeMap = {
|
||||
'RF': 'RAISED FACE',
|
||||
'RAISED_FACE': 'RAISED FACE',
|
||||
'FF': 'FLAT FACE',
|
||||
'FLAT_FACE': 'FLAT FACE',
|
||||
'RTJ': 'RING TYPE JOINT',
|
||||
'RING_TYPE_JOINT': 'RING TYPE JOINT'
|
||||
};
|
||||
|
||||
const rawFlangeType = flangeDetails.flange_type || '';
|
||||
const rawFacingType = flangeDetails.facing_type || '';
|
||||
|
||||
|
||||
// rawFlangeType에서 facing 정보 분리 (예: "WN RF" -> "WN")
|
||||
let cleanFlangeType = rawFlangeType;
|
||||
let extractedFacing = rawFacingType;
|
||||
|
||||
// facing 정보가 flange_type에 포함된 경우 분리
|
||||
if (rawFlangeType.includes(' RF')) {
|
||||
cleanFlangeType = rawFlangeType.replace(' RF', '').trim();
|
||||
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'RAISED_FACE';
|
||||
} else if (rawFlangeType.includes(' FF')) {
|
||||
cleanFlangeType = rawFlangeType.replace(' FF', '').trim();
|
||||
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'FLAT_FACE';
|
||||
} else if (rawFlangeType.includes(' RTJ')) {
|
||||
cleanFlangeType = rawFlangeType.replace(' RTJ', '').trim();
|
||||
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'RING_TYPE_JOINT';
|
||||
}
|
||||
|
||||
let displayType = flangeTypeMap[cleanFlangeType] || '-';
|
||||
let facingType = facingTypeMap[extractedFacing] || '-';
|
||||
|
||||
// Description에서 추출
|
||||
if (displayType === '-') {
|
||||
const desc = description.toUpperCase();
|
||||
if (desc.includes('ORIFICE')) {
|
||||
displayType = 'ORIFICE FLANGE';
|
||||
} else if (desc.includes('SPECTACLE')) {
|
||||
displayType = 'SPECTACLE BLIND';
|
||||
} else if (desc.includes('PADDLE')) {
|
||||
displayType = 'PADDLE BLIND';
|
||||
} else if (desc.includes('SPACER')) {
|
||||
displayType = 'SPACER';
|
||||
} else if (desc.includes('REDUCING') || desc.includes('RED')) {
|
||||
displayType = 'REDUCING FLANGE';
|
||||
} else if (desc.includes('BLIND')) {
|
||||
displayType = 'BLIND FLANGE';
|
||||
} else if (desc.includes('WN RF') || desc.includes('WN-RF')) {
|
||||
displayType = 'WELD NECK FLANGE';
|
||||
if (facingType === '-') facingType = 'RAISED FACE';
|
||||
} else if (desc.includes('WN FF') || desc.includes('WN-FF')) {
|
||||
displayType = 'WELD NECK FLANGE';
|
||||
if (facingType === '-') facingType = 'FLAT FACE';
|
||||
} else if (desc.includes('WN RTJ') || desc.includes('WN-RTJ')) {
|
||||
displayType = 'WELD NECK FLANGE';
|
||||
if (facingType === '-') facingType = 'RING TYPE JOINT';
|
||||
} else if (desc.includes('WN')) {
|
||||
displayType = 'WELD NECK FLANGE';
|
||||
} else if (desc.includes('SO RF') || desc.includes('SO-RF')) {
|
||||
displayType = 'SLIP ON FLANGE';
|
||||
if (facingType === '-') facingType = 'RAISED FACE';
|
||||
} else if (desc.includes('SO FF') || desc.includes('SO-FF')) {
|
||||
displayType = 'SLIP ON FLANGE';
|
||||
if (facingType === '-') facingType = 'FLAT FACE';
|
||||
} else if (desc.includes('SO')) {
|
||||
displayType = 'SLIP ON FLANGE';
|
||||
} else if (desc.includes('SW')) {
|
||||
displayType = 'SOCKET WELD FLANGE';
|
||||
} else {
|
||||
displayType = 'FLANGE';
|
||||
}
|
||||
}
|
||||
|
||||
if (facingType === '-') {
|
||||
const desc = description.toUpperCase();
|
||||
if (desc.includes('RF')) {
|
||||
facingType = 'RAISED FACE';
|
||||
} else if (desc.includes('FF')) {
|
||||
facingType = 'FLAT FACE';
|
||||
} else if (desc.includes('RTJ')) {
|
||||
facingType = 'RING TYPE JOINT';
|
||||
}
|
||||
}
|
||||
|
||||
// 원본 설명에서 스케줄 추출
|
||||
let schedule = '-';
|
||||
const upperDesc = description.toUpperCase();
|
||||
|
||||
// SCH 40, SCH 80 등의 패턴 찾기
|
||||
if (upperDesc.includes('SCH')) {
|
||||
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
|
||||
if (schMatch && schMatch[1]) {
|
||||
schedule = `SCH ${schMatch[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 압력 등급 추출
|
||||
let pressure = '-';
|
||||
const pressureMatch = description.match(/(\d+)LB/i);
|
||||
if (pressureMatch) {
|
||||
pressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FLANGE',
|
||||
subtype: displayType, // 풀네임 플랜지 타입
|
||||
facing: facingType, // 새로 추가: 끝단처리 정보
|
||||
size: material.size_spec || '-',
|
||||
pressure: flangeDetails.pressure_rating || pressure,
|
||||
schedule: schedule,
|
||||
grade: material.full_material_grade || material.material_grade || '-',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개',
|
||||
isFlange: true // 플랜지 구분용 플래그
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = parseFlangeInfo(material);
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
if (sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseFlangeInfo(a);
|
||||
const bInfo = parseFlangeInfo(b);
|
||||
const aValue = aInfo[sortConfig.key] || '';
|
||||
const bValue = bInfo[sortConfig.key] || '';
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
if (selectedMaterials.size === filteredMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(filteredMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `FLANGE_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'FLANGE',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'FLANGE',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'FLANGE');
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
if (onPurchasedMaterialsUpdate) {
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 클라이언트 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'FLANGE',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
setSelectedMaterials(new Set());
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
// 필터 헤더 컴포넌트
|
||||
const FilterableHeader = ({ sortKey, filterKey, children }) => (
|
||||
<div className="filterable-header" style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
onClick={() => handleSort(sortKey)}
|
||||
style={{ cursor: 'pointer', flex: 1 }}
|
||||
>
|
||||
{children}
|
||||
{sortConfig.key === sortKey && (
|
||||
<span style={{ marginLeft: '4px' }}>
|
||||
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
fontSize: '12px',
|
||||
color: '#6b7280'
|
||||
}}
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</div>
|
||||
{showFilterDropdown === filterKey && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
padding: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 1000,
|
||||
minWidth: '150px'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Filter ${children}...`}
|
||||
value={columnFilters[filterKey] || ''}
|
||||
onChange={(e) => setColumnFilters({
|
||||
...columnFilters,
|
||||
[filterKey]: e.target.value
|
||||
})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Flange Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ minWidth: '1400px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 250px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
|
||||
<FilterableHeader sortKey="facing" filterKey="facing">Facing</FilterableHeader>
|
||||
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
|
||||
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
|
||||
<FilterableHeader sortKey="schedule" filterKey="schedule">Schedule</FilterableHeader>
|
||||
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
|
||||
<div>Purchase Quantity</div>
|
||||
<div>Additional Request</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
<div>
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseFlangeInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 250px',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500', textAlign: 'center' }}>
|
||||
{info.subtype}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.facing}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.pressure}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.schedule}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.grade}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'center' }}>
|
||||
{info.quantity} {info.unit}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Flange Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No flange materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlangeMaterialsView;
|
||||
693
tkeg/web/src/components/bom/materials/GasketMaterialsView.jsx
Normal file
693
tkeg/web/src/components/bom/materials/GasketMaterialsView.jsx
Normal file
@@ -0,0 +1,693 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const GasketMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
fileId,
|
||||
jobNo,
|
||||
user
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
const parseGasketInfo = (material) => {
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
const purchaseQty = Math.ceil(qty * 1.05 / 5) * 5; // 5% 여유율 + 5의 배수
|
||||
|
||||
const description = material.original_description || '';
|
||||
|
||||
// 가스켓 타입 풀네임 매핑
|
||||
const gasketTypeMap = {
|
||||
'SWG': 'SPIRAL WOUND GASKET',
|
||||
'RTJ': 'RING TYPE JOINT',
|
||||
'FF': 'FULL FACE GASKET',
|
||||
'RF': 'RAISED FACE GASKET',
|
||||
'SHEET': 'SHEET GASKET',
|
||||
'O-RING': 'O-RING GASKET'
|
||||
};
|
||||
|
||||
// 타입 추출 및 풀네임 변환
|
||||
let gasketType = '-';
|
||||
const typeMatch = description.match(/\b(SWG|RTJ|FF|RF|SHEET|O-RING)\b/i);
|
||||
if (typeMatch) {
|
||||
const shortType = typeMatch[1].toUpperCase();
|
||||
gasketType = gasketTypeMap[shortType] || shortType;
|
||||
}
|
||||
|
||||
// 크기 정보 추출 (예: 1 1/2")
|
||||
let size = material.size_spec || material.size_inch || '-';
|
||||
if (size === '-') {
|
||||
const sizeMatch = description.match(/(\d+(?:\s+\d+\/\d+)?(?:\.\d+)?)\s*"/);
|
||||
if (sizeMatch) {
|
||||
size = sizeMatch[1] + '"';
|
||||
}
|
||||
}
|
||||
|
||||
// 압력등급 추출
|
||||
let pressure = '-';
|
||||
const pressureMatch = description.match(/(\d+LB)/i);
|
||||
if (pressureMatch) {
|
||||
pressure = pressureMatch[1];
|
||||
}
|
||||
|
||||
// 구조 정보 추출 (H/F/I/O)
|
||||
let structure = '-';
|
||||
if (description.includes('H/F/I/O')) {
|
||||
structure = 'H/F/I/O';
|
||||
}
|
||||
|
||||
// 재질 상세 정보 추출 (SS304/GRAPHITE/SS304/SS304)
|
||||
let material_detail = '-';
|
||||
const materialMatch = description.match(/H\/F\/I\/O\s+([^,]+)/);
|
||||
if (materialMatch) {
|
||||
material_detail = materialMatch[1].trim();
|
||||
// 두께 정보 제거
|
||||
material_detail = material_detail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim();
|
||||
}
|
||||
|
||||
// 두께 정보 추출
|
||||
let thickness = '-';
|
||||
const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i);
|
||||
if (thicknessMatch) {
|
||||
thickness = thicknessMatch[1] + 'mm';
|
||||
}
|
||||
|
||||
return {
|
||||
type: gasketType, // 풀네임으로 표시 (SPIRAL WOUND GASKET)
|
||||
size: size,
|
||||
pressure: pressure,
|
||||
structure: structure, // H/F/I/O
|
||||
material: material_detail, // SS304/GRAPHITE/SS304/SS304
|
||||
thickness: thickness,
|
||||
userRequirements: material.user_requirements?.join(', ') || '-',
|
||||
purchaseQuantity: purchaseQty,
|
||||
isGasket: true
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = parseGasketInfo(material);
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
if (sortConfig && sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseGasketInfo(a);
|
||||
const bInfo = parseGasketInfo(b);
|
||||
|
||||
if (!aInfo || !bInfo) return 0;
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
// 값이 없는 경우 처리
|
||||
if (aValue === undefined && bValue === undefined) return 0;
|
||||
if (aValue === undefined) return 1;
|
||||
if (bValue === undefined) return -1;
|
||||
|
||||
// 숫자인 경우 숫자로 비교
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||
}
|
||||
|
||||
// 문자열로 비교
|
||||
const aStr = String(aValue).toLowerCase();
|
||||
const bStr = String(bValue).toLowerCase();
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aStr.localeCompare(bStr);
|
||||
} else {
|
||||
return bStr.localeCompare(aStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
|
||||
if (selectedMaterials.size === selectableMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
if (purchasedMaterials.has(materialId)) {
|
||||
return; // 구매신청된 자재는 선택 불가
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `GASKET_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 가스켓 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'GASKET',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'GASKET',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'GASKET');
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
if (onPurchasedMaterialsUpdate) {
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 클라이언트 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'GASKET',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Gasket Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ minWidth: '1400px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 200px 120px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="type"
|
||||
filterKey="type"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="pressure"
|
||||
filterKey="pressure"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Pressure
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="structure"
|
||||
filterKey="structure"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Structure
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="material"
|
||||
filterKey="material"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Material
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="thickness"
|
||||
filterKey="thickness"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Thickness
|
||||
</FilterableHeader>
|
||||
<div>User Requirements</div>
|
||||
<div>Additional Request</div>
|
||||
<FilterableHeader
|
||||
sortKey="purchaseQuantity"
|
||||
filterKey="purchaseQuantity"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Purchase Quantity
|
||||
</FilterableHeader>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseGasketInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 200px 120px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500', textAlign: 'center' }}>
|
||||
{info.type}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.pressure}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.structure}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.material}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.thickness}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{info.userRequirements}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center', fontWeight: '500' }}>
|
||||
{info.purchaseQuantity.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Gasket Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No gasket materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GasketMaterialsView;
|
||||
792
tkeg/web/src/components/bom/materials/PipeMaterialsView.jsx
Normal file
792
tkeg/web/src/components/bom/materials/PipeMaterialsView.jsx
Normal file
@@ -0,0 +1,792 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader, MaterialTable } from '../shared';
|
||||
|
||||
const PipeMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
fileId,
|
||||
jobNo,
|
||||
user,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [brandInputs, setBrandInputs] = useState({}); // 브랜드 입력 상태
|
||||
const [savingBrand, setSavingBrand] = useState({}); // 브랜드 저장 중 상태
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
const [savedBrands, setSavedBrands] = useState({}); // 저장된 브랜드 상태
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingBrand, setEditingBrand] = useState({}); // 브랜드 편집 모드
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedBrandsData = {};
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
// 백엔드에서 가져온 데이터가 있으면 저장된 상태로 설정
|
||||
if (material.brand && material.brand.trim()) {
|
||||
savedBrandsData[material.id] = material.brand.trim();
|
||||
}
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedBrands(savedBrandsData);
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedBrands({});
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 브랜드 저장 함수
|
||||
const handleSaveBrand = async (materialId, brand) => {
|
||||
if (!brand.trim()) return;
|
||||
|
||||
setSavingBrand(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/brand`, { brand: brand.trim() });
|
||||
setSavedBrands(prev => ({ ...prev, [materialId]: brand.trim() }));
|
||||
setEditingBrand(prev => ({ ...prev, [materialId]: false }));
|
||||
setBrandInputs(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { brand: brand.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('브랜드 저장 실패:', error);
|
||||
alert('브랜드 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingBrand(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 브랜드 편집 시작
|
||||
const handleEditBrand = (materialId, currentBrand) => {
|
||||
setEditingBrand(prev => ({ ...prev, [materialId]: true }));
|
||||
setBrandInputs(prev => ({ ...prev, [materialId]: currentBrand || '' }));
|
||||
};
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// 파이프 구매 수량 계산 (백엔드 그룹화 데이터 활용)
|
||||
const calculatePipePurchase = (material) => {
|
||||
const pipeDetails = material.pipe_details || {};
|
||||
|
||||
// 백엔드에서 이미 그룹화된 데이터 사용
|
||||
let pipeCount = 1; // 기본값
|
||||
let totalBomLengthMm = 0;
|
||||
|
||||
if (pipeDetails.pipe_count && pipeDetails.total_length_mm) {
|
||||
// 백엔드에서 그룹화된 데이터 사용
|
||||
pipeCount = pipeDetails.pipe_count; // 실제 단관 개수
|
||||
totalBomLengthMm = pipeDetails.total_length_mm; // 이미 합산된 총 길이
|
||||
} else {
|
||||
// 개별 파이프 데이터인 경우
|
||||
pipeCount = material.quantity || 1;
|
||||
|
||||
// 길이 정보 우선순위: length_mm > length > pipe_details.length_mm
|
||||
let singlePipeLengthMm = 0;
|
||||
if (material.length_mm) {
|
||||
singlePipeLengthMm = material.length_mm;
|
||||
} else if (material.length) {
|
||||
singlePipeLengthMm = material.length * 1000; // m를 mm로 변환
|
||||
} else if (pipeDetails.length_mm) {
|
||||
singlePipeLengthMm = pipeDetails.length_mm;
|
||||
}
|
||||
|
||||
totalBomLengthMm = singlePipeLengthMm * pipeCount;
|
||||
}
|
||||
|
||||
// 여유분 포함 계산: 각 단관당 2mm 여유분 추가
|
||||
const allowancePerPipe = 2; // mm
|
||||
const totalAllowanceMm = allowancePerPipe * pipeCount;
|
||||
const totalLengthWithAllowance = totalBomLengthMm + totalAllowanceMm; // mm
|
||||
|
||||
// 6,000mm(6m) 표준 길이로 필요한 본수 계산 (올림)
|
||||
const standardLengthMm = 6000; // mm
|
||||
const requiredStandardPipes = Math.ceil(totalLengthWithAllowance / standardLengthMm);
|
||||
|
||||
return {
|
||||
pipeCount, // 단관 개수
|
||||
totalBomLengthMm, // 총 BOM 길이 (mm)
|
||||
totalLengthWithAllowance, // 여유분 포함 총 길이 (mm)
|
||||
totalLengthM: totalLengthWithAllowance / 1000, // 총 길이 (m)
|
||||
requiredStandardPipes, // 필요한 표준 파이프 본수
|
||||
standardLengthMm,
|
||||
allowancePerPipe,
|
||||
totalAllowanceMm,
|
||||
// 디버깅용 정보
|
||||
isGrouped: !!(pipeDetails.pipe_count && pipeDetails.total_length_mm)
|
||||
};
|
||||
};
|
||||
|
||||
// 파이프 정보 파싱 (개선된 로직)
|
||||
const parsePipeInfo = (material) => {
|
||||
const calc = calculatePipePurchase(material);
|
||||
const pipeDetails = material.pipe_details || {};
|
||||
|
||||
// User 요구사항 추출 (분류기에서 제공된 정보)
|
||||
const userRequirements = material.user_requirements || [];
|
||||
const userReqText = userRequirements.length > 0 ? userRequirements.join(', ') : '-';
|
||||
|
||||
return {
|
||||
// Type 컬럼 제거 (모두 PIPE로 동일)
|
||||
type: pipeDetails.manufacturing_method || 'SMLS', // Subtype을 Type으로 변경
|
||||
size: material.size_spec || '-',
|
||||
schedule: pipeDetails.schedule || material.schedule || '-',
|
||||
grade: material.full_material_grade || material.material_grade || '-',
|
||||
userRequirements: userReqText, // User 요구사항
|
||||
length: calc.totalLengthWithAllowance, // 여유분 포함 총 길이 (mm)
|
||||
quantity: calc.pipeCount, // 단관 개수
|
||||
unit: `${calc.requiredStandardPipes}본`, // 6m 표준 파이프 필요 본수
|
||||
details: calc,
|
||||
isPipe: true
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = parsePipeInfo(material);
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
if (sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parsePipeInfo(a);
|
||||
const bInfo = parsePipeInfo(b);
|
||||
const aValue = aInfo[sortConfig.key] || '';
|
||||
const bValue = bInfo[sortConfig.key] || '';
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제 (구매된 자재 제외)
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
|
||||
if (selectedMaterials.size === selectableMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택 (구매된 자재는 선택 불가)
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
if (purchasedMaterials.has(materialId)) {
|
||||
return; // 구매된 자재는 선택 불가
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `PIPE_Materials_${timestamp}.xlsx`;
|
||||
|
||||
// 사용자 요구사항 포함
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 파이프 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'PIPE',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'PIPE',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'PIPE');
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
if (onPurchasedMaterialsUpdate) {
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 클라이언트 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'PIPE',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
// 필터 헤더 컴포넌트
|
||||
const FilterableHeader = ({ sortKey, filterKey, children }) => (
|
||||
<div className="filterable-header" style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
onClick={() => handleSort(sortKey)}
|
||||
style={{ cursor: 'pointer', flex: 1 }}
|
||||
>
|
||||
{children}
|
||||
{sortConfig.key === sortKey && (
|
||||
<span style={{ marginLeft: '4px' }}>
|
||||
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
fontSize: '12px',
|
||||
color: '#6b7280'
|
||||
}}
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</div>
|
||||
{showFilterDropdown === filterKey && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
padding: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 1000,
|
||||
minWidth: '150px'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Filter ${children}...`}
|
||||
value={columnFilters[filterKey] || ''}
|
||||
onChange={(e) => setColumnFilters({
|
||||
...columnFilters,
|
||||
[filterKey]: e.target.value
|
||||
})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Pipe Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
maxHeight: '600px'
|
||||
}}>
|
||||
{/* 테이블 내용 - 헤더와 본문이 함께 스크롤 */}
|
||||
<div style={{
|
||||
minWidth: '1200px'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 120px 120px 120px 150px 200px 120px 100px 120px 300px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="type"
|
||||
filterKey="type"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="schedule"
|
||||
filterKey="schedule"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Schedule
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="grade"
|
||||
filterKey="grade"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Material Grade
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="userRequirements"
|
||||
filterKey="userRequirements"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
User Requirements
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="length"
|
||||
filterKey="length"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Length (MM)
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="quantity"
|
||||
filterKey="quantity"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Quantity (EA)
|
||||
</FilterableHeader>
|
||||
<div>Purchase Unit</div>
|
||||
<div>Additional Request</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
<div>
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parsePipeInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 120px 120px 120px 150px 200px 120px 100px 120px 300px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.type}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.schedule}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.grade}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{info.userRequirements}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{Math.round(info.length).toLocaleString()}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
|
||||
{info.quantity}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>
|
||||
{info.unit}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Pipe Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No pipe materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipeMaterialsView;
|
||||
628
tkeg/web/src/components/bom/materials/SpecialMaterialsView.jsx
Normal file
628
tkeg/web/src/components/bom/materials/SpecialMaterialsView.jsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const SpecialMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
jobNo,
|
||||
fileId,
|
||||
user,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// SPECIAL 자재 정보 파싱
|
||||
const parseSpecialInfo = (material) => {
|
||||
const description = material.original_description || '';
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
|
||||
// Type 추출 (큰 범주: 우선순위 기반 분류)
|
||||
let type = 'SPECIAL';
|
||||
const descUpper = description.toUpperCase();
|
||||
|
||||
// 우선순위 1: 주요 장비 타입 (OIL PUMP, COMPRESSOR 등이 FLG보다 우선)
|
||||
if (descUpper.includes('OIL PUMP') || (descUpper.includes('PUMP') && !descUpper.includes('FITTING'))) {
|
||||
type = 'OIL PUMP';
|
||||
} else if (descUpper.includes('COMPRESSOR')) {
|
||||
type = 'COMPRESSOR';
|
||||
} else if (descUpper.includes('VALVE') && !descUpper.includes('FLG')) {
|
||||
type = 'VALVE';
|
||||
}
|
||||
// 우선순위 2: 구조물/부품 타입
|
||||
else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) {
|
||||
// FLG가 있어도 주요 장비가 함께 있으면 장비 타입 우선
|
||||
if (descUpper.includes('OIL PUMP') || descUpper.includes('COMPRESSOR')) {
|
||||
if (descUpper.includes('OIL PUMP')) {
|
||||
type = 'OIL PUMP';
|
||||
} else if (descUpper.includes('COMPRESSOR')) {
|
||||
type = 'COMPRESSOR';
|
||||
}
|
||||
} else {
|
||||
type = 'FLANGE';
|
||||
}
|
||||
} else if (descUpper.includes('FITTING')) {
|
||||
type = 'FITTING';
|
||||
} else if (descUpper.includes('PIPE')) {
|
||||
type = 'PIPE';
|
||||
}
|
||||
|
||||
// 도면 정보 (drawing_name 또는 line_no에서 추출)
|
||||
const drawing = material.drawing_name || material.line_no || '-';
|
||||
|
||||
// 설명을 항목별로 분리 (콤마, 세미콜론, 파이프 등으로 구분)
|
||||
const parts = description
|
||||
.split(/[,;|\/]/)
|
||||
.map(part => part.trim())
|
||||
.filter(part => part.length > 0);
|
||||
|
||||
// 최대 4개 항목으로 제한
|
||||
const detail1 = parts[0] || '-';
|
||||
const detail2 = parts[1] || '-';
|
||||
const detail3 = parts[2] || '-';
|
||||
const detail4 = parts[3] || '-';
|
||||
|
||||
return {
|
||||
type,
|
||||
drawing,
|
||||
detail1,
|
||||
detail2,
|
||||
detail3,
|
||||
detail4,
|
||||
quantity: qty,
|
||||
originalQuantity: qty,
|
||||
purchaseQuantity: qty,
|
||||
isSpecial: true
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링 및 정렬된 자료
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
const info = parseSpecialInfo(material);
|
||||
|
||||
// 컬럼 필터 적용
|
||||
for (const [key, filterValue] of Object.entries(columnFilters)) {
|
||||
if (filterValue && filterValue.trim()) {
|
||||
const materialValue = String(info[key] || '').toLowerCase();
|
||||
const filter = filterValue.toLowerCase();
|
||||
if (!materialValue.includes(filter)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 정렬 적용
|
||||
if (sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseSpecialInfo(a);
|
||||
const bInfo = parseSpecialInfo(b);
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = () => {
|
||||
const selectableMaterials = filteredMaterials.filter(material =>
|
||||
!purchasedMaterials.has(material.id)
|
||||
);
|
||||
|
||||
const allSelected = selectableMaterials.every(material =>
|
||||
selectedMaterials.has(material.id)
|
||||
);
|
||||
|
||||
if (allSelected) {
|
||||
// 전체 해제
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
selectableMaterials.forEach(material => {
|
||||
newSelected.delete(material.id);
|
||||
});
|
||||
setSelectedMaterials(newSelected);
|
||||
} else {
|
||||
// 전체 선택
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
selectableMaterials.forEach(material => {
|
||||
newSelected.add(material.id);
|
||||
});
|
||||
setSelectedMaterials(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택/해제
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `SPECIAL_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 스페셜 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'SPECIAL',
|
||||
filename: excelFileName,
|
||||
user: user?.username || 'unknown'
|
||||
});
|
||||
|
||||
// 2. 구매신청 생성
|
||||
console.log('📝 구매신청 생성 중...');
|
||||
const purchaseResponse = await api.post('/purchase-request/create', {
|
||||
materials_data: dataWithRequirements,
|
||||
file_id: fileId,
|
||||
job_no: jobNo
|
||||
});
|
||||
|
||||
console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
|
||||
|
||||
// 3. 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', purchaseResponse.data.request_id);
|
||||
formData.append('filename', excelFileName);
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
// 4. 구매신청된 자재들을 비활성화
|
||||
const purchasedIds = selectedMaterialsData.map(m => m.id);
|
||||
onPurchasedMaterialsUpdate(purchasedIds);
|
||||
|
||||
// 5. 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
|
||||
// 6. 클라이언트에서도 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ 스페셜 엑셀 내보내기 완료');
|
||||
alert(`스페셜 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
|
||||
|
||||
// 구매신청 관리 페이지로 이동
|
||||
if (onNavigate) {
|
||||
onNavigate('purchase-requests');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 스페셜 엑셀 내보내기 실패:', error);
|
||||
alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const allSelected = filteredMaterials.length > 0 &&
|
||||
filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
|
||||
.every(material => selectedMaterials.has(material.id));
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
|
||||
borderRadius: '12px',
|
||||
color: 'white'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
|
||||
Special Items
|
||||
</h2>
|
||||
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>
|
||||
특수 제작 품목 관리 ({filteredMaterials.length}개)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: selectedMaterials.size > 0 ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.2s ease',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
>
|
||||
구매신청 ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
border: '1px solid #e5e7eb',
|
||||
minWidth: '1400px'
|
||||
}}>
|
||||
<div style={{ minWidth: '1400px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 150px 200px 200px 200px 200px 200px 250px 150px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '2px solid #e2e8f0',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="type"
|
||||
filterKey="type"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="drawing"
|
||||
filterKey="drawing"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Drawing
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="detail1"
|
||||
filterKey="detail1"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Detail 1
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="detail2"
|
||||
filterKey="detail2"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Detail 2
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="detail3"
|
||||
filterKey="detail3"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Detail 3
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="detail4"
|
||||
filterKey="detail4"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Detail 4
|
||||
</FilterableHeader>
|
||||
<div>Additional Request</div>
|
||||
<div>Purchase Quantity</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
<div>
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseSpecialInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 150px 200px 200px 200px 200px 200px 250px 150px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.type}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.drawing}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail1}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail2}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail3}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail4}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.purchaseQuantity}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#6b7280',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
marginTop: '20px'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📋</div>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#374151' }}>스페셜 자재가 없습니다</h3>
|
||||
<p style={{ margin: 0 }}>특수 제작이 필요한 자재가 발견되면 여기에 표시됩니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecialMaterialsView;
|
||||
687
tkeg/web/src/components/bom/materials/SupportMaterialsView.jsx
Normal file
687
tkeg/web/src/components/bom/materials/SupportMaterialsView.jsx
Normal file
@@ -0,0 +1,687 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const SupportMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
jobNo,
|
||||
fileId,
|
||||
user
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
const parseSupportInfo = (material) => {
|
||||
const desc = material.original_description || '';
|
||||
const descUpper = desc.toUpperCase();
|
||||
|
||||
// 서포트 타입 분류 (백엔드 분류기와 동일한 로직)
|
||||
let supportType = 'U-BOLT'; // 기본값
|
||||
|
||||
if (descUpper.includes('URETHANE') || descUpper.includes('BLOCK SHOE') || descUpper.includes('우레탄')) {
|
||||
supportType = 'URETHANE BLOCK SHOE';
|
||||
} else if (descUpper.includes('CLAMP') || descUpper.includes('클램프')) {
|
||||
// 클램프 타입 상세 분류 (CL-1, CL-2, CL-3 등)
|
||||
const clampMatch = desc.match(/CL[-\s]*(\d+)/i);
|
||||
if (clampMatch) {
|
||||
supportType = `CLAMP CL-${clampMatch[1]}`;
|
||||
} else {
|
||||
supportType = 'CLAMP CL-1'; // 기본값
|
||||
}
|
||||
} else if (descUpper.includes('HANGER') || descUpper.includes('행거')) {
|
||||
supportType = 'HANGER';
|
||||
} else if (descUpper.includes('SPRING') || descUpper.includes('스프링')) {
|
||||
supportType = 'SPRING HANGER';
|
||||
} else if (descUpper.includes('GUIDE') || descUpper.includes('가이드')) {
|
||||
supportType = 'GUIDE';
|
||||
} else if (descUpper.includes('ANCHOR') || descUpper.includes('앵커')) {
|
||||
supportType = 'ANCHOR';
|
||||
}
|
||||
|
||||
// User Requirements 추출 (분류기에서 제공된 것 우선)
|
||||
const userRequirements = material.user_requirements || [];
|
||||
|
||||
// 구매 수량 계산 (서포트는 취합된 숫자 그대로)
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
const purchaseQty = qty;
|
||||
|
||||
// Material Grade 처리 - 우레탄 블럭슈의 경우 두께 정보 포함
|
||||
let materialGrade = material.full_material_grade || material.material_grade || '-';
|
||||
|
||||
if (supportType === 'URETHANE BLOCK SHOE') {
|
||||
// 두께 정보 추출 (40t, 27t 등 - 여기서 t는 thickness를 의미)
|
||||
const thicknessMatch = desc.match(/(\d+)\s*[tT]/);
|
||||
if (thicknessMatch) {
|
||||
const thickness = `${thicknessMatch[1]}t`;
|
||||
if (materialGrade === '-' || !materialGrade) {
|
||||
materialGrade = thickness;
|
||||
} else if (!materialGrade.includes(thickness)) {
|
||||
materialGrade = `${materialGrade} ${thickness}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: supportType,
|
||||
size: material.main_nom || material.size_inch || material.size_spec || '-',
|
||||
grade: materialGrade,
|
||||
userRequirements: userRequirements.join(', ') || '-',
|
||||
additionalReq: '-',
|
||||
purchaseQuantity: `${purchaseQty} EA`,
|
||||
originalQuantity: qty,
|
||||
isSupport: true
|
||||
};
|
||||
};
|
||||
|
||||
// 동일한 서포트 항목 합산
|
||||
const consolidateSupportMaterials = (materials) => {
|
||||
const consolidated = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
const info = parseSupportInfo(material);
|
||||
const key = `${info.type}|${info.size}|${info.grade}`;
|
||||
|
||||
if (!consolidated[key]) {
|
||||
consolidated[key] = {
|
||||
...material,
|
||||
// Material Grade 정보를 parsedInfo에서 가져와서 설정
|
||||
material_grade: info.grade,
|
||||
full_material_grade: info.grade,
|
||||
consolidatedQuantity: info.originalQuantity,
|
||||
consolidatedIds: [material.id],
|
||||
parsedInfo: info
|
||||
};
|
||||
} else {
|
||||
consolidated[key].consolidatedQuantity += info.originalQuantity;
|
||||
consolidated[key].consolidatedIds.push(material.id);
|
||||
}
|
||||
});
|
||||
|
||||
// 합산된 수량으로 구매 수량 재계산 (서포트는 취합된 숫자 그대로)
|
||||
return Object.values(consolidated).map(item => {
|
||||
const purchaseQty = item.consolidatedQuantity;
|
||||
|
||||
return {
|
||||
...item,
|
||||
parsedInfo: {
|
||||
...item.parsedInfo,
|
||||
originalQuantity: item.consolidatedQuantity,
|
||||
purchaseQuantity: `${purchaseQty} EA`
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
// 먼저 합산 처리
|
||||
let consolidated = consolidateSupportMaterials(materials);
|
||||
|
||||
// 필터링
|
||||
let filtered = consolidated.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = material.parsedInfo;
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
// 정렬
|
||||
if (sortConfig && sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = a.parsedInfo;
|
||||
const bInfo = b.parsedInfo;
|
||||
|
||||
if (!aInfo || !bInfo) return 0;
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
// 값이 없는 경우 처리
|
||||
if (aValue === undefined && bValue === undefined) return 0;
|
||||
if (aValue === undefined) return 1;
|
||||
if (bValue === undefined) return -1;
|
||||
|
||||
// 숫자인 경우 숫자로 비교
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||
}
|
||||
|
||||
// 문자열로 비교
|
||||
const aStr = String(aValue).toLowerCase();
|
||||
const bStr = String(bValue).toLowerCase();
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aStr.localeCompare(bStr);
|
||||
} else {
|
||||
return bStr.localeCompare(aStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(material =>
|
||||
!material.consolidatedIds.some(id => purchasedMaterials.has(id))
|
||||
);
|
||||
|
||||
if (selectedMaterials.size === selectableMaterials.flatMap(m => m.consolidatedIds).length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
const allIds = selectableMaterials.flatMap(m => m.consolidatedIds);
|
||||
setSelectedMaterials(new Set(allIds));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||
const handleMaterialSelect = (consolidatedMaterial) => {
|
||||
const hasAnyPurchased = consolidatedMaterial.consolidatedIds.some(id => purchasedMaterials.has(id));
|
||||
if (hasAnyPurchased) {
|
||||
return; // 구매신청된 자재가 포함된 경우 선택 불가
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
const allSelected = consolidatedMaterial.consolidatedIds.every(id => newSelected.has(id));
|
||||
|
||||
if (allSelected) {
|
||||
// 모두 선택된 경우 모두 해제
|
||||
consolidatedMaterial.consolidatedIds.forEach(id => newSelected.delete(id));
|
||||
} else {
|
||||
// 일부 또는 전체 미선택인 경우 모두 선택
|
||||
consolidatedMaterial.consolidatedIds.forEach(id => newSelected.add(id));
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
// 선택된 합산 자료 가져오기
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectedConsolidatedMaterials = filteredMaterials.filter(consolidatedMaterial =>
|
||||
consolidatedMaterial.consolidatedIds.some(id => selectedMaterials.has(id))
|
||||
);
|
||||
|
||||
if (selectedConsolidatedMaterials.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `SUPPORT_Materials_${timestamp}.xlsx`;
|
||||
|
||||
// 합산된 자료를 엑셀 형태로 변환
|
||||
const dataWithRequirements = selectedConsolidatedMaterials.map(consolidatedMaterial => ({
|
||||
...consolidatedMaterial,
|
||||
// 합산된 수량으로 덮어쓰기
|
||||
quantity: consolidatedMaterial.consolidatedQuantity,
|
||||
// 사용자 요구사항은 대표 ID 기준 (저장된 데이터 우선)
|
||||
user_requirement: savedRequests[consolidatedMaterial.id] || userRequirements[consolidatedMaterial.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 서포트 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'SUPPORT',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedConsolidatedMaterials.flatMap(cm => cm.consolidatedIds);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'SUPPORT',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity, // 이미 합산된 수량
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 엑셀 파일을 서버에 업로드
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'SUPPORT');
|
||||
|
||||
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||
await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
console.log('✅ 엑셀 파일 서버 업로드 완료');
|
||||
|
||||
// 4. 구매된 자재 목록 업데이트 (비활성화)
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
console.log('✅ 구매된 자재 목록 업데이트 완료');
|
||||
|
||||
// 5. 클라이언트에 파일 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} else {
|
||||
throw new Error(response.data?.message || '구매신청 생성 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'SUPPORT',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Support Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
maxHeight: '600px'
|
||||
}}>
|
||||
<div style={{ minWidth: '1200px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 150px 180px 200px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const selectableMaterials = filteredMaterials.filter(material =>
|
||||
!material.consolidatedIds.some(id => purchasedMaterials.has(id))
|
||||
);
|
||||
return selectedMaterials.size === selectableMaterials.flatMap(m => m.consolidatedIds).length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="type"
|
||||
filterKey="type"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="grade"
|
||||
filterKey="grade"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Material Grade
|
||||
</FilterableHeader>
|
||||
<div>User Requirements</div>
|
||||
<div>Additional Request</div>
|
||||
<FilterableHeader
|
||||
sortKey="purchaseQuantity"
|
||||
filterKey="purchaseQuantity"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Purchase Quantity
|
||||
</FilterableHeader>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
{filteredMaterials.map((consolidatedMaterial, index) => {
|
||||
const info = consolidatedMaterial.parsedInfo;
|
||||
const hasAnyPurchased = consolidatedMaterial.consolidatedIds.some(id => purchasedMaterials.has(id));
|
||||
const allSelected = consolidatedMaterial.consolidatedIds.every(id => selectedMaterials.has(id));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`consolidated-${index}`}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 150px 180px 200px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: allSelected ? '#eff6ff' : (hasAnyPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!allSelected && !hasAnyPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!allSelected && !hasAnyPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={() => handleMaterialSelect(consolidatedMaterial)}
|
||||
disabled={hasAnyPurchased}
|
||||
style={{
|
||||
cursor: hasAnyPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: hasAnyPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.type}
|
||||
{hasAnyPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.grade}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.userRequirements}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[consolidatedMaterial.id] && savedRequests[consolidatedMaterial.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[consolidatedMaterial.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(consolidatedMaterial.id, savedRequests[consolidatedMaterial.id])}
|
||||
disabled={hasAnyPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: hasAnyPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: hasAnyPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: hasAnyPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[consolidatedMaterial.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[consolidatedMaterial.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={hasAnyPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: hasAnyPurchased ? 0.5 : 1,
|
||||
cursor: hasAnyPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(consolidatedMaterial.id, userRequirements[consolidatedMaterial.id] || '')}
|
||||
disabled={hasAnyPurchased || savingRequest[consolidatedMaterial.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: hasAnyPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: hasAnyPurchased || savingRequest[consolidatedMaterial.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: hasAnyPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[consolidatedMaterial.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.purchaseQuantity}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Support Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No support materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportMaterialsView;
|
||||
@@ -0,0 +1,561 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const UnclassifiedMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
jobNo,
|
||||
fileId,
|
||||
user,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// 미분류 자재 정보 파싱 (원본 그대로 표시)
|
||||
const parseUnclassifiedInfo = (material) => {
|
||||
const description = material.original_description || material.description || '';
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
|
||||
return {
|
||||
description: description || '-',
|
||||
size: material.main_nom || material.size_spec || '-',
|
||||
drawing: material.drawing_name || material.line_no || '-',
|
||||
lineNo: material.line_no || '-',
|
||||
quantity: qty,
|
||||
originalQuantity: qty,
|
||||
purchaseQuantity: qty,
|
||||
isUnclassified: true
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링 및 정렬된 자료
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
const info = parseUnclassifiedInfo(material);
|
||||
|
||||
// 컬럼 필터 적용
|
||||
for (const [key, filterValue] of Object.entries(columnFilters)) {
|
||||
if (filterValue && filterValue.trim()) {
|
||||
const materialValue = String(info[key] || '').toLowerCase();
|
||||
const filter = filterValue.toLowerCase();
|
||||
if (!materialValue.includes(filter)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 정렬 적용
|
||||
if (sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseUnclassifiedInfo(a);
|
||||
const bInfo = parseUnclassifiedInfo(b);
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = () => {
|
||||
const selectableMaterials = filteredMaterials.filter(material =>
|
||||
!purchasedMaterials.has(material.id)
|
||||
);
|
||||
|
||||
const allSelected = selectableMaterials.every(material =>
|
||||
selectedMaterials.has(material.id)
|
||||
);
|
||||
|
||||
if (allSelected) {
|
||||
// 전체 해제
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
selectableMaterials.forEach(material => {
|
||||
newSelected.delete(material.id);
|
||||
});
|
||||
setSelectedMaterials(newSelected);
|
||||
} else {
|
||||
// 전체 선택
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
selectableMaterials.forEach(material => {
|
||||
newSelected.add(material.id);
|
||||
});
|
||||
setSelectedMaterials(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택/해제
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `UNCLASSIFIED_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 미분류 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'UNCLASSIFIED',
|
||||
filename: excelFileName,
|
||||
user: user?.username || 'unknown'
|
||||
});
|
||||
|
||||
// 2. 구매신청 생성
|
||||
console.log('📝 구매신청 생성 중...');
|
||||
const purchaseResponse = await api.post('/purchase-request/create', {
|
||||
materials_data: dataWithRequirements,
|
||||
file_id: fileId,
|
||||
job_no: jobNo
|
||||
});
|
||||
|
||||
console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
|
||||
|
||||
// 3. 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', purchaseResponse.data.request_id);
|
||||
formData.append('filename', excelFileName);
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
// 4. 구매신청된 자재들을 비활성화
|
||||
const purchasedIds = selectedMaterialsData.map(m => m.id);
|
||||
onPurchasedMaterialsUpdate(purchasedIds);
|
||||
|
||||
// 5. 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
|
||||
// 6. 클라이언트에서도 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ 미분류 엑셀 내보내기 완료');
|
||||
alert(`미분류 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
|
||||
|
||||
// 구매신청 관리 페이지로 이동
|
||||
if (onNavigate) {
|
||||
onNavigate('purchase-requests');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 미분류 엑셀 내보내기 실패:', error);
|
||||
alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const allSelected = filteredMaterials.length > 0 &&
|
||||
filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
|
||||
.every(material => selectedMaterials.has(material.id));
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #64748b 0%, #475569 100%)',
|
||||
borderRadius: '12px',
|
||||
color: 'white'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
|
||||
Unclassified Materials
|
||||
</h2>
|
||||
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>
|
||||
분류되지 않은 자재 관리 ({filteredMaterials.length}개)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: selectedMaterials.size > 0 ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.2s ease',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
>
|
||||
구매신청 ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
border: '1px solid #e5e7eb',
|
||||
minWidth: '1200px'
|
||||
}}>
|
||||
<div style={{ minWidth: '1200px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 400px 150px 200px 150px 250px 150px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '2px solid #e2e8f0',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="description"
|
||||
filterKey="description"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Description
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="drawing"
|
||||
filterKey="drawing"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Drawing
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="lineNo"
|
||||
filterKey="lineNo"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Line No
|
||||
</FilterableHeader>
|
||||
<div>Additional Request</div>
|
||||
<div>Purchase Quantity</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
<div>
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseUnclassifiedInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 400px 150px 200px 150px 250px 150px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
textAlign: 'left',
|
||||
paddingLeft: '8px',
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{info.description}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.drawing}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.lineNo}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.purchaseQuantity}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#6b7280',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
marginTop: '20px'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>❓</div>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#374151' }}>미분류 자재가 없습니다</h3>
|
||||
<p style={{ margin: 0 }}>분류되지 않은 자재가 발견되면 여기에 표시됩니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnclassifiedMaterialsView;
|
||||
840
tkeg/web/src/components/bom/materials/ValveMaterialsView.jsx
Normal file
840
tkeg/web/src/components/bom/materials/ValveMaterialsView.jsx
Normal file
@@ -0,0 +1,840 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const ValveMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
fileId,
|
||||
jobNo,
|
||||
user
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [brandInputs, setBrandInputs] = useState({}); // 브랜드 입력 상태
|
||||
const [savingBrand, setSavingBrand] = useState({}); // 브랜드 저장 중 상태
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
const [savedBrands, setSavedBrands] = useState({}); // 저장된 브랜드 상태
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingBrand, setEditingBrand] = useState({}); // 브랜드 편집 모드
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedBrandsData = {};
|
||||
const savedRequestsData = {};
|
||||
|
||||
console.log('🔍 ValveMaterialsView useEffect 트리거됨:', materials.length, '개 자재');
|
||||
console.log('🔍 현재 materials 배열:', materials.map(m => ({id: m.id, brand: m.brand, user_requirement: m.user_requirement})));
|
||||
|
||||
materials.forEach(material => {
|
||||
// 백엔드에서 가져온 데이터가 있으면 저장된 상태로 설정
|
||||
if (material.brand && material.brand.trim()) {
|
||||
savedBrandsData[material.id] = material.brand.trim();
|
||||
console.log('✅ 브랜드 로드됨:', material.id, '→', material.brand);
|
||||
}
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
console.log('✅ 요구사항 로드됨:', material.id, '→', material.user_requirement);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('💾 최종 저장된 브랜드:', savedBrandsData);
|
||||
console.log('💾 최종 저장된 요구사항:', savedRequestsData);
|
||||
|
||||
// 상태 업데이트를 즉시 반영하기 위해 setTimeout 사용
|
||||
setSavedBrands(savedBrandsData);
|
||||
setSavedRequests(savedRequestsData);
|
||||
|
||||
// 상태 업데이트 후 강제 리렌더링 확인
|
||||
setTimeout(() => {
|
||||
console.log('🔄 상태 업데이트 후 확인 - savedBrands:', savedBrandsData);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
console.log('🔄 ValveMaterialsView useEffect 실행 - materials 길이:', materials?.length || 0);
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
console.log('⚠️ materials가 비어있거나 undefined');
|
||||
// 빈 상태로 초기화
|
||||
setSavedBrands({});
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
const parseValveInfo = (material) => {
|
||||
const valveDetails = material.valve_details || {};
|
||||
const description = material.original_description || '';
|
||||
const descUpper = description.toUpperCase();
|
||||
|
||||
// 1. 벨브 타입 파싱 (한글명으로 표시)
|
||||
let valveType = '';
|
||||
if (descUpper.includes('SIGHT GLASS') || descUpper.includes('사이트글라스')) {
|
||||
valveType = 'SIGHT GLASS';
|
||||
} else if (descUpper.includes('STRAINER') || descUpper.includes('스트레이너')) {
|
||||
valveType = 'STRAINER';
|
||||
} else if (descUpper.includes('GATE') || descUpper.includes('게이트')) {
|
||||
valveType = 'GATE VALVE';
|
||||
} else if (descUpper.includes('BALL') || descUpper.includes('볼')) {
|
||||
valveType = 'BALL VALVE';
|
||||
} else if (descUpper.includes('CHECK') || descUpper.includes('체크')) {
|
||||
valveType = 'CHECK VALVE';
|
||||
} else if (descUpper.includes('GLOBE') || descUpper.includes('글로브')) {
|
||||
valveType = 'GLOBE VALVE';
|
||||
} else if (descUpper.includes('BUTTERFLY') || descUpper.includes('버터플라이')) {
|
||||
valveType = 'BUTTERFLY VALVE';
|
||||
} else if (descUpper.includes('NEEDLE') || descUpper.includes('니들')) {
|
||||
valveType = 'NEEDLE VALVE';
|
||||
} else if (descUpper.includes('RELIEF') || descUpper.includes('릴리프')) {
|
||||
valveType = 'RELIEF VALVE';
|
||||
} else {
|
||||
valveType = 'VALVE';
|
||||
}
|
||||
|
||||
// 2. 사이즈 정보
|
||||
const size = material.main_nom || material.size_inch || material.size_spec || '-';
|
||||
|
||||
// 3. 압력 등급
|
||||
const pressure = material.pressure_rating ||
|
||||
(descUpper.match(/(\d+)\s*LB/) ? descUpper.match(/(\d+)\s*LB/)[0] : '-');
|
||||
|
||||
// 4. 브랜드 (사용자 입력 가능)
|
||||
const brand = '-'; // 기본값, 사용자가 입력할 수 있도록
|
||||
|
||||
// 5. 추가 정보 추출 (3-WAY, DOUL PLATE, DOUBLE DISC 등)
|
||||
let additionalInfo = '';
|
||||
const additionalPatterns = [
|
||||
'3-WAY', '3WAY', 'THREE WAY',
|
||||
'DOUL PLATE', 'DOUBLE PLATE', 'DUAL PLATE',
|
||||
'DOUBLE DISC', 'DUAL DISC',
|
||||
'SWING', 'LIFT', 'TILTING',
|
||||
'WAFER', 'LUG', 'FLANGED',
|
||||
'FULL BORE', 'REDUCED BORE',
|
||||
'FIRE SAFE', 'ANTI STATIC'
|
||||
];
|
||||
|
||||
for (const pattern of additionalPatterns) {
|
||||
if (descUpper.includes(pattern)) {
|
||||
if (additionalInfo) {
|
||||
additionalInfo += ', ';
|
||||
}
|
||||
additionalInfo += pattern;
|
||||
}
|
||||
}
|
||||
|
||||
if (!additionalInfo) {
|
||||
additionalInfo = '-';
|
||||
}
|
||||
|
||||
// 6. 연결 방식 (투입구/Connection Type)
|
||||
let connectionType = '';
|
||||
if (descUpper.includes('SW X THRD') || descUpper.includes('SW×THRD')) {
|
||||
connectionType = 'SW×THRD';
|
||||
} else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) {
|
||||
connectionType = 'FLG';
|
||||
} else if (descUpper.includes('SW') || descUpper.includes('SOCKET')) {
|
||||
connectionType = 'SW';
|
||||
} else if (descUpper.includes('THRD') || descUpper.includes('THREAD')) {
|
||||
connectionType = 'THRD';
|
||||
} else if (descUpper.includes('BW') || descUpper.includes('BUTT WELD')) {
|
||||
connectionType = 'BW';
|
||||
} else {
|
||||
connectionType = '-';
|
||||
}
|
||||
|
||||
// 7. 구매 수량 계산 (기본 수량 그대로)
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
const purchaseQuantity = `${qty} EA`;
|
||||
|
||||
return {
|
||||
type: valveType, // 벨브 종류만 (GATE VALVE, BALL VALVE 등)
|
||||
size: size,
|
||||
pressure: pressure,
|
||||
brand: brand, // 브랜드 (사용자 입력 가능)
|
||||
additionalInfo: additionalInfo, // 추가 정보 (3-WAY, DOUL PLATE 등)
|
||||
connection: connectionType, // 투입구/연결방식 (SW, FLG 등)
|
||||
additionalRequest: '-', // 추가 요구사항 (기존 User Requirement)
|
||||
purchaseQuantity: purchaseQuantity,
|
||||
originalQuantity: qty,
|
||||
isValve: true
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = parseValveInfo(material);
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
if (sortConfig && sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseValveInfo(a);
|
||||
const bInfo = parseValveInfo(b);
|
||||
|
||||
if (!aInfo || !bInfo) return 0;
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
// 값이 없는 경우 처리
|
||||
if (aValue === undefined && bValue === undefined) return 0;
|
||||
if (aValue === undefined) return 1;
|
||||
if (bValue === undefined) return -1;
|
||||
|
||||
// 숫자인 경우 숫자로 비교
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||
}
|
||||
|
||||
// 문자열로 비교
|
||||
const aStr = String(aValue).toLowerCase();
|
||||
const bStr = String(bValue).toLowerCase();
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aStr.localeCompare(bStr);
|
||||
} else {
|
||||
return bStr.localeCompare(aStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||
// 브랜드 저장 함수
|
||||
const handleSaveBrand = async (materialId, brand) => {
|
||||
if (!brand.trim()) return;
|
||||
|
||||
setSavingBrand(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/brand`, { brand: brand.trim() });
|
||||
// 성공 시 저장된 상태로 전환
|
||||
setSavedBrands(prev => ({ ...prev, [materialId]: brand.trim() }));
|
||||
setEditingBrand(prev => ({ ...prev, [materialId]: false }));
|
||||
setBrandInputs(prev => ({ ...prev, [materialId]: '' })); // 입력 필드 초기화
|
||||
|
||||
// materials 배열도 업데이트 (카테고리 변경 시 데이터 유지를 위해)
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { brand: brand.trim() });
|
||||
console.log('✅ materials 배열 업데이트 완료:', materialId, '→', brand.trim());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('브랜드 저장 실패:', error);
|
||||
alert('브랜드 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingBrand(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 브랜드 편집 시작
|
||||
const handleEditBrand = (materialId, currentBrand) => {
|
||||
setEditingBrand(prev => ({ ...prev, [materialId]: true }));
|
||||
setBrandInputs(prev => ({ ...prev, [materialId]: currentBrand || '' }));
|
||||
};
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
// 성공 시 저장된 상태로 전환
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' })); // 입력 필드 초기화
|
||||
|
||||
// materials 배열도 업데이트 (카테고리 변경 시 데이터 유지를 위해)
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
console.log('✅ materials 배열 업데이트 완료:', materialId, '→', request.trim());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
|
||||
if (selectedMaterials.size === selectableMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
if (purchasedMaterials.has(materialId)) {
|
||||
return; // 구매신청된 자재는 선택 불가
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `VALVE_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 밸브 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'VALVE',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'VALVE',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'VALVE');
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
if (onPurchasedMaterialsUpdate) {
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 클라이언트 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'VALVE',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Valve Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
maxHeight: '600px'
|
||||
}}>
|
||||
<div style={{ minWidth: '1600px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 120px 150px 180px 180px 150px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="type"
|
||||
filterKey="type"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="pressure"
|
||||
filterKey="pressure"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Pressure
|
||||
</FilterableHeader>
|
||||
<div>Brand</div>
|
||||
<FilterableHeader
|
||||
sortKey="additionalInfo"
|
||||
filterKey="additionalInfo"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Additional Info
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="connection"
|
||||
filterKey="connection"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Connection
|
||||
</FilterableHeader>
|
||||
<div>Additional Request</div>
|
||||
<FilterableHeader
|
||||
sortKey="purchaseQuantity"
|
||||
filterKey="purchaseQuantity"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Purchase Quantity
|
||||
</FilterableHeader>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseValveInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 120px 150px 180px 180px 150px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.type}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.pressure}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{(() => {
|
||||
// 디버깅: 렌더링 시점의 상태 확인
|
||||
const hasEditingBrand = !!editingBrand[material.id];
|
||||
const hasSavedBrand = !!savedBrands[material.id];
|
||||
const shouldShowSaved = !hasEditingBrand && hasSavedBrand;
|
||||
|
||||
if (material.id === 11789) { // 테스트 자재만 로그
|
||||
console.log(`🎨 UI 렌더링 - ID ${material.id}:`, {
|
||||
editingBrand: hasEditingBrand,
|
||||
savedBrandExists: hasSavedBrand,
|
||||
savedBrandValue: savedBrands[material.id],
|
||||
shouldShowSaved: shouldShowSaved,
|
||||
allSavedBrands: Object.keys(savedBrands),
|
||||
renderingMode: shouldShowSaved ? 'SAVED_VIEW' : 'INPUT_VIEW'
|
||||
});
|
||||
}
|
||||
|
||||
// 명시적으로 boolean 반환
|
||||
return shouldShowSaved ? true : false;
|
||||
})() ? (
|
||||
// 저장된 상태 - 브랜드 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedBrands[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditBrand(material.id, savedBrands[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={brandInputs[material.id] || ''}
|
||||
onChange={(e) => setBrandInputs({
|
||||
...brandInputs,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter brand..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
textAlign: 'center',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveBrand(material.id, brandInputs[material.id] || '')}
|
||||
disabled={isPurchased || savingBrand[material.id] || !brandInputs[material.id]?.trim()}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#3b82f6',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingBrand[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased || !brandInputs[material.id]?.trim() ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingBrand[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.additionalInfo}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.connection}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.purchaseQuantity}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Valve Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No valve materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValveMaterialsView;
|
||||
10
tkeg/web/src/components/bom/materials/index.js
Normal file
10
tkeg/web/src/components/bom/materials/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// BOM Materials Components
|
||||
export { default as PipeMaterialsView } from './PipeMaterialsView';
|
||||
export { default as FittingMaterialsView } from './FittingMaterialsView';
|
||||
export { default as FlangeMaterialsView } from './FlangeMaterialsView';
|
||||
export { default as ValveMaterialsView } from './ValveMaterialsView';
|
||||
export { default as GasketMaterialsView } from './GasketMaterialsView';
|
||||
export { default as BoltMaterialsView } from './BoltMaterialsView';
|
||||
export { default as SupportMaterialsView } from './SupportMaterialsView';
|
||||
export { default as SpecialMaterialsView } from './SpecialMaterialsView';
|
||||
export { default as UnclassifiedMaterialsView } from './UnclassifiedMaterialsView';
|
||||
78
tkeg/web/src/components/bom/shared/FilterableHeader.jsx
Normal file
78
tkeg/web/src/components/bom/shared/FilterableHeader.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
|
||||
const FilterableHeader = ({
|
||||
sortKey,
|
||||
filterKey,
|
||||
children,
|
||||
sortConfig,
|
||||
onSort,
|
||||
columnFilters,
|
||||
onFilterChange,
|
||||
showFilterDropdown,
|
||||
setShowFilterDropdown
|
||||
}) => {
|
||||
return (
|
||||
<div className="filterable-header" style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
onClick={() => onSort(sortKey)}
|
||||
style={{ cursor: 'pointer', flex: 1 }}
|
||||
>
|
||||
{children}
|
||||
{sortConfig && sortConfig.key === sortKey && (
|
||||
<span style={{ marginLeft: '4px' }}>
|
||||
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
fontSize: '12px',
|
||||
color: '#6b7280'
|
||||
}}
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</div>
|
||||
{showFilterDropdown === filterKey && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
padding: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 1000,
|
||||
minWidth: '150px'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Filter ${children}...`}
|
||||
value={columnFilters[filterKey] || ''}
|
||||
onChange={(e) => onFilterChange({
|
||||
...columnFilters,
|
||||
[filterKey]: e.target.value
|
||||
})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterableHeader;
|
||||
161
tkeg/web/src/components/bom/shared/MaterialTable.jsx
Normal file
161
tkeg/web/src/components/bom/shared/MaterialTable.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
|
||||
const MaterialTable = ({
|
||||
children,
|
||||
className = '',
|
||||
style = {}
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`material-table ${className}`}
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
...style
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MaterialTableHeader = ({
|
||||
children,
|
||||
gridColumns,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`material-table-header ${className}`}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: gridColumns,
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MaterialTableBody = ({
|
||||
children,
|
||||
maxHeight = '600px',
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`material-table-body ${className}`}
|
||||
style={{
|
||||
maxHeight,
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MaterialTableRow = ({
|
||||
children,
|
||||
gridColumns,
|
||||
isSelected = false,
|
||||
isPurchased = false,
|
||||
isLast = false,
|
||||
onClick,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`material-table-row ${className}`}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: gridColumns,
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: !isLast ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
cursor: onClick ? 'pointer' : 'default'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased && !onClick) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased && !onClick) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MaterialTableCell = ({
|
||||
children,
|
||||
align = 'left',
|
||||
fontWeight = 'normal',
|
||||
color = '#1f2937',
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`material-table-cell ${className}`}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color,
|
||||
fontWeight,
|
||||
textAlign: align
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MaterialTableEmpty = ({
|
||||
icon = '📦',
|
||||
title = 'No Materials Found',
|
||||
message = 'No materials available',
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`material-table-empty ${className}`}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{icon}</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 복합 컴포넌트로 export
|
||||
MaterialTable.Header = MaterialTableHeader;
|
||||
MaterialTable.Body = MaterialTableBody;
|
||||
MaterialTable.Row = MaterialTableRow;
|
||||
MaterialTable.Cell = MaterialTableCell;
|
||||
MaterialTable.Empty = MaterialTableEmpty;
|
||||
|
||||
export default MaterialTable;
|
||||
3
tkeg/web/src/components/bom/shared/index.js
Normal file
3
tkeg/web/src/components/bom/shared/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// BOM Shared Components
|
||||
export { default as FilterableHeader } from './FilterableHeader';
|
||||
export { default as MaterialTable } from './MaterialTable';
|
||||
536
tkeg/web/src/components/bom/tabs/BOMFilesTab.jsx
Normal file
536
tkeg/web/src/components/bom/tabs/BOMFilesTab.jsx
Normal file
@@ -0,0 +1,536 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../../../api';
|
||||
|
||||
const BOMFilesTab = ({
|
||||
selectedProject,
|
||||
user,
|
||||
bomFiles,
|
||||
setBomFiles,
|
||||
selectedBOM,
|
||||
onBOMSelect,
|
||||
refreshTrigger
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [revisionDialog, setRevisionDialog] = useState({ open: false, file: null });
|
||||
const [groupedFiles, setGroupedFiles] = useState({});
|
||||
|
||||
// BOM 파일 목록 로드 함수
|
||||
const loadBOMFiles = async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const projectCode = selectedProject.official_project_code || selectedProject.job_no;
|
||||
const encodedProjectCode = encodeURIComponent(projectCode);
|
||||
const response = await api.get(`/files/project/${encodedProjectCode}`);
|
||||
const files = response.data || [];
|
||||
|
||||
setBomFiles(files);
|
||||
|
||||
// BOM 이름별로 그룹화
|
||||
const groups = groupFilesByBOM(files);
|
||||
setGroupedFiles(groups);
|
||||
|
||||
} catch (err) {
|
||||
console.error('BOM 파일 로드 실패:', err);
|
||||
setError('BOM 파일을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// BOM 파일 목록 로드
|
||||
useEffect(() => {
|
||||
loadBOMFiles();
|
||||
}, [selectedProject, refreshTrigger, setBomFiles]);
|
||||
|
||||
// 파일을 BOM 이름별로 그룹화
|
||||
const groupFilesByBOM = (fileList) => {
|
||||
const groups = {};
|
||||
|
||||
fileList.forEach(file => {
|
||||
const bomName = file.bom_name || file.original_filename;
|
||||
if (!groups[bomName]) {
|
||||
groups[bomName] = [];
|
||||
}
|
||||
groups[bomName].push(file);
|
||||
});
|
||||
|
||||
// 각 그룹 내에서 리비전 번호로 정렬
|
||||
Object.keys(groups).forEach(bomName => {
|
||||
groups[bomName].sort((a, b) => {
|
||||
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
|
||||
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
|
||||
return revB - revA; // 최신 리비전이 위로
|
||||
});
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
// BOM 선택 처리
|
||||
const handleBOMClick = (bomFile) => {
|
||||
if (onBOMSelect) {
|
||||
onBOMSelect(bomFile);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 삭제
|
||||
const handleDeleteFile = async (fileId, bomName) => {
|
||||
if (!window.confirm(`이 파일을 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/files/delete/${fileId}`);
|
||||
|
||||
// 파일 목록 새로고침
|
||||
const projectCode = selectedProject.official_project_code || selectedProject.job_no;
|
||||
const encodedProjectCode = encodeURIComponent(projectCode);
|
||||
const response = await api.get(`/files/project/${encodedProjectCode}`);
|
||||
const files = response.data || [];
|
||||
setBomFiles(files);
|
||||
setGroupedFiles(groupFilesByBOM(files));
|
||||
|
||||
} catch (err) {
|
||||
console.error('파일 삭제 실패:', err);
|
||||
setError('파일 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 리비전 업로드
|
||||
const handleRevisionUpload = (parentFile) => {
|
||||
setRevisionDialog({
|
||||
open: true,
|
||||
file: parentFile
|
||||
});
|
||||
};
|
||||
|
||||
// 리비전 업로드 성공 핸들러
|
||||
const handleRevisionUploadSuccess = () => {
|
||||
setRevisionDialog({ open: false, file: null });
|
||||
// BOM 파일 목록 새로고침
|
||||
loadBOMFiles();
|
||||
};
|
||||
|
||||
// 파일 업로드 처리
|
||||
const handleFileUpload = async (file) => {
|
||||
if (!file || !revisionDialog.file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
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);
|
||||
|
||||
const response = await api.post('/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
alert(`리비전 업로드 성공! ${response.data.revision}`);
|
||||
handleRevisionUploadSuccess();
|
||||
} else {
|
||||
alert(response.data.message || '리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 업로드 실패:', error);
|
||||
alert('리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 포맷팅
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR');
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '60px',
|
||||
textAlign: 'center',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
<div style={{ fontSize: '24px', marginBottom: '16px' }}>⏳</div>
|
||||
<div>Loading BOM files...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#fee2e2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
color: '#dc2626'
|
||||
}}>
|
||||
<div style={{ fontSize: '20px', marginBottom: '8px' }}>⚠️</div>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (bomFiles.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '60px',
|
||||
textAlign: 'center',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📄</div>
|
||||
<h3 style={{ fontSize: '20px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No BOM Files Found
|
||||
</h3>
|
||||
<p style={{ fontSize: '16px', margin: 0 }}>
|
||||
Upload your first BOM file using the Upload tab
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '40px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
BOM Files & Revisions
|
||||
</h2>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
Select a BOM file to manage its materials
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1d4ed8'
|
||||
}}>
|
||||
{Object.keys(groupedFiles).length} BOM Groups • {bomFiles.length} Total Files
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOM 파일 그룹 목록 */}
|
||||
<div style={{ display: 'grid', gap: '24px' }}>
|
||||
{Object.entries(groupedFiles).map(([bomName, files]) => {
|
||||
const latestFile = files[0]; // 최신 리비전
|
||||
const isSelected = selectedBOM?.id === latestFile.id;
|
||||
|
||||
return (
|
||||
<div key={bomName} style={{
|
||||
background: isSelected ? '#eff6ff' : 'white',
|
||||
border: isSelected ? '2px solid #3b82f6' : '1px solid #e5e7eb',
|
||||
borderRadius: '16px',
|
||||
padding: '24px',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => handleBOMClick(latestFile)}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: isSelected ? '#1d4ed8' : '#374151',
|
||||
margin: '0 0 8px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<span style={{ fontSize: '24px' }}>📋</span>
|
||||
{bomName}
|
||||
{isSelected && (
|
||||
<span style={{
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
SELECTED
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '12px',
|
||||
fontSize: '14px',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
<div>
|
||||
<span style={{ fontWeight: '500' }}>Latest:</span> {latestFile.revision || 'Rev.0'}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontWeight: '500' }}>Revisions:</span> {Math.max(0, files.length - 1)}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontWeight: '500' }}>Updated:</span> {formatDate(latestFile.upload_date)}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontWeight: '500' }}>Size:</span> {latestFile.file_size ? `${Math.round(latestFile.file_size / 1024)} KB` : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginLeft: '16px' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRevisionUpload(latestFile);
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'white',
|
||||
color: '#f59e0b',
|
||||
border: '1px solid #f59e0b',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
📝 New Revision
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteFile(latestFile.id, bomName);
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리비전 히스토리 */}
|
||||
{files.length > 1 && (
|
||||
<div style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
marginTop: '16px'
|
||||
}}>
|
||||
<h4 style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Revision History
|
||||
</h4>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{files.map((file, index) => (
|
||||
<div key={file.id} style={{
|
||||
background: index === 0 ? '#dbeafe' : 'white',
|
||||
color: index === 0 ? '#1d4ed8' : '#6b7280',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{file.revision || 'Rev.0'}
|
||||
{index === 0 && ' (Latest)'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택 안내 */}
|
||||
{!isSelected && (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
background: 'rgba(59, 130, 246, 0.05)',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center',
|
||||
fontSize: '14px',
|
||||
color: '#3b82f6',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
Click to select this BOM for material management
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 향후 기능 안내 */}
|
||||
<div style={{
|
||||
marginTop: '40px',
|
||||
padding: '24px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
🚧 Coming Soon: Advanced Revision Features
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', marginBottom: '8px' }}>📊</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
|
||||
Visual Timeline
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b7280' }}>
|
||||
Interactive revision history
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', marginBottom: '8px' }}>🔍</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
|
||||
Diff Comparison
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b7280' }}>
|
||||
Compare changes between revisions
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', marginBottom: '8px' }}>⏪</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
|
||||
Rollback System
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b7280' }}>
|
||||
Restore previous versions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리비전 업로드 다이얼로그 */}
|
||||
{revisionDialog.open && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '500px',
|
||||
width: '90%',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600' }}>
|
||||
📝 리비전 업로드: {revisionDialog.file?.original_filename || 'BOM 파일'}
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '16px', fontSize: '14px', color: '#6b7280' }}>
|
||||
새로운 리비전 파일을 선택해주세요.
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginBottom: '16px',
|
||||
padding: '8px',
|
||||
border: '2px dashed #d1d5db',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setRevisionDialog({ open: false, file: null })}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#f3f4f6',
|
||||
color: '#374151',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMFilesTab;
|
||||
105
tkeg/web/src/components/bom/tabs/BOMMaterialsTab.jsx
Normal file
105
tkeg/web/src/components/bom/tabs/BOMMaterialsTab.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import BOMManagementPage from '../../../pages/BOMManagementPage';
|
||||
|
||||
const BOMMaterialsTab = ({
|
||||
selectedProject,
|
||||
user,
|
||||
selectedBOM,
|
||||
onNavigate
|
||||
}) => {
|
||||
// BOMManagementPage에 필요한 props 구성
|
||||
const bomManagementProps = {
|
||||
onNavigate,
|
||||
user,
|
||||
selectedProject,
|
||||
fileId: selectedBOM?.id,
|
||||
jobNo: selectedBOM?.job_no || selectedProject?.official_project_code || selectedProject?.job_no,
|
||||
bomName: selectedBOM?.bom_name || selectedBOM?.original_filename,
|
||||
revision: selectedBOM?.revision || 'Rev.0',
|
||||
filename: selectedBOM?.original_filename
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
minHeight: '600px'
|
||||
}}>
|
||||
{/* 헤더 정보 */}
|
||||
<div style={{
|
||||
padding: '24px 40px',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
background: '#f8fafc'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Material Management
|
||||
</h2>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
BOM: {selectedBOM?.bom_name || selectedBOM?.original_filename} • {selectedBOM?.revision || 'Rev.0'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#1d4ed8' }}>
|
||||
{selectedBOM?.id || 'N/A'}
|
||||
</div>
|
||||
<div style={{ color: '#1d4ed8', fontWeight: '500' }}>
|
||||
File ID
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#059669' }}>
|
||||
{selectedBOM?.revision || 'Rev.0'}
|
||||
</div>
|
||||
<div style={{ color: '#059669', fontWeight: '500' }}>
|
||||
Revision
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOM 관리 페이지 임베드 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
// BOMManagementPage의 기본 패딩을 제거하기 위한 스타일 오버라이드
|
||||
'& > div': {
|
||||
padding: '0 !important',
|
||||
background: 'transparent !important',
|
||||
minHeight: 'auto !important'
|
||||
}
|
||||
}}>
|
||||
<BOMManagementPage {...bomManagementProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMMaterialsTab;
|
||||
494
tkeg/web/src/components/bom/tabs/BOMUploadTab.jsx
Normal file
494
tkeg/web/src/components/bom/tabs/BOMUploadTab.jsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import api from '../../../api';
|
||||
|
||||
const BOMUploadTab = ({
|
||||
selectedProject,
|
||||
user,
|
||||
onUploadSuccess,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [selectedFiles, setSelectedFiles] = useState([]);
|
||||
const [bomName, setBomName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// 파일 검증
|
||||
const validateFile = (file) => {
|
||||
const allowedTypes = [
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/csv'
|
||||
];
|
||||
|
||||
const maxSize = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx|xls|csv)$/i)) {
|
||||
return '지원되지 않는 파일 형식입니다. Excel 또는 CSV 파일만 업로드 가능합니다.';
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
return '파일 크기가 너무 큽니다. 50MB 이하의 파일만 업로드 가능합니다.';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 파일 선택 처리
|
||||
const handleFileSelect = useCallback((files) => {
|
||||
const fileList = Array.from(files);
|
||||
const validFiles = [];
|
||||
const errors = [];
|
||||
|
||||
fileList.forEach(file => {
|
||||
const error = validateFile(file);
|
||||
if (error) {
|
||||
errors.push(`${file.name}: ${error}`);
|
||||
} else {
|
||||
validFiles.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFiles(validFiles);
|
||||
setError('');
|
||||
|
||||
// 첫 번째 파일명을 기본 BOM 이름으로 설정 (확장자 제거)
|
||||
if (validFiles.length > 0 && !bomName) {
|
||||
const fileName = validFiles[0].name;
|
||||
const nameWithoutExt = fileName.replace(/\.[^/.]+$/, '');
|
||||
setBomName(nameWithoutExt);
|
||||
}
|
||||
}, [bomName]);
|
||||
|
||||
// 드래그 앤 드롭 처리
|
||||
const handleDragOver = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
}, [handleFileSelect]);
|
||||
|
||||
// 파일 선택 버튼 클릭
|
||||
const handleFileButtonClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// 파일 업로드
|
||||
const handleUpload = async () => {
|
||||
if (selectedFiles.length === 0) {
|
||||
setError('업로드할 파일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bomName.trim()) {
|
||||
setError('BOM 이름을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedProject) {
|
||||
setError('프로젝트를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
let uploadedFile = null;
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const file = selectedFiles[i];
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', selectedProject.official_project_code || selectedProject.job_no);
|
||||
formData.append('bom_name', bomName.trim());
|
||||
formData.append('revision', 'Rev.0'); // 새 업로드는 항상 Rev.0
|
||||
|
||||
const response = await api.post('/files/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const progress = Math.round(
|
||||
((i * 100) + (progressEvent.loaded * 100) / progressEvent.total) / selectedFiles.length
|
||||
);
|
||||
setUploadProgress(progress);
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.message || '업로드 실패');
|
||||
}
|
||||
|
||||
// 첫 번째 파일의 정보를 저장
|
||||
if (i === 0) {
|
||||
uploadedFile = {
|
||||
id: response.data.file_id,
|
||||
bom_name: bomName.trim(),
|
||||
revision: 'Rev.0',
|
||||
job_no: selectedProject.official_project_code || selectedProject.job_no,
|
||||
original_filename: file.name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setSuccess(`${selectedFiles.length}개 파일이 성공적으로 업로드되었습니다!`);
|
||||
|
||||
// 업로드 성공 즉시 콜백 호출 (파일 목록 새로고침)
|
||||
if (onUploadSuccess) {
|
||||
onUploadSuccess(uploadedFile);
|
||||
}
|
||||
|
||||
// 파일 초기화
|
||||
setSelectedFiles([]);
|
||||
setBomName('');
|
||||
|
||||
} catch (err) {
|
||||
console.error('업로드 실패:', err);
|
||||
setError(`업로드 실패: ${err.response?.data?.detail || err.message}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 제거
|
||||
const removeFile = (index) => {
|
||||
const newFiles = selectedFiles.filter((_, i) => i !== index);
|
||||
setSelectedFiles(newFiles);
|
||||
|
||||
if (newFiles.length === 0) {
|
||||
setBomName('');
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 크기 포맷팅
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '40px' }}>
|
||||
{/* BOM 이름 입력 */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
BOM Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bomName}
|
||||
onChange={(e) => setBomName(e.target.value)}
|
||||
placeholder="Enter BOM name..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
fontSize: '16px',
|
||||
transition: 'border-color 0.2s ease',
|
||||
outline: 'none'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파일 드롭 영역 */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
border: `3px dashed ${dragOver ? '#3b82f6' : '#d1d5db'}`,
|
||||
borderRadius: '16px',
|
||||
padding: '60px 40px',
|
||||
textAlign: 'center',
|
||||
background: dragOver ? '#eff6ff' : '#f9fafb',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '24px'
|
||||
}}
|
||||
onClick={handleFileButtonClick}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
|
||||
{dragOver ? '📁' : '📄'}
|
||||
</div>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
{dragOver ? 'Drop files here' : 'Upload BOM Files'}
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#6b7280',
|
||||
margin: '0 0 16px 0'
|
||||
}}>
|
||||
Drag and drop your Excel or CSV files here, or click to browse
|
||||
</p>
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#3b82f6'
|
||||
}}>
|
||||
<span>📋</span>
|
||||
Supported: .xlsx, .xls, .csv (Max 50MB)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 숨겨진 파일 입력 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* 선택된 파일 목록 */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
Selected Files ({selectedFiles.length})
|
||||
</h4>
|
||||
<div style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
padding: '16px'
|
||||
}}>
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div key={index} style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
marginBottom: index < selectedFiles.length - 1 ? '8px' : '0',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '20px' }}>📄</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: '500', color: '#374151' }}>
|
||||
{file.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280' }}>
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
style={{
|
||||
background: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 진행률 */}
|
||||
{uploading && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
|
||||
Uploading...
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500', color: '#3b82f6' }}>
|
||||
{uploadProgress}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '8px',
|
||||
background: '#e5e7eb',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${uploadProgress}%`,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fee2e2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '16px' }}>⚠️</span>
|
||||
<div style={{ fontSize: '14px', color: '#dc2626', whiteSpace: 'pre-line' }}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 성공 메시지 */}
|
||||
{success && (
|
||||
<div style={{
|
||||
background: '#dcfce7',
|
||||
border: '1px solid #bbf7d0',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '16px' }}>✅</span>
|
||||
<div style={{ fontSize: '14px', color: '#059669' }}>
|
||||
{success}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 버튼 */}
|
||||
<div style={{ display: 'flex', gap: '16px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || selectedFiles.length === 0 || !bomName.trim()}
|
||||
style={{
|
||||
padding: '12px 32px',
|
||||
background: (uploading || selectedFiles.length === 0 || !bomName.trim())
|
||||
? '#d1d5db'
|
||||
: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: (uploading || selectedFiles.length === 0 || !bomName.trim())
|
||||
? 'not-allowed'
|
||||
: 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload BOM'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 가이드 정보 */}
|
||||
<div style={{
|
||||
marginTop: '40px',
|
||||
padding: '24px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
📋 Upload Guidelines
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '20px'
|
||||
}}>
|
||||
<div>
|
||||
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#059669', marginBottom: '8px' }}>
|
||||
✅ Supported Formats
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
|
||||
<li>Excel files (.xlsx, .xls)</li>
|
||||
<li>CSV files (.csv)</li>
|
||||
<li>Maximum file size: 50MB</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#3b82f6', marginBottom: '8px' }}>
|
||||
📊 Required Columns
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
|
||||
<li>Description (자재명/품명)</li>
|
||||
<li>Quantity (수량)</li>
|
||||
<li>Size information (사이즈)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#f59e0b', marginBottom: '8px' }}>
|
||||
⚡ Auto Processing
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
|
||||
<li>Automatic material classification</li>
|
||||
<li>WELD GAP items excluded</li>
|
||||
<li>Ready for material management</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMUploadTab;
|
||||
163
tkeg/web/src/components/common/ErrorBoundary.jsx
Normal file
163
tkeg/web/src/components/common/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: errorInfo
|
||||
});
|
||||
|
||||
// 에러 로깅
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// 에러 컨텍스트 정보 로깅
|
||||
if (this.props.errorContext) {
|
||||
console.error('Error context:', this.props.errorContext);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||||
padding: '40px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '40px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
textAlign: 'center',
|
||||
maxWidth: '600px'
|
||||
}}>
|
||||
<div style={{ fontSize: '64px', marginBottom: '24px' }}>⚠️</div>
|
||||
|
||||
<h2 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#dc2626',
|
||||
margin: '0 0 16px 0',
|
||||
letterSpacing: '-0.025em'
|
||||
}}>
|
||||
Something went wrong
|
||||
</h2>
|
||||
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
marginBottom: '32px',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
An unexpected error occurred. Please try refreshing the page or contact support if the problem persists.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px', justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 24px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.transform = 'translateY(-2px)';
|
||||
e.target.style.boxShadow = '0 8px 25px 0 rgba(59, 130, 246, 0.5)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.transform = 'translateY(0)';
|
||||
e.target.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null, errorInfo: null })}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 24px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#f9fafb';
|
||||
e.target.style.borderColor = '#9ca3af';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'white';
|
||||
e.target.style.borderColor = '#d1d5db';
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 개발 환경에서만 에러 상세 정보 표시 */}
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details style={{
|
||||
marginTop: '32px',
|
||||
textAlign: 'left',
|
||||
background: '#f8fafc',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<summary style={{
|
||||
cursor: 'pointer',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Error Details (Development)
|
||||
</summary>
|
||||
<pre style={{
|
||||
fontSize: '12px',
|
||||
color: '#dc2626',
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{this.state.error && this.state.error.toString()}
|
||||
<br />
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
170
tkeg/web/src/components/common/UserMenu.jsx
Normal file
170
tkeg/web/src/components/common/UserMenu.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { useState } from 'react';
|
||||
import { config } from '../../config';
|
||||
|
||||
const UserMenu = ({ user, onNavigate, onLogout }) => {
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: '#f8f9fa',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#495057',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#e9ecef';
|
||||
e.target.style.borderColor = '#dee2e6';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = '#f8f9fa';
|
||||
e.target.style.borderColor = '#e9ecef';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{(user?.name || user?.username || 'U').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{user?.name || user?.username}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
{user?.role === 'system' ? '시스템 관리자' :
|
||||
user?.role === 'admin' ? '관리자' : '사용자'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
transform: showUserMenu ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease'
|
||||
}}>
|
||||
▼
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: '8px',
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 1050,
|
||||
minWidth: '200px'
|
||||
}}>
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<div style={{ padding: '8px 16px', borderBottom: '1px solid #f1f3f4' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{user?.name || user?.username}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
{user?.department || user?.role || ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
window.open(config.tkuserUrl, '_blank');
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||
>
|
||||
⚙️ 계정 관리 (tkuser)
|
||||
</button>
|
||||
|
||||
{(user?.role === 'admin' || user?.role === 'system') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onNavigate('system-settings');
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||
>
|
||||
🔧 시스템 설정
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid #f1f3f4', marginTop: '4px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLogout();
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#dc3545',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||
>
|
||||
🚪 로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserMenu;
|
||||
3
tkeg/web/src/components/common/index.js
Normal file
3
tkeg/web/src/components/common/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Common Components
|
||||
export { default as UserMenu } from './UserMenu';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
541
tkeg/web/src/components/revision/RevisionManagementPanel.jsx
Normal file
541
tkeg/web/src/components/revision/RevisionManagementPanel.jsx
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* 리비전 관리 패널 컴포넌트
|
||||
* BOM 관리 페이지에 통합되어 리비전 기능을 제공
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRevisionManagement } from '../../hooks/useRevisionManagement';
|
||||
|
||||
const RevisionManagementPanel = ({
|
||||
jobNo,
|
||||
currentFileId,
|
||||
previousFileId,
|
||||
onRevisionComplete,
|
||||
onRevisionCancel
|
||||
}) => {
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
currentSession,
|
||||
sessionStatus,
|
||||
createRevisionSession,
|
||||
getSessionStatus,
|
||||
compareCategory,
|
||||
getSessionChanges,
|
||||
processRevisionAction,
|
||||
completeSession,
|
||||
cancelSession,
|
||||
getRevisionSummary,
|
||||
getSupportedCategories,
|
||||
clearError
|
||||
} = useRevisionManagement();
|
||||
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
const [categoryChanges, setCategoryChanges] = useState({});
|
||||
const [revisionSummary, setRevisionSummary] = useState(null);
|
||||
const [processingActions, setProcessingActions] = useState(new Set());
|
||||
|
||||
// 컴포넌트 초기화
|
||||
useEffect(() => {
|
||||
initializeRevisionPanel();
|
||||
}, [currentFileId, previousFileId]);
|
||||
|
||||
// 세션 상태 모니터링
|
||||
useEffect(() => {
|
||||
if (currentSession?.session_id) {
|
||||
const interval = setInterval(() => {
|
||||
refreshSessionStatus();
|
||||
}, 5000); // 5초마다 상태 갱신
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [currentSession]);
|
||||
|
||||
const initializeRevisionPanel = async () => {
|
||||
try {
|
||||
// 지원 카테고리 로드
|
||||
const categoriesResult = await getSupportedCategories();
|
||||
if (categoriesResult.success) {
|
||||
setCategories(categoriesResult.data);
|
||||
}
|
||||
|
||||
// 리비전 세션 생성
|
||||
if (currentFileId && previousFileId) {
|
||||
const sessionResult = await createRevisionSession(jobNo, currentFileId, previousFileId);
|
||||
if (sessionResult.success) {
|
||||
console.log('✅ 리비전 세션 생성 완료');
|
||||
await refreshSessionStatus();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 패널 초기화 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSessionStatus = async () => {
|
||||
if (currentSession?.session_id) {
|
||||
try {
|
||||
await getSessionStatus(currentSession.session_id);
|
||||
await loadRevisionSummary();
|
||||
} catch (error) {
|
||||
console.error('세션 상태 갱신 실패:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadRevisionSummary = async () => {
|
||||
if (currentSession?.session_id) {
|
||||
try {
|
||||
const summaryResult = await getRevisionSummary(currentSession.session_id);
|
||||
if (summaryResult.success) {
|
||||
setRevisionSummary(summaryResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 요약 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryCompare = async (category) => {
|
||||
if (!currentSession?.session_id) return;
|
||||
|
||||
try {
|
||||
const result = await compareCategory(currentSession.session_id, category);
|
||||
if (result.success) {
|
||||
// 변경사항 로드
|
||||
const changesResult = await getSessionChanges(currentSession.session_id, category);
|
||||
if (changesResult.success) {
|
||||
setCategoryChanges(prev => ({
|
||||
...prev,
|
||||
[category]: changesResult.data.changes
|
||||
}));
|
||||
}
|
||||
|
||||
await refreshSessionStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`카테고리 ${category} 비교 실패:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionProcess = async (changeId, action, notes = '') => {
|
||||
setProcessingActions(prev => new Set(prev).add(changeId));
|
||||
|
||||
try {
|
||||
const result = await processRevisionAction(changeId, action, notes);
|
||||
if (result.success) {
|
||||
// 해당 카테고리 변경사항 새로고침
|
||||
if (selectedCategory) {
|
||||
const changesResult = await getSessionChanges(currentSession.session_id, selectedCategory);
|
||||
if (changesResult.success) {
|
||||
setCategoryChanges(prev => ({
|
||||
...prev,
|
||||
[selectedCategory]: changesResult.data.changes
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
await refreshSessionStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('액션 처리 실패:', error);
|
||||
} finally {
|
||||
setProcessingActions(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(changeId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteRevision = async () => {
|
||||
if (!currentSession?.session_id) return;
|
||||
|
||||
try {
|
||||
const result = await completeSession(currentSession.session_id);
|
||||
if (result.success) {
|
||||
onRevisionComplete?.(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 완료 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelRevision = async (reason = '') => {
|
||||
if (!currentSession?.session_id) return;
|
||||
|
||||
try {
|
||||
const result = await cancelSession(currentSession.session_id, reason);
|
||||
if (result.success) {
|
||||
onRevisionCancel?.(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 취소 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getActionColor = (action) => {
|
||||
const colors = {
|
||||
'new_material': '#10b981',
|
||||
'additional_purchase': '#f59e0b',
|
||||
'inventory_transfer': '#8b5cf6',
|
||||
'purchase_cancel': '#ef4444',
|
||||
'quantity_update': '#3b82f6',
|
||||
'maintain': '#6b7280'
|
||||
};
|
||||
return colors[action] || '#6b7280';
|
||||
};
|
||||
|
||||
const getActionLabel = (action) => {
|
||||
const labels = {
|
||||
'new_material': '신규 자재',
|
||||
'additional_purchase': '추가 구매',
|
||||
'inventory_transfer': '재고 이관',
|
||||
'purchase_cancel': '구매 취소',
|
||||
'quantity_update': '수량 업데이트',
|
||||
'maintain': '유지'
|
||||
};
|
||||
return labels[action] || action;
|
||||
};
|
||||
|
||||
if (!currentSession) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
border: '2px dashed #cbd5e1'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', color: '#64748b', marginBottom: '8px' }}>
|
||||
🔄 리비전 세션 초기화 중...
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#94a3b8' }}>
|
||||
자재 비교를 위한 세션을 준비하고 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
padding: '20px 24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '20px', fontWeight: '600' }}>
|
||||
📊 리비전 관리
|
||||
</h3>
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: '14px', opacity: 0.9 }}>
|
||||
Job: {jobNo} | 세션 ID: {currentSession.session_id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleCompleteRevision}
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
opacity: loading ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
✅ 완료
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCancelRevision('사용자 요청')}
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
opacity: loading ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
❌ 취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: '#dc2626',
|
||||
padding: '12px 24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span>⚠️ {error}</span>
|
||||
<button
|
||||
onClick={clearError}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#dc2626',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 진행 상황 */}
|
||||
{sessionStatus && (
|
||||
<div style={{ padding: '20px 24px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '16px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#10b981' }}>
|
||||
{sessionStatus.session_info.added_count || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>추가</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#ef4444' }}>
|
||||
{sessionStatus.session_info.removed_count || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>제거</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#f59e0b' }}>
|
||||
{sessionStatus.session_info.changed_count || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>변경</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#6b7280' }}>
|
||||
{sessionStatus.session_info.unchanged_count || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>유지</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행률 바 */}
|
||||
<div style={{
|
||||
background: '#f1f5f9',
|
||||
borderRadius: '8px',
|
||||
height: '8px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 100%)',
|
||||
height: '100%',
|
||||
width: `${sessionStatus.progress_percentage || 0}%`,
|
||||
transition: 'width 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
진행률: {Math.round(sessionStatus.progress_percentage || 0)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 탭 */}
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
|
||||
gap: '8px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
{categories.map(category => {
|
||||
const hasChanges = revisionSummary?.category_summaries?.[category.key];
|
||||
const isActive = selectedCategory === category.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.key}
|
||||
onClick={() => {
|
||||
setSelectedCategory(category.key);
|
||||
if (!categoryChanges[category.key]) {
|
||||
handleCategoryCompare(category.key);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: isActive
|
||||
? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'
|
||||
: hasChanges
|
||||
? 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)'
|
||||
: 'white',
|
||||
color: isActive ? 'white' : hasChanges ? '#92400e' : '#64748b',
|
||||
border: isActive ? 'none' : '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
{hasChanges && (
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
{hasChanges.total_changes}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 선택된 카테고리의 변경사항 */}
|
||||
{selectedCategory && categoryChanges[selectedCategory] && (
|
||||
<div style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<h4 style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#1e293b'
|
||||
}}>
|
||||
{categories.find(c => c.key === selectedCategory)?.name} 변경사항
|
||||
</h4>
|
||||
|
||||
{categoryChanges[selectedCategory].map((change, index) => (
|
||||
<div
|
||||
key={change.id || index}
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
marginBottom: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#1e293b',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{change.material_description}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
display: 'flex',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<span>이전: {change.previous_quantity || 0}</span>
|
||||
<span>현재: {change.current_quantity || 0}</span>
|
||||
<span>차이: {change.quantity_difference > 0 ? '+' : ''}{change.quantity_difference}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
style={{
|
||||
background: getActionColor(change.revision_action),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{getActionLabel(change.revision_action)}
|
||||
</span>
|
||||
|
||||
{change.action_status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleActionProcess(change.id, change.revision_action)}
|
||||
disabled={processingActions.has(change.id)}
|
||||
style={{
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
cursor: processingActions.has(change.id) ? 'not-allowed' : 'pointer',
|
||||
opacity: processingActions.has(change.id) ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
{processingActions.has(change.id) ? '처리중...' : '처리'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{change.action_status === 'completed' && (
|
||||
<span style={{
|
||||
color: '#10b981',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
✅ 완료
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionManagementPanel;
|
||||
Reference in New Issue
Block a user