feat(tkeg): tkeg BOM 자재관리 서비스 초기 세팅 (api + web + docker-compose)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-16 15:41:58 +09:00
parent 2699242d1f
commit 1e1d2f631a
160 changed files with 60367 additions and 0 deletions

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

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

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

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

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

View File

@@ -0,0 +1,516 @@
import React, { useState } from 'react';
import {
Box,
Card,
CardContent,
CardHeader,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
Alert,
Tabs,
Tab,
Button,
Checkbox,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stack,
Divider
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
ShoppingCart as ShoppingCartIcon,
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
Remove as RemoveIcon
} from '@mui/icons-material';
const MaterialComparisonResult = ({
comparison,
onConfirmPurchase,
loading = false
}) => {
const [selectedTab, setSelectedTab] = useState(0);
const [selectedItems, setSelectedItems] = useState(new Set());
const [confirmDialog, setConfirmDialog] = useState(false);
const [purchaseConfirmations, setPurchaseConfirmations] = useState({});
if (!comparison || !comparison.success) {
return (
<Alert severity="info">
비교할 이전 리비전이 없거나 비교 데이터를 불러올 없습니다.
</Alert>
);
}
const { summary, new_items, modified_items, removed_items, purchase_summary } = comparison;
// 탭 변경 핸들러
const handleTabChange = (event, newValue) => {
setSelectedTab(newValue);
setSelectedItems(new Set()); // 탭 변경시 선택 초기화
};
// 아이템 선택 핸들러
const handleItemSelect = (materialHash, checked) => {
const newSelected = new Set(selectedItems);
if (checked) {
newSelected.add(materialHash);
} else {
newSelected.delete(materialHash);
}
setSelectedItems(newSelected);
};
// 전체 선택/해제
const handleSelectAll = (items, checked) => {
const newSelected = new Set(selectedItems);
items.forEach(item => {
if (checked) {
newSelected.add(item.material_hash);
} else {
newSelected.delete(item.material_hash);
}
});
setSelectedItems(newSelected);
};
// 발주 확정 다이얼로그 열기
const handleOpenConfirmDialog = () => {
const confirmations = {};
// 선택된 신규 항목
new_items.forEach(item => {
if (selectedItems.has(item.material_hash)) {
confirmations[item.material_hash] = {
material_hash: item.material_hash,
description: item.description,
confirmed_quantity: item.additional_needed,
supplier_name: '',
unit_price: 0
};
}
});
// 선택된 변경 항목 (추가 필요량만)
modified_items.forEach(item => {
if (selectedItems.has(item.material_hash) && item.additional_needed > 0) {
confirmations[item.material_hash] = {
material_hash: item.material_hash,
description: item.description,
confirmed_quantity: item.additional_needed,
supplier_name: '',
unit_price: 0
};
}
});
setPurchaseConfirmations(confirmations);
setConfirmDialog(true);
};
// 발주 확정 실행
const handleConfirmPurchase = () => {
const confirmationList = Object.values(purchaseConfirmations).filter(
conf => conf.confirmed_quantity > 0
);
if (confirmationList.length > 0) {
onConfirmPurchase?.(confirmationList);
}
setConfirmDialog(false);
setSelectedItems(new Set());
};
// 수량 변경 핸들러
const handleQuantityChange = (materialHash, quantity) => {
setPurchaseConfirmations(prev => ({
...prev,
[materialHash]: {
...prev[materialHash],
confirmed_quantity: parseFloat(quantity) || 0
}
}));
};
// 공급업체 변경 핸들러
const handleSupplierChange = (materialHash, supplier) => {
setPurchaseConfirmations(prev => ({
...prev,
[materialHash]: {
...prev[materialHash],
supplier_name: supplier
}
}));
};
// 단가 변경 핸들러
const handlePriceChange = (materialHash, price) => {
setPurchaseConfirmations(prev => ({
...prev,
[materialHash]: {
...prev[materialHash],
unit_price: parseFloat(price) || 0
}
}));
};
const renderSummaryCard = () => (
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
리비전 비교 요약
</Typography>
<Stack direction="row" spacing={2} flexWrap="wrap">
<Chip
icon={<AddIcon />}
label={`신규: ${summary.new_items_count}`}
color="success"
variant="outlined"
/>
<Chip
icon={<EditIcon />}
label={`변경: ${summary.modified_items_count}`}
color="warning"
variant="outlined"
/>
<Chip
icon={<RemoveIcon />}
label={`삭제: ${summary.removed_items_count}`}
color="error"
variant="outlined"
/>
</Stack>
{purchase_summary.additional_purchase_needed > 0 && (
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="body2">
<strong>추가 발주 필요:</strong> {purchase_summary.additional_purchase_needed} 항목
(신규 {purchase_summary.total_new_items} + 증량 {purchase_summary.total_increased_items})
</Typography>
</Alert>
)}
</CardContent>
</Card>
);
const renderNewItemsTable = () => (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={selectedItems.size > 0 && selectedItems.size < new_items.length}
checked={new_items.length > 0 && selectedItems.size === new_items.length}
onChange={(e) => handleSelectAll(new_items, e.target.checked)}
/>
</TableCell>
<TableCell>품명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell align="right">필요수량</TableCell>
<TableCell align="right">기존재고</TableCell>
<TableCell align="right">추가필요</TableCell>
<TableCell>재질</TableCell>
</TableRow>
</TableHead>
<TableBody>
{new_items.map((item, index) => (
<TableRow
key={item.material_hash}
selected={selectedItems.has(item.material_hash)}
hover
>
<TableCell padding="checkbox">
<Checkbox
checked={selectedItems.has(item.material_hash)}
onChange={(e) => handleItemSelect(item.material_hash, e.target.checked)}
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.description}
</Typography>
</TableCell>
<TableCell>{item.size_spec}</TableCell>
<TableCell align="right">
<Chip
label={item.quantity}
size="small"
color="primary"
variant="outlined"
/>
</TableCell>
<TableCell align="right">{item.available_stock}</TableCell>
<TableCell align="right">
<Chip
label={item.additional_needed}
size="small"
color={item.additional_needed > 0 ? "error" : "success"}
/>
</TableCell>
<TableCell>
<Typography variant="caption" color="textSecondary">
{item.material_grade}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
const renderModifiedItemsTable = () => (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={selectedItems.size > 0 && selectedItems.size < modified_items.length}
checked={modified_items.length > 0 && selectedItems.size === modified_items.length}
onChange={(e) => handleSelectAll(modified_items, e.target.checked)}
/>
</TableCell>
<TableCell>품명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell align="right">이전수량</TableCell>
<TableCell align="right">현재수량</TableCell>
<TableCell align="right">증감</TableCell>
<TableCell align="right">기존재고</TableCell>
<TableCell align="right">추가필요</TableCell>
</TableRow>
</TableHead>
<TableBody>
{modified_items.map((item, index) => (
<TableRow
key={item.material_hash}
selected={selectedItems.has(item.material_hash)}
hover
>
<TableCell padding="checkbox">
<Checkbox
checked={selectedItems.has(item.material_hash)}
onChange={(e) => handleItemSelect(item.material_hash, e.target.checked)}
disabled={item.additional_needed <= 0} // 추가 필요량이 없으면 선택 불가
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.description}
</Typography>
</TableCell>
<TableCell>{item.size_spec}</TableCell>
<TableCell align="right">{item.previous_quantity}</TableCell>
<TableCell align="right">{item.current_quantity}</TableCell>
<TableCell align="right">
<Chip
label={item.quantity_diff > 0 ? `+${item.quantity_diff}` : item.quantity_diff}
size="small"
color={item.quantity_diff > 0 ? "error" : "success"}
/>
</TableCell>
<TableCell align="right">{item.available_stock}</TableCell>
<TableCell align="right">
<Chip
label={item.additional_needed}
size="small"
color={item.additional_needed > 0 ? "error" : "success"}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
const renderRemovedItemsTable = () => (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>품명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell align="right">삭제된 수량</TableCell>
</TableRow>
</TableHead>
<TableBody>
{removed_items.map((item, index) => (
<TableRow key={item.material_hash}>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.description}
</Typography>
</TableCell>
<TableCell>{item.size_spec}</TableCell>
<TableCell align="right">
<Chip
label={item.quantity}
size="small"
color="default"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
const renderConfirmDialog = () => (
<Dialog
open={confirmDialog}
onClose={() => setConfirmDialog(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Stack direction="row" alignItems="center" spacing={1}>
<ShoppingCartIcon />
<Typography variant="h6">발주 확정</Typography>
</Stack>
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
선택한 {Object.keys(purchaseConfirmations).length} 항목의 발주를 확정합니다.
수량과 공급업체 정보를 확인해주세요.
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>품명</TableCell>
<TableCell align="right">확정수량</TableCell>
<TableCell>공급업체</TableCell>
<TableCell align="right">단가</TableCell>
<TableCell align="right">총액</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.values(purchaseConfirmations).map((conf) => (
<TableRow key={conf.material_hash}>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{conf.description}
</Typography>
</TableCell>
<TableCell align="right">
<TextField
size="small"
type="number"
value={conf.confirmed_quantity}
onChange={(e) => handleQuantityChange(conf.material_hash, e.target.value)}
sx={{ width: 80 }}
/>
</TableCell>
<TableCell>
<TextField
size="small"
placeholder="공급업체"
value={conf.supplier_name}
onChange={(e) => handleSupplierChange(conf.material_hash, e.target.value)}
sx={{ width: 120 }}
/>
</TableCell>
<TableCell align="right">
<TextField
size="small"
type="number"
placeholder="0"
value={conf.unit_price}
onChange={(e) => handlePriceChange(conf.material_hash, e.target.value)}
sx={{ width: 80 }}
/>
</TableCell>
<TableCell align="right">
<Typography variant="body2">
{(conf.confirmed_quantity * conf.unit_price).toLocaleString()}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialog(false)}>
취소
</Button>
<Button
onClick={handleConfirmPurchase}
variant="contained"
startIcon={<CheckCircleIcon />}
disabled={loading}
>
발주 확정
</Button>
</DialogActions>
</Dialog>
);
return (
<Box>
{renderSummaryCard()}
<Card>
<CardHeader
title="자재 비교 상세"
action={
selectedItems.size > 0 && (
<Button
variant="contained"
startIcon={<ShoppingCartIcon />}
onClick={handleOpenConfirmDialog}
disabled={loading}
>
선택 항목 발주 확정 ({selectedItems.size})
</Button>
)
}
/>
<CardContent>
<Tabs value={selectedTab} onChange={handleTabChange}>
<Tab
label={`신규 항목 (${new_items.length})`}
icon={<AddIcon />}
/>
<Tab
label={`수량 변경 (${modified_items.length})`}
icon={<EditIcon />}
/>
<Tab
label={`삭제 항목 (${removed_items.length})`}
icon={<RemoveIcon />}
/>
</Tabs>
<Box sx={{ mt: 2 }}>
{selectedTab === 0 && renderNewItemsTable()}
{selectedTab === 1 && renderModifiedItemsTable()}
{selectedTab === 2 && renderRemovedItemsTable()}
</Box>
</CardContent>
</Card>
{renderConfirmDialog()}
</Box>
);
};
export default MaterialComparisonResult;

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
// BOM Components
export * from './materials';
export * from './shared';

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

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

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

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

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

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

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
// BOM Shared Components
export { default as FilterableHeader } from './FilterableHeader';
export { default as MaterialTable } from './MaterialTable';

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
// Common Components
export { default as UserMenu } from './UserMenu';
export { default as ErrorBoundary } from './ErrorBoundary';

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